Skip to content

event_loop: don't debug-panic on late main-VM enqueue_task_concurrent during process.exit - #36020

Open
robobun wants to merge 6 commits into
mainfrom
farm/b37fdd39/enqueue-task-concurrent-main-vm-exit
Open

event_loop: don't debug-panic on late main-VM enqueue_task_concurrent during process.exit#36020
robobun wants to merge 6 commits into
mainfrom
farm/b37fdd39/enqueue-task-concurrent-main-vm-exit

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

What

Under BUN_DESTRUCT_VM_ON_EXIT=1 (set by the CI runner), process.exit() on the main thread runs VirtualMachine::destroy() (which sets has_terminated = true) before libc::exit(). If a thread-pool fs.write that was blocked in the kernel returns during the atexit chain, its work-pool completion calls enqueue_task_concurrent against a VM whose has_terminated is already set, and the debug_assertions-gated guard panics:

panic: EventLoop.enqueueTaskConcurrent: VM has terminated
  ...
  <bun_jsc::event_loop::EventLoop>::enqueue_task_concurrent     src/jsc/event_loop.rs:1000
  AsyncFSTask<Write>::work_pool_callback                        src/runtime/node/node_fs.rs:1314
  <bun_threading::thread_pool::Thread>::run

Seen on linux-x64-asan in build 82810 via test/js/node/fs/fs.test.ts (a createWriteStream to a FIFO whose reader is held by the parent process).

Why this is correct

The main-thread VirtualMachine box is process-static (global_exit() never deallocs it). destroy() leaves both the concurrent_tasks MPSC queue and the uws loop intact, so the late push + wakeup are sound; the task just lands in a queue that is never drained again. Release builds already behave that way since the guard is compiled out. The assert exists to catch producers that enqueue into a worker VM after destroy(), where the box is about to be raw-dealloc'd in WebWorker::shutdown(), so the guard is kept for that case.

Fix

Gate the debug panic on !vm.is_main_thread().

Test

test/js/node/fs/fs-write-exit-race.test.ts reproduces the race deterministically on Linux: the child opens a FIFO, dispatches a blocking fs.write to the thread pool, and registers a libc atexit handler (via bun:ffi cc()) that closes the only reader fd and then usleep()s. The handler runs after destroy() has returned, so the blocked write() gets EPIPE and posts its completion while has_terminated is already set.

On an unfixed debug/ASAN build the child emits the panic + backtrace (assertion fails); with the fix it exits with {stdout: "alive\n", stderr: "", exitCode: 0}.

Related: #34154 tackles the worker-VM side of this class (the UAF) and leaves the guard for workers in place; this change only stops the main-VM false positive.


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/fs/fs-write-exit-race.test.ts

…r process.exit

Under BUN_DESTRUCT_VM_ON_EXIT (set by the CI runner), process.exit() on the
main thread runs VirtualMachine::destroy(), which sets has_terminated = true,
before libc exit(). A thread-pool fs.write that was blocked in the kernel and
returns during the atexit chain (for example because the only FIFO reader
closed) then calls enqueue_task_concurrent against a terminated VM. The
debug-assertions guard in enqueue_task_concurrent turned that into

  panic: EventLoop.enqueueTaskConcurrent: VM has terminated

on the work-pool thread. The main-thread VirtualMachine box is never
dealloc'd, and neither concurrent_tasks nor the uws loop is torn down, so the
late push + wakeup are safe; the task simply lands in a queue that is never
drained again. Limit the assert to worker VMs, where has_terminated precedes a
raw dealloc and the push is UAF-adjacent (the class of bug the assert was
added to catch).

The test drives the race deterministically: a tinycc-compiled atexit handler
closes the FIFO reader and usleep()s after destroy() has returned, so the
blocked write unblocks and posts while has_terminated is already set.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The debug termination guard now distinguishes main-thread and worker VMs. A Linux ASAN test reproduces a blocked filesystem write during process.exit() and verifies clean child-process termination.

VM shutdown race handling

Layer / File(s) Summary
Worker VM termination guard
src/jsc/event_loop.rs
enqueue_task_concurrent panics only for terminated non-main-thread VMs in debug builds.
Blocked fs.write exit regression
test/js/node/fs/fs-write-exit-race.test.ts
An ASAN Linux test coordinates a blocked FIFO write, process.exit(), and atexit cleanup, then checks exit code, stdout, and stderr.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: allowing late main-VM concurrent task enqueueing during process exit.
Description check ✅ Passed The description covers what changed, why it is correct, and how it was tested with a regression case.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:30 PM PT - Jul 26th, 2026

@robobun, your commit 4cffc9ad15282a3a1e8007214b8ffb25be6f56d0 passed in Build #83019! 🎉


🧪   To try this PR locally:

bunx bun-pr 36020

That installs a local version of the PR into your bun-36020 executable, so you can run:

bun-36020 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nits: checked that the new cross-thread vm.is_main_thread() read is race-free (worker is set once at VM init and never mutated), and traced global_exit()destroy()bun_core::Global::exit() to confirm the main-thread VM box is never dealloc'd, so the late concurrent_tasks.push + wakeup after has_terminated = true are sound as claimed.

Extended reasoning...

The two inline findings are both non-blocking test-hygiene nits. The runtime change itself is a one-line narrowing of a cfg!(debug_assertions)-only panic — release builds are unaffected. I verified the added vm.is_main_thread() call reads self.worker.is_none(), a field set once at VM construction and never mutated, so calling it from the thread-pool completion path introduces no new data race beyond the pre-existing non-atomic has_terminated read. I also read VirtualMachine::global_exit() end-to-end and confirmed it calls self.destroy() then bun_core::Global::exit() without freeing the VM box, matching the PR's justification that the main-thread push lands in a never-drained-but-still-valid queue. Deferring rather than approving because relaxing a cross-thread teardown assert on a lifetime-invariant claim is worth a maintainer's eyes.

Comment thread test/js/node/fs/fs-write-exit-race.test.ts Outdated
Comment thread test/js/node/fs/fs-write-exit-race.test.ts Outdated
Non-ASAN Linux builds take the quick_exit() branch of Global::exit(), which
skips __cxa_atexit handlers, so the atexit-driven reader close never runs
there and the test passes vacuously. Gate on isASAN so the skip condition
matches the only builds where both the mechanism and the debug assert exist.

Replace the fixed 50ms setTimeout with a bounded readSync poll on the
O_NONBLOCK reader: the first byte visible on rd proves the work-pool thread
has entered the kernel write() and is blocked.
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
@robobun
robobun force-pushed the farm/b37fdd39/enqueue-task-concurrent-main-vm-exit branch from 4effc50 to 49f1c50 Compare July 26, 2026 21:58
Comment thread test/js/node/fs/fs-write-exit-race.test.ts
…iter bytes

The bounded readSync poll used to fall through silently on deadline expiry
and emit the same "alive" output as the success path, so the parent could
not tell whether the race was actually armed. Track a sawBytes flag and
exit 1 with a stderr message on fallthrough instead; the parent assertion
already checks stderr/exitCode.
Comment thread test/js/node/fs/fs-write-exit-race.test.ts
TinyCC ships only builtin headers, so a system #include may fail to resolve
on some runners and trip its longjmp error path (which conflicts with ASAN).
Match the existing __cxa_atexit forward-decl so the helper compiles with no
system headers.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all four rounds of prior feedback have been addressed in be3956b, 80e7d7e, 49f1c50, and 332829f.

What was reviewed:

  • The assert narrowing is sound: destroy() doesn't touch concurrent_tasks or the uws loop, and global_exit() never dealloc's the main-thread VM box, so the late push+wakeup are benign; the worker path (raw-dealloc'd in WebWorker::shutdown) keeps the guard.
  • is_main_thread() is self.worker.is_none() — no side effects, safe to call from a thread-pool thread against the terminated-but-static main VM.
  • Test now gates on isASAN, polls readSync for the observable writer-in-kernel signal, fails loudly on deadline expiry, and forward-declares close/usleep so TinyCC never hits its longjmp error path under ASAN.
Extended reasoning...

Overview

Two-file change: a 3-line narrowing of the cfg!(debug_assertions)-only guard in EventLoop::enqueue_task_concurrent (src/jsc/event_loop.rs) so it only panics for terminated worker VMs, plus a new Linux+ASAN regression test (test/js/node/fs/fs-write-exit-race.test.ts) that deterministically reproduces the CI flake from build 82810.

Security risks

None. The change only affects a debug-build assertion; release builds already compile the guard out entirely and take the same push + wakeup path being permitted here. The test spawns a hermetic child in a tempDir with no network or elevated-privilege interaction.

Level of scrutiny

Low-to-medium. VM shutdown ordering is subtle, but the change surface is minimal: it turns a debug false-positive into a no-op on the one path (global_exitdestroy → atexit chain, main-thread VM only) where the pushed task is provably harmless — the VM box is process-static, concurrent_tasks is left intact by destroy(), and the queue is simply never drained again. I traced is_main_thread() (= worker.is_none()), has_terminated (set at the tail of destroy()), and global_exit() to confirm the PR description's claims. The worker-VM case, which #34154 addresses, retains the guard.

Other factors

This PR has gone through four rounds of my own review feedback, each addressed promptly:

  1. ASAN-only reachability of __cxa_atexit → gated on !isASAN (be3956b)
  2. setTimeout(50) → replaced with bounded readSync poll (be3956b)
  3. Silent poll-deadline fallthrough → sawBytes flag + stderr + exit(1) (80e7d7e)
  4. <unistd.h> under TinyCC+ASAN → forward-declared close/usleep (332829f)

The comment-cop bot's length complaint was also addressed (49f1c50). No bugs were surfaced by the bug-hunting system on the current revision. The test's remaining reliance on cc() in an ASAN subprocess matches the pattern of test/regression/issue/26249 and 20144, which run green on linux-x64-asan; with system headers removed, the residual TinyCC/ASAN risk I flagged is eliminated.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all four rounds of feedback have been addressed.

What was reviewed:

  • The runtime change narrows a cfg!(debug_assertions)-only guard; release behavior is unchanged, and is_main_thread() reads self.worker (set once at VM init), so no new cross-thread hazard is introduced beyond the pre-existing has_terminated read.
  • Test now gates on isASAN, polls for the writer-in-kernel signal with a loud deadline failure, and forward-declares close/usleep so tinycc never hits its longjmp error path.
  • Checked that helper.c uses no system headers and that the cc()-in-subprocess-under-ASAN pattern matches the two existing ungated precedents (issue 20144 / 26249).
Extended reasoning...

Overview

Two files: a 3-line change to src/jsc/event_loop.rs narrowing the debug-only has_terminated panic in enqueue_task_concurrent to worker VMs only, and a new Linux+ASAN regression test at test/js/node/fs/fs-write-exit-race.test.ts that deterministically reproduces the late work-pool completion racing process.exit().

Security risks

None. The change relaxes a cfg!(debug_assertions) assertion; release builds already push into the concurrent queue unconditionally on this path. The main-thread VirtualMachine box is process-static (global_exit never deallocs it) and destroy() leaves both the MPSC queue and uws loop intact, so the late push+wakeup are sound. The worker-VM guard — the case that IS UAF-adjacent because WebWorker::shutdown raw-deallocs the box — remains in place.

Level of scrutiny

Low for the runtime change: it only affects debug builds, the reasoning is documented, and is_main_thread() (self.worker.is_none()) reads init-time-immutable state so there's no new cross-thread read hazard. Medium for the test: it's an elaborate fixture (FIFO + tinycc __cxa_atexit handler) that only runs on the linux-x64-asan lane, but it has been through four review iterations — isASAN gating (be3956b), poll-instead-of-sleep (be3956b), loud deadline failure (80e7d7e), and forward-declared libc symbols to avoid TinyCC's longjmp under ASAN (332829f) — and now follows harness conventions.

Other factors

The author's evidence marker notes the test couldn't be run locally (platform-specific), so CI is the arbiter; if the fixture misbehaves on the linux-x64-asan lane it will fail loudly (the parent asserts an exact {stdout, stderr, exitCode} object), not vacuously. All prior inline comments are marked resolved and the comment-cop bot is satisfied after the comment was reduced to one line (49f1c50).

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff itself is green.

The new test (test/js/node/fs/fs-write-exit-race.test.ts) passed on debian-13-x64-asan in both #82940 and the retrigger #83019, which is the only lane where it runs (Linux + ASAN gate). All GitHub Actions checks (format, clippy, lint, comment-cop) pass.

The remaining red is unrelated flake that passed on retry in both runs: complex-workspace.test.ts, webview-chrome.test.ts, filter-workspace.test.ts / no-orphans.test.ts / require-cache.test.ts / fetch-leak.test.ts, expo.test.ts, plus a darwin-14-aarch64-test-bun agent expiry in #82940. None of those touch the event loop or node:fs, and the src/ change is inside cfg!(debug_assertions) so it compiles out of release builds entirely.

Ready for maintainer review.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant