Skip to content

Wait for off-thread jobs before freeing a terminated worker's VM - #36983

Closed
robobun wants to merge 14 commits into
mainfrom
farm/aa76752d/worker-offthread-teardown-fence
Closed

Wait for off-thread jobs before freeing a terminated worker's VM#36983
robobun wants to merge 14 commits into
mainfrom
farm/aa76752d/worker-offthread-teardown-fence

Conversation

@robobun

@robobun robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

worker.terminate() (and the other three teardown doors: process.exit() in the worker, an uncaught throw, an unhandled rejection) frees the worker's VirtualMachine box, its EventLoop, the uws loop, and the JSC heap while jobs that VM handed to other threads are still running. Every such job holds raw pointers back into that memory: WorkPool bodies post completions with enqueue_task_concurrent, the HTTP thread's fetch/S3 callbacks read javascript_vm, napi execute callbacks write into ArrayBuffer stores, the bundler thread reads the VM's env loader. Natural exit is safe (each job's KeepAlive holds the event loop open), but WebWorker::spin breaks out of that loop immediately on terminate and shutdown() never re-checked, so teardown raced every in-flight job.

Repro (any of the families below; aborts under ASAN with heap-use-after-free in the pool thread's completion post):

const { Worker } = require("node:worker_threads");
const src = `
  const { parentPort } = require("node:worker_threads");
  const { pbkdf2 } = require("node:crypto");
  for (let i = 0; i < 3; i++)
    (async () => { for (;;) await new Promise(r => pbkdf2("pw", "salt", 150000, 64, "sha512", r)); })();
  parentPort.postMessage("up");
`;
const w = new Worker(src, { eval: true });
w.once("message", () => w.terminate());

The fix makes teardown wait for those jobs:

  • EventLoop.outstanding_offthread: a counter of off-thread jobs whose body can still touch VM-owned memory. Every schedule site that pairs a KeepAlive::ref_ with a WorkPool/HTTP-thread/bundler-thread handoff takes a count; the off-thread body releases it through a local pointer copy after its last VM access (usually right after the completion enqueue).
  • WebWorker::shutdown gains a fence between markShuttingDown and the queued-task drain: set a per-loop cancel flag (cancel-aware pool bodies skip their compute), run the new per-VM cancel-hook registry (in-flight fetch aborts; S3 requests get schedule_shutdown_by_id), then wait for the counter to reach zero. Completions posted during the wait are reclaimed unrun by the existing drain, while JSC is still alive.
  • If the wait exceeds 10s (a blocked filesystem op, an addon execute that never returns, a bundler plugin round-trip the dead JS thread can never answer), the VM, its loops, the JSC heap, and the cloned env loader are leaked instead of freed: a bounded leak on a pathological terminate instead of a use-after-free.
  • New drain arms release the completions that now reliably reach the queue at teardown (AnyTaskJob, ConcurrentPromiseTask types, password results, S3 tasks, bundler completion, napi async work, async zlib) without running JS.

Bracketed families: WorkTask (blob read/write, web CompressionStream, dns), AnyTaskJob (pbkdf2, scrypt, randomFill, the C++ keygen/sign jobs, Bun.zstd*, secrets), ConcurrentPromiseTask (glob scan, transpiler, image, blob copy), AsyncFSTask (the 42 async fs ops), AsyncReaddirRecursiveTask, AsyncCpTask, ConcurrentCppTask (webcrypto, node:sqlite async), PasswordJob, shell builtins' ShellTask, async node:zlib/brotli/zstd, napi_async_work, fs.watchFile's initial stat, FetchTasklet, S3 simple + streaming, JSBundleCompletionTask.

Also fixed on the fetch path: the HTTP thread's last-ref reclaim used to park the tasklet box for the process-exit drain even for worker VMs, whose JSC handles are long gone by then; worker tasklets now leak the box instead (the buffers were already released).

Known limits, called out in comments: the Windows-only libuv flows (UVFSRequest for open/read/write/close, WriteFileWindows, CopyFileWindows) are separate machinery, scheduled on the worker's own uv loop rather than the WorkPool, and not bracketed here; a bundler build waiting on a JS plugin response stalls to the deadline and leaks (the plugin protocol has no cancellation); WorkTask context payloads queued at teardown are still requeued rather than freed (status quo).

Verification: a new ASAN-gated test file test/js/web/workers/worker-terminate-offthread.test.ts with a door x family matrix (12 off-thread families; terminate/exit/throw doors) plus the four-door fetch matrix, and the async zlib test in test/js/node/zlib/zlib-worker-terminate.test.ts. On an unfixed build the matrix reports ASAN heap-use-after-free in EventLoop::vm_ref / VirtualMachine::event_loop_shared across the families (10 hits in one run); with the fix 20/20 pass. The tests tolerate exactly one known unrelated failure signature: debug builds can still trip JSC's ExceptionScope::assertNoException when terminate lands mid-dispatch, which reproduces on unfixed main before teardown even starts and is tracked separately; any sanitizer report still fails the test.

Supersedes #35155 and #36575, and generalizes the counter from #36855 (napi addon test coverage stays with that PR; its mechanism is included here). #36818 (fs write buffer pin) and #35161 (dns same-thread teardown) remain separate.


[review] gate passed · iteration 2 · 27 files touched

fails on main (without fix)
ASAN without fix: 11 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/zlib/zlib-worker-terminate.test.ts test/js/web/workers/worker-terminate-offthread.test.ts
bun test v1.4.0 (2cf2b799e)

test/js/web/workers/worker-terminate-offthread.test.ts:
(pass) worker teardown with off-thread jobs in flight does not UAF > crypto.pbkdf2 (AnyTaskJob), uncaught throw [7812.07ms]
(pass) worker teardown with off-thread jobs in flight does not UAF > crypto.pbkdf2 (AnyTaskJob), terminate() [8453.92ms]
232 |             stdout: "pipe",
233 |             stderr: "pipe",
234 |           });
235 |           const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
236 |           // Any sanitizer report is the bug this file exists to catch.
237 |           expect(stderr).not.toContain("AddressSanitizer");
                                   ^
error: expect(received).not.toContain(expected)

Expected to not contain: "AddressSanitizer"
Received: "=================================================================\n==337058==ERROR: AddressSanitizer: heap-use-after-fre
... (truncated)

release without fix: 27 skipped
bun test v1.4.0-canary.1 (2cf2b799e)

test/js/web/workers/worker-terminate-offthread.test.ts:
(skip) worker teardown with off-thread jobs in flight does not UAF > crypto.pbkdf2 (AnyTaskJob), terminate()
(skip) worker teardown with off-thread jobs in flight does not UAF > crypto.pbkdf2 (AnyTaskJob), process.exit()
(skip) worker teardown with off-thread jobs in flight does not UAF > crypto.pbkdf2 (AnyTaskJob), uncaught throw
(skip) worker teardown with off-thread jobs in flight does not UAF > crypto.scrypt (AnyTaskJob), terminate()
(skip) worker teardown with off-thread jobs in flight does not UAF > Bun.password.hash (PasswordJob), terminate()
(skip) worker teardown with off-thread jobs in flight does not UAF > Bun.zstdCompress (AnyTaskJob), terminate()
(skip) worker teardown with off-thread jobs in flight does not UAF > zlib.brotliCompress q11 high-entropy (NativeBrotli), terminate()
(skip) worker teardown with off-thread jobs in flight does not UAF > Bun.Glob.scan (ConcurrentPromiseTask), terminate()
(skip) worker teardown with off-thread jobs in flight does not UAF > Bun.Transpiler.transform (ConcurrentPromiseTask), terminate()
(skip) worker teardown with off-threa
... (truncated)
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/js/node/zlib/zlib-worker-terminate.test.ts test/js/web/workers/worker-terminate-offthread.test.ts
bun test v1.4.0 (2cf2b799e)

test/js/web/workers/worker-terminate-offthread.test.ts:
(pass) worker teardown with off-thread jobs in flight does not UAF > crypto.pbkdf2 (AnyTaskJob), terminate() [8101.82ms]
(pass) worker teardown with off-thread jobs in flight does not UAF > crypto.scrypt (AnyTaskJob), terminate() [8387.03ms]
(pass) worker teardown with off-thread jobs in flight does not UAF > crypto.pbkdf2 (AnyTaskJob), process.exit() [8674.53ms]
(pass) worker teardown with off-thread jobs in flight does not UAF > crypto.pbkdf2 (AnyTaskJob), uncaught throw [8815.30ms]
(pass) worker teardown with off-thread jobs in flight does not UAF > Bun.password.hash (PasswordJob), terminate() [9772.85ms]
(pass) worker teardown with off-thread jobs in flight does not UAF > fs.promises read/write (AsyncFSTask), terminate() [2639.19ms]
(pass) worker teardown with off-thread jobs in flight does not UAF > Bun.Glob.scan (ConcurrentPromiseTask), terminat
... (truncated)

