Skip to content

FreeBSD: process hangs on exit after a successful conversation (JSC thread-suspend signal sent but never handled) #44595

Open

Description

@samm-git

Summary

After a successful conversation, the OpenCode (bun --compile) process hangs on exit on FreeBSD. This is a separate bug from the compiled-binary freeze in #44572 (that one is the aarch64 Wasm instruction-cache publication and is fixed by the membarrier patch). This exit hang is architecture-independent — it reproduces on amd64 as well as aarch64 — and it is in the WTF/JSC thread-suspension handshake.

Environment

  • FreeBSD 15.1-RELEASE aarch64 and FreeBSD 14.4-RELEASE amd64
  • bun 1.4.2 (built from source; also with the official FreeBSD binary)
  • OpenCode 2.0.22, TUI run as opencode --standalone

Reproduction

Start the TUI, complete one or more turns, then exit (Ctrl-C twice). The process never exits; the main thread is parked in native code. It hangs with or without heap snapshots (SIGUSR1) — it is the exit path.

What it is

Symbolized lldb capture (all threads) at the hang:

  • main: JSC::MachineThreads::tryCopyOtherThreadStacks → parked (wants the thread-suspend machinery)
  • Worker: JSC::Heap::runTaskInParallel → WTF::Condition::waitUntilUnchecked (parked)
  • VMTraps SignalSender: JSC::VMTraps::SignalSender::work → WTF::sendMessageScoped → WTF::Thread::suspend → sem_wait

So a VMTraps::SignalSender is trying to stop the VM owner thread and never completes.

Pinpointed with an instrumented build

With temporary logging in Thread::suspend / Thread::resume / signalHandlerSuspendResume (the handler logging via async-signal-safe write(2)) and VMTraps::SignalSender::work:

[VMTRAPS] suspend owner uid=13
[SUSP] enter target uid=13 cnt=0 handle=0x... name=[Worker] curUid=21 sig=30
[SUSP] killed uid=13 result=0
  • the target is the Worker thread (the owner of the Worker VM),
  • the signal number is 30 (SIGPWR),
  • pthread_kill succeeds (result=0),
  • but the signal handler never runs — [HANDLER] count is 0 for the whole run, and [SUSP] woken is never printed (the globalSemaphoreForSuspendResume->wait() never returns).

The Worker thread is blocked in an interruptible __umtx_op_wait_uint_private (sleepq_wait_sig) at the time, so the signal should be deliverable. procstat sigfastblock shows a sigfastblock pointer for every thread, so this looks like the suspend signal is blocked or deferred on that thread (or its disposition differs), i.e. the signal is sent but never dispatched to signalHandlerSuspendResume.

Notes:

  • Thread::suspend only signals when the target's m_suspendCount == 0, so it is not an "already suspended" early-out.
  • This happens with the signal-based barrier patch reverted and with the membarrier fix applied, so the Wasm path is not involved.

Why this matters

Thread::suspend is not just used by Wasm publishing — it is the GC's stop-the-world (stack scanning) and VMTraps mechanism on FreeBSD. A suspend that is sent but never acknowledged wedges the whole process. It is the remaining reason a compiled OpenCode binary cannot be exited cleanly on FreeBSD.

Possible directions

  1. Ensure the suspend signal is unblocked on every JSC/Worker/bun thread (it must be serviced even when the thread is parked in a WTF::ParkingLot/ThreadCondition wait).
  2. Backport the generation-based SuspendResumeRequest handler from oven-sh/WebKit main ([WTF] The thread suspend signal handler acts only on requests from suspend() and resume(), PR bun tsconfig #693) — it makes the handler attribute signals to the correct thread, but it only helps if the handler actually runs.
  3. Have VMTraps::SignalSender avoid suspending a thread that is itself parked/in a GC (or make the sender tolerant of a target that does not respond).

Happy to provide the full lldb bt all captures and the instrumented-build logs.

Activity

  1. samm-git commented on Oct 5, 2026

    @samm-git
    Author

    Found it — bun overrides JSC's SIGUSR1 handler on FreeBSD, which is why Thread::suspend() never gets its ack.

    src/jsc/bindings/BunProcess.cpp guards JS signal listeners against JSC's suspend signal with an #if OS(LINUX):

    #if OS(LINUX)
        if (signalNumber != SIGKILL && signalNumber != SIGSTOP && signalNumber != g_wtfConfig.sigThreadSuspendResume) {
    #elif OS(DARWIN) || OS(FREEBSD)
        if (signalNumber != SIGKILL && signalNumber != SIGSTOP) {   // no suspend-signal guard

    JSC uses SIGPWR on Linux but SIGUSR1 on FreeBSD (wtf/posix/ThreadingPOSIX.cpp). So on FreeBSD, if JS registers a SIGUSR1 listener, bun installs its own handler and clobbers JSC's. The suspend signal is then delivered to the wrong handler, globalSemaphoreForSuspendResume is never posted, and VM teardown deadlocks (VMTraps::SignalSender → Thread::suspend → sem_wait, with a GC helper blocked on the suspend lock it holds).

    Evidence:

    • File-fd instrumentation of Thread::signalHandlerSuspendResume: the suspender's S:enter is logged, the handler's H:enter never is — so the handler never executes.
    • ktrace: PSIG SIGUSR1 caught ... code=SI_LWP on the owner thread, delivered to a non-JSC handler; the mask is clear and the sigfastblock word is 0.
    • oven-sh/WebKit#693 does not help (it only reworks the handler's internals); the problem is that the handler is not installed.

    Fix: add signalNumber != g_wtfConfig.sigThreadSuspendResume to the FreeBSD branch (matching Linux). With it, the deterministic exit test is exit=OK 15/15 (was HANG 5/5), and all conversation turns still pass.

    Fix PR: #44596

  2. added
    bugSomething isn't working
    jscRelated to an issue in JavaScriptCore
    on Oct 16, 2026
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

    bugSomething isn't workingjscRelated to an issue in JavaScriptCorenode:process

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions