You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
orlyc hangs on aarch64 when compiling a package (embedded mem-sim server never returns) #554
482 seconds. That matches the 8m06s measured for the same command on the bare runner, and it is faster than the amd64 leg's 11m05s. The LTO release build is completely healthy on aarch64.
Step 11 then emitted its header line and nothing else for ~37 minutes, until the job was cancelled. Earlier attempts died at the 90- and 180-minute caps in the same place.
And from the Dockerfile's own comment on that line:
each orlyc run stands up and tears down its embedded mem-sim server, so this also smokes the binaries during the image build
So the suspicion is that orlyc's embedded mem-sim server comes up and never completes its handshake or teardown on aarch64.
Why CI did not catch this
#550 established that aarch64 builds clean and passes make test — 1753 build jobs, 203 test binaries, zero failures. That is true and still true. But make debug / make test do not exercise the orlyc shell-out path, which is a known gap in this repo's testing, and lang_test.py (which does drive orlyc) is not part of the arm job.
So the arm coverage we have proves the engine and the language runtime work, and says nothing about orlyc actually compiling a package on that architecture. The docker image build was the first thing to try it.
Suggested next steps
Reproduce minimally: on an arm runner, jhm -c release orly/orlyc then a single orlyc -o /tmp/out sample.orly, with a timeout and a stack dump on expiry. Much cheaper than a docker build.
Add lang_test.py to the aarch64 CI job. It drives orlyc end to end and would have caught this. It is also the natural place for the arm job to grow now that the basic build is proven.
Same command, same package, same runner generation, both arches in one run:
result
amd64
rc=0, produced sample.1.so (166KB)
arm64
still alive after 300s, 18 threads, killed
So it is aarch64-specific and it is a hang, not slow work. Repro takes ~9 minutes (build orlyc alone, run it on one package with a deadline) rather than a 45-minute docker build.
Every other thread is idle-blocked behind it — fiber runners asleep in TRunner::Run (fiber.cc:187), event loops in ep_poll, one in do_wait_intr_irq. Nothing is spinning. That is a missed wakeup, not work in progress.
The mechanism
orlyc runs the package's compile-time test{} blocks on indy (#262), and that path goes through RunUntilCtrlC:
TMasker masker(*TSet(TSet::Full)); // block all signals
THandlerInstaller handler(SIGINT, [](int){ ShutDown(); });
TScheduler scheduler(*this, pthread_self(), &main_job); // main_job runs on a workersigsuspend(&*TSet(TSet::Exclude, { SIGINT })); // wait for SIGINT
So orlyc terminates only when SIGINT reaches the main thread. It is waiting for a signal that never arrives on aarch64.
Worth noting this is a shutdown/wakeup bug, not a compilation bug: the package compiles (the sample.1.so on amd64 is byte-produced by the same code path), and #550 established the engine and its 203 test binaries are fine on arm. What fails is the handshake that tells orlyc the tests are done.
That is the same family as #537's ~TClient teardown deadlock — a completion signal that the exception/fast path skipped — and as #440's scheduler shutdown work. Those are the places I would look first.
The arm job being informational was the right call. Had it been a gate on the strength of make test alone, this would have looked like a green platform.
Not attempting a fix here
This is a signal/scheduler-shutdown change in the engine, which this project's own convention says should not happen in the session that found it. The repro workflow is on feat/548-multiarch-image and takes ~9 minutes per run, so whoever picks it up has a fast loop.
#552 (multi-arch image) stays blocked on this, and needs no changes of its own — its build half was correct throughout; jhm -c release finishes in 482s on arm, faster than amd64's 11m05s.
Thread 14 — the fiber runner executing that teardown jump:
#1 __GI___libc_read (fd=26, nbytes=8) ← blocking read on an eventfd
#2 Base::TEventSemaphore::Pop event_semaphore.cc:41
#3 Orly::Indy::Disk::TDurableManager::~TDurableManager durable_manager.cc:207
#9 Orly::Indy::TManager::~TManager manager.cc:172
Identical stacks 60 seconds apart — wedged, not slow. And the tests themselves passed: the worker reached orlyc.cc:218, which is server->Shutdown(), so RunPackageTests returned fine. This is purely a shutdown bug.
The suspicious part
server.cc line 1402 explains why teardown runs on a fiber:
Tear down the fiber-entangled managers on a fiber, while every runner is still alive: ~TDurableManager blocks on TSingleSem (fiber-only) for its writer/merger fibers
But the thing it is actually blocked on is not a fiber primitive. In durable_manager.h:
if (writer_hosted) { SchedulerExitedSem.Pop(); } // blocking read() — parks the OS THREADif (merger_hosted) { SchedulerExitedSem.Pop(); }
So a destructor deliberately placed on a fiber — because its waits were believed to be fiber-only — parks the entire fiber-runner thread in read().
What it is waiting for is pushed only after a scheduler job returns:
WriterHostHandle = scheduler->ScheduleCancelable([this, frame_pool_manager] {
Fiber::LaunchSlowFiberSched(&WriterScheduler, frame_pool_manager);
SchedulerExitedSem.Push(); // only reached when that runner loop exits
});
So the wait consumes a worker while waiting for work that itself needs workers to make progress. That is the shape of the deadlock, and it is exactly the family #440 dealt with ("TScheduler::Schedule queues past the cap but never launches another worker... a loop that never runs can neither do its part of the update pipeline nor confirm its exit to Shutdown()'s joins").
Why aarch64 and not x86 is then just scheduling luck deciding whether those pushes land before the destructor starts waiting.
Where I have stopped, and why
I was given the go-ahead to attempt the fix and I am declining to push one, because every candidate I can see is a guess about the fiber/scheduler shutdown protocol rather than something I can demonstrate:
make SchedulerExitedSem fiber-aware — but TSingleSem has different semantics and these are pushed from a thread context, not a fiber;
yield the fiber while polling a non-blocking Pop — plausible, but changes a blocking contract other callers may rely on.
Choosing between those needs someone who knows which invariant is load-bearing. I have been wrong three times on this bug already by reasoning ahead of evidence (memory thrash, aarch64 LTO cost, qemu emulation — all refuted by measurement), and a speculative engine change here could easily convert a clean aarch64 deadlock into an intermittent one everywhere.
What is ready for whoever takes it
Repro in ~9 minutes on feat/548-multiarch-image: builds orlyc alone, runs one package with a 300s deadline, captures full backtraces plus a second snapshot, uploads both as artifacts. amd64 runs alongside as a control and passes.
Root cause: two OS threads on one stack. Fix in #555
The teardown deadlock in the comment above is real but it is the symptom. Nothing is wrong with the shutdown protocol, and none of the three candidate fixes I listed were needed.
What is actually happening
A fiber's C stack outlives the OS thread that last ran it. TFrames come from a cross-thread pool — TThreadLocalPool::Free is explicitly a cross-thread free list and TryAllocUncommon steals from peer pools — and they are routinely re-latched onto a runner belonging to a different thread. TJumpRunnable does precisely that by design: allocate a frame from the calling thread's pool, latch it onto someone else's runner.
The compiler assumes the opposite. GCC computes the thread pointer once per function and derives every __thread access in that function from it. On aarch64 that is one mrs x20, tpidr_el0, hoisted out of the loop. From the release orlyc that hangs:
545edc: mrs x20, tpidr_el0 <- computed ONCE, at function entry
545ee8: add x20, x20, #0x38 <- x20 = &LocalRunner in THIS thread's TLS
545ef8: ... <- loop top (TFrame::Run's for(;;))
545f24: blr x1 <- run the runnable (may resume on another thread)
545f28: ldr x0, [x20] <- LocalRunner, from the CACHED address
545f30: bl switch_to_fiber
545f34: b 545ef8
TFrame::Run() reads TRunner::LocalRunnerafter the runnable returns, to switch back to "my" scheduler. On a frame that has migrated threads, that read returns the previous thread's runner, and the fiber longjmps 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 — not scheduling luck — is the entire arm-vs-x86 difference. I had that wrong in the comment above.
How it produces this hang
The durable manager's writer host job lands on BGFastRunner's stack, so WriterScheduler.Run() never returns and the job never reaches its SchedulerExitedSem.Push(). ~TDurableManager blocks on the matching Pop() forever, and since that destructor runs on the teardown fiber, TServer::Shutdown() wedges behind it.
Instrumenting the destructor shows the merger half completing and the writer half never arriving:
dtor writer_hosted=1 merger_hosted=1
dtor WriterFinishedSem done
dtor MergerFinishedSem done
dtor both runners ShutDown()
MERGER host RUN RETURNED
MERGER host PUSHED
dtor SchedulerExitedSem pop 1 done <- and then nothing
In the wedged process both runners' KeepRunning read false and both frame queues were empty — the writer's loop had nothing left to do and every reason to exit. It simply was not running its own loop any more.
The kernel settles it. Two distinct tasks, identical user stack pointer (field 8 of /proc/<pid>/task/<tid>/syscall):
0xffff9c9ce... is tid 17746's own thread stack. 17754 is the writer host, executing on it. This also explains the two threads in the original dump that reported the same TRunner *this — they really were in the same frame, on the same stack.
Fix
#555 routes the three fiber thread-locals through accessors GCC may not inline, clone or fold (noipa), so the thread pointer is re-read at every access and the property holds through -flto. Call sites are unchanged.
Nothing cheaper works, measured rather than assumed: 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.
Verification, and a correction to the guard suggested above
On a native aarch64 box configured to the CI runner's four-core topology (which is what decides the fast/slow runner counts):
result
before, release
wedged on the first attempt, 3 out of 3
after, release
20 / 20 runs exit 0
after, jhm -c debug whole tree
1753 jobs, clean
Step 2 of the original suggestion — put lang_test.py in the aarch64 job — would not have caught this. A debug build does not reproduce it: 15 pre-fix debug runs were all clean. The guard that would catch it is compiling one package with a releaseorlyc on arm, which is exactly what the repro workflow on the #548 branch already does in about nine minutes. Filing that as its own issue rather than bundling it.
#552 unblocks once #555 lands; its build half never needed changing.
Closing evidence, since the local 20/20 in the comment above was only a proxy for the thing that actually found this.
With #555 merged, the arm64 docker image job — the workload whose "37 minutes of silence" opened this issue — is green end to end in 9m48s, every step included:
success Build image <- used to hang here
success Confirm the built image is the intended architecture
success Start container
success MCP smoke against the container
success In-container orlyc compile <- and here
success REPL mode against the image
The orlyc repro (arm64) job built for this issue also passes now, at 6m55s.
One more refuted hypothesis, for the record
That makes four. Memory pressure, QEMU emulation, and "aarch64 LTO codegen for this tree is enormously expensive, at least 18x the x86 leg" were all wrong; this bug was the whole of it.
The LTO claim is worth striking explicitly because it outlived the other two and was still being repeated as settled. The arm64 image job is now faster than amd64's 12m04s. There was never anything slow about aarch64 here — the >90 and >180 minute runs were this deadlock, not the compiler.
What is not closed by this
#556 tracks the CI guard. Note the shape of it changed once the cause was known: a debug build does not reproduce this class (15 pre-fix debug runs were clean, while release wedged on the first attempt 3 out of 3), because the defect is the optimiser hoisting a thread-pointer read out of a loop and -O0 does not do that. So step 2 of this issue's original "suggested next steps" — add lang_test.py to the aarch64 job, which builds debug — would have caught nothing.
The same hazard applies in principle to three other thread-locals a fiber can read after a switch (TDiskController::TEvent::LocalEventPool, TLocalReadFileCache::Cache, TLocalWalkerCache::Cache). None is implicated here and none is touched by #555; each is an audit of its own.
Activity
ci: minimal repro for the orlyc hang on aarch64 (#554)
ci: fix the repro's package path, and assert its inputs exist (#554)
ohohoreilly commentedon Sep 15, 2026
Reproduced, with a controlled comparison
Same command, same package, same runner generation, both arches in one run:
rc=0, producedsample.1.so(166KB)So it is aarch64-specific and it is a hang, not slow work. Repro takes ~9 minutes (build
orlycalone, run it on one package with a deadline) rather than a 45-minute docker build.Where it is stuck
The main thread, in full:
Every other thread is idle-blocked behind it — fiber runners asleep in
TRunner::Run(fiber.cc:187), event loops inep_poll, one indo_wait_intr_irq. Nothing is spinning. That is a missed wakeup, not work in progress.The mechanism
orlycruns the package's compile-timetest{}blocks on indy (#262), and that path goes throughRunUntilCtrlC:So
orlycterminates only when SIGINT reaches the main thread. It is waiting for a signal that never arrives on aarch64.Worth noting this is a shutdown/wakeup bug, not a compilation bug: the package compiles (the
sample.1.soon amd64 is byte-produced by the same code path), and #550 established the engine and its 203 test binaries are fine on arm. What fails is the handshake that tellsorlycthe tests are done.That is the same family as #537's
~TClientteardown deadlock — a completion signal that the exception/fast path skipped — and as #440's scheduler shutdown work. Those are the places I would look first.Two things this also says about coverage
make testdoes not exercise this. portability: replace the only x86-specific code with __builtin_prefetch, and ask CI about aarch64 (#548) #550's aarch64 result (1753 jobs, 203 binaries, zero failures) is real and stays real; it simply never runsorlyc's embedded-server path.lang_test.pydoes, and is not in the arm job. Adding it there is the cheap guard.make testalone, this would have looked like a green platform.Not attempting a fix here
This is a signal/scheduler-shutdown change in the engine, which this project's own convention says should not happen in the session that found it. The repro workflow is on
feat/548-multiarch-imageand takes ~9 minutes per run, so whoever picks it up has a fast loop.#552 (multi-arch image) stays blocked on this, and needs no changes of its own — its build half was correct throughout;
jhm -c releasefinishes in 482s on arm, faster than amd64's 11m05s.ci: capture full backtraces and a second snapshot for the orlyc hang (#…
ohohoreilly commentedon Sep 15, 2026
Root-caused to a two-thread deadlock in teardown
Full 18-thread dump attached to the CI run. Two threads matter; the other 16 are fiber runners idle-spinning normally at
fiber.cc:187.Thread 18 — scheduler worker running the main job:
Thread 14 — the fiber runner executing that teardown jump:
Identical stacks 60 seconds apart — wedged, not slow. And the tests themselves passed: the worker reached
orlyc.cc:218, which isserver->Shutdown(), soRunPackageTestsreturned fine. This is purely a shutdown bug.The suspicious part
server.ccline 1402 explains why teardown runs on a fiber:But the thing it is actually blocked on is not a fiber primitive. In
durable_manager.h:~TDurableManager(203-208) waits on both kinds:So a destructor deliberately placed on a fiber — because its waits were believed to be fiber-only — parks the entire fiber-runner thread in
read().What it is waiting for is pushed only after a scheduler job returns:
So the wait consumes a worker while waiting for work that itself needs workers to make progress. That is the shape of the deadlock, and it is exactly the family #440 dealt with ("TScheduler::Schedule queues past the cap but never launches another worker... a loop that never runs can neither do its part of the update pipeline nor confirm its exit to Shutdown()'s joins").
Why aarch64 and not x86 is then just scheduling luck deciding whether those pushes land before the destructor starts waiting.
Where I have stopped, and why
I was given the go-ahead to attempt the fix and I am declining to push one, because every candidate I can see is a guess about the fiber/scheduler shutdown protocol rather than something I can demonstrate:
SchedulerExitedSemfiber-aware — butTSingleSemhas different semantics and these are pushed from a thread context, not a fiber;Choosing between those needs someone who knows which invariant is load-bearing. I have been wrong three times on this bug already by reasoning ahead of evidence (memory thrash, aarch64 LTO cost, qemu emulation — all refuted by measurement), and a speculative engine change here could easily convert a clean aarch64 deadlock into an intermittent one everywhere.
What is ready for whoever takes it
feat/548-multiarch-image: buildsorlycalone, runs one package with a 300s deadline, captures full backtraces plus a second snapshot, uploads both as artifacts. amd64 runs alongside as a control and passes.jhm -c releaseis 482s on arm, faster than amd64).ohohoreilly commentedon Sep 16, 2026
Root cause: two OS threads on one stack. Fix in #555
The teardown deadlock in the comment above is real but it is the symptom. Nothing is wrong with the shutdown protocol, and none of the three candidate fixes I listed were needed.
What is actually happening
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 they are routinely re-latched onto a runner belonging to a different thread.TJumpRunnabledoes precisely that by design: allocate a frame from the calling thread's pool, latch 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 onemrs x20, tpidr_el0, hoisted out of the loop. From the releaseorlycthat hangs:TFrame::Run()readsTRunner::LocalRunnerafter the runnable returns, to switch back to "my" scheduler. On a frame that has migrated threads, that read returns the previous thread's runner, and the fiberlongjmps 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 — not scheduling luck — is the entire arm-vs-x86 difference. I had that wrong in the comment above.
How it produces this hang
The durable manager's writer host job lands on
BGFastRunner's stack, soWriterScheduler.Run()never returns and the job never reaches itsSchedulerExitedSem.Push().~TDurableManagerblocks on the matchingPop()forever, and since that destructor runs on the teardown fiber,TServer::Shutdown()wedges behind it.Instrumenting the destructor shows the merger half completing and the writer half never arriving:
In the wedged process both runners'
KeepRunningreadfalseand both frame queues were empty — the writer's loop had nothing left to do and every reason to exit. It simply was not running its own loop any more.The kernel settles it. Two distinct tasks, identical user stack pointer (field 8 of
/proc/<pid>/task/<tid>/syscall):0xffff9c9ce...is tid 17746's own thread stack. 17754 is the writer host, executing on it. This also explains the two threads in the original dump that reported the sameTRunner *this— they really were in the same frame, on the same stack.Fix
#555 routes the three fiber thread-locals through accessors GCC may not inline, clone or fold (
noipa), so the thread pointer is re-read at every access and the property holds through-flto. Call sites are unchanged.Nothing cheaper works, measured rather than assumed:
volatile,thread_local, anasm volatilememory clobber,returns_twiceonswitch_to_fiber, and all four-ftls-modelsettings each still emit one loop-invariantmrs.Verification, and a correction to the guard suggested above
On a native aarch64 box configured to the CI runner's four-core topology (which is what decides the fast/slow runner counts):
jhm -c debugwhole treeStep 2 of the original suggestion — put
lang_test.pyin the aarch64 job — would not have caught this. A debug build does not reproduce it: 15 pre-fix debug runs were all clean. The guard that would catch it is compiling one package with a releaseorlycon arm, which is exactly what the repro workflow on the #548 branch already does in about nine minutes. Filing that as its own issue rather than bundling it.#552 unblocks once #555 lands; its build half never needed changing.
ohohoreilly commentedon Sep 16, 2026
Confirmed against the originating workload
Closing evidence, since the local 20/20 in the comment above was only a proxy for the thing that actually found this.
With #555 merged, the arm64 docker image job — the workload whose "37 minutes of silence" opened this issue — is green end to end in 9m48s, every step included:
The
orlyc repro (arm64)job built for this issue also passes now, at 6m55s.One more refuted hypothesis, for the record
That makes four. Memory pressure, QEMU emulation, and "aarch64 LTO codegen for this tree is enormously expensive, at least 18x the x86 leg" were all wrong; this bug was the whole of it.
The LTO claim is worth striking explicitly because it outlived the other two and was still being repeated as settled. The arm64 image job is now faster than amd64's 12m04s. There was never anything slow about aarch64 here — the >90 and >180 minute runs were this deadlock, not the compiler.
What is not closed by this
#556 tracks the CI guard. Note the shape of it changed once the cause was known: a debug build does not reproduce this class (15 pre-fix debug runs were clean, while release wedged on the first attempt 3 out of 3), because the defect is the optimiser hoisting a thread-pointer read out of a loop and
-O0does not do that. So step 2 of this issue's original "suggested next steps" — addlang_test.pyto the aarch64 job, which builds debug — would have caught nothing.The same hazard applies in principle to three other thread-locals a fiber can read after a switch (
TDiskController::TEvent::LocalEventPool,TLocalReadFileCache::Cache,TLocalWalkerCache::Cache). None is implicated here and none is touched by #555; each is an audit of its own.Read the interner's lock depth through opaque calls so a migrated fib…