release with fix: 27 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 740ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/12] cxx obj/unified/UnifiedSource-src_jsc_bindings_node-0.cpp.o
[2/12] cxx obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o
[3/12] cxx obj/src/jsc/bindings/bindings.cpp.o
[4/12] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[5/12] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[6/12] cxx obj/unified/UnifiedSource-src_uws_sys-0.cpp.o
[7/12] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[8/12] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[8/12] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_event_loop v0.0.0 (/workspace/bun/src/event_loop)
�[1m�[92m   Compiling�[0m bun_options_types v0.0.0 (/workspace/bun/src/options_types)
�[1m�[92m   Compiling�[0m bun_http v0.0.0 (/workspace/bun/src/http)
�[1m�[92m   Compiling�[0m bun_crash_handler v0.
... (truncated)
diff hotspot
src/event_loop/AnyEventLoop.rs                     |  14 +
 src/event_loop/lib.rs                              |   2 +
 src/jsc/ConcurrentPromiseTask.rs                   |  16 +-
 src/jsc/CppTask.rs                                 |   4 +
 src/jsc/RuntimeTranspilerStore.rs                  |   6 +
 src/jsc/VirtualMachine.rs                          |  65 +++-
 src/jsc/WorkTask.rs                                |   4 +
 src/jsc/any_task_job.rs                            |  13 +-
 src/jsc/event_loop.rs                              |  76 ++++-
 src/jsc/web_worker.rs                              |  69 +++-
 src/runtime/api/Archive.rs                         |   9 +-
 src/runtime/api/js_bundle_completion_task.rs       |  11 +-
 src/runtime/crypto/PasswordObject.rs               |  32 +-
 src/runtime/dispatch.rs                            | 173 +++++++++-
 src/runtime/napi/napi_body.rs                      |  43 ++-
 src/runtime/node/node_fs.rs                        | 135 +++++++-
 src/runtime/node/node_fs_stat_watcher.rs           |  13 +
 src/runtime/node/node_zlib_binding.rs              |  58 +++-
 src/runtime/shell/builtin/cp.rs                    |  19 +-
 src/runtime/shell/builtin/rm.rs                    |  16 +
 src/runtime/shell/interpreter.rs                   |  19 ++
 src/runtime/webcore/fetch/FetchTasklet.rs          |  60 +++-
 src/runtime/webcore/s3/client.rs                   |  29 ++
 src/runtime/webcore/s3/download_stream.rs          |  41 ++-
 src/runtime/webcore/s3/simple_request.rs           |  54 ++-
 test/js/node/zlib/zlib-worker-terminate.test.ts    |  90 +++++
 .../web/workers/worker-terminate-offthread.test.ts | 365 +++++++++++++++++++++
 27 files changed, 1366 insertions(+), 70 deletions(-)

gate history · 6 passed · 0 rejected · iteration 2

evidence per changed file
file                                          reads  edits  tests
src/event_loop/AnyEventLoop.rs                    2      3      0
src/event_loop/lib.rs                             1      2      0
src/jsc/ConcurrentPromiseTask.rs                  2      3      0
src/jsc/CppTask.rs                                2      2      0
src/jsc/RuntimeTranspilerStore.rs                 1      1      0
src/jsc/VirtualMachine.rs                         7      7      0
src/jsc/WorkTask.rs                               2      3      0
src/jsc/any_task_job.rs                           2      2      0
src/jsc/event_loop.rs                             5      7      0
src/jsc/web_worker.rs                             6     11      0
src/runtime/api/Archive.rs                        1      1      0
src/runtime/api/js_bundle_completion_task.rs      3      3      0
src/runtime/crypto/PasswordObject.rs              6      9      0
src/runtime/dispatch.rs                           7     10      0
src/runtime/napi/napi_body.rs                     2      3      0
src/runtime/node/node_fs.rs                      11     14      0
(+ 11 more files)

worker.terminate() (and process.exit() / uncaught throw / unhandled
rejection inside a worker) freed the VirtualMachine box, its EventLoop,
the uws loop, and the JSC heap while jobs the VM had handed to other
threads were still running: WorkPool bodies, HTTP-thread fetch/S3
callbacks, napi execute callbacks, the bundler thread. Each of those
holds raw pointers back into that memory and posts its completion with
enqueue_task_concurrent, so teardown raced every in-flight job into a
use-after-free. Natural exit was safe only because each job's KeepAlive
holds the event loop open; the terminate path broke out of the loop
without any equivalent wait.

WebWorker::shutdown now fences: EventLoop tracks outstanding off-thread
jobs (every schedule site that pairs a KeepAlive ref with an off-thread
handoff takes a count; the off-thread body releases it after its last
VM access), shutdown sets a per-loop cancel flag (cancel-aware pool
bodies skip their compute), runs a per-VM cancel-hook fan-out (fetch
aborts, S3 shutdown-by-id), and waits for the count to reach zero
before WebWorker__teardownJSCVM. Completions posted during the wait are
reclaimed unrun by the existing shutdown drain via new per-tag release
arms. If the wait exceeds 10s the VM and everything a straggler can
still reach are leaked instead of freed.

Also: the HTTP thread's last-ref fetch reclaim no longer parks worker
tasklets for the process-exit drain (whose deinit would walk the
worker's freed JSC handles on the main thread); worker tasklets leak
the small box instead.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

The PR adds off-thread event-loop fences, worker-termination cancellation hooks, bounded shutdown draining, shutdown-safe cleanup for asynchronous tasks, and regression tests for active worker operations.

Worker shutdown fencing

Layer / File(s) Summary
Event-loop off-thread fence
src/event_loop/..., src/jsc/event_loop.rs
Event-loop handles expose job begin/end operations. EventLoop tracks active jobs, cancellation, bounded draining, and shutdown-aware task release.
Termination cancellation and worker drain
src/jsc/VirtualMachine.rs, src/jsc/web_worker.rs
VMs register termination hooks. Worker shutdown invokes hooks, waits for jobs, and retains VM-dependent resources after a timeout.
Generic task fencing and completion dispatch
src/jsc/..., src/runtime/api/..., src/runtime/crypto/..., src/runtime/shell/...
Concurrent, work-pool, password, bundle, archive, transpiler, and shell tasks fence event-loop access through scheduling and completion enqueueing.
Async task shutdown cleanup
src/runtime/dispatch.rs, src/runtime/napi/..., src/runtime/node/...
Shutdown paths reclaim queued N-API, compression, filesystem, password, HTTP, S3, and related tasks without running callbacks.
Fetch and S3 request cancellation
src/runtime/webcore/fetch/..., src/runtime/webcore/s3/...
HTTP tasks register cancellation hooks and release event-loop fences after terminal callbacks.
Worker termination regression coverage
test/js/node/zlib/..., test/js/web/workers/...
ASAN-gated tests terminate workers during compression, off-thread tasks, and streaming fetch operations.

Possibly related issues

  • oven-sh/bun#33911: Addresses worker termination races involving in-flight fetch callbacks and VM teardown synchronization.

Possibly related PRs

  • oven-sh/bun#36575: Modifies FetchTasklet and worker shutdown handling for in-flight HTTP callbacks.
  • oven-sh/bun#36855: Modifies event-loop shutdown barriers and napi_async_work lifecycle handling.
  • oven-sh/bun#36817: Modifies AnyTaskJob and worker shutdown handling for asynchronous VM access.
🚥 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 and concisely describes the main change: waiting for off-thread jobs before freeing a terminated worker VM.
Description check ✅ Passed The description explains the fix, affected job families, known limits, and verification results, covering both required template topics despite missing the exact headings.

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

Comment thread src/event_loop/AnyEventLoop.rs Outdated
Comment thread src/jsc/ConcurrentPromiseTask.rs Outdated
Comment thread src/jsc/ConcurrentPromiseTask.rs Outdated
Comment thread src/jsc/CppTask.rs Outdated
Comment thread src/jsc/CppTask.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/WorkTask.rs Outdated
Comment thread src/jsc/WorkTask.rs Outdated
Comment thread src/jsc/any_task_job.rs Outdated
Comment thread src/jsc/any_task_job.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/web_worker.rs Outdated
Comment thread src/jsc/web_worker.rs Outdated
Comment thread src/runtime/node/node_zlib_binding.rs
Comment thread src/jsc/event_loop.rs
Comment thread src/runtime/napi/napi_body.rs
Comment thread test/js/node/zlib/zlib-worker-terminate.test.ts Outdated
…ib test assertions

The shutdown drain no longer frees the napi_async_work box: the addon
owns it (napi_create_async_work hands the handle out and only
napi_delete_async_work may free it, possibly from a cleanup hook or an
experimental-module finalizer), so freeing it risked a double-free. The
box now leaks with the VM, like the addon's data already did.

fs.watchFile's InitialStatTask gets the same off-thread bracket as its
siblings (begin at schedule, end on every exit of run_owned via a
local): the scheduler's shutdown wait only covers its periodic task, and
the watcher is not appended to the scheduler until the initial stat
completes, so an in-flight initial stat could post to a freed VM.

zlib-worker-terminate.test.ts now asserts stderr is empty modulo the one
tolerated terminate() abort, matching worker-terminate-offthread.test.ts,
and runs its subprocess with leak detection off.
Comment thread src/runtime/napi/napi_body.rs
Comment thread src/runtime/node/node_fs_stat_watcher.rs
Comment thread src/runtime/node/node_fs_stat_watcher.rs
Comment thread src/runtime/node/node_fs_stat_watcher.rs
Comment thread src/jsc/event_loop.rs
Comment thread src/runtime/webcore/s3/simple_request.rs
S3 requests carried no abort signal, so AsyncHTTP::init left them with
the sentinel async_http_id 0 and never registered their sockets in the
abort tracker: the terminate cancel hook's schedule_shutdown_by_id
matched nothing. list_objects (a second S3HttpSimpleTask construction
site) also had no fence bracket at all, so a completed s3.list() on a
worker underflowed the off-thread counter and every later terminate rode
the full 10s deadline and leaked the VM.

The simple task now carries a signal store like the streaming task and
passes it to AsyncHTTP::init (real id, abort-tracker registration), both
construction sites bracket the fence and register the cancel hook, and
both hooks additionally set the task's abort signal so a request that
has not started yet fails fast in the HTTP thread's queued-abort scan
instead of connecting to a server the dying worker can no longer accept
on.

Adds a Bun.S3Client.list lane to the terminate matrix, with the worker
template gaining an async wrapper so the lane can complete one list
before the door fires, which makes an unbalanced counter a
deterministic stall the dt guard catches.
Comment thread src/runtime/webcore/s3/client.rs
Comment thread src/runtime/webcore/s3/client.rs
Comment thread src/runtime/webcore/s3/download_stream.rs
Comment thread src/runtime/webcore/s3/simple_request.rs
Comment thread src/runtime/webcore/s3/simple_request.rs
@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

CI state as of build 89345 (2cf2b79): 195 of 196 jobs green. The one red is the linux x64-asan lane failing on test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts, the pre-existing RSA keygen leak described above; it reproduces on main without this branch, is tracked separately, and has been the only hard failure on every build of this PR. Everything else passed, including the worker terminate matrix (27 lanes), the zlib and fetch teardown tests, and the new S3Client.list lane.

The branch is ready for review. The one open question from the latency measurements is whether the 10 s OFFTHREAD_JOB_WAIT_MS deadline stays as is; the numbers in the comment above support keeping it, and it is a one-line change if a different tradeoff is preferred.

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

No new issues found after the earlier fixes (Archive/RuntimeTranspilerStore bracketing, the offthread_drained gate on the S3 streaming drain arm, and the main-thread early-return in the cancel-hook registry). This is a large cross-thread memory-safety change across ~15 off-thread job families plus a new leak-on-timeout policy in WebWorker::shutdown, so it should get a human pass.

What was reviewed:

  • begin/end pairing at every WorkPool::schedule / HTTP-thread / bundler-thread site named in the diff, and that each offthread_job_end goes through a local after the enqueue
  • the drained gating in web_worker.rs — every free past step 3 is skipped on timeout, and release_queued_tasks_for_shutdown requeues the multi-post S3 streaming box when !drained
  • terminate_cancel_hook for S3 touches only signal_store + the captured id, not the HTTP-thread-owned http storage
  • the new release_unrun / release_at_shutdown arms don't run JS and JSC is still live when they run
Extended reasoning...

Overview

This PR adds a barrier (EventLoop::outstanding_offthread) that WebWorker::shutdown waits on before freeing the worker's VM box, EventLoop, uws loop, and JSC heap. Every off-thread schedule site that hands a job to the WorkPool, HTTP thread, or bundler thread now increments the counter at schedule time and decrements it (through a local pointer copy) after the job's last VM access on the off thread. A 10s deadline leaks the VM instead of freeing it if the counter never drains. A per-VM cancel-hook registry lets shutdown abort in-flight fetch/S3 requests to bound the wait. The shutdown drain (__bun_release_task_at_shutdown) gains ~10 new arms to reclaim completions that now reliably reach the queue.

The change spans 27 files: the core mechanism in event_loop.rs / web_worker.rs / VirtualMachine.rs, per-family bracketing in WorkTask, AnyTaskJob, ConcurrentPromiseTask, ConcurrentCppTask, AsyncFSTask, AsyncReaddirRecursiveTask, AsyncCpTask, PasswordJob, ShellTask (+ custom cp/rm schedulers), node_zlib, napi_async_work, fs.watchFile initial stat, FetchTasklet, S3 simple + streaming + list, JSBundleCompletionTask, Archive::AsyncTask, and RuntimeTranspilerStore. Two new ASAN-gated test files exercise a door × family matrix.

Security risks

None in the traditional sense — this is a use-after-free fix. The risk surface is that an unbalanced begin/end pair either stalls every worker terminate to the 10s deadline (then leaks the VM) or, if a family is missed, leaves a UAF window open. The test matrix's dt > 9000 guard catches the former for every covered family; missed families are the residual risk.

Level of scrutiny

High. Per REVIEW.md this is the most-blocked category (native memory safety, cross-thread lifetime, refcount balancing on every terminal path). The change introduces a new invariant that every future off-thread schedule site must uphold, adds a leak-on-timeout policy with a hard-coded 10s constant, and modifies the fetch hot path (cancel-hook register/unregister on every worker fetch). The ShellCpTask / ShellRmTask custom-scheduler bracketing and the rm verbose-post fence in particular are subtle enough to warrant a maintainer's eye on the ordering.

Other factors

Three earlier findings from a prior automated pass were all addressed (Archive + RuntimeTranspilerStore bracketing; offthread_drained gating on the S3 streaming drain arm; main-thread early-return in the hook registry). CodeRabbit's readdir-result-leak and download-stream-race findings were also addressed. CI shows one pre-existing unrelated ASAN leak in AsyncLocalStorage-tracking.test.ts (RSA keygen, reproduces on main). The PR body's known-limits section explicitly names the Windows libuv flows and the bundler-plugin round-trip as out of scope.

@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

An ASAN repro on main hit another face of the worker-teardown door this fence covers, and it lands in the WorkTask family, which the test matrix here does not exercise yet: CompressionStream('brotli') with a write over 128 KiB (the async codec threshold) in flight when terminate() runs. On main the report is a heap-use-after-free in BrotliEncoderCompressStream on the pool thread, freed by the stream cell's CFinalizer under WebWorker__teardownJSCVM -> lastChanceToFinalize.

#37139 takes the coder's own lifetime (the in-flight ctx now holds a reference), which also covers the main-thread BUN_DESTRUCT_VM_ON_EXIT=1 exit path that this fence does not reach. With that change in, the surviving report on the worker repro is exactly this PR's subject, now with a WorkTask stack:

READ of size 8, thread (Bun Pool 2)
    EventLoop::vm_ref src/jsc/event_loop.rs:1044
    EventLoop::enqueue_task_concurrent src/jsc/event_loop.rs:1000
    WorkTask<CompressionAsyncCtx>::on_finish src/jsc/WorkTask.rs:140
freed by thread (Worker): WebWorker::shutdown VM dealloc

Suggested scenario for worker-terminate-offthread.test.ts (the matrix has AnyTaskJob, ConcurrentPromiseTask, AsyncFSTask, etc., but nothing in the WorkTask family):

const s = new CompressionStream('brotli');
const w = s.writable.getWriter();
const big = new Uint8Array(6 << 20);
for (let i = 0; i < big.length; i += 3) big[i] = (i * 2654435761) >>> 24;
w.write(big).catch(() => {});
w.close().catch(() => {});
s.readable.getReader().read().catch(() => {});

Note the parent has to outlive the abandoned compute for the enqueue face to fire: without the fence the completion posts seconds after the terminate, so a parent that exits right away can miss it.

Jarred-Sumner pushed a commit that referenced this pull request Aug 7, 2026
…ad transforms (#37139)

### Repro

`CompressionStream('brotli')` with a chunk over 128 KiB runs the codec
step on a WorkPool thread. Tearing down the VM while that step is in
flight frees the native coder under the pool thread:

```js
// ASAN build, BUN_DESTRUCT_VM_ON_EXIT=1 (the CI test runner sets this)
const s = new CompressionStream("brotli");
const w = s.writable.getWriter();
const big = new Uint8Array(6 << 20);
for (let i = 0; i < big.length; i += 3) big[i] = (i * 2654435761) >>> 24;
w.write(big).catch(() => {});
w.close().catch(() => {});
s.readable.getReader().read().catch(() => {});
setTimeout(() => process.exit(0), 15);
```

```
==ERROR: AddressSanitizer: heap-use-after-free ... thread (Bun Pool 0)
    #0 UpdateNodes vendor/brotli/c/enc/backward_references_hq.c:468
    ...
    #4 BrotliEncoderCompressStream vendor/brotli/c/enc/encode.c:1661
    #5 CompressionStreamCoder::transform src/runtime/webcore/CompressionStreamCoder.rs:367
freed by:
    BrotliEncoderDestroyInstance
    CompressionStreamCoder__destroy
    JSCompressionStream.cpp:176 (CFinalizer)
    JSC::Heap::CFinalizerOwner::finalize -> Heap::lastChanceToFinalize
```

The same free-under-the-pool-thread happens on `worker.terminate()` /
`process.exit()` inside a worker while a large write is in flight
(`WebWorker::shutdown` -> `WebWorker__teardownJSCVM` ->
`lastChanceToFinalize`). Other faces of the same report: READ 1 in
`BrotliEstimateBitCostsForLiterals` / `UpdateNodes`, WRITE 4 in
`StoreAndFindMatchesH10`. `DecompressionStream` has the identical
finalizer shape, and zstd/zlib formats share the path.

### Cause

The stream cell's CFinalizer (registered in the constructor) destroys
`m_coder` unconditionally. During normal operation the in-flight task's
`Strong` root keeps the cell from being swept, and the eager
ClearAlgorithms release already defers on `m_asyncCodecInFlight`. But
`Heap::lastChanceToFinalize` at VM teardown runs every finalizer
regardless of roots, so the coder (brotli ring buffer + hasher, zlib
window, zstd ctx) is freed while the pool thread is still inside
`transform`.

### Fix

Reference-count the coder. The JS cell holds one reference, released
where it released before (finalizer, or the eager ClearAlgorithms path;
both already null the cell's pointer first, so
`CompressionStreamCoder__destroy` keeps its signature and call sites).
Each in-flight `CompressionAsyncCtx` takes its own reference when the
async step is scheduled and drops it with the ctx on the JS thread. The
backend is freed when the last reference drops, so teardown releases the
cell's hold but can no longer free the state under the pool thread. On
the teardown paths where the completion never gets delivered, the coder
is abandoned with the dying process instead of freed early, which is the
bounded-leak tradeoff the worker teardown path already takes elsewhere.

Related: #36983 fences `WebWorker::shutdown` on outstanding off-thread
jobs, which closes the worker-terminate door from the other side (and is
still needed for it: after this change, the worker repro's surviving
report moves to `EventLoop::enqueue_task_concurrent` via
`WorkTask::on_finish` on the freed worker loop, which is exactly the bug
that PR addresses, now with a `WorkTask` stack). This change covers what
the fence cannot: the main-thread `BUN_DESTRUCT_VM_ON_EXIT=1` exit path,
and the coder's own lifetime independent of teardown ordering.

### Verification

- New test in `test/js/web/streams/compression.test.ts` (ASAN-gated):
fails on the unfixed build with the ASan report above, passes with the
fix.
- Main-thread repro: 5/5 clean runs with the fix (was UAF on every run
before).
- `test/js/web/streams/compression.test.ts` (37),
`test/regression/issue/18413-all-compressions.test.ts`,
`test/regression/issue/23314/zstd-large-decompression.test.ts`, and the
four node webstreams compression compat tests all pass.

<!-- robobun:evidence:begin -->

---

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

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/streams/compression.test.ts
bun test v1.4.0 (38b3183)

test/js/web/streams/compression.test.ts:
(pass) TransformStream.prototype getters reject native transform subclasses (0) [13.39ms]
(pass) TransformStream.prototype getters reject native transform subclasses (1) [2.50ms]
(pass) TransformStream.prototype getters reject native transform subclasses (2) [2.66ms]
(pass) TransformStream.prototype getters reject native transform subclasses (3) [2.27ms]
(pass) CompressionStream and DecompressionStream > brotli > compresses data with brotli [15.13ms]
(pass) CompressionStream and DecompressionStream > brotli > decompresses brotli data [21.20ms]
(pass) CompressionStream and DecompressionStream > brotli > round-trip compression with brotli [51.32ms]
(pass) CompressionStream and DecompressionStream > zstd > compresses data with zstd [9.46ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses zstd data [18.82ms]
(pass) CompressionStream and DecompressionStream > zstd > round-trip compression with zstd [36.88ms]
(
... (truncated)

release without fix: 1 skipped
bun test v1.4.0-canary.1 (0ac8ea9)

test/js/web/streams/compression.test.ts:
(pass) TransformStream.prototype getters reject native transform subclasses (0) [0.42ms]
(pass) TransformStream.prototype getters reject native transform subclasses (1) [0.07ms]
(pass) TransformStream.prototype getters reject native transform subclasses (2) [0.06ms]
(pass) TransformStream.prototype getters reject native transform subclasses (3) [0.03ms]
(pass) CompressionStream and DecompressionStream > brotli > compresses data with brotli [0.91ms]
(pass) CompressionStream and DecompressionStream > brotli > decompresses brotli data [0.78ms]
(pass) CompressionStream and DecompressionStream > brotli > round-trip compression with brotli [1.80ms]
(pass) CompressionStream and DecompressionStream > zstd > compresses data with zstd [0.58ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses zstd data [0.43ms]
(pass) CompressionStream and DecompressionStream > zstd > round-trip compression with zstd [1.02ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses a multi-frame zstd stream [0.28ms]
(pass) CompressionStream and DecompressionStream > zstd > decompr
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
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/js/web/streams/compression.test.ts
bun test v1.4.0 (38b3183)

test/js/web/streams/compression.test.ts:
(pass) TransformStream.prototype getters reject native transform subclasses (0) [13.88ms]
(pass) TransformStream.prototype getters reject native transform subclasses (1) [2.67ms]
(pass) TransformStream.prototype getters reject native transform subclasses (2) [2.66ms]
(pass) TransformStream.prototype getters reject native transform subclasses (3) [2.15ms]
(pass) CompressionStream and DecompressionStream > brotli > compresses data with brotli [15.57ms]
(pass) CompressionStream and DecompressionStream > brotli > decompresses brotli data [22.42ms]
(pass) CompressionStream and DecompressionStream > brotli > round-trip compression with brotli [51.98ms]
(pass) CompressionStream and DecompressionStream > zstd > compresses data with zstd [10.10ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses zstd data [18.91ms]
(pass) CompressionStream and DecompressionStream > zstd > round-trip compression with zstd [38.62ms]

... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 820ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/12] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 239 extern-C blocks audited
[1/12] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
.../bindings/webcore/streams/JSCompressionStream.h |  7 ++--
 .../webcore/streams/JSCompressionStreamShared.h    |  1 +
 src/runtime/webcore/CompressionStreamCoder.rs      | 43 +++++++++++++++----
 test/js/web/streams/compression.test.ts            | 49 +++++++++++++++++++++-
 4 files changed, 87 insertions(+), 13 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                                      reads  edits  tests
src/jsc/bindings/webcore/streams/JSCompressionStream.h        2      2      0
…sc/bindings/webcore/streams/JSCompressionStreamShared.h      2      2      0
src/runtime/webcore/CompressionStreamCoder.rs                 3      6      0
test/js/web/streams/compression.test.ts                       1      1      0
```

</details>

<!-- robobun:evidence:end -->
springmin pushed a commit to springmin/bun that referenced this pull request Aug 8, 2026
…ad transforms (oven-sh#37139)

### Repro

`CompressionStream('brotli')` with a chunk over 128 KiB runs the codec
step on a WorkPool thread. Tearing down the VM while that step is in
flight frees the native coder under the pool thread:

```js
// ASAN build, BUN_DESTRUCT_VM_ON_EXIT=1 (the CI test runner sets this)
const s = new CompressionStream("brotli");
const w = s.writable.getWriter();
const big = new Uint8Array(6 << 20);
for (let i = 0; i < big.length; i += 3) big[i] = (i * 2654435761) >>> 24;
w.write(big).catch(() => {});
w.close().catch(() => {});
s.readable.getReader().read().catch(() => {});
setTimeout(() => process.exit(0), 15);
```

```
==ERROR: AddressSanitizer: heap-use-after-free ... thread (Bun Pool 0)
    #0 UpdateNodes vendor/brotli/c/enc/backward_references_hq.c:468
    ...
    #4 BrotliEncoderCompressStream vendor/brotli/c/enc/encode.c:1661
    #5 CompressionStreamCoder::transform src/runtime/webcore/CompressionStreamCoder.rs:367
freed by:
    BrotliEncoderDestroyInstance
    CompressionStreamCoder__destroy
    JSCompressionStream.cpp:176 (CFinalizer)
    JSC::Heap::CFinalizerOwner::finalize -> Heap::lastChanceToFinalize
```

The same free-under-the-pool-thread happens on `worker.terminate()` /
`process.exit()` inside a worker while a large write is in flight
(`WebWorker::shutdown` -> `WebWorker__teardownJSCVM` ->
`lastChanceToFinalize`). Other faces of the same report: READ 1 in
`BrotliEstimateBitCostsForLiterals` / `UpdateNodes`, WRITE 4 in
`StoreAndFindMatchesH10`. `DecompressionStream` has the identical
finalizer shape, and zstd/zlib formats share the path.

### Cause

The stream cell's CFinalizer (registered in the constructor) destroys
`m_coder` unconditionally. During normal operation the in-flight task's
`Strong` root keeps the cell from being swept, and the eager
ClearAlgorithms release already defers on `m_asyncCodecInFlight`. But
`Heap::lastChanceToFinalize` at VM teardown runs every finalizer
regardless of roots, so the coder (brotli ring buffer + hasher, zlib
window, zstd ctx) is freed while the pool thread is still inside
`transform`.

### Fix

Reference-count the coder. The JS cell holds one reference, released
where it released before (finalizer, or the eager ClearAlgorithms path;
both already null the cell's pointer first, so
`CompressionStreamCoder__destroy` keeps its signature and call sites).
Each in-flight `CompressionAsyncCtx` takes its own reference when the
async step is scheduled and drops it with the ctx on the JS thread. The
backend is freed when the last reference drops, so teardown releases the
cell's hold but can no longer free the state under the pool thread. On
the teardown paths where the completion never gets delivered, the coder
is abandoned with the dying process instead of freed early, which is the
bounded-leak tradeoff the worker teardown path already takes elsewhere.

Related: oven-sh#36983 fences `WebWorker::shutdown` on outstanding off-thread
jobs, which closes the worker-terminate door from the other side (and is
still needed for it: after this change, the worker repro's surviving
report moves to `EventLoop::enqueue_task_concurrent` via
`WorkTask::on_finish` on the freed worker loop, which is exactly the bug
that PR addresses, now with a `WorkTask` stack). This change covers what
the fence cannot: the main-thread `BUN_DESTRUCT_VM_ON_EXIT=1` exit path,
and the coder's own lifetime independent of teardown ordering.

### Verification

- New test in `test/js/web/streams/compression.test.ts` (ASAN-gated):
fails on the unfixed build with the ASan report above, passes with the
fix.
- Main-thread repro: 5/5 clean runs with the fix (was UAF on every run
before).
- `test/js/web/streams/compression.test.ts` (37),
`test/regression/issue/18413-all-compressions.test.ts`,
`test/regression/issue/23314/zstd-large-decompression.test.ts`, and the
four node webstreams compression compat tests all pass.

<!-- robobun:evidence:begin -->

---

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

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/streams/compression.test.ts
bun test v1.4.0 (38b3183)

test/js/web/streams/compression.test.ts:
(pass) TransformStream.prototype getters reject native transform subclasses (0) [13.39ms]
(pass) TransformStream.prototype getters reject native transform subclasses (1) [2.50ms]
(pass) TransformStream.prototype getters reject native transform subclasses (2) [2.66ms]
(pass) TransformStream.prototype getters reject native transform subclasses (3) [2.27ms]
(pass) CompressionStream and DecompressionStream > brotli > compresses data with brotli [15.13ms]
(pass) CompressionStream and DecompressionStream > brotli > decompresses brotli data [21.20ms]
(pass) CompressionStream and DecompressionStream > brotli > round-trip compression with brotli [51.32ms]
(pass) CompressionStream and DecompressionStream > zstd > compresses data with zstd [9.46ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses zstd data [18.82ms]
(pass) CompressionStream and DecompressionStream > zstd > round-trip compression with zstd [36.88ms]
(
... (truncated)

release without fix: 1 skipped
bun test v1.4.0-canary.1 (0ac8ea9)

test/js/web/streams/compression.test.ts:
(pass) TransformStream.prototype getters reject native transform subclasses (0) [0.42ms]
(pass) TransformStream.prototype getters reject native transform subclasses (1) [0.07ms]
(pass) TransformStream.prototype getters reject native transform subclasses (2) [0.06ms]
(pass) TransformStream.prototype getters reject native transform subclasses (3) [0.03ms]
(pass) CompressionStream and DecompressionStream > brotli > compresses data with brotli [0.91ms]
(pass) CompressionStream and DecompressionStream > brotli > decompresses brotli data [0.78ms]
(pass) CompressionStream and DecompressionStream > brotli > round-trip compression with brotli [1.80ms]
(pass) CompressionStream and DecompressionStream > zstd > compresses data with zstd [0.58ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses zstd data [0.43ms]
(pass) CompressionStream and DecompressionStream > zstd > round-trip compression with zstd [1.02ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses a multi-frame zstd stream [0.28ms]
(pass) CompressionStream and DecompressionStream > zstd > decompr
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
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/js/web/streams/compression.test.ts
bun test v1.4.0 (38b3183)

test/js/web/streams/compression.test.ts:
(pass) TransformStream.prototype getters reject native transform subclasses (0) [13.88ms]
(pass) TransformStream.prototype getters reject native transform subclasses (1) [2.67ms]
(pass) TransformStream.prototype getters reject native transform subclasses (2) [2.66ms]
(pass) TransformStream.prototype getters reject native transform subclasses (3) [2.15ms]
(pass) CompressionStream and DecompressionStream > brotli > compresses data with brotli [15.57ms]
(pass) CompressionStream and DecompressionStream > brotli > decompresses brotli data [22.42ms]
(pass) CompressionStream and DecompressionStream > brotli > round-trip compression with brotli [51.98ms]
(pass) CompressionStream and DecompressionStream > zstd > compresses data with zstd [10.10ms]
(pass) CompressionStream and DecompressionStream > zstd > decompresses zstd data [18.91ms]
(pass) CompressionStream and DecompressionStream > zstd > round-trip compression with zstd [38.62ms]

... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 820ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/12] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 239 extern-C blocks audited
[1/12] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
.../bindings/webcore/streams/JSCompressionStream.h |  7 ++--
 .../webcore/streams/JSCompressionStreamShared.h    |  1 +
 src/runtime/webcore/CompressionStreamCoder.rs      | 43 +++++++++++++++----
 test/js/web/streams/compression.test.ts            | 49 +++++++++++++++++++++-
 4 files changed, 87 insertions(+), 13 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                                      reads  edits  tests
src/jsc/bindings/webcore/streams/JSCompressionStream.h        2      2      0
…sc/bindings/webcore/streams/JSCompressionStreamShared.h      2      2      0
src/runtime/webcore/CompressionStreamCoder.rs                 3      6      0
test/js/web/streams/compression.test.ts                       1      1      0
```

</details>

<!-- robobun:evidence:end -->
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this was superseded by #37075 (Worker / worker_threads lifetimes and ordered VM teardown, merged 2026-08-08), which makes a terminating worker wait for or release its in-flight off-thread work (thread pool, HTTP thread, bundle thread, napi) before the VM is freed.

Both test files from this branch (test/js/node/zlib/zlib-worker-terminate.test.ts and test/js/web/workers/worker-terminate-offthread.test.ts) pass unmodified against an ASAN debug build of current main (04148c8), 28 pass / 0 fail across three runs, whereas 11 of them failed on main when this PR was opened.

@robobun robobun closed this Aug 13, 2026
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.

2 participants