Skip to content

inspector: drain queued frontend messages to the debugger thread before exit - #36524

Open
robobun wants to merge 7 commits into
mainfrom
farm/f06b030b/inspector-drain-on-exit
Open

inspector: drain queued frontend messages to the debugger thread before exit#36524
robobun wants to merge 7 commits into
mainfrom
farm/f06b030b/inspector-drain-on-exit

Conversation

@robobun

@robobun robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Adopts #34219 by @hughescr (a Rust port of the closed #29307). Fixes #34218.

Problem

When a bun test run's last tests are synchronous, the main thread can queue their final TestReporter.start/TestReporter.end events for the detached debugger thread to deliver, print the run summary, and fall straight through to exit() before that thread's already-posted delivery task runs. The events were produced (the tests ran and passed) but never reach the frontend. There is no error and no way to detect the loss from the frontend side, so IDE test UIs silently miss the final results.

Cause

sendMessageToDebuggerThread in BunDebugger.cpp posts a task to the debugger thread's ScriptExecutionContext to invoke onMessageFn (which writes to the frontend socket). VirtualMachine::global_exit has no step that waits for that posted task before exit() tears the debugger thread down mid-delivery.

Fix

Add Bun__debugger__drain(), called from global_exit immediately before teardown when a debugger is attached. A new totalPendingDebuggerMessages atomic counts messages handed off but not yet passed to onMessageFn. If any are pending, drain posts a FIFO sentinel task to the same debugger-thread context and waits (capped at 250ms) for it to run. Concurrent tasks on that context run FIFO, so once the sentinel has run, every receiveMessagesOnDebuggerThread invocation posted before drain was called has already invoked write() for every message it was holding. The wait is capped so a wedged debugger thread cannot block process exit indefinitely; on timeout this degrades to pre-fix behavior for whatever hadn't been delivered yet. When nothing is pending the drain is a no-op, so there is no added exit latency on the common path.

receiveMessagesOnDebuggerThread now also declares a TopExceptionScope around the JSC::call into onMessageFn: on a thrown exception the batch is conservatively treated as undelivered (count left pending, at worst one extra capped wait at exit), and a non-termination exception is reported via reportUncaughtExceptionAtEventLoop so it isn't swallowed.

This covers graceful main-process exits only (every global_exit() call site: CLI run/test/repl, process.exit(), bake production). A process torn down by a signal or abort() does not run global_exit() and is unchanged.

Difference from #34219

The original PR additionally included a second "layer (b)" bounded sleep (150ms, gated on a sticky hasQueuedAnyDebuggerMessage flag) intended to give the debugger thread's event loop time to flush socket-layer send buffers. That sleep is dropped here: the gate is set on the first protocol response and never clears, so it was effectively an unconditional +150ms on every inspected process exit with no completion condition. The FIFO sentinel alone is sufficient for #34218 (test passes 5/5 without it), and WebSocket-level backpressure flushing is a separate concern tracked in #34220 which fixes it at the writer rather than by sleeping at exit.

Verification

New test in test/cli/inspect/test-reporter.test.ts ("flushes pending inspector messages to the frontend before process exit") spawns a batch of bun test --inspect-wait=unix:... subprocesses in parallel, each running trivial synchronous tests with a normally-reading inspector client attached. Parallel spawns supply the CPU contention needed to starve the debugger thread and make the race bite reliably. The test asserts every subprocess's client received found, start, and end for every test (all pass, exit 0). Synchronization waits on both the subprocess exited promise and the Unix socket's own FIN (data-ordered), not a fixed sleep.

Fail-before (3/3 runs, varying scheduler outcomes as expected of a race):

$ USE_SYSTEM_BUN=1 bun test test/cli/inspect/test-reporter.test.ts -t "flushes pending"
error: expect(received).toBe(expected)
Expected: 5
Received: 1
(fail) TestReporter inspector protocol > flushes pending inspector messages to the frontend before process exit
# subsequent runs: Received: 3, Received: 0

Pass-after (5/5 runs):

$ bun bd test test/cli/inspect/test-reporter.test.ts -t "flushes pending"
(pass) TestReporter inspector protocol > flushes pending inspector messages to the frontend before process exit

Why this race does not reproduce under ASAN debug builds

Global::exit() in src/bun_core/Global.rs calls quick_exit() on Linux, which terminates all threads immediately without running atexit handlers. Under ASAN (cfg(bun_asan), which is the default for bun bd on Linux) it calls libc_exit() instead so ASAN's atexit reporting can run. During libc_exit()'s atexit-handler phase the debugger thread is still schedulable and delivers its queued task, so the loss never manifests. This is a compile-time branch (build_options::ENABLE_ASAN), not a runtime toggle.

This means the standard fail-before proof (bun bd test without the src/ change) cannot observe the failure: an ASAN debug subprocess always reaches libc_exit() and the debugger thread catches up. The race is provable under any non-ASAN build:

# release (quick_exit): 3/3 fail without fix
USE_SYSTEM_BUN=1 bun test test/cli/inspect/test-reporter.test.ts -t "flushes pending"

# debug-no-asan (quick_exit): fails without fix, 5/5 pass with fix
bun run build --asan=off test test/cli/inspect/test-reporter.test.ts -t "flushes pending"

A 32-wide probe against an unmodified ASAN debug subprocess showed 0/32 loss; the same probe against release showed 19/32 loss. The test still provides real coverage on release CI lanes, where quick_exit() is the exit path that users actually hit.

Full test/cli/inspect/test-reporter.test.ts is green with the fix under both ASAN and non-ASAN debug.


[review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/inspect/test-reporter.test.ts
bun test v1.4.0 (b1567b6c8)

test/cli/inspect/test-reporter.test.ts:
(pass) TestReporter inspector protocol > retroactively reports tests when TestReporter.enable is called after tests are discovered [1642.17ms]
(pass) TestReporter inspector protocol > flushes pending inspector messages to the frontend before process exit [635.50ms]

 2 pass
 0 fail
 31 expect() calls
Ran 2 tests across 1 file. [4.55s]
__F:0:S:0

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/cli/inspect/test-reporter.test.ts:
(pass) TestReporter inspector protocol > retroactively reports tests when TestReporter.enable is called after tests are discovered [58.87ms]
414 |       }
415 | 
416 |       for (const { stderr, exitCode, found, started, ended } of results) {
417 |         expect(stderr).toContain(`${testCount} pass`);
418 |         // Zero trailing loss at any of the three reporting stages.
419 |         expect(found.size).toBe(testCount);
                                 ^
error: expect(received).toBe(expected)

Expected: 5
Received: 0

      at <anonymous> (/workspace/bun/test/cli/inspect/test-reporter.test.ts:419:28)
(fail) TestReporter inspector protocol > flushes pending inspector messages to the frontend before process exit [50.66ms]

 1 pass
 1 fail
 45 expect() calls
Ran 2 tests across 1 file. [304.00ms]
__F:1:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/inspect/test-reporter.test.ts
bun test v1.4.0 (b1567b6c8)

test/cli/inspect/test-reporter.test.ts:
(pass) TestReporter inspector protocol > retroactively reports tests when TestReporter.enable is called after tests are discovered [1633.32ms]
(pass) TestReporter inspector protocol > flushes pending inspector messages to the frontend before process exit [640.11ms]

 2 pass
 0 fail
 31 expect() calls
Ran 2 tests across 1 file. [4.37s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 722ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/1141] cc obj/vendor/zlib/arch/x86/adler32_avx2.c.o
[2/1141] cc obj/vendor/zstd/lib/common/error_private.c.o
[3/1141] cc obj/vendor/zlib/arch/x86/adler32_avx512.c.o
[4/1141] cc obj/vendor/zstd/lib/common/debug.c.o
[5/1141] cc obj/vendor/zstd/lib/common/threading.c.o
[6/1141] cc obj/vendor/zstd/lib/common/pool.c.o
[7/1141] cc obj/vendor/zstd/lib/common/zstd_common.c.o
[8/1141] cc obj/vendor/zstd/lib/common/entropy_common.c.o
[9/1141] cc obj/vendor/zstd/lib/common/fse_decompress.c.o
[10/1141] cc obj/vendor/zstd/lib/common/xxhash.c.o
[11/1141] cc obj/vendor/zstd/lib/compress/fse_compress.c.o
[12/1141] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[13/1141] cc obj/vendor/zstd/lib/compress/hist.c.o
[14/1141] cc obj/vendor/zlib/arch/x86/adler32_avx512_vnni.c.o
[15/1141] cc obj/vendor/zstd/lib/compress/zstd_compress_literals.c.o
[16/1141] gen cpp.rs (cppbind)
[17/1141] cc obj/vendor/zlib/arch/x86/compare256_avx512.c.o
[18/1
... (truncated)
diff hotspot
src/jsc/Debugger.rs                    |   6 ++
 src/jsc/VirtualMachine.rs              |   5 ++
 src/jsc/bindings/BunDebugger.cpp       |  57 +++++++++++++++-
 test/cli/inspect/test-reporter.test.ts | 118 ++++++++++++++++++++++++++++++++-
 4 files changed, 183 insertions(+), 3 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                    reads  edits  tests
src/jsc/Debugger.rs                         4      4      0
src/jsc/VirtualMachine.rs                   5      2      0
src/jsc/bindings/BunDebugger.cpp            8     14      0
test/cli/inspect/test-reporter.test.ts      7     12      0

…re exit

When the last tests in a `bun test` run are synchronous, the main thread can
queue their final TestReporter start/end events for the detached debugger
thread, print the run summary, and fall straight through to exit() before
that thread's already-posted delivery task runs. The events were produced
but never reach the frontend, and there is no error and no way to detect the
loss from the frontend side.

Root cause: `sendMessageToDebuggerThread` posts a task to the debugger
thread's ScriptExecutionContext to invoke onMessageFn. Nothing in
`VirtualMachine::global_exit` waits for that posted task before exit()
tears the thread down.

Fix: add `Bun__debugger__drain()`, called from `global_exit` when a
debugger is attached. A new `totalPendingDebuggerMessages` atomic counts
messages handed off but not yet passed to onMessageFn; if any are pending,
drain posts a FIFO sentinel task to the same context and waits (capped at
250ms) for it to run. Tasks posted to that context run FIFO, so once the
sentinel has run, every message queued before drain was called has already
had write() invoked for it. A second shorter wait (150ms, gated on
`hasQueuedAnyDebuggerMessage`) gives the debugger thread's own event loop
time to flush anything already handed to the socket layer's send buffer.
Both waits are capped so a wedged debugger thread or non-reading frontend
cannot block exit indefinitely; on timeout this degrades to pre-fix
behavior for whatever hadn't been delivered yet.

Adopts #34219 by @hughescr (itself a Rust port of the closed #29307).

Fixes #34218.

Co-authored-by: Craig R. Hughes <craig.git@rungie.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 23 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f8e9d9d8-6b88-4e9b-933c-7c6e0e59d95b

📥 Commits

Reviewing files that changed from the base of the PR and between f426a8e and d49c779.

📒 Files selected for processing (4)
  • src/jsc/Debugger.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/BunDebugger.cpp
  • test/cli/inspect/test-reporter.test.ts

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

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is ready. Self-review findings addressed (layer (b) 150ms sleep dropped in 268d010; FIFO sentinel alone suffices). test/cli/inspect/test-reporter.test.ts is green on all CI lanes across three builds (#86108, #86127, and #93725 after merging main in d49c779); remaining CI reds are unrelated flakes (hot.test, inspect-error-leak, sourcetextmodule-leak, cluster-shared-leak, etc.) that also appear on main.

Fail-before note: the race only reproduces on non-ASAN builds (Linux quick_exit() path). ASAN builds call libc_exit() whose atexit phase gives the debugger thread time to deliver, so bun bd test (ASAN by default on Linux) cannot observe the failure without the fix. Verified fail-before under USE_SYSTEM_BUN=1 (3/3) and bun run build --asan=off (2/3), and pass-after under both (5/5). Full analysis in the PR body under "Why this race does not reproduce under ASAN debug builds".

Reproduced with:

USE_SYSTEM_BUN=1 bun test test/cli/inspect/test-reporter.test.ts -t "flushes pending"

Comment thread src/jsc/Debugger.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
@github-actions

Copy link
Copy Markdown
Contributor

The detailed rationale lives in the PR body; in-code comments are reduced to
the minimum needed to understand the concurrency design at each site.
Comment thread src/jsc/Debugger.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
The detailed two-layer design rationale (FIFO sentinel ordering guarantee,
separate sticky gate flag, capped waits) lives in the PR body.

@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 finding, I also checked: DebuggerDrainSignal is captured by Ref in the posted lambda, so a layer-(a) timeout on the main thread doesn't leave the debugger-thread task with a dangling semaphore; and the test's connect(url, onClose)junit-reporter.ts does wire the second arg to the socket close handler, so await socketClosed.promise won't hang.

Extended reasoning...

This PR adds cross-thread synchronization (atomics, semaphores, posted tasks between the main VM thread and the detached debugger thread) inside VirtualMachine::global_exit. That is squarely in the highest-scrutiny category per REVIEW.md (thread affinity, exit-path completeness), and the inline finding about layer (b) being an unconditional 150ms sleep on every inspected exit is a real design concern a maintainer should weigh — so this is not auto-approvable. The two items in the message are the concrete side-checks I ran and ruled out so a later pass need not re-derive them.

Comment thread src/jsc/bindings/BunDebugger.cpp Outdated
The sticky hasQueuedAnyDebuggerMessage gate is set on the first protocol
response and never clears, so every inspected process exit paid a fixed
+150ms with no completion condition. The FIFO sentinel alone (which wakes
as soon as the debugger thread has called write() for every queued message)
is sufficient for the bug in #34218; WebSocket-level backpressure flushing
is a separate concern tracked in #34220. Test passes 5/5 with the sentinel
alone.

@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.

My earlier 150ms flush-grace concern was addressed in 268d010Bun__debugger__drain is now a single signaled FIFO-sentinel wait that short-circuits when totalPendingDebuggerMessages == 0. Beyond the description nit, I also checked that DebuggerDrainSignal is ref-counted so a timed-out wait can't leave the already-posted task holding a dangling semaphore, and that the pending-count increment sits inside debuggerThreadMessagesLock so the debugger thread's swap+decrement can't undercount it.

Extended reasoning...

The author addressed my previous review by removing the layer-(b) 150ms sleep entirely (commit 268d010). The remaining drain path is a bounded, signaled wait: post a FIFO sentinel to the debugger thread's context, waitFor(250ms) on a ref-counted BinarySemaphore, and only enter at all when the atomic pending-message counter is nonzero. I verified the ref-counted DebuggerDrainSignal avoids the dangling-pointer-on-timeout hazard, that the atomic increment is under the same lock as the message append (so swap+decrement in receiveMessagesOnDebuggerThread cannot observe an unaccounted message), and that the test's connect(url, onClose) close-callback wiring matches junit-reporter.ts's signature. Not approving: this is cross-thread synchronization on the global_exit teardown path and the FIFO ordering of postTaskConcurrently is load-bearing for correctness — worth a human look even though I found no defect beyond the stale description.

Comment thread src/jsc/bindings/BunDebugger.cpp

@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.

I didn't find any bugs, but this touches cross-thread synchronization on the process-exit path and drops the socket-flush layer from #34219 in favor of deferring to #34220 — worth a maintainer confirming that trade-off.

What was reviewed:

  • totalPendingDebuggerMessages counter balance across send / receive / disconnect / exception paths — all covered; exception path leaves count pending which is bounded to one 250ms wait.
  • DebuggerDrainSignal lifetime on timeout — ref-counted, lambda holds a Ref, so no dangling pointer if waitFor returns first.
  • Test synchronization — awaits both proc.exited and the Unix socket FIN (data-ordered), afterEach SIGKILLs stragglers, connect() does accept the onClose callback used here.
Extended reasoning...

Overview

The PR adds Bun__debugger__drain() (BunDebugger.cpp) and calls it from VirtualMachine::global_exit (VirtualMachine.rs, via a thin wrapper in Debugger.rs) so that inspector protocol messages queued for the detached debugger thread are delivered before exit() tears that thread down. A process-global atomic totalPendingDebuggerMessages counts messages handed off but not yet passed to onMessageFn; when nonzero, drain posts a FIFO sentinel task to the debugger thread's ScriptExecutionContext and waits up to 250ms for it to run. receiveMessagesOnDebuggerThread gains a TopExceptionScope so a throwing onMessageFn is reported rather than swallowed and the batch is conservatively left counted. A new regression test in test-reporter.test.ts spawns 4-16 --inspect-wait=unix: subprocesses in parallel and asserts every found/start/end event arrives.

Security risks

None identified. This is inspector-thread lifecycle plumbing; no user-controlled input reaches the new code beyond what already flowed through sendMessageToDebuggerThread.

Level of scrutiny

High. global_exit runs on every graceful process exit, and the new code sits on a cross-thread path (main → debugger thread) with atomics, a semaphore, and a FIFO-ordering assumption on postTaskConcurrently. The change also encodes a design decision — layer (b) from the adopted #34219 (a 150ms socket-flush grace) was dropped after my earlier review flagged it as an unconditional per-exit sleep, with the rationale that socket-level backpressure is #34220's concern. That is reasonable, but a maintainer should confirm the FIFO-sentinel-only design is acceptable for the intermediate state.

Other factors

Prior review feedback (the unconditional 150ms sleep, and the stale PR description) has been addressed in 268d010 and the description update. The comment-cop bot flags on verbose comments were resolved by trimming to single lines. The counter accounting looks balanced on every path I traced (increment inside debuggerThreadMessagesLock; decrement on the disconnected early-return and on successful JSC::call; deliberately left pending on throw). The DebuggerDrainSignal is ThreadSafeRefCounted and the posted lambda captures a Ref, so a timed-out wait cannot leave a dangling pointer for the already-posted task. The test correctly branches width/rounds/timeout on isASAN || isDebug, uses stdout: "ignore" to avoid pipe deadlock, and cleans up spawned processes in afterEach. Given the concurrency subtlety and the exit-path placement, a human familiar with the debugger threading model should sign off.

…or-drain-on-exit

# Conflicts:
#	test/cli/inspect/test-reporter.test.ts
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:50 PM PT - Aug 12th, 2026

@robobun, your commit d49c779 has some failures in Build #93725 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 36524

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

bun-36524 --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.

I reviewed this PR and found no bugs in the current revision (prior findings — the unconditional 150ms sleep and the stale description — were both addressed). Because it adds cross-thread synchronization on the process exit path (atomic pending-message accounting, a ref-counted semaphore sentinel posted to the debugger thread's task queue, and a new TopExceptionScope around onMessageFn), a human look at the design would still be worthwhile.

What was reviewed:

  • Counter accounting: fetch_add is inside debuggerThreadMessagesLock so the swap+decrement in receiveMessagesOnDebuggerThread can't undercount; the disconnected-early-return decrements the dropped batch.
  • DebuggerDrainSignal is ThreadSafeRefCounted and the posted lambda captures a Ref, so a timed-out main-thread wait can't leave the task holding a dangling semaphore.
  • Placement in global_exit is before teardown/Global::exit, gated on self.debugger.is_some(), so the common no-debugger path adds no latency.
  • The sentinel and delivery tasks both go through postTaskConcurrentlyBun__VmHandle__queueTaskConcurrently, so the FIFO-ordering claim rests on that queue's ordering.
Extended reasoning...

Overview

This PR fixes #34218: when bun test finishes with synchronous tests, the main thread can queue the final TestReporter events for the detached debugger thread and then call exit() before that thread's delivery task runs, so IDE frontends silently miss the last results. The fix adds Bun__debugger__drain() (BunDebugger.cpp), called from VirtualMachine::global_exit (VirtualMachine.rs) via a thin Rust wrapper (Debugger.rs). A process-global atomic totalPendingDebuggerMessages counts messages handed off but not yet passed to onMessageFn; when non-zero, drain posts a FIFO sentinel task to the debugger thread's ScriptExecutionContext and waits up to 250ms for it. receiveMessagesOnDebuggerThread also gains a TopExceptionScope so a throwing onMessageFn is reported and the batch conservatively left pending. A new regression test in test-reporter.test.ts spawns parallel --inspect-wait=unix: subprocesses to reproduce the race.

Security risks

None identified. The change is confined to the debugger/inspector plumbing and the process-exit path; it does not touch parsing of untrusted input, auth, crypto, or file/network permissions. The 250ms wait is bounded, so a wedged debugger thread cannot indefinitely delay exit.

Level of scrutiny

Medium-high. This is concurrency code on the exit path: an atomic counter shared across two threads with acquire/release ordering, a ref-counted semaphore captured by a cross-thread lambda, and a new exception scope around a JSC::call. The failure modes (undercount → drain skipped, overcount → 250ms extra wait, dangling semaphore on timeout, exception left pending on the debugger VM) are all subtle. The design choices — FIFO-sentinel vs. per-connection drain, 250ms cap, process-global counter rather than per-connection — are reasonable but worth a maintainer's eye. It is not a simple mechanical change.

Other factors

Two prior review rounds by this bot already ran on earlier revisions. The first flagged the original "layer (b)" — a stack-local BinarySemaphore gated on a sticky flag amounting to an unconditional +150ms on every inspected exit — which was removed in 268d010. The second flagged a stale PR description, since updated. All comment-cop inline threads are resolved. The PR description is unusually thorough (including why the race doesn't reproduce under ASAN debug builds due to libc_exit vs quick_exit), and the test has documented fail-before/pass-after evidence. The bug hunting system found no issues on the current revision. Given the concurrency subtlety and exit-path placement, deferring for a human sign-off rather than auto-approving.

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.

TestReporter inspector events are silently, permanently lost if the process exits before the debugger thread finishes delivering them

1 participant