Skip to content

orlyc hangs on aarch64 when compiling a package (embedded mem-sim server never returns) #554

Closed

Description

@ohohoreilly

Split out of #548, where it was masquerading as "the arm build is slow".

It is not the build

Docker build on a native ubuntu-24.04-arm runner, step timings straight from the log:

#10 [build 5/7] make bootstrap && jhm -c release ...   DONE 482.2s
#11 [build 6/7] orlyc -o /tmp/pkgout sample.orly ...   <entered, then silent>

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.

That is a hang, not slowness.

Emulation is ruled out

client=arm64 server=arm64
registered binfmt handlers: none
ubuntu:24.04 -> arm64

Native throughout; the apt output shows the aarch64-linux-gnu toolchain.

What step 11 does

orlyc -o /tmp/pkgout /src/orly/clients/mcp/smoke/sample.orly

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

  1. 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.
  2. 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.
  3. Once root-caused, First-run funnel: the REPL never teaches the commutative field calls, and the README buries the use case #548's multi-arch image unblocks with no changes to the workflow already written there — the build half was never the issue.

Not a regression

Nothing here is new breakage. aarch64 has never been a supported platform; this is the first time anything tried to run orlyc on it.

Activity

  1. ohohoreilly commented on Sep 15, 2026

    @ohohoreilly
    MemberAuthor

    Reproduced, with a controlled comparison

    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.

    Where it is stuck

    The main thread, in full:

    #0  __GI___sigsuspend
    #1  Base::TScheduler::TPolicy::RunUntilCtrlC        base/scheduler.cc:61
    #2  Base::TScheduler::TPolicy::RunUntilCtrlC        base/scheduler.cc:48
    #3  RunTestsOnIndy                                  orly/orlyc.cc:145
    #4  CompileCode                                     orly/orlyc.cc:278
    #5  main                                            orly/orlyc.cc:299
    

    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 worker
    sigsuspend(&*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.

    Two things this also says about coverage

    1. make test does 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 runs orlyc's embedded-server path. lang_test.py does, and is not in the arm job. Adding it there is the cheap guard.
    2. 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.

  2. ohohoreilly commented on Sep 15, 2026

    @ohohoreilly
    MemberAuthor

    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:

    #4  ___pthread_cond_wait
    #5  Orly::Indy::Fiber::TJumpRunnable::operator()
    #6  Orly::Server::TServer::Shutdown            server.cc:1428
    #7  RunTestsOnIndy lambda                      orlyc.cc:218
    

    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:

    752: Fiber::TSingleSem     MergerFinishedSem      ← fiber-aware
    753: Fiber::TSingleSem     WriterFinishedSem      ← fiber-aware
    787: Base::TEventSemaphore SchedulerExitedSem     ← raw blocking read()
    

    ~TDurableManager (203-208) waits on both kinds:

    if (writer_hosted) { SchedulerExitedSem.Pop(); }   // blocking read() — parks the OS THREAD
    if (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;
    • move the runner-exit wait out of the destructor to before or after the fiber jump — changes teardown ordering that server: no flush-on-shutdown — durability window is the merge cadence #440 tuned deliberately;
    • 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.
    • The exact deadlock pair above.
    • docker: build and publish a multi-arch image (#548) #552 is blocked only on this; its build half is correct (jhm -c release is 482s on arm, faster than amd64).
  3. ohohoreilly commented on Sep 16, 2026

    @ohohoreilly
    MemberAuthor

    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::LocalRunner after 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):

    tid 17746 syscall: 115 0x0 0x0 0xffff9c9ce520 0xffff9c9ce520 0x0 0x0 0xffff9c9ce490 ...
    tid 17754 syscall: 115 0x0 0x0 0xffff9c9ce520 0xffff9c9ce520 0x0 0x0 0xffff9c9ce490 ...
    

    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 release orlyc 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.

  4. ohohoreilly commented on Sep 16, 2026

    @ohohoreilly
    MemberAuthor

    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:

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions