Repository navigation
Fix the aarch64 orlyc hang: fiber thread-locals must not be cached across a fiber switch - #555
Merged
Merged
Conversation
) A fiber's C stack outlives the OS thread that last ran it. Frames come from a cross-thread pool and are routinely re-latched onto a runner belonging to a different thread, so a fiber resumes on whatever thread the runner that picked it up is running on. The compiler assumes the opposite. GCC computes the thread pointer once per function -- one `mrs x, tpidr_el0` hoisted out of the loop on aarch64 -- and derives every `__thread` access in that function from it. TFrame::Run() reads TRunner::LocalRunner *after* the runnable returns, to switch back to "my" scheduler. On a frame that had migrated, that read returned the previous thread's runner and the fiber longjmp'ed onto THAT thread's scheduler stack: two OS threads executing one stack, and the hijacked runner's Run() never returning. That is the hang. The durable manager's writer host job never left its runner loop, so it never pushed SchedulerExitedSem, so ~TDurableManager blocked on it forever and TServer::Shutdown() wedged. Confirmed from the kernel: two tasks reporting the identical user stack pointer. x86-64 folds the TLS base into each access's addressing mode, so there is nothing to hoist and nothing to go stale, which is why only arm showed it. Route the three fiber thread-locals through TFiberSafeLocal, whose accessors are `noipa`: GCC may not inline, clone, or fold them, so the thread pointer is re-read at every access, and it holds through -flto. Call sites are unchanged -- the slot converts, dereferences and assigns exactly as the bare pointer did. Nothing cheaper suppresses the hoist. `volatile`, `thread_local`, an `asm volatile` memory clobber, `returns_twice` on switch_to_fiber, and all four -ftls-model settings each still emit one loop-invariant `mrs`. Verified on a native aarch64 container reproducing the CI runner's four-core topology: before, `orlyc` wedged on the first attempt three times out of three; after, twenty consecutive runs exit 0. `jhm -c debug` builds the whole tree (1753 jobs) clean on the same box.
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #554. Unblocks #552.
What was actually wrong
Not a shutdown-ordering bug. Two OS threads were executing the same stack.
A fiber's C stack outlives the OS thread that last ran it:
TFrames come from a cross-thread pool (TThreadLocalPool::Freeis explicitly a cross-thread free list, andTryAllocUncommonsteals from peer pools) and are routinely re-latched onto a runner belonging to a different thread.TJumpRunnabledoes exactly this by design — it allocates a frame from the calling thread's pool and latches it onto someone else's runner.The compiler assumes the opposite. GCC computes the thread pointer once per function and derives every
__threadaccess in that function from it. On aarch64 that is a singlemrs x20, tpidr_el0hoisted out of the loop:TFrame::Run()readsTRunner::LocalRunnerafter the runnable returns, to switch back to "my" scheduler. On a frame that had migrated, that read returned the previous thread's runner, and the fiberlongjmped onto that thread's scheduler stack.x86-64 folds the TLS base into each access's addressing mode, so there is nothing to hoist and nothing to go stale. That is the whole of the arm-vs-x86 difference — not scheduling luck.
How that produced the hang
The durable manager's writer host job hijacked
BGFastRunner's stack, soWriterScheduler.Run()never returned and the job never reached itsSchedulerExitedSem.Push().~TDurableManagerblocked on the matchingPop()forever, and because that destructor runs on the teardown fiber,TServer::Shutdown()wedged with it.Instrumenting the destructor showed it exactly — the merger half completes, the writer half never does:
Both runners'
KeepRunningreadfalsein the wedged process and both frame queues were empty, so the writer's loop had nothing left to do and every reason to exit — it just was not running its own loop any more.The kernel confirms the stack sharing. Two distinct tasks, identical user stack pointer:
0xffff9c9ce...is tid 17746's own thread stack. 17754 is the writer host, running on it.The fix
The three fiber thread-locals —
TRunner::LocalRunner,TFrame::LocalFrame,TFrame::LocalFramePool— becomeTFiberSafeLocalslots whose accessors arenoipa. GCC may not inline, clone, or infer anything about anoipafunction, so the thread pointer is re-read at every access, and it survives-flto(verified in the built binary:StartFramenow has nomrsand calls the accessor each iteration).Every existing call site is unchanged. The slot converts, dereferences, tests and assigns exactly as the bare pointer did; one site that passed it to a deduced template parameter (
Base::AssertTrue) now spells the read out as.Get().Nothing cheaper suppresses the hoist. Measured, not assumed —
volatile,thread_local, anasm volatilememory clobber,returns_twiceonswitch_to_fiber, and all four-ftls-modelsettings each still emit one loop-invariantmrs. The thread pointer is not memory as far as the optimiser is concerned; it is a function invariant, and only an opaque call re-reads it.Verification
On a native aarch64 container configured to match the CI runner's four-core topology (which decides the fast/slow runner counts):
jhm -c debugwhole treeWorth recording for whoever writes the regression guard: a debug build does not reproduce this — 15 pre-fix debug runs were all clean. So adding
lang_test.pyto the aarch64 job as #554 suggested would not by itself have caught it; the cheap guard that would is compiling one package with a releaseorlycon arm, which is what the repro workflow on the #548 branch already does in about nine minutes. Filing that separately rather than bundling it here.Scope
Deliberately limited to the fiber layer's own thread-locals, which is where the bug is and where the load-bearing decision (which stack does this thread run on) is made. The same hazard exists in principle for other thread-locals a fiber can read after a switch —
TDiskController::TEvent::LocalEventPool,TLocalReadFileCache::Cache,TLocalWalkerCache::Cache. None is implicated in this hang and each is an audit of its own, so they are not touched here.