Skip to content

fix(ohos): set isTTY=false on non-TTY stdout/stderr/stdin - #12

Closed
social4hyq wants to merge 1 commit into
springmin:ohos-aarch64from
social4hyq:claude/ohos-fix-istty
Closed

social4hyq wants to merge 1 commit into
springmin:ohos-aarch64from
social4hyq:claude/ohos-fix-istty

Conversation

@social4hyq

Copy link
Copy Markdown

Summary

process.stdout.isTTY, process.stderr.isTTY, and process.stdin.isTTY return undefined instead of false in non-TTY contexts on OHOS. This fix explicitly sets isTTY = false when constructing non-TTY ReadStream/WriteStream objects in ProcessObjectInternals.ts.

Root cause

The non-TTY code paths in getStdioWriteStream and getStdinStream construct fs.WriteStream / fs.ReadStream directly, which do not set an isTTY property. Node.js sets isTTY=false unconditionally on non-TTY streams.

Changes

  • src/js/builtins/ProcessObjectInternals.ts: Added stream.isTTY = false in the non-TTY branch of getStdioWriteStream, and if (!isTTY) stream.isTTY = false after stream construction in getStdinStream.

Verification

  • Pre-fix: process.stdout.isTTY returns undefined when invoked via ssh + podman exec
  • Post-fix: CI build needed (builtins are embedded in binary)
  • Compat test coverage: isTTY_undefined_in_non_tty (ohos-bun-compat test 17_tty)

Related

  • Failure signature: isTTY_undefined_in_non_tty
  • Applies to stdin, stdout, and stderr streams

process.stdout.isTTY, process.stderr.isTTY, and process.stdin.isTTY
returned undefined instead of false in non-TTY contexts (e.g., when
invoked via ssh + podman exec on OpenHarmony). Node.js sets isTTY=false
unconditionally on non-TTY streams.

The root cause is that the non-TTY code paths in getStdioWriteStream
and getStdinStream construct fs.WriteStream / fs.ReadStream directly,
which do not set an isTTY property. The fix explicitly sets
isTTY = false in both non-TTY branches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@social4hyq social4hyq closed this Jun 13, 2026
@social4hyq
social4hyq deleted the claude/ohos-fix-istty branch June 13, 2026 01:35
springmin pushed a commit that referenced this pull request Jun 23, 2026
…e re-enters the event loop (oven-sh#32597)

Sentry BUN-2WJA / BUN-2WKB (~290 events combined, Windows x86_64,
`http_server=True`, bun 1.2.23 through 1.3.14):

```
Segmentation fault at address 0xFFFFFFFFFFFFFFFF
  endWithSink      src/runtime/webcore/Sink.zig:577
  endFromJS        src/runtime/webcore/streams.zig:1200
  finalize         src/runtime/webcore/streams.zig:1301
  clearAndFree     src/collections/baby_list.zig:148
  memset           (fault at 0xFFFFFFFFFFFFFFFF)
```

## Cause

The generated `JSReadable*Controller` `end()` and `close()` host
functions (`src/codegen/generate-jssink.ts`) stash `m_sinkPtr` in a
local, call `controller->detach()`, and only afterward dereference the
stashed pointer via `endWithSink()` / `${name}__close()`:

```cpp
void *ptr = controller->wrapped();
controller->detach();              // runs onClose JS synchronously
return ${name}__endWithSink(ptr, lexicalGlobalObject);  // derefs ptr
```

`detach()` invokes the stored `onClose` callback. For a `type: "direct"`
stream this is `readDirectStream`'s `close(stream, reason)`, which calls
`underlyingSource.cancel()`. That is arbitrary user code running while
`ptr` is still live on the C++ stack.

If the stream's `pull()` promise has already settled,
`RequestContext::on_resolve_stream` is sitting in the microtask queue.
Any path from `cancel()` that drains microtasks (e.g. the server-side
drain points in `on_response` / `do_render_with_body`, or an explicit
`drainMicrotasks()`) runs `handle_resolve_stream`, which calls
`destroy_sink` and frees the `HTTPServerWritable`. `endWithSink(ptr)`
then enters `end_from_js` on the freed allocation; `finalize()` reads
garbage for `pooled_buffer` / `buffer.cap` / `buffer.ptr` and faults in
the `memset` the allocator's free-scrub path performs.

The same ordering appears in the Rust port (`streams.rs` / `Sink.rs`)
unchanged.

## Fix

In `${controller}__end` and `${controller}__close`, finish the native
sink operation before any JS runs:

1. Call `${name}__controllerDetached(ptr, controller)` and null
`m_sinkPtr` up front (so `end_from_js`'s own `signal.close()` stays a
no-op, matching the previous behaviour, and so the later `detach()`
won't touch the native side again).
2. Run `endWithSink(ptr)` / `close(ptr)`.
3. Call `controller->detach()` last. With `m_sinkPtr` already null it
only clears `m_onPull` and fires `onClose`; by now we hold no reference
into the sink, so re-entrant teardown is safe.

## Verification

New ASAN-gated test in
`test/js/bun/http/serve-direct-readable-stream.test.ts` reproduces the
exact UAF deterministically by draining microtasks from the stream's
`cancel()` callback (the test uses
`require("bun:jsc").drainMicrotasks()` to force the drain that the
production crash hits via the server's own drain points).

<details>
<summary>ASAN output on the unfixed build</summary>

```
==22203==ERROR: AddressSanitizer: heap-use-after-free on address 0x6ee5f87602ca
READ of size 1 at 0x6ee5f87602ca thread T0
    #0 HTTPServerWritable::end_from_js src/runtime/webcore/streams.rs:1831
    #2 JSSink::js_end_with_sink src/runtime/webcore/Sink.rs:1107
    #4 WebCore::JSReadableHTTPResponseSinkController__end JSSink.cpp:620

freed by thread T0 here:
    #10 HTTPServerWritable::destroy src/runtime/webcore/streams.rs:1950
    #11 RequestContext::destroy_sink src/runtime/server/RequestContext.rs:1930
    #12 RequestContext::handle_resolve_stream src/runtime/server/RequestContext.rs:2680
    #13 RequestContext::on_resolve_stream src/runtime/server/RequestContext.rs:2716
    ...
    oven-sh#24 JSC::VM::drainMicrotasks()
```
</details>

With the fix the fixture completes normally. Existing suites
(`serve.test.ts`, `bun-server.test.ts`,
`direct-readable-stream.test.tsx`, `streams.test.js`, the sink leak
tests) show no new failures against the unfixed build.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
springmin pushed a commit that referenced this pull request Jun 27, 2026
…sweep (oven-sh#32729)

### Crash

```
ASSERTION FAILED: vm().currentThreadIsHoldingAPILock() => vm().heap.mutatorState() != MutatorState::Sweeping
vendor/WebKit/Source/JavaScriptCore/runtime/JSCell.cpp(179) : bool JSC::JSCell::validateIsNotSweeping() const
```

Backtrace (from a release-asan build with asserts):

```
#3  JSC::JSCell::validateIsNotSweeping()
#4  JSC::JSCell::classInfo() const
#5  WTF::uncheckedDowncast<WebCore::JSResumableFetchSink>(JSValue const&)
#6  ResumableFetchSinkPrototype__ondrainSetCachedValue
#7  bun_runtime::webcore::fetch::fetch_tasklet::FetchTasklet::ignore_remaining_response_body
#8  JSC::WeakBlock::sweep()          <- inside GC sweep (Weak finalizer)
#9  JSC::WeakSet::sweep()
#10 JSC::PreciseAllocation::sweep()
#12 JSC::Heap::finalize()
oven-sh#21 JSC::LocalAllocator::allocateSlowCase
oven-sh#23 JSC::ErrorInstance::create        <- ordinary allocation kicked off GC
```

Found by the syscall fault-injection fuzzer's client-side grammar
scenario (fetch/node:http with abort + transient errno on the client
socket). Reproduces ~4/5 under `BUN_JSC_collectContinuously=1`.

### Cause

`FetchTasklet::on_response_finalize` is the
`WeakRefOwner<FetchResponse>::finalize` callback and runs inside
`WeakBlock::sweep` while `MutatorState == Sweeping`. When the response
body is `Locked` without a pending promise or stream it calls
`ignore_remaining_response_body()`, which called:

- `ResumableSink::detach_js()`: writes the sink wrapper's cached
`ondrain` / `oncancel` / `stream` slots via the generated
`ResumableFetchSinkPrototype__*SetCachedValue` helpers. Each does
`uncheckedDowncast<JSResumableFetchSink>(thisValue)`, which reaches
`JSCell::classInfo()` and then issues a write barrier on the wrapper
cell.
- `clear_stream_handlers()`: reaches `ReadableStreamTag__tagged` ->
`object->inherits<JSReadableStream>()` (guarded today, but one boolean
away).

Calling `classInfo()` on any cell while the mutator is sweeping is
forbidden: the cell's `Structure` may already have been swept. Assert
builds catch it; release builds corrupt the heap.

### Fix

Thread a `from_finalizer` flag through `ignore_remaining_response_body`.
When `true` (the `on_response_finalize` caller) skip `detach_js()` and
`clear_stream_handlers()`; only native state is touched. The sink's
JS-side detach still happens from `clear_sink()` in
`FetchTasklet::deinit()`, which runs as an event-loop `ConcurrentTask`
outside any sweep, so nothing leaks.

The `on_stream_cancelled_callback` caller (reader `.cancel()`, runs from
JS on the event loop) passes `false` and keeps the immediate detach.

Also corrects the `ResumableSink::detach_js` doc comment that claimed
finalizer safety.

### Verification

New test at `test/js/web/fetch/fetch-response-finalizer-sweep.test.ts`:
a child process under `BUN_JSC_collectContinuously=1` does 12 iterations
of `fetch()` with a user-constructed `ReadableStream` body (so the sink
takes the JS route with a Strong `js_this`) against a raw TCP server
that sends headers + a partial chunked body and never terminates it,
then drops the `Response` unconsumed and runs `Bun.gc(true)`.

Without the fix (`bun bd`, src/ stashed):

```
exitCode: 134
stderr: ASSERTION FAILED: vm().currentThreadIsHoldingAPILock() => vm().heap.mutatorState() != MutatorState::Sweeping
```

With the fix: `stdout: "ok"`, `exitCode: 0`.

`test/js/web/fetch/fetch-backpressure.test.ts` (exercises the
`on_stream_cancelled_callback` path) passes unchanged.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
springmin pushed a commit that referenced this pull request Jun 27, 2026
…oven-sh#32742)

### What does this PR do?

Fixes a use-after-free in the HTTP client's CONNECT proxy tunnel, caught
by ASAN:

```
READ of size 8 at 0x61e00001fe80 thread T6
  #0 Option<RefPtr<ProxyTunnel>>::as_ref
  #1 proxy_tunnel::on_close            ProxyTunnel.rs:525
  #2 SSLWrapper::trigger_close_callback uws/lib.rs:833
  #3 SSLWrapper::handle_reading         uws/lib.rs:1053
  ...
freed by thread T6 here (same stack, same `handle_reading` call):
  #5 AsyncHTTP::on_async_http_callback_raw            AsyncHTTP.rs:819
  #7 HTTPClient::send_progress_update_without_stage_check
  #9 proxy_tunnel::on_data              ProxyTunnel.rs:350
  #11 SSLWrapper::trigger_data_callback uws/lib.rs:824
  #12 SSLWrapper::handle_reading        uws/lib.rs:1046
```

`SSLWrapper::handle_reading` flushes pending decrypted bytes to the data
callback, then runs the close callback, guarded only by
`closed_notified`:

1. The flushed data callback completes a keep-alive response through the
tunnel. A fatal TLS record error sets only `fatal_error` — none of the
shutdown flags — so the wrapper passed `tunnel_poolable`'s
`!is_shutdown()` check and the tunnel was handed to the keep-alive pool.
Nothing called `wrapper.shutdown()`, so `closed_notified` was never
latched. Dispatching the final result then freed the
`ThreadlocalAsyncHTTP` that embeds the `HTTPClient`.
2. The guard (`ssl.is_none() || closed_notified()`) passes.
3. `trigger_close_callback()` invokes `on_close(handlers.ctx)` with
`ctx` pointing at the freed client.

The pooling branch is the only terminal path that doesn't go through
`close_proxy_tunnel(true)` → `wrapper.shutdown()` → `closed_notified`,
which is the latch the read loop relies on. `SSLWrapper::shutdown`
already special-cases the *close_notify* flavor of this for exactly that
reason; the fatal-error flavor never reaches `shutdown()`.

The fix is one predicate: a tunnel whose wrapper has a fatal error or
pending unconsumed input/output is not poolable. That routes it through
the orderly teardown that latches `closed_notified`, and the pending-I/O
half closes the same hole for a tunnel pooled from a mid-loop data
callback while more decrypted bytes or queued output remain. Both are
also required for the pool to be correct on its own terms — a poisoned
or dirty TLS session must not be handed to the next request.

### How did you verify your code works?

New regression test in `test/js/bun/http/proxy.test.ts` (next to the
existing close_notify sibling): an HTTPS keep-alive response through a
CONNECT proxy with a corrupt TLS record appended to the same TCP burst,
followed by a second request that can only complete if the HTTP client
thread survived the first.

Against an unfixed ASAN debug build the fixture aborts every run:

```
==20981==ERROR: AddressSanitizer: heap-use-after-free on address 0x61e00001fe80
READ of size 8 at 0x61e00001fe80 thread T6
...
exit=134
```

With this change it prints `4096 200 200` and exits 0 with no ASAN
report. `test/js/bun/http/proxy.test.ts` (49/49),
`fetch-proxy-connect-tunnel-split-envelope.test.ts`,
`fetch-proxy-tls-intern-race.test.ts`, and `fetch-keepalive.test.ts` all
pass.
springmin pushed a commit that referenced this pull request Jul 2, 2026
…h#33186)

### Repro

```sh
printf '{"name":"x","version":"1.0.0"}' > package.json
bun pm pkg set 'contributors[0]=alice'
```

On a release build (1.4.0 and current `main`) this exits 0 and writes
freed heap bytes into `package.json` as the property key:

```json
{
"name": "x",
"version": "1.0.0",
"P\x01\x00\x00\x00tors": {
  "\x00": "alice"
}
}
```

Depending on what was in the freed allocation the result is often not
valid JSON at all. Any `bun pm pkg set` key path containing `[index]`
hits it.

Under ASAN it is a deterministic `heap-use-after-free`:

```
ERROR: AddressSanitizer: heap-use-after-free
READ of size 1
    #0 bun_js_printer::write_pre_quoted_string_inner   src/js_printer/lib.rs:1014
    #7 PmPkgCommand::save_package_json                 src/runtime/cli/pm_pkg_command.rs:909
freed by thread T0 here:
    #7  <Box<[u8]> as Drop>::drop
    #12 PmPkgCommand::set_value                        src/runtime/cli/pm_pkg_command.rs:661
previously allocated by thread T0 here:
    #10 <Box<[u8]> as From<&[u8]>>::from
    #11 PmPkgCommand::parse_key_path                   src/runtime/cli/pm_pkg_command.rs:583
```

<details>
<summary>full ASAN report</summary>

```
=================================================================
==16563==ERROR: AddressSanitizer: heap-use-after-free on address 0x73423c7c0670 at pc 0x00000f583cc5 bp 0x7fff2667e950 sp 0x7fff2667e948
READ of size 1 at 0x73423c7c0670 thread T0
    #0 0x00000f583cc4 in _RINvCs59Hqei94dXF_14bun_js_printer29write_pre_quoted_string_innerINtB2_16StdWriterAdapterQINtB2_6WriterNtB2_12BufferWriterEEKVNtNtB2_8Encoding4Utf8UECsgBGN0jRPILJ_11bun_bundler /workspace/bun/src/js_printer/lib.rs:1014:79
    #1 0x00000ebda439 in <bun_js_printer::__gated_printer::Printer<&mut bun_js_printer::Writer<bun_js_printer::BufferWriter>, false, false, false, true, false>>::print_string_characters_utf8 /workspace/bun/src/js_printer/lib.rs:2641:21
    #2 0x00000ebdb7a7 in <bun_js_printer::__gated_printer::Printer<&mut bun_js_printer::Writer<bun_js_printer::BufferWriter>, false, false, false, true, false>>::print_string_characters_e_string /workspace/bun/src/js_printer/lib.rs:4546:22
    #3 0x00000ebdb238 in <bun_js_printer::__gated_printer::Printer<&mut bun_js_printer::Writer<bun_js_printer::BufferWriter>, false, false, false, true, false>>::print_string_literal_e_string /workspace/bun/src/js_printer/lib.rs:3018:18
    #4 0x00000ebd2be2 in <bun_js_printer::__gated_printer::Printer<&mut bun_js_printer::Writer<bun_js_printer::BufferWriter>, false, false, false, true, false>>::print_property /workspace/bun/src/js_printer/lib.rs:4807:34
    #5 0x00000ebc0166 in <bun_js_printer::__gated_printer::Printer<&mut bun_js_printer::Writer<bun_js_printer::BufferWriter>, false, false, false, true, false>>::print_expr /workspace/bun/src/js_printer/lib.rs:3962:38
    #6 0x00000ee574c1 in bun_js_printer::print_json::<&mut bun_js_printer::Writer<bun_js_printer::BufferWriter>> /workspace/bun/src/js_printer/lib.rs:8071:13
    #7 0x00000c05c270 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::save_package_json /workspace/bun/src/runtime/cli/pm_pkg_command.rs:909:25
    #8 0x00000c060cfb in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec_set /workspace/bun/src/runtime/cli/pm_pkg_command.rs:330:13
    #9 0x00000c05d333 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec /workspace/bun/src/runtime/cli/pm_pkg_command.rs:73:32
    #10 0x00000bf919d0 in <bun_runtime::cli::package_manager_command::PackageManagerCommand>::exec /workspace/bun/src/runtime/cli/package_manager_command.rs:704:13
    #11 0x00000c3fbb87 in bun_runtime::cli::command::exec_pm /workspace/bun/src/runtime/cli/mod.rs:1591:34
    #12 0x00000c3f2b86 in bun_runtime::cli::command::start /workspace/bun/src/runtime/cli/mod.rs:1309:43
    #13 0x00000bfad16c in bun_runtime::cli::cli::start /workspace/bun/src/runtime/cli/mod.rs:573:27
    #14 0x00000bb3c034 in main /workspace/bun/src/bun_bin/lib.rs:230:5
    #15 0x77223ccc7ca7 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    oven-sh#16 0x77223ccc7d64 in __libc_start_main csu/../csu/libc-start.c:360:3
    oven-sh#17 0x0000099d1d1d in __wrap___libc_start_main /workspace/bun/build/debug/../../src/jsc/bindings/workaround-missing-symbols.cpp:487:12

0x73423c7c0670 is located 0 bytes inside of 12-byte region [0x73423c7c0670,0x73423c7c067c)
freed by thread T0 here:
    #0 0x000007ae192a in free crtstuff.c
    #1 0x00000bb3c5a7 in <std::alloc::System as core::alloc::global::GlobalAlloc>::dealloc /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/sys/alloc/unix.rs:48:18
    #2 0x00000bb3be9a in __rustc::__rust_dealloc /workspace/bun/src/bun_bin/lib.rs:56:15
    #3 0x00001258b05f in alloc::alloc::dealloc_nonnull /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:128:14
    #4 0x0000125872fe in <alloc::alloc::Global>::deallocate_impl_runtime /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:229:22
    #5 0x000012586364 in <alloc::alloc::Global>::deallocate_impl /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:344:9
    #6 0x00001258d79c in <alloc::alloc::Global as core::alloc::Allocator>::deallocate /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:462:23
    #7 0x000012582946 in <alloc::boxed::Box<[u8]> as core::ops::drop::Drop>::drop /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/boxed.rs:1956:24
    #8 0x000012572e44 in core::ptr::drop_in_place::<alloc::boxed::Box<[u8]>> /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:809:1
    #9 0x000011f8d429 in core::ptr::drop_in_place::<[alloc::boxed::Box<[u8]>]> /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:809:1
    #10 0x00000ef6b73a in <alloc::vec::Vec<alloc::boxed::Box<[u8]>> as core::ops::drop::Drop>::drop /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:4258:13
    #11 0x00000ef69e64 in core::ptr::drop_in_place::<alloc::vec::Vec<alloc::boxed::Box<[u8]>>> /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:809:1
    #12 0x00000c061846 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::set_value /workspace/bun/src/runtime/cli/pm_pkg_command.rs:661:5
    #13 0x00000c061038 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec_set /workspace/bun/src/runtime/cli/pm_pkg_command.rs:325:13
    #14 0x00000c05d333 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec /workspace/bun/src/runtime/cli/pm_pkg_command.rs:73:32
    #15 0x00000bf919d0 in <bun_runtime::cli::package_manager_command::PackageManagerCommand>::exec /workspace/bun/src/runtime/cli/package_manager_command.rs:704:13
    oven-sh#16 0x00000c3fbb87 in bun_runtime::cli::command::exec_pm /workspace/bun/src/runtime/cli/mod.rs:1591:34
    oven-sh#17 0x00000c3f2b86 in bun_runtime::cli::command::start /workspace/bun/src/runtime/cli/mod.rs:1309:43
    oven-sh#18 0x00000bfad16c in bun_runtime::cli::cli::start /workspace/bun/src/runtime/cli/mod.rs:573:27
    oven-sh#19 0x00000bb3c034 in main /workspace/bun/src/bun_bin/lib.rs:230:5
    oven-sh#20 0x77223ccc7ca7 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

previously allocated by thread T0 here:
    #0 0x000007ae1bc8 in malloc crtstuff.c
    #1 0x00000bb3c520 in <std::alloc::System as core::alloc::global::GlobalAlloc>::alloc /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/sys/alloc/unix.rs:14:22
    #2 0x00000bb3be30 in __rustc::__rust_alloc /workspace/bun/src/bun_bin/lib.rs:56:15
    #3 0x00001258b335 in alloc::alloc::alloc /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:101:9
    #4 0x000012586b81 in <alloc::alloc::Global>::alloc_impl_runtime /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:210:73
    #5 0x0000125862b6 in <alloc::alloc::Global>::alloc_impl /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:332:9
    #6 0x00001258d86a in <alloc::alloc::Global as core::alloc::Allocator>::allocate /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:449:14
    #7 0x00001257dbd3 in <alloc::boxed::Box<[u8]>>::try_clone_from_ref_in /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/boxed.rs:881:29
    #8 0x00001257da49 in <alloc::boxed::Box<[u8]>>::clone_from_ref_in /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/boxed.rs:840:15
    #9 0x00001257d3f4 in <alloc::boxed::Box<[u8]>>::clone_from_ref /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/boxed.rs:793:9
    #10 0x000012581e34 in <alloc::boxed::Box<[u8]> as core::convert::From<&[u8]>>::from /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/boxed/convert.rs:77:9
    #11 0x00000c05a47f in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::parse_key_path /workspace/bun/src/runtime/cli/pm_pkg_command.rs:583:37
    #12 0x00000c061608 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::set_value /workspace/bun/src/runtime/cli/pm_pkg_command.rs:643:30
    #13 0x00000c061038 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec_set /workspace/bun/src/runtime/cli/pm_pkg_command.rs:325:13
    #14 0x00000c05d333 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec /workspace/bun/src/runtime/cli/pm_pkg_command.rs:73:32
```

</details>

### Cause

`parse_key_path` returned a `Vec<Box<[u8]>>`, and `set_value` /
`set_nested` inserted those boxed segments into the manifest AST by
reference: `E::Object::put` constructs `EString::init(key)`, whose
documented contract is that `key` is arena-owned (it records the slice,
it does not copy it). The vector is a local of `set_value`, so it
dropped before `exec_set` reached `save_package_json`, and the JSON
printer then read the dangling keys.

The non-bracket path in `set_value` did not have the bug: it borrowed
its segments straight out of the argv key, which outlives the whole
command. The bracket path differed only by the unnecessary boxing.

### Fix

`parse_key_path` now returns `Vec<&[u8]>`. Every segment is a literal
sub-slice of the input key, so nothing ever needed owning. With the
boxing gone, `set_value`'s separate non-bracket branch and its
`set_nested_simple` helper (which existed only to avoid the allocation)
were exact duplicates of the bracket path, so they are deleted and all
keys route through `parse_key_path` + `set_nested`.

`set_nested_simple`'s trailing `root.put(current_key, nested)` was a
no-op: `ExprData::EObject` is a `StoreRef` handle, so mutating the copy
returned by `root.get()` already mutates the stored object, and the put
re-stores the same handle. Dropping it with the function changes nothing
(and the prior bracket path, `set_nested`, never had it).

Intentionally not changed here: `set 'contributors[0]=alice'` produces
`"contributors": {"0": "alice"}`, an object keyed by the digit string,
rather than the array npm's `pkg set` creates, and `set 'array[]=x'`
still errors with `InvalidPath` instead of appending. Both are the npm
compat gap tracked in oven-sh#22035, which is separate from the memory safety
of the key names and is not closed by this PR.

### Verification

New test in `test/cli/install/bun-pm-pkg.test.ts` reparses the written
file and asserts the exact object. Without the fix it fails on release
(`SyntaxError: JSON Parse error: Invalid escape character x`) and on the
ASAN debug build (the child aborts on the use-after-free). With the fix
the full `bun-pm-pkg.test.ts` suite passes (74 pass, 0 fail).
springmin pushed a commit that referenced this pull request Jul 13, 2026
…wn (oven-sh#34035)

Fixes `test/bake/dev/request-cookies.test.ts` going red on the `debian
13 x64-asan` lane (seen in [build
72183](https://buildkite.com/bun/bun/builds/72183#019f55d5-6839-41cf-b0f5-3c56ada43ef9)
and [build 71964](https://buildkite.com/bun/bun/builds/71964)):

```
dev| ==1715==ERROR: AddressSanitizer: SEGV on unknown address 0x000000007490
error: DevServer panicked
      at gracefulExit (test/bake/bake-harness.ts:614:17)
✗  DEV:request-cookies-1: request.cookies.get() basic functionality
```

### Cause

`~DevServerSourceProvider` held a raw `Zig::GlobalObject*` and called
`m_globalObject->bunVM()` to reach `Bun__removeDevServerSourceProvider`.
Under `BUN_DESTRUCT_VM_ON_EXIT=1` (set by the CI runner for the asan
lane), the harness's `process.exit(0)` runs
`Zig__GlobalObject__destructOnExit`, which does
`gcUnprotect(globalObject)` then `collectNow(Sync, Full)` then two
`vm.derefSuppressingSaferCPPChecking()`. The global object cell is swept
during `collectNow`, but the provider's last `Ref` is only released
later from `~CodeCache` inside `~JSC::VM`, so the destructor read
`m_bunVM` out of a freed cell.

With bmalloc the freed cell usually still holds the old value and the
read happens to work, which is why this was ~0.5% in CI and never
reproduced locally. When the memory is reused with a zero at that offset
the Rust side receives a null `VirtualMachine*` and the next access is
`(null)->source_mappings.mutex`, which lands at exactly 0x7490.

Deterministic ASAN backtrace with `Malloc=1`:

```
==79839==ERROR: AddressSanitizer: heap-use-after-free ...
    #0  Zig::GlobalObject::bunVM() const  ZigGlobalObject.h:353
    #1  Bake::DevServerSourceProvider::~DevServerSourceProvider()  DevServerSourceProvider.h:65
    ...
    #7  JSC::SourceCodeKey::~SourceCodeKey()
    #12 JSC::CodeCacheMap::~CodeCacheMap()
    oven-sh#16 JSC::VM::~VM()
    oven-sh#18 Zig__GlobalObject__destructOnExit  ZigGlobalObject.cpp:4049
    oven-sh#19 VirtualMachine::global_exit  VirtualMachine.rs:1603
    oven-sh#20 Bun__Process__exit
```

### Fix

Store the Rust `VirtualMachine*` directly (`void* m_bunVM`), captured in
`create()`, so the destructor no longer indirects through a GC cell.
This mirrors `Zig::SourceProvider`, which already stores `m_bunVM` for
the same reason. The Rust `VirtualMachine` outlives every GC cell (step
10 of `global_exit` is `self.destroy()`, after `destructOnExit` has
finished).

### Verification

New ASAN-only case in `test/bake/dev/server-sourcemap.test.ts` runs the
dev server with `Malloc=1` + `BUN_DESTRUCT_VM_ON_EXIT=1` so ASAN poisons
the swept global-object cell, making the UAF deterministic. Added an
`env` option to the `devTest` harness so the test can set those for the
spawned dev server.

```
# fail-before (src/ stashed)
SUMMARY: AddressSanitizer: heap-use-after-free ZigGlobalObject.h:353:48 in Zig::GlobalObject::bunVM() const
(fail)  DEV:server-sourcemap-5: DevServerSourceProvider destructor does not touch the swept global object on process exit

# pass-after
(pass)  DEV:server-sourcemap-5: DevServerSourceProvider destructor does not touch the swept global object on process exit
```

`test/bake/dev/server-sourcemap.test.ts` (5 tests) and
`test/bake/dev/request-cookies.test.ts` (2 tests) are green.
`request-cookies.test.ts` now also passes under the full CI LeakSan
config (`BUN_DESTRUCT_VM_ON_EXIT=1` + `detect_leaks=1`).

The bug is from a89e61f (oven-sh#22138), which introduced
`DevServerSourceProvider` with the raw global-object pointer.

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

---

**[review]** gate passed · iteration 1 · 3 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/bake/dev/request-cookies.test.ts test/bake/dev/server-sourcemap.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (722d6f0)

test/bake/dev/server-sourcemap.test.ts:
Dev server testing directory: /tmp/bun-dev-test-tI2Y82
bun add v1.4.0 (722d6f0)
Resolving dependencies
Resolved, downloaded and extracted [2]
Saved lockfile

installed react@0.0.0-experimental-603e6108-20241029
installed react-dom@0.0.0-experimental-603e6108-20241029
installed react-server-dom-bun@0.0.0-experimental-603e6108-20241029
installed react-refresh@0.0.0-experimental-603e6108-20241029

6 packages installed [462.00ms]
bun install v1.4.0 (722d6f0)

Checked 6 installs across 7 packages (no changes) [167.00ms]
�[0;30mdev|�[0m Started development server: http://localhost:37377
�[0;30mdev|�[0m �[32mBundled page in 2125ms�[0m�[2m:�[0
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (1498d7b)

test/bake/dev/server-sourcemap.test.ts:
Dev server testing directory: /tmp/bun-dev-test-7LPeWv
bun add v1.4.0-canary.1 (1498d7b)
Resolving dependencies
Resolved, downloaded and extracted [0]
Saved lockfile

installed react@0.0.0-experimental-603e6108-20241029
installed react-dom@0.0.0-experimental-603e6108-20241029
installed react-server-dom-bun@0.0.0-experimental-603e6108-20241029
installed react-refresh@0.0.0-experimental-603e6108-20241029

6 packages installed [9.00ms]
bun install v1.4.0-canary.1 (1498d7b)

Checked 6 installs across 7 packages (no changes) [0.00ms]
�[0;30mdev|�[0m Started development server: http://localhost:43275
�[0;30mdev|�[0m �[32mBundled page in 47ms�[0m�[2m:�[0m pages/[...slug].tsx �[2m+ 2 more�[0m
�[0;30mdev|�[0m �[0m�[1m1 |�[0m �[0m�[35mexport�[0m �[0m�[35mdefault�[0m �[0m�[35masync�[0m �[0m�[35mfunction�[0m MyPage(params) {
�[0;30mdev|�[0m �[0m�[1m2 |�[0m   myFunc()�[0m�[2m;�[0m
�[0;30mdev|�[0m �[0m�[1m3 |�[0m   �[0m�[35mreturn�[0m �[0m<�[0mh1>{JSON�[0m�[3m�[1m.stringify�[0m(params)}�[0m<�[0m/h1>�[0m�[2m;�[0m
�[0;30mdev|�[0m �[0m�[1m4 |�[0m }
�[0;30mdev|�[0m �[0m�[1m5 |�[0m 
�[0;30mdev|�[0m �[0m�
... (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/bake/dev/request-cookies.test.ts test/bake/dev/server-sourcemap.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (722d6f0)

test/bake/dev/server-sourcemap.test.ts:
Dev server testing directory: /tmp/bun-dev-test-dkR1se
bun add v1.4.0 (722d6f0)
Resolving dependencies
Resolved, downloaded and extracted [0]
Saved lockfile

installed react@0.0.0-experimental-603e6108-20241029
installed react-dom@0.0.0-experimental-603e6108-20241029
installed react-server-dom-bun@0.0.0-experimental-603e6108-20241029
installed react-refresh@0.0.0-experimental-603e6108-20241029

6 packages installed [119.00ms]
bun install v1.4.0 (722d6f0)

Checked 6 installs across 7 packages (no changes) [97.00ms]
�[0;30mdev|�[0m Started development server: http://localhost:44249
�[0;30mdev|�[0m �[32mBundled page in 2351ms�[0m�[2m:�[0m
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 690ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[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
... (truncated)
```

</details>

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

```
src/runtime/bake/DevServerSourceProvider.h | 13 +++++++-----
 test/bake/bake-harness.ts                  |  5 +++++
 test/bake/dev/server-sourcemap.test.ts     | 34 ++++++++++++++++++++++++++++++
 3 files changed, 47 insertions(+), 5 deletions(-)
```

</details>

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

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

```
file                                        reads  edits  tests
src/runtime/bake/DevServerSourceProvider.h      1      2      0
test/bake/bake-harness.ts                       8      2      0
test/bake/dev/server-sourcemap.test.ts          1      4      0
```

</details>

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Jul 15, 2026
…ry rewrite (oven-sh#34271)

`test/js/bun/util/filesystem_router.test.ts` went red on alpine x64 in
build [73276](https://buildkite.com/bun/bun/builds/73276): the `reload()
while Bun.build() resolves the same directory` subprocess segfaulted in
`bust_dir_cache_recursive`, inlined from `NonNull::new`.

## Cause

`RealFS::entries_at` (`src/resolver/lib.rs`) replaces a cached
`DirEntry` in place when the caller's resolver generation is newer than
the cached listing's. The replacement at `*e_ptr = new_entry` drops the
old `DirEntry`, which drops its `data: StringHashMap<*mut Entry>` and
frees the hashmap's bucket allocation. The function's comment says
`entries_mutex held by caller`, but that is only true on one of the five
paths that reach it: `dir_info_uncached`, when entered from
`dir_info_cached_miss`. The other callers (`finalize_result`,
`handle_esm_resolution`, `load_index_with_extension`,
`Transpiler::run_env_loader`) all reach `entries_at` after
`dir_info_cached_maybe_log` has already returned and released both
`RESOLVER_MUTEX` and `entries_mutex`.

`FileSystemRouter::reload()` and `RouteLoader::load` iterate the same
`DirEntry.data` map under `entries_mutex` (the snapshot pattern oven-sh#33056
introduced for exactly this kind of concurrent rewrite). With
`entries_at`'s rewrite unsynchronized, a `Bun.build()` on the bundler
thread can drop the map while `reload()` on the JS thread is
mid-iteration.

The generation mismatch is what makes `entries_at` enter its rewrite
branch, so the window only opens once the bundle thread has processed at
least one batch (it bumps its own generation after every queue drain);
every subsequent `Bun.build()` then re-reads any directory that
`reload()` just refreshed to generation 0.

ASAN catches it as a heap-use-after-free with the two sides of the race
laid out exactly:

```
READ of size 16 (thread T0):
  #6 HashMap::values
  #7 StringHashMap<*mut Entry>::values                       src/collections/array_hash_map.rs:1864
  #8 FileSystemRouter::bust_dir_cache_recursive              src/runtime/api/filesystem_router.rs:395
  #9 FileSystemRouter::bust_dir_cache                        src/runtime/api/filesystem_router.rs:451
  #10 FileSystemRouter::reload                               src/runtime/api/filesystem_router.rs:476

freed by thread T11 (Bundler):
  #11 drop_in_place<bun_resolver::fs_full::DirEntry>
  #12 bun_resolver::fs::RealFS::entries_at                   src/resolver/lib.rs:1639
  #13 DirInfo::get_entries_ref                               src/resolver/dir_info.rs:266
  #14 Resolver::finalize_result                              src/resolver/resolver.rs:1714
  #15 Resolver::resolve_and_auto_install                     src/resolver/resolver.rs:1485
  ...
  oven-sh#23 BundleThread::generate_in_new_thread                   src/bundler/BundleThread.rs:276

previously allocated by thread T0:
  oven-sh#17 HashMap::reserve
  oven-sh#18 Resolver::dir_info_cached_miss                         src/resolver/resolver.rs:4591
  oven-sh#19 Resolver::dir_info_cached_maybe_log                    src/resolver/resolver.rs:4201
  oven-sh#20 Resolver::read_dir_info                                src/resolver/resolver.rs:4118
  oven-sh#21 FileSystemRouter::reload                               src/runtime/api/filesystem_router.rs:492
```

(The use side is sometimes `RouteLoader::load` at
`src/router/lib.rs:816` instead; same map, same lock.)

This has been the shape of `entries_at` since the Rust port; oven-sh#33056
narrowed the race by snapshotting under the lock but assumed the rewrite
side already held it.

## Fix

`entries_at` now takes `entries_mutex` itself, matching
`read_directory_with_iterator` which already does. The one call path
that reaches it with the lock already held (`dir_info_cached_miss` ->
`dir_info_uncached` -> `parent_.get_entries_ref`) routes through a new
`entries_at_locked` / `get_entries_ref_locked` pair so the non-recursive
mutex is not re-entered. That path is the only one that passes a
non-`None` parent to `dir_info_uncached`; the other caller
(`dir_info_for_resolution`) passes `None`, so the parent branch
containing the accessor never runs there.

## Test

The existing concurrency test now awaits one `Bun.build()` first, so the
bundle thread's generation is already past zero when the concurrent
rounds start, and then runs forty reload/build rounds instead of one.
That is the shape that reaches the stale-generation rewrite at all; the
original single-round fixture usually completes with every build still
on generation 0.

The race is scheduling-dependent. Pinning the fixture to a single core
reproduces the ASAN use-after-free on roughly 3 in 10 runs against an
unpatched debug build and 0 in 15 with this change; with all 16 cores
available the unpatched build reproduces at roughly 1 in 30. The
assertions are otherwise the same as before, so the test continues to
cover the behavior oven-sh#33056 added.

Also ran the full `filesystem_router.test.ts`,
`test/bundler/bun-build-api.test.ts` (including the thousands-of-builds
test that exercises the generation path heavily),
`test/js/bun/resolve/resolve.test.ts`, `test/cli/hot/hot.test.ts`,
`test/cli/watch/watch.test.ts`, `test/bake/framework-router.test.ts`,
and `bun run rust:check-all` (10/10 targets).

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

---

**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/util/filesystem_router.test.ts

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Jul 16, 2026
…e drain (oven-sh#34278)

## Problem

`test/js/node/test/parallel/test-worker-stdio-flush.js` went red on the
`debian 13 x64-asan` lane of [build
73374](https://buildkite.com/bun/bun/builds/73374) with:

```
==18202==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 32 byte(s) in 1 object(s) allocated from:
    #9  ConcurrentTask::new src/event_loop/ConcurrentTask.rs:305
    #10 ConcurrentTask::create src/event_loop/ConcurrentTask.rs:319
    #12 bun_jsc::virtual_machine_exports::queue_task_concurrently src/jsc/virtual_machine_exports.rs:140
    #13 ScriptExecutionContext::postTaskConcurrently src/jsc/bindings/ScriptExecutionContext.cpp:266
    #14 ScriptExecutionContext::postTaskTo src/jsc/bindings/ScriptExecutionContext.cpp:125
    #15 MessagePortPipe::scheduleDrain src/jsc/bindings/webcore/MessagePortPipe.cpp:74
    oven-sh#16 MessagePort::postMessage src/jsc/bindings/webcore/MessagePort.cpp:143
```

The leaked allocation is a `ConcurrentTask` (and the `EventLoopTask` it
wraps) left in an exiting worker's `concurrent_tasks` queue after the
queue has been drained for the last time.

## Cause

`WebWorker::shutdown()` runs `process.on('exit')` handlers, then drains
the worker's concurrent queue via `release_queued_tasks_for_shutdown()`,
then enters `WebWorker__teardownJSCVM` which (first thing) calls
`ctx->markTerminating()`. `ScriptExecutionContext::postTaskTo` already
refuses to enqueue onto a terminating context, but between the drain and
the flag flip there is a short window where a cross-thread poster still
sees `isTerminating() == false` and enqueues.

In the failing test the worker writes to `process.stdout` inside its
`exit` handler. The parent's captured-stdout reader acks each chunk with
`port.postMessage(true)` (`src/js/node/worker_threads.ts`
`makePortReadable._read`), which routes through
`MessagePortPipe::scheduleDrain` to `postTaskTo(workerCtxId, ...)`. When
the ack lands in that window it is pushed onto the worker's
`concurrent_tasks`; nothing drains it again, and the worker's VM box is
`dealloc`'d raw, so LSan reports the `ConcurrentTask` as a direct leak.

The window is a few assignments plus one FFI call wide, so it hits
probabilistically; the `release-asan` build is fast enough to line up
occasionally, debug essentially never.

The ordering was introduced in oven-sh#31216; oven-sh#29917 described the same gap ("a
task posted between this drain and `removeFromContextsMap()` inside
`teardownJSCVM` still leaks") but left it open.

## Fix

- `ScriptExecutionContext::markTerminating()` now takes
`allScriptExecutionContextsMapLock`, the same lock `postTaskTo` holds
across its `isTerminating()` check and `postTaskConcurrently()` enqueue.
That makes the flag flip a proper fence against concurrent posters: any
`postTaskTo` critical section either runs entirely before
`markTerminating()` (its task is visible to the subsequent drain) or
entirely after (it observes `true` and drops).
- `WebWorker::shutdown()` calls the new `extern "C"
ScriptExecutionContext__markTerminating` immediately before
`release_queued_tasks_for_shutdown()`, closing the window. The later
`markTerminating()` inside `WebWorker__teardownJSCVM` is now redundant
but harmless.

No behaviour change for `process.on('exit')` itself: that runs before
the new call, so a parent ack posted while the handler is running is
still enqueued and then freed by the drain (never executed, same as
before). Only posts that would have landed after the drain are now
dropped instead of leaked.

## Verification

The gap is too narrow to reproduce unassisted against a debug build: 200
iterations of the Node test with the CI LSan env, and 150 worker
shutdowns with 64 Atomics-synchronized MessagePorts each, all pass on an
unpatched `bun bd`. Widening the gap with a temporary
`std::thread::sleep(5ms)` between `release_queued_tasks_for_shutdown()`
and `WebWorker__teardownJSCVM` makes it deterministic:

| build | `test-worker-stdio-flush.js` under LSan | 200-port
Atomics-synchronized probe |
| --- | --- | --- |
| unpatched + 5 ms sleep | 5/5 leak (`32 byte(s) ConcurrentTask`) | 5/5
leak |
| this PR + 5 ms sleep | 10/10 clean | 5/5 clean |
| this PR (no sleep) | 50/50 clean | clean |

`test/js/node/worker_threads/worker-shutdown-post-leak.test.ts` runs the
worker-stdio-on-exit scenario under `detect_leaks=1` as an ASAN-lane
guard (in a fresh file so it actually runs; `worker_destruction.test.ts`
is ASAN-quarantined via `test/expectations.txt`). The race is not
observable on the debug gate without `src/` instrumentation, so the
fail-before half will not fire there; `test-worker-stdio-flush.js` on
the release-asan lane remains the primary signal.

Related: oven-sh#31216 (introduced the ordering), oven-sh#29917 (described but left
the remaining window).

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

---

**no test proof** · iteration 1 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/node/worker_threads/worker-shutdown-post-leak.test.ts

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Jul 23, 2026
…cation (oven-sh#35144)

## What

`http_proxy=HTTP://host:port` (or any scheme not spelled in lowercase)
rejected every request through `fetch` and `bun install` with
`UnsupportedProxyProtocol`, while the same string passed via `fetch(url,
{ proxy: "HTTP://..." })` worked. Similarly, a server responding with
`Location: HTTPS://host/...` failed the redirect with
`UnsupportedRedirectProtocol`.

## Why

RFC 3986 section 3.1 defines the URL scheme as case-insensitive, and
both curl and undici's `EnvHttpProxyAgent` accept the uppercase form.
The `{ proxy }` option path goes through the WHATWG URL parser, which
lowercases the scheme; the `http_proxy` / `HTTPS_PROXY` environment
variables are parsed by `bun_url::URL::parse`, which is a borrowing
parser and keeps `protocol` as a raw slice of the input. The proxy
protocol check in `HTTPThread` and the `is_http()`/`is_https()` helpers
compared those bytes exactly. The redirect follower slices the scheme
out of the raw `Location` header bytes before WHATWG normalization runs
and compared the same way.

## Fix

- `bun_url::URL::is_http`, `is_https`, `is_s3`, `is_file`, and
`has_http_like_protocol` now compare ASCII case-insensitively.
- The two inline scheme checks in `HTTPThread` go through
`has_http_like_protocol()`.
- The two `Location` scheme comparisons in the redirect follower go
through `strings::eql_case_insensitive_ascii`.

This also fixes `get_port_auto()` defaulting `HTTPS://proxy` (no
explicit port) to 80 instead of 443, and `HTTPClient::is_https()`
picking the plaintext context for an `HTTPS://` proxy.

## Verification

```
$ USE_SYSTEM_BUN=1 bun test test/js/bun/http/proxy.test.ts -t "http_proxy env var scheme"
(fail) http_proxy=HTTP://... is accepted
  error: UnsupportedProxyProtocol fetching "http://127.0.0.1:.../x"
 1 pass / 3 fail

$ USE_SYSTEM_BUN=1 bun test test/js/web/fetch/fetch-redirect.test.ts -t "Location scheme"
(fail) Location: HTTP://...
  error: UnsupportedRedirectProtocol fetching "http://127.0.0.1:.../start"
 0 pass / 3 fail

$ bun bd test test/js/bun/http/proxy.test.ts -t "http_proxy env var scheme"
 4 pass / 0 fail
$ bun bd test test/js/web/fetch/fetch-redirect.test.ts -t "Location scheme"
 3 pass / 0 fail
```

Full `proxy.test.ts` (62 tests) and `fetch-redirect.test.ts` (15 tests)
pass.

Related: oven-sh#16182 (this covers scheme case only; full WHATWG normalization
of the proxy env URL is still open)

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

---

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

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

```console
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/proxy.test.ts test/js/web/fetch/fetch-redirect.test.ts
bun test v1.4.0 (c50a91c)

test/js/bun/http/proxy.test.ts:
(pass) GET non-TLS proxy -> non-TLS body type undefined [853.99ms]
(pass) POST non-TLS proxy -> non-TLS body type string [836.44ms]
(pass) GET TLS proxy -> non-TLS body type undefined [985.30ms]
(pass) GET non-TLS proxy -> TLS body type undefined [1142.59ms]
(pass) POST non-TLS proxy -> TLS body type string [1177.59ms]
(pass) POST TLS proxy -> non-TLS body type string [525.95ms]
(pass) GET TLS proxy -> TLS body type undefined [752.01ms]
(pass) POST TLS proxy -> TLS body type string [769.62ms]
(pass) proxy can handle redirects with non-TLS server > with empty body oven-sh#12007 [937.61ms]
(pass) proxy can handle redirects with non-TLS server > with body oven-sh#12007 [1119.27ms]
(pass) proxy can handle redirects with TLS server > with empty body oven-sh#12007 [1255.51ms]
(pass) proxy can handle redirects with TLS server > with body oven-sh#12007 [1104.45ms]
(pass) proxy can handle redirects with non-TLS server > with chunked body #12
... (truncated)

release without fix: 3 failed, 1 skipped
bun test v1.4.0-canary.1 (6930da6)

test/js/bun/http/proxy.test.ts:
(pass) POST non-TLS proxy -> non-TLS body type string [33.08ms]
(pass) GET non-TLS proxy -> non-TLS body type undefined [33.34ms]
(pass) POST TLS proxy -> non-TLS body type string [38.45ms]
(pass) GET TLS proxy -> non-TLS body type undefined [38.48ms]
(pass) POST non-TLS proxy -> TLS body type string [41.92ms]
(pass) GET non-TLS proxy -> TLS body type undefined [44.32ms]
(pass) GET TLS proxy -> TLS body type undefined [49.48ms]
(pass) POST TLS proxy -> TLS body type string [49.48ms]
(pass) proxy can handle redirects with non-TLS server > with empty body oven-sh#12007 [52.35ms]
(pass) proxy can handle redirects with non-TLS server > with body oven-sh#12007 [53.21ms]
(pass) proxy can handle redirects with TLS server > with body oven-sh#12007 [56.46ms]
(pass) proxy can handle redirects with TLS server > with empty body oven-sh#12007 [58.78ms]
(pass) proxy can handle redirects with non-TLS server > with chunked body oven-sh#12007 [650.88ms]
(pass) proxy can handle redirects with TLS server > with chunked body oven-sh#12007 [649.97ms]
(pass) non-TLS origin redirect through HTTPS proxy forwards every hop through the proxy [8.02ms]
(pass) unsupp
... (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/bun/http/proxy.test.ts test/js/web/fetch/fetch-redirect.test.ts
bun test v1.4.0 (c50a91c)

test/js/bun/http/proxy.test.ts:
(pass) GET non-TLS proxy -> non-TLS body type undefined [759.01ms]
(pass) POST non-TLS proxy -> non-TLS body type string [742.07ms]
(pass) GET TLS proxy -> non-TLS body type undefined [934.80ms]
(pass) GET non-TLS proxy -> TLS body type undefined [995.92ms]
(pass) POST non-TLS proxy -> TLS body type string [1016.42ms]
(pass) POST TLS proxy -> non-TLS body type string [401.94ms]
(pass) GET TLS proxy -> TLS body type undefined [650.76ms]
(pass) POST TLS proxy -> TLS body type string [572.68ms]
(pass) proxy can handle redirects with non-TLS server > with empty body oven-sh#12007 [817.57ms]
(pass) proxy can handle redirects with non-TLS server > with body oven-sh#12007 [923.98ms]
(pass) proxy can handle redirects with TLS server > with empty body oven-sh#12007 [1008.17ms]
(pass) proxy can handle redirects with TLS server > with body oven-sh#12007 [949.15ms]
(pass) proxy can handle redirects with non-TLS server > with chunked body oven-sh#12007
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 695ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] 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_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap v0.0.0 (/workspace/bun/src/clap)
�[1m�[92m   Compiling�[0m bun_valkey v0
... (truncated)
```

</details>

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

```
src/http/HTTPThread.rs                   |  7 +--
 src/http/lib.rs                          | 20 ++++++--
 src/url/lib.rs                           | 12 +++--
 test/js/bun/http/proxy.test.ts           | 87 ++++++++++++++++++++++++++++++++
 test/js/web/fetch/fetch-redirect.test.ts | 46 +++++++++++++++++
 5 files changed, 159 insertions(+), 13 deletions(-)
```

</details>

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

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

```
file                                      reads  edits  tests
src/http/HTTPThread.rs                        1      2      0
src/http/lib.rs                               3      2      0
src/url/lib.rs                                3      3      0
test/js/bun/http/proxy.test.ts                1      1      0
test/js/web/fetch/fetch-redirect.test.ts      1      1      0
```

</details>

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Jul 29, 2026
…ed (oven-sh#36247)

## What

`test/js/bun/http/bun-serve-html.test.ts` segfaults on `windows-aarch64`
after oven-sh#36175 landed (builds 84162, 84194; one earlier sighting in
83933):

```
panic(main thread): Segmentation fault at address 0x48
Features: ... dev_server(14) ...
```

Symbolicated in oven-sh#36214 as `AsyncFSTask<Access>::run_from_js_thread` with
`self = null`, i.e. a zeroed `ConcurrentTask` was dispatched.

## Cause

`DevServer.watcher_atomics.events[*].concurrent_task` is the intrusive
MPSC node the watcher thread links into `EventLoop.concurrent_tasks`
when it submits a hot-reload event. It was an inline field of
`DevServer`, so `server.stop()` → `drop(Box<DevServer>)` freed it while
it was still linked. The next `tick_concurrent` then read
`.next`/`.task`/`.auto_delete` from freed memory. ASAN on Linux
confirms:

```
heap-use-after-free: ConcurrentTask::get_next (unbounded_queue.rs)
  ← BatchIterator::next ← EventLoop::tick_concurrent_with_count
freed by: Box<DevServer>::drop ← NewServer::deinit_if_we_can
  ← NewServer::stop ← dispose_from_js (using server)
```

On release builds the freed block reads back as zeros, so the copied
`Task` is `{tag: 0, ptr: null}`; tag 0 is `task_tag::Access`, whose
`run_from_js_thread` loads `self.result` at offset `0x48`.

The bug is latent and platform-agnostic. oven-sh#36175 exposed it because the
CI runner now spawns the napi addon prebuild in the background while
serial tests run; that writes under the watched project root, so the
`jsx-runtime` DevServers in this test file now reliably receive a
hot-reload event between the last `await fetch` and `using server`
disposal.

## Fix

`watcher_atomics` is now a `NonNull<WatcherAtomics>` owned via
`bun_core::heap::into_raw`, so the allocation can outlive `DevServer`
and every queued pointer keeps allocation-root provenance.
`watcher_acquire_event`, `watcher_release_and_submit_event` and
`recycle_event_from_dev_server` take `*mut Self` and derive the returned
`*mut HotReloadEvent` (and the linked `concurrent_task` node) from that
root pointer via raw place projections rather than from a `&mut
WatcherAtomics` reborrow.

`Drop for DevServer` reads `next_event` after `Watcher::shutdown` has
serialised out the watcher thread (which guarantees it is stable):

- `DONE`: nothing is queued; clear and `heap::destroy` as before.
- otherwise: a `concurrent_task` is still linked (or its `Task` is
already in the drain FIFO). Null `owner` on every event and leave the
allocation alive.

`HotReloadEvent::run` checks `owner.is_null()` first; when set it
reclaims the allocation via the new `atomics` backref and returns
without touching the dead `DevServer`. The `# Safety` contracts on `run`
and the `BakeHotReloadEvent` dispatch arm are updated to describe the
null-owner case.

## Test

`test/js/bun/http/bun-serve-html-hot-reload-drop.test.ts` creates a
development server, bundles once so `app.js` is watched, synchronously
rewrites `app.js`, spins briefly without yielding so the watcher thread
can enqueue, disposes the server, then yields. Ten iterations. In a
separate file because the React-bundling cases in
`bun-serve-html.test.ts` already exceed the default per-test timeout
under a debug+ASAN build on `main`.

<details><summary>fail-before (debug+ASAN, src/ at main)</summary>

```
==25521==ERROR: AddressSanitizer: heap-use-after-free on address 0x79315e4743e8
READ of size 8 at 0x79315e4743e8 thread T0
    #2 <ConcurrentTask as Node>::get_next                unbounded_queue.rs:82
    #3 BatchIterator<ConcurrentTask>::next               unbounded_queue.rs:135
    #4 EventLoop::tick_concurrent_with_count             event_loop.rs:507
0x79315e4743e8 is located 488 bytes inside of 16512-byte region
freed by thread T0 here:
    #9  Box<DevServer>::drop
    #12 NewServer<false,true>::deinit_if_we_can          mod.rs:1770
    #13 NewServer<false,true>::stop                      mod.rs:1665
    #14 NewServer<false,true>::dispose_from_js           server_body.rs:2584
```

</details>

Passes with the fix in ~2.4s under debug+ASAN (also on a local
`windows-aarch64` debug build, where the original
`bun-serve-html.test.ts` is now 19/19);
`test/bake/deinitialization.test.ts` still green.

Supersedes the producer half of oven-sh#36214 (which adds a sentinel for the
same zeroed-task symptom).

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

---

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

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

```console
ASAN without fix: 1 failed, 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/bun-serve-html.test.ts test/js/bun/http/bun-serve-html-hot-reload-drop.test.ts
bun test v1.4.0 (5f6622f)

test/js/bun/http/bun-serve-html.test.ts:
waitForServer /tmp/html-css-js_ObkyZk {
  "/": "/tmp/html-css-js_ObkyZk/index.html",
  "/dashboard": "/tmp/html-css-js_ObkyZk/dashboard.html",
}
[0.12ms] bundle index.html 1.09 KB
[0.05ms] bundle dashboard.html 1.27 KB
(pass) serve html [630.67ms]
waitForServer /tmp/bun-serve-html-txt_5C6B7a {
  "/": "/tmp/bun-serve-html-txt_5C6B7a/index.html",
}
[0.15ms] bundle index.html 0.40 KB
HASH efbnbska
(pass) serve plugins > basic plugin [556.20ms]
waitForServer /tmp/html-css-js-failing-plugin_OPRhwb {
  "/": "/tmp/html-css-js-failing-plugin_OPRhwb/index.html",
}
error: Plugin failed intentionally
    at /tmp/html-css-js-failing-plugin_OPRhwb/styles.css:0
error: Plugin failed intentionally
    at /tmp/html-css-js-failing-plugin_OPRhwb/styles.css:0
(pass) serve plugins > serve html with failing plugin [491.35ms]
waitForServer /tmp/html-css-js-empty-plugins_biqnN6 {
  "/": "/tmp/htm
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (96ff7ec)

test/js/bun/http/bun-serve-html.test.ts:
waitForServer /tmp/html-css-js_ZmgkBG {
  "/": "/tmp/html-css-js_ZmgkBG/index.html",
  "/dashboard": "/tmp/html-css-js_ZmgkBG/dashboard.html",
}
[0.00ms] bundle index.html 1.09 KB
[0.00ms] bundle dashboard.html 1.27 KB
(pass) serve html [25.17ms]
waitForServer /tmp/bun-serve-html-txt_uWNogT {
  "/": "/tmp/bun-serve-html-txt_uWNogT/index.html",
}
[0.00ms] bundle index.html 0.40 KB
HASH efbnbska
(pass) serve plugins > basic plugin [17.25ms]
waitForServer /tmp/html-css-js-failing-plugin_Kd1a8p {
  "/": "/tmp/html-css-js-failing-plugin_Kd1a8p/index.html",
}
error: Plugin failed intentionally
    at /tmp/html-css-js-failing-plugin_Kd1a8p/styles.css:0
error: Plugin failed intentionally
    at /tmp/html-css-js-failing-plugin_Kd1a8p/styles.css:0
(pass) serve plugins > serve html with failing plugin [16.33ms]
waitForServer /tmp/html-css-js-empty-plugins_Ecz7qJ {
  "/": "/tmp/html-css-js-empty-plugins_Ecz7qJ/index.html",
}
[0.00ms] bundle index.html 0.71 KB
(pass) serve plugins > empty plugin array [13.23ms]
Waiting for server
waitForServer /tmp/html-css-js-concurrent-plugins_l7wQg5 {
  "/": "/tmp/
... (truncated)
```

</details>

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

```console
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/bun-serve-html.test.ts test/js/bun/http/bun-serve-html-hot-reload-drop.test.ts
bun test v1.4.0 (5f6622f)

test/js/bun/http/bun-serve-html.test.ts:
waitForServer /tmp/html-css-js_CpXhx4 {
  "/": "/tmp/html-css-js_CpXhx4/index.html",
  "/dashboard": "/tmp/html-css-js_CpXhx4/dashboard.html",
}
[0.09ms] bundle index.html 1.09 KB
[0.05ms] bundle dashboard.html 1.27 KB
(pass) serve html [588.64ms]
waitForServer /tmp/bun-serve-html-txt_rjZL4e {
  "/": "/tmp/bun-serve-html-txt_rjZL4e/index.html",
}
[0.15ms] bundle index.html 0.40 KB
HASH efbnbska
(pass) serve plugins > basic plugin [552.11ms]
waitForServer /tmp/html-css-js-failing-plugin_D8NJ2O {
  "/": "/tmp/html-css-js-failing-plugin_D8NJ2O/index.html",
}
error: Plugin failed intentionally
    at /tmp/html-css-js-failing-plugin_D8NJ2O/styles.css:0
error: Plugin failed intentionally
    at /tmp/html-css-js-failing-plugin_D8NJ2O/styles.css:0
(pass) serve plugins > serve html with failing plugin [505.55ms]
waitForServer /tmp/html-css-js-empty-plugins_vK0DVI {
  "/": "/tmp/htm
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 673ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/7] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[2/7] 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
... (truncated)
```

</details>

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

```
src/runtime/bake/DevServer.rs                      |  63 +++-
 src/runtime/bake/dev_server/lifecycle.rs           |  12 +-
 src/runtime/bake/dev_server/mod.rs                 | 401 +++++++++++----------
 src/runtime/dispatch.rs                            |  10 +-
 .../http/bun-serve-html-hot-reload-drop.test.ts    |  82 +++++
 test/js/bun/http/bun-serve-html.test.ts            |  10 +-
 6 files changed, 374 insertions(+), 204 deletions(-)
```

</details>

**gate history** · 3 passed · 2 rejected · iteration 4

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

```
file                                                     reads  edits  tests
src/runtime/bake/DevServer.rs                               10     13      0
src/runtime/bake/dev_server/lifecycle.rs                     5      9      0
src/runtime/bake/dev_server/mod.rs                          13     12      0
src/runtime/dispatch.rs                                      3      2      0
test/js/bun/http/bun-serve-html-hot-reload-drop.test.ts      1      5      0
test/js/bun/http/bun-serve-html.test.ts                      6      9      0
```

</details>

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

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
springmin pushed a commit that referenced this pull request Jul 29, 2026
…rier (oven-sh#36337)

`JSNativeStreamSourceAdapter::m_controller` was a
`JSC::Weak<JSReadableStreamDefaultController>`. When the native pull
promise is rejected (socket fault on a fetch body) the adapter is queued
as the `onNativePullRejected` reaction context, which roots the
**adapter** but not the **controller**: the adapter's only edge to it
was the `Weak`. `FetchTasklet` releases both native `Strong<>`s to the
body stream before that microtask drains, so a GC in between can leave
the entire consumer graph (`controller -> stream -> reader -> pipe op ->
destination -> writer -> readyPromise`) white. The subsequent error
cascade then enqueues the pipe's writes-drained shutdown deferral
against a corpse `op`, and `performPipeShutdownAction(AbortDestination)`
dereferences a swept `readyPromise`:

```
ASSERTION FAILED: result   JSObject.h(583) JSGlobalObject *JSC::JSObject::realm() const
#5  JSC::JSObject::realm()
#6  JSC::JSPromise::rejectPromise
#7  JSC::JSPromise::reject
#8  Bun::WebStreams::writableStreamDefaultWriterEnsureReadyPromiseRejected
#9  Bun::WebStreams::writableStreamStartErroring
#10 Bun::WebStreams::writableStreamAbort
#11 WebCore::performPipeShutdownAction (AbortDestination)
#12 WebCore::JSStreamPipeToOperation::onWritesFinishedForShutdown
```

On builds without the assert the same path is a silent write into
freed/reused promise memory.

## Fix

Hold `m_controller` as a visited internal field so a queued adapter
roots the controller directly. The edge is cleared on every terminal
path (`nativeSourcePullRejected`, `nativeSourceCallClose`,
`nativeSourceCancel`); `controller->algorithmContext` is cleared by
`readableStreamDefaultControllerClearAlgorithms`, so the abandoned case
is an ordinary intra-heap cycle mark-sweep collects.
`NewSource::this_jsvalue` is only `Strong` during FileReader I/O, where
pinning the consumer graph is the correct behavior anyway.

With the `Weak` gone the adapter no longer needs a destructor, so it is
now a `JSInternalFieldObjectImpl<5>`: the five JSValue members (handle,
pendingView, closer, drainValue, controller) are internal fields visited
by the base class, with typed accessors at call sites. The scalar
members (chunkSize, flag bitfield, text-decode state) stay as plain
members.

## Verification

`native-source-onclose-leak.test.ts` (the partial-read + `releaseLock`
abandonment tests for Blob/fetch/File sources) continues to pass,
confirming the cycle does not pin. `streams.test.js`,
`pipeTo-signal-leak.test.ts`, `compression.test.ts`, `blob.test.ts` all
pass.

The crash itself is 0/1800 standalone; it reproduces ~1/3 only under a
fault-injected tracer replay. `pipeTo-shutdown-gc.test.ts` exercises the
shape (native body source, socket fault mid-stream, fire-and-forget
`pipeTo` under `collectContinuously`, `AbortDestination` shutdown arm)
as a regression surface.

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

---

**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/web/streams/pipeTo-shutdown-gc.test.ts

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Aug 2, 2026
…during VM shutdown (oven-sh#36750)

Fixes `test/js/bun/http/bun-serve-html-405.test.ts` going red on
x64-asan (build [87498](https://buildkite.com/bun/bun/builds/87498) and
several unrelated PR builds since ~87000).

## Repro

```
BUN_DESTRUCT_VM_ON_EXIT=1 ASAN_OPTIONS=detect_leaks=1 \
LSAN_OPTIONS=suppressions=test/leaksan.supp \
bun-debug test test/js/bun/http/bun-serve-html-405.test.ts
```

```
Indirect leak of 2104 byte(s) in 1 object(s) allocated from:
    ...
    #11 new<bun_runtime::server::NewServer<false, true>>
    #12 init<false, true> src/runtime/server/mod.rs:2009:47
    #13 bun_runtime::api::bun_object::serve src/runtime/api/BunObject.rs:1564:26
    ...
    oven-sh#21 BunObject_callback_serve src/runtime/api/BunObject.rs:230:25
SUMMARY: AddressSanitizer: 2942 byte(s) leaked in 7 allocation(s).
```

10/10 without this change, 0/10 with it (local debug+ASAN).

## Cause

`using server = Bun.serve({ development: true, routes: { "/": html } })`
disposes via `stop(true)`, which makes `deinit_if_we_can()` downgrade
`js_value` to `Weak` and return. The `NewServer` Box is only freed once
the JS wrapper's `finalize()` fires and `schedule_deinit()` enqueues the
actual `deinit()` as a `ManagedTask`.

When the wrapper survives to `lastChanceToFinalize`
(`BUN_DESTRUCT_VM_ON_EXIT=1`, which the CI runner sets on ASAN lanes),
`global_exit()` has already had its last event-loop tick. The enqueued
task never runs, and `EventLoop::deinit()` drops the task box without a
cleanup (`ManagedTask::new` sets `cleanup: None`). The 2104-byte
`NewServer<false, true>` Box, its `config.static_routes` Vec, the
`html_bundle::Route` it refcounts, and the route's path strings are all
orphaned. `Route.server: Cell<Option<AnyServer>>` points back at the
server so LSan sees a pointer cycle and reports every allocation as
indirect.

The path has always existed, but before oven-sh#35356 the per-tick GC sampler
usually collected the wrapper during the handful of event-loop ticks
between the test body and `global_exit()`, so `schedule_deinit()` ran
while the loop was still live. With only the 1s idle-timer GC, a single
fast test like this one reaches shutdown with the wrapper still alive
more often (about half the PR builds since the merge).

## Fix

`schedule_deinit()` now sets `DEINIT_SCHEDULED` and returns without
enqueueing when `is_shutting_down()`. `finalize()` then frees the Box
synchronously when the server has been fully drained: it unboxes via
`Box::into_raw` first so the dealloc goes through the raw owning pointer
rather than a `&mut self` frame (whose FnEntry protector would make the
dealloc Stacked-Borrows UB, same pattern as `Listener::finalize` /
`UDPSocket::finalize`). Every JSC handle on the Drop chain
(`JSPromiseStrong`, `JsRef`, `UserRouteBuilder.callback: Strong`)
funnels through `Strong::Impl::destroy`, which is a no-op past
`is_shutting_down()`, so freeing here is safe.

The inline free is gated on `TERMINATED`: `NewApp::destroy` runs
`us_socket_group_deinit`, which unlinks the socket group from the loop's
list without closing any sockets still in it. A graceful `stop()` only
closes the listener and leaves keep-alive sockets open in the group;
destroying the app there would orphan them (seen as a 280-byte
`us_poll_t` direct leak on
`vendor/elysia/test/core/before-handle-arrow.test.ts` with an earlier
revision of this PR, and a `US_ASSERT(head_sockets==NULL)` abort on the
debug build). `TERMINATED` is set only once `app.close()` has run, so
the inline free is taken for abruptly-stopped servers (what `using
server` does) and skipped for gracefully-stopped ones, which is
identical to `main`'s behaviour for them.

Other callers that can reach `schedule_deinit()` past shutdown (a last
request draining inside `close_all_socket_groups`, which runs before
`lastChanceToFinalize`) only set the flag and leave the Box, since
`NewApp::destroy` there would delete the uws socket group mid-iteration.

## Verification

Two ASAN-only subprocess tests added:
- abrupt `stop(true)` of a dev server with an HTML route under
`BUN_DESTRUCT_VM_ON_EXIT=1` + `detect_leaks=1`: fails on `main` with the
7-allocation LSan report above; passes with this change.
- graceful `stop()` of a plain server with a keep-alive client
connection: passes on both `main` and this change (asserts
`us_socket_group_deinit`'s `head_sockets==NULL` precondition, which an
earlier revision of this change violated).

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

---

**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/bun/http/bun-serve-html-405.test.ts

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Aug 2, 2026
)

## What

`JSSink::assign_to_stream` now detaches the freshly created
`JSReadable*SinkController` (nulling its `m_sinkPtr`) when the C++
stream-pump setup returns an error, before returning to the caller.

## Why

The generated `${name}__assignToStream` functions create the controller
with `m_sinkPtr = sinkPtr` and then call into
`GlobalObject::assignToStream` → `readDirectStream` /
`readStreamIntoSink`. If that setup throws (for example a direct
`ReadableStream` whose `pull` getter throws), the controller is never
started, so nothing ever calls `end()`/`close()` to null `m_sinkPtr`.
The caller's error path (`Writable::init` for `Bun.spawn`) then releases
and frees the native sink. When the controller is later swept, its
destructor runs `${name}__controllerDetached` / `${name}__finalize` on
freed memory.

ASAN report:

```
heap-use-after-free on address 0x799feed81c78
READ of size 1
  #0 JSSink<FileSink>::js_controller_detached  Sink.rs:567
  #1 FileSink__controllerDetached              generated_jssink.rs:179
  #2 JSReadableFileSinkController::~JSReadableFileSinkController()

freed by:
  #12 FileSink::deinit                         FileSink.rs:1142
  oven-sh#16 Writable::pipe_release                   Writable.rs:70
  oven-sh#17 Writable::init                           Writable.rs:339
  oven-sh#18 spawn_maybe_sync                         js_bun_spawn_bindings.rs:1379
```

The fix is at the generic `JSSink::assign_to_stream` layer so it covers
every sink type (`FileSink`, `NetworkSink`, `FetchRequestBodySink`,
...), not just the spawn path.

## Repro

```js
const { openSync, closeSync } = require("node:fs");
const fd = openSync("/tmp/out.txt", "w");
let armed = false;
const stream = new ReadableStream({
  type: "direct",
  get pull() { if (armed) throw new Error("pull unavailable"); return () => {}; },
});
armed = true;
try {
  Bun.spawn({ cmd: [process.execPath, "-e", "0"], stdio: [stream, fd, "ignore"] });
} catch {}
closeSync(fd);
Bun.gc(true);   // sweep -> controller dtor -> UAF
```

## Tests

The two existing `spawn.test.ts` cases that cover the
stdin-stream-setup-throws path now force a full GC in the child fixture
so the controller destructor runs deterministically under debug+ASAN as
well. Previously they were only failing on the release-asan lane (where
the whole file has been quarantined as `[ASAN] [TIMEOUT]`), which is why
this went unnoticed.

```
bun bd test test/js/bun/spawn/spawn.test.ts -t "stdin stream setup fails"
```

fails on `main` (ASAN heap-use-after-free in the child's stderr) and
passes with this change.
`spawn-stdin-readable-stream-edge-cases.test.ts` and
`body-stream.test.ts` continue to pass.
springmin pushed a commit that referenced this pull request Aug 5, 2026
… sink ends inline (oven-sh#36939)

### Crash

Sentry [BUN-3BZF](https://bun-p9.sentry.io/issues/?query=BUN-3BZF)
(2,975 events since 2026-05-25, macOS-dominant): `Panic: called
Option::unwrap() on a None value` at `FetchTasklet::callback`'s
`task_ref.http.as_mut().unwrap()`, reached from the HTTP thread's result
dispatch (`us_internal_ssl_on_data -> HTTPClient::fail ->
dispatch_result_and_reset -> AsyncHTTP::on_async_http_callback_raw ->
FetchTasklet::callback`). `http` is set once at creation and cleared
only at deinit, so the panic means the callback ran against a freed
`FetchTasklet`.

### Cause

`start_request_stream` takes a `+1` on the tasklet that must be released
exactly once by `write_end_request`. For a native `ByteStream` request
body (an upstream response body piped into `fetch()`),
`wire_native_sink` installs the sink's `source` handle *before* any of
its `EndedInline` returns (`ReadableStream.rs:328` vs `:337/:352/:359`),
so a stream that picked up an error or its last chunk between `fetch()`
and the `can_stream` tick comes back `EndedInline` with a native source
attached.

The `EndedInline` arm released the `+1` (via `write_end_request`) but
left `self.sink` installed with `ended == false`. Every terminal path
then runs `cancel_request_body_sink`, which saw a "live" native sink and
took its native arm: `abort_task()` plus a second `write_end_request` —
releasing the same `+1` again.

The double release collapses the refcount while the other owners (the
JS-side initial ref and the HTTP thread's in-flight ref) still use the
tasklet. Under ASAN the deterministic form is the trace below (deinit
runs inside `cancel_request_body_sink`, then `on_progress_update` keeps
using `self`). In release builds the same imbalance frees the tasklet
while it is still in use (or double-frees, handing a live tasklet's
block back to the allocator), which surfaces as downstream crashes in
the fetch completion path — the BUN-3BZF unwrap is the tasklet's `http`
field read from freed/recycled memory.

```
READ of size 8 ... core::mem::replace::<bun_jsc::js_promise::Strong>
  #2 FetchTasklet::on_progress_update        FetchTasklet.rs:1158
freed by thread T0 here:
  #12 FetchTasklet::deinit                   FetchTasklet.rs:509
  oven-sh#16 FetchTasklet::write_end_request        FetchTasklet.rs:2281
  oven-sh#17 FetchTasklet::cancel_request_body_sink FetchTasklet.rs:2368
  oven-sh#18 FetchTasklet::on_progress_update       FetchTasklet.rs:1143
```

### Fix

Leave the sink in the same state `end_from_stream` (the normal native
termination) leaves it: `ended = true`, source and task detached. The
terminal `cancel_request_body_sink` then hits its existing `if
sink.ended { return }` guard and cannot release the ref a second time
(it also no longer spuriously aborts a request whose body simply ended
inline).

### Verification

- New fixture `fetch-stream-body-ended-inline-fixture.ts` drives the
window: an upstream server that advertises a larger `content-length`
than it sends and closes a few ms later, piped as the body of a TLS
`fetch()` (the handshake keeps the wire-attempt window open), 100
iterations.
- Unfixed debug+ASAN build: heap-use-after-free with the trace above,
8/8 runs.
- Fixed build: `bun bd test
test/js/web/fetch/fetch-abort-stream-body.test.ts` passes (5 pass, 1
pre-existing skip), including the new test.
- `test/js/web/fetch/body-stream.test.ts`: 9086 pass / 0 fail.
`fetch.test.ts` and `fetch.stream.test.ts`: identical pass/fail counts
to an unfixed baseline in the same container (the failures are
pre-existing network/timeout issues).
- The test is `skipIf(!isASAN)`: the release build corrupts silently, so
only sanitizer lanes can observe the failure.
springmin pushed a commit that referenced this pull request Aug 8, 2026
…zed mid-run (oven-sh#37177)

### Problem

Terminating a worker while a `Bun.$` command's external subprocess is
still running leaks the whole in-flight exec. LSan on a debug build
reports (among 6 allocations, 1592 bytes):

```
Indirect leak of 416 byte(s) in 1 object(s) allocated from:
    ... PipeReader::create ... Readable::init ...
    #12 <bun_runtime::shell::subproc::ShellSubprocess>::spawn_maybe_sync_impl /workspace/bun/src/runtime/shell/subproc.rs:699
```

With a buffer stdin redirect (`$\`cmd < ${buf}\``), the pending static
stdin writer and its copied stdin bytes (1 MB in the repro) leak as
well; a `> file` redirect leaks its `CowFd`; a `> ${arraybuffer}`
redirect leaks the pinned buffer.

Repro: a worker starts `Bun.$` running an external command that blocks
(e.g. `sh -c '... sleep ...'`), the main thread calls
`worker.terminate()` while it runs, process exits under
`ASAN_OPTIONS=detect_leaks=1`.

### Cause

`worker.terminate()` tears down the worker's VM while the shell command
is in flight. `Heap::lastChanceToFinalize` sweeps the
`JSShellInterpreter` wrapper regardless of its pending activity, so
`Interpreter::deinit_from_finalizer` runs in the `NeedsFullCleanup`
state. That path freed subshell-owned envs and the root shell/io, but
dropped the node arena without deiniting live nodes. `Node` has no
`Drop` for its raw-pointer-owned resources, so an in-flight `Cmd`'s
`ShellSubprocess` box, its `Arc<PipeReader>`s (stdout/stderr), their
file polls, the `Process`, a pending `StaticPipeWriter` stdin, and any
`redirection_fd` were all leaked, and the child process was left running
with no owner.

### Fix

- `Interpreter::deinit_from_finalizer`: on `NeedsFullCleanup`, deinit
every live `Cmd` node before the existing env walk (slots are left
occupied so the env walk still sees pipeline-duped Cmd envs; the arena
`Vec` drops with the box). `Cmd::deinit` already kills the child and
frees the subprocess.
- `ShellSubprocess::deinit_in_flight_io` (new): called from
`Cmd::deinit` before freeing the box, it stops stdio that is still
active, which only happens on this forced-teardown path. Mid-read pipe
readers are stopped with `BufferedReader::deinit()` (deregisters the
poll, closes the fd, fires no `on_reader_done`), mirroring how the JS
`Subprocess` finalizer closes a mid-read reader. Capture chunks still
queued on the shell's `IOWriter` are cancelled because its queue holds a
raw pointer into the `PipeReader` being freed. A pending buffer-stdin
writer is closed the same way `Subprocess::close_io` does it: claim
`start()`'s outstanding ref, `close()` (which releases `create()`'s ref
via `on_close_io`), then release the claimed ref.
- `Cmd::deinit`: restructured so no `&mut Cmd` borrow is live across the
exec teardown, since the stdin close re-enters this same `Cmd` through
`on_close_io` -> `buffered_input_close` (a no-op once `exec` is taken).
- `Cmd::deinit_from_finalizer` / unpin defusal: review of the first
iteration caught that dropping a `> ${arraybuffer}` redirect's pinned
buffer from inside `Heap::lastChanceToFinalize` calls
`JSC__JSValue__unpinArrayBuffer` on a `JSC::ArrayBuffer` impl that
`Heap::m_arrayBuffers.lastChanceToFinalize()` already deleted (verified
as a heap-use-after-free under `Malloc=1`, which forces system malloc so
ASAN can see JSC's libpas-backed memory; without it the write is
silent). The finalizer walk now clears the pinned values first, for both
the subprocess `BufferedOutput` and builtin `PinnedArrayBuf` holders, so
the drops skip the unpin while the `Strong` handles still release
normally. The builtin side of this was reachable before this PR too, via
the node arena's `Vec` drop.

POSIX only: on Windows the forced teardown is skipped, matching the
existing leak-over-UAF tradeoff of `abort_after_failed_start` (closing
live libuv sources from the finalizer is unsafe there).

Behavior note: the child process is now killed (SIGKILL) when the
interpreter is finalized mid-run, instead of being orphaned with nothing
ever observing it. That is the same behavior `Cmd::deinit` already has
for a live child, and after the owning VM is gone nothing could ever
consume the child's pipes or exit status. The killed child is not reaped
until the bun process exits (the process watcher was detached before the
kill), so it shows as a zombie until then; the previous behavior left a
live orphan running indefinitely plus the leak.

### Verification

`test/js/bun/shell/shell-worker-terminate-leak.test.ts`:

- Five LSan variants (asan lane): plain command, buffer stdin,
two-command pipeline, `> file` redirect, `> ${arraybuffer}` redirect.
All five fail on main with the leak reports above and pass with the fix.
- One `Malloc=1` ASAN variant pinning the unpin-after-finalize
use-after-free: passes on main and with this PR, fails on the first
iteration of this PR (which freed the buffer without defusing the pin).
- One functional Linux test, not gated on ASAN (so it also runs on
main-branch CI where the asan lane is dropped): asserts the in-flight
child is actually killed at terminate, observed via `/proc/<pid>/stat`
as zombie-or-gone. Fails on main (the child stays alive), passes with
the fix.

Full shell suite and worker suites pass (the only local failures are
load-dependent RSS-timeout tests and a dns teardown test that fail
identically on main; the latter is the separately tracked node_fs
binding leak).

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

---

**no test proof** · iteration 1 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/shell/shell-worker-terminate-leak.test.ts

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Aug 10, 2026
…ven-sh#37273)

### Problem

`H2FrameParser::handle_received_stream_id` creates a `Stream` box,
inserts it into the stream map, and then invokes the JS `streamStart`
callback directly via `callback.call` without arming the
`DispatchGuard`, while still holding the raw `*mut Stream`. Every other
JS dispatch site in the parser arms the guard, because `rewrite_read`
frees streams queued in `pending_engine_stream_closes` only at dispatch
depth 0.

JS reached from inside that callback (the `Http2Stream` constructor
calls `this.on("pause", ...)`, so a patched `EventEmitter.prototype.on`
runs there; the handler also calls back into native `rstStream` for
refused streams) can close the just-created stream, queueing its
deferred free, and then re-enter `parser.read()` at depth 0. The drain
frees the box, and the callback return path writes the stream context
through the dangling pointer:

```
==ERROR: AddressSanitizer: heap-use-after-free ...
    #1 <bun_runtime::api::h2_frame_parser_body::Stream>::set_context src/runtime/api/bun/h2_frame_parser.rs:2110
    #2 <...H2FrameParser>::handle_received_stream_id src/runtime/api/bun/h2_frame_parser.rs:5372
    #3 <...H2FrameParser>::get_next_stream src/runtime/api/bun/h2_frame_parser.rs:8335
freed by:
   #12 <...H2FrameParser>::rewrite_read::{closure#3} src/runtime/api/bun/h2_frame_parser.rs:5804
```

The callers that keep dereferencing the returned pointer (`request()`,
`get_next_stream`, the engine HEADERS path) were exposed to the same
freed box.

### Fix

Arm `enter_dispatch` across the callback, matching the invariant
documented on `enter_dispatch` (every section that holds a `Stream`
pointer while user JS can run must arm the guard). With the guard armed,
the deferred-close drain cannot run while the callback executes, so the
pointer stays valid for `set_context` and for the callers.

Also skip the context install when the callback closed the stream:
`free_resources` already dropped its `sctx` root, and re-inserting one
afterwards would pin the dead JS stream object until the session dies.

This is the guard-arming fix for the pre-existing issue flagged during
review of oven-sh#37272 (that PR only removes dead code around it).

### Verification

New test in `test/js/node/http2/node-http2-streams-rehash.test.ts` (the
file covering this class of reentrancy bugs) reproduces the exact
sequence: close the new stream and re-enter `read()` from inside the
`streamStart` callback. Without the fix it fails on every build tier:
heap-use-after-free under the ASAN debug build, and on release builds
`getStreamContext(2)` throws "Invalid stream id" because the drain
already freed the entry inside the callback. With the fix the entry
survives the callback with no context installed (covering the
skip-install branch), and a follow-up depth-0 `read()` asserts the
deferred close then actually drains. Existing http2 suites
(`node-http2.test.js`, `h2-conformance.test.ts`, the staged h2 tests,
node's server-push parallel tests) pass with the change.

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

---

**[review]** gate passed · iteration 1 · 2 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/node/http2/node-http2-streams-rehash.test.ts"
bun test v1.4.0 (8f79562)

test/js/node/http2/node-http2-streams-rehash.test.ts:
(pass) session.request() from a stream 'timeout' listener during forEachStream does not UAF on hashmap rehash [3284.09ms]
(pass) http2 client request() does not hold *Stream across user-controlled options getters [6184.76ms]
198 |       env: bunEnv,
199 |       stdout: "pipe",
200 |       stderr: "pipe",
201 |     });
202 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
203 |     expect({ stdout: stdout.trim(), exitCode, stderr }).toMatchObject({ stdout: "OK", exitCode: 0 });
                                                              ^
error: expect(received).toMatchObject(expected)

  {
-   "exitCode": 0,
-   "stdout": "OK",
+   "exitCode": 1,
+   "stderr": 
+ "=================================================================
+ ==101685==ERROR: AddressSanitizer: heap-use-after-free on address 0x79be9bb005c0 at pc 0x00000e7ec22e bp 
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (7725ac8)

test/js/node/http2/node-http2-streams-rehash.test.ts:
(pass) session.request() from a stream 'timeout' listener during forEachStream does not UAF on hashmap rehash [163.99ms]
(pass) http2 client request() does not hold *Stream across user-controlled options getters [78.42ms]
(pass) closing the new stream and re-entering read() inside the streamStart callback does not UAF [31.29ms]
(pass) http2 client write callback that opens new streams during flushQueue does not UAF [49.40ms]
(pass) DeferredTaskQueue::run tolerates an on_auto_flush callback that unregisters itself and returns true [46.51ms]

 5 pass
 0 fail
 5 expect() calls
Ran 5 tests across 1 file. [513.00ms]
__F:0:S:0
```

</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/node/http2/node-http2-streams-rehash.test.ts"
bun test v1.4.0 (8f79562)

test/js/node/http2/node-http2-streams-rehash.test.ts:
(pass) session.request() from a stream 'timeout' listener during forEachStream does not UAF on hashmap rehash [3278.34ms]
(pass) http2 client request() does not hold *Stream across user-controlled options getters [6171.66ms]
(pass) closing the new stream and re-entering read() inside the streamStart callback does not UAF [1906.06ms]
(pass) http2 client write callback that opens new streams during flushQueue does not UAF [2819.28ms]
(pass) DeferredTaskQueue::run tolerates an on_auto_flush callback that unregisters itself and returns true [2618.43ms]

 5 pass
 0 fail
 5 expect() calls
Ran 5 tests across 1 file. [19.19s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 689ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[1/6] 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 v
... (truncated)
```

</details>

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

```
src/runtime/api/bun/h2_frame_parser.rs             |  19 +++-
 .../node/http2/node-http2-streams-rehash.test.ts   | 100 +++++++++++++++++++++
 2 files changed, 115 insertions(+), 4 deletions(-)
```

</details>

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

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

```
file                                                  reads  edits  tests
src/runtime/api/bun/h2_frame_parser.rs                   10      4      0
test/js/node/http2/node-http2-streams-rehash.test.ts      2      3      0
```

</details>

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

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
springmin pushed a commit that referenced this pull request Aug 10, 2026
…ven-sh#37273)

### Problem

`H2FrameParser::handle_received_stream_id` creates a `Stream` box,
inserts it into the stream map, and then invokes the JS `streamStart`
callback directly via `callback.call` without arming the
`DispatchGuard`, while still holding the raw `*mut Stream`. Every other
JS dispatch site in the parser arms the guard, because `rewrite_read`
frees streams queued in `pending_engine_stream_closes` only at dispatch
depth 0.

JS reached from inside that callback (the `Http2Stream` constructor
calls `this.on("pause", ...)`, so a patched `EventEmitter.prototype.on`
runs there; the handler also calls back into native `rstStream` for
refused streams) can close the just-created stream, queueing its
deferred free, and then re-enter `parser.read()` at depth 0. The drain
frees the box, and the callback return path writes the stream context
through the dangling pointer:

```
==ERROR: AddressSanitizer: heap-use-after-free ...
    #1 <bun_runtime::api::h2_frame_parser_body::Stream>::set_context src/runtime/api/bun/h2_frame_parser.rs:2110
    #2 <...H2FrameParser>::handle_received_stream_id src/runtime/api/bun/h2_frame_parser.rs:5372
    #3 <...H2FrameParser>::get_next_stream src/runtime/api/bun/h2_frame_parser.rs:8335
freed by:
   #12 <...H2FrameParser>::rewrite_read::{closure#3} src/runtime/api/bun/h2_frame_parser.rs:5804
```

The callers that keep dereferencing the returned pointer (`request()`,
`get_next_stream`, the engine HEADERS path) were exposed to the same
freed box.

### Fix

Arm `enter_dispatch` across the callback, matching the invariant
documented on `enter_dispatch` (every section that holds a `Stream`
pointer while user JS can run must arm the guard). With the guard armed,
the deferred-close drain cannot run while the callback executes, so the
pointer stays valid for `set_context` and for the callers.

Also skip the context install when the callback closed the stream:
`free_resources` already dropped its `sctx` root, and re-inserting one
afterwards would pin the dead JS stream object until the session dies.

This is the guard-arming fix for the pre-existing issue flagged during
review of oven-sh#37272 (that PR only removes dead code around it).

### Verification

New test in `test/js/node/http2/node-http2-streams-rehash.test.ts` (the
file covering this class of reentrancy bugs) reproduces the exact
sequence: close the new stream and re-enter `read()` from inside the
`streamStart` callback. Without the fix it fails on every build tier:
heap-use-after-free under the ASAN debug build, and on release builds
`getStreamContext(2)` throws "Invalid stream id" because the drain
already freed the entry inside the callback. With the fix the entry
survives the callback with no context installed (covering the
skip-install branch), and a follow-up depth-0 `read()` asserts the
deferred close then actually drains. Existing http2 suites
(`node-http2.test.js`, `h2-conformance.test.ts`, the staged h2 tests,
node's server-push parallel tests) pass with the change.

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

---

**[review]** gate passed · iteration 1 · 2 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/node/http2/node-http2-streams-rehash.test.ts"
bun test v1.4.0 (8f79562)

test/js/node/http2/node-http2-streams-rehash.test.ts:
(pass) session.request() from a stream 'timeout' listener during forEachStream does not UAF on hashmap rehash [3284.09ms]
(pass) http2 client request() does not hold *Stream across user-controlled options getters [6184.76ms]
198 |       env: bunEnv,
199 |       stdout: "pipe",
200 |       stderr: "pipe",
201 |     });
202 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
203 |     expect({ stdout: stdout.trim(), exitCode, stderr }).toMatchObject({ stdout: "OK", exitCode: 0 });
                                                              ^
error: expect(received).toMatchObject(expected)

  {
-   "exitCode": 0,
-   "stdout": "OK",
+   "exitCode": 1,
+   "stderr": 
+ "=================================================================
+ ==101685==ERROR: AddressSanitizer: heap-use-after-free on address 0x79be9bb005c0 at pc 0x00000e7ec22e bp 
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (7725ac8)

test/js/node/http2/node-http2-streams-rehash.test.ts:
(pass) session.request() from a stream 'timeout' listener during forEachStream does not UAF on hashmap rehash [163.99ms]
(pass) http2 client request() does not hold *Stream across user-controlled options getters [78.42ms]
(pass) closing the new stream and re-entering read() inside the streamStart callback does not UAF [31.29ms]
(pass) http2 client write callback that opens new streams during flushQueue does not UAF [49.40ms]
(pass) DeferredTaskQueue::run tolerates an on_auto_flush callback that unregisters itself and returns true [46.51ms]

 5 pass
 0 fail
 5 expect() calls
Ran 5 tests across 1 file. [513.00ms]
__F:0:S:0
```

</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/node/http2/node-http2-streams-rehash.test.ts"
bun test v1.4.0 (8f79562)

test/js/node/http2/node-http2-streams-rehash.test.ts:
(pass) session.request() from a stream 'timeout' listener during forEachStream does not UAF on hashmap rehash [3278.34ms]
(pass) http2 client request() does not hold *Stream across user-controlled options getters [6171.66ms]
(pass) closing the new stream and re-entering read() inside the streamStart callback does not UAF [1906.06ms]
(pass) http2 client write callback that opens new streams during flushQueue does not UAF [2819.28ms]
(pass) DeferredTaskQueue::run tolerates an on_auto_flush callback that unregisters itself and returns true [2618.43ms]

 5 pass
 0 fail
 5 expect() calls
Ran 5 tests across 1 file. [19.19s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 689ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[1/6] 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 v
... (truncated)
```

</details>

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

```
src/runtime/api/bun/h2_frame_parser.rs             |  19 +++-
 .../node/http2/node-http2-streams-rehash.test.ts   | 100 +++++++++++++++++++++
 2 files changed, 115 insertions(+), 4 deletions(-)
```

</details>

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

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

```
file                                                  reads  edits  tests
src/runtime/api/bun/h2_frame_parser.rs                   10      4      0
test/js/node/http2/node-http2-streams-rehash.test.ts      2      3      0
```

</details>

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

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
springmin pushed a commit that referenced this pull request Aug 13, 2026
…ails on POSIX (oven-sh#37774)

### Problem
- On POSIX, a Buffer or Blob passed as a child's `stdin` leaks the
native writer that pumps it into the child when the write fails before
the buffer drains, typically `EPIPE` because the child closed its stdin
without reading it.
- Reached by the shell's `cmd < ${buffer}` redirect on every POSIX
configuration, and by `Bun.spawn` always on macOS but on Linux only when
memfd is unavailable.
- Under LeakSanitizer the shell's stranded writers appear as `Direct
leak of 1024 byte(s) in 2 object(s)` from
`StaticPipeWriter<ShellSubprocess>::create`; with refcount logging the
count stays at 1 forever after `onError(err=EPIPE: Broken pipe
(send()))` and `onClose()`.
- Cause: the writer holds a ref on itself while a write is in flight,
and only a drained buffer or the owner closing it at exit released that
ref. After a failed write the writer closes itself and the owner empties
its slot, so nothing that could release the ref can still reach the
writer.

### Fix
- The writer's close callback releases the in-flight ref on every
platform, not only Windows. Sound because close is the last callback a
parent receives for an error, and every release site claims the same
token first, so exactly one of them (drain completion, close after a
failure, or the owner) releases.
- The POSIX drain loop now only writes and cannot invoke callbacks
itself; an error is always returned and reported once by the caller.
This deletes the arm that oven-sh#35297 cited when it fixed the same leak on
Windows only; the arm was unreachable, so no behaviour changes here.
- Two rarer error paths get the same report-then-close shape: a failed
poll re-registration used to report without closing, and a zero-byte
write closed without releasing. Both are reasoned about, not tested. Out
of scope, pre-existing: on Windows a write failing synchronously inside
`start()` still strands the ref; tracked separately.
- Verification: a new ASAN-lane test runs a fixture under
`detect_leaks=1` that drives both owners through failing and draining
writes. It fails 4 of 4 runs without the fix with the report above and
passes 5 of 5 with it. LSan only reports the shell-owned writers; the
`Bun.spawn` cases exercise the same teardown without being what fails.

### Background
- The static pipe writer is the native object that writes one fixed
in-memory buffer (a Buffer or Blob `stdin`) into a child's stdin pipe.
It is refcounted: one ref lives in the owning process's stdin slot, a
second is taken when the write starts so the object outlives the write.
- It has two owners: a `Bun.spawn` subprocess and a shell subprocess for
the `< ${buffer}` redirect. On Linux `Bun.spawn` normally hands the
child a memfd and never creates the writer, which is why the test
disables memfd; the shell redirect always uses it.
- The buffered pipe writer underneath reports to its parent through
three callbacks: bytes drained, error, closed. Parents are written
assuming closed is the last one they receive, so freeing the object
there is safe only if nothing underneath touches it afterwards.
- The `started` flag is the token for the in-flight ref: whichever site
swaps it to false does the release, so three possible release sites
cannot release twice.
- `EPIPE` is what writing to a pipe returns once the reader has closed
its end; it is how a child closing its stdin reaches the parent while
the buffer is still being written.

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

---

**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/spawn/spawn-stdin-pipe-fd-leak.test.ts

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

<details>
<summary>Original description</summary>

On POSIX, a Buffer/Blob `stdin` whose write fails before it has drained
(typically EPIPE, because the child closed its stdin without reading it)
leaks the native `StaticPipeWriter` that pumps the buffer into the
child. Two owners share that writer: the shell's `cmd < ${buffer}`
redirect, which uses it on every POSIX configuration, and `Bun.spawn`,
which on Linux only uses it when memfd is unavailable
(`BUN_FEATURE_FLAG_DISABLE_MEMFD=1`, or no `memfd_create`; otherwise the
child gets a memfd) and on macOS always.

### Repro

```ts
// BUN_FEATURE_FLAG_DISABLE_MEMFD=1 BUN_DEBUG_StaticPipeWriter=1 BUN_DEBUG_ref_count=1 bun-debug run repro.ts
const big = Buffer.alloc(1 << 20, 0x61);
const proc = Bun.spawn({ cmd: ["sh", "-c", "exec 0<&-; sleep 0.3"], stdin: big, stdout: "ignore" });
console.log("exit", await proc.exited);
```

Before, for the writer:

```
StaticPipeWriter(0x..) start()
0x..   ref 1 -> 2                      start()
StaticPipeWriter(0x..) onError(err=EPIPE: Broken pipe (send()))
StaticPipeWriter(0x..) onClose()
0x.. deref 2 -> 1                      owner drops create()'s ref in on_close_io
```

and the count stays at 1 forever. After, the same trace continues with
`deref 1 -> 0`. `$\`cmd < ${big}\`` with a command that exits without
reading stdin shows the same before/after (no memfd flag involved), and
under LeakSanitizer the shell's stranded writers come out as

```
Direct leak of 1024 byte(s) in 2 object(s) allocated from:
    ...
    #12 <StaticPipeWriter<ShellSubprocess>>::create src/spawn/static_pipe_writer.rs:110
    #13 <shell::subproc::Writable>::init src/runtime/shell/subproc.rs:1149
```

### Cause

`start()` takes a ref on the writer and sets `started`; that ref was
released from `on_write` when the buffer drained, or by the owner
(`Subprocess::on_process_exit` / `close_io`) if it closed the writer
while the slot still held it. A failed write takes neither route:
`PosixBufferedWriter::_on_error` runs `on_error` and then `close()`,
`on_close` tells the owner, the owner empties its slot and drops
`create()`'s ref, and nothing is left that can find the writer to
release `start()`'s ref.

oven-sh#35297 fixed exactly this on Windows by releasing in `on_close`, and
left POSIX alone because of the shape of
`PosixPipeWriter::drain_buffered_data`: it had an arm that, for an error
after a partial drain, called `on_error` inline (closing the writer) and
returned `Wrote(n)`, after which `on_poll` delivered `on_write(n,
Drained)` to the same object, so a release in `on_close` looked unsafe.
That arm was in fact unreachable (`try_write` reports a short write as
`Pending`, never as `Wrote`, so the drain loop never gets to an error
with bytes already drained), but as written it was the documented reason
the POSIX release site did not exist.

### Fix

* `drain_buffered_data` (src/io/PipeWriter.rs) loses that arm and takes
`&self`, so it structurally cannot dispatch callbacks: an error is
always returned as `Err` and reported once by the caller, and `on_close`
is the last callback a parent sees. That is the contract the parents are
written against (`FileSink::on_close` releases its keep-alive as "the
last thing", `Terminal::on_writer_close` derefs the terminal,
`FileSink::on_auto_flush` documents `flush()` returning `Err` as the
error contract); this makes it explicit rather than a property of which
arms happen to be reachable. No behaviour changes here.
* `StaticPipeWriter::on_close` (src/spawn/static_pipe_writer.rs) claims
and releases `start()`'s ref on every platform, not just Windows. This
is the behavioural fix. The release is the last access: the frames
underneath it (`close_impl` -> `close` -> `_on_error` -> `on_poll`) do
nothing with the writer after the callback, which is the same shape
`SecurityScanSubprocess` (whose owner ref is the last one) already
relies on in `on_close_io` today. `on_write` keeps taking the token
before it closes and the owners keep taking it before they close, so
exactly one site releases the ref.
* `PosixBufferedWriter::register_poll` reports a failed re-registration
through `_on_error` (report, then `close()`), like every other error
report and like the streaming writer's `register_poll` already did,
instead of reporting and leaving the writer open. Raised in review: that
(ENOMEM-class) failure was the one report not followed by the `on_close`
that now releases the ref; `Bun.spawn` would still have picked the
writer up at exit, the shell had nothing to pick it up with. Reasoned
about rather than tested, since it needs the registration syscall itself
to fail.
* The `EndOfFile` arm of `on_write` releases the ref as well (non-final,
since the owner's ref is dropped by the `on_close` the buffered writer
delivers right after) instead of closing the writer itself and leaving
the ref outstanding. That arm needs `write(2)` to return 0 on a pipe, so
it is reasoned about rather than tested; it is the same change oven-sh#37755
makes to this arm.

Known gap, pre-existing and not touched here: on Windows a `uv_write`
that fails synchronously inside `start()` closes the writer (`on_close`
runs, the owner drops its ref) before `start()` sets `started`, which
strands the ref the same way. Fixing it needs `SecurityScanSubprocess`'s
post-`start()` release to become token-aware as well, so it is tracked
separately.

Related: oven-sh#34697 proposes the same `drain_buffered_data` change as one
item of a broader hardening pass, but is 600+ commits behind with
conflicts in three files. oven-sh#37755 moves these callbacks onto raw `this`
pointers and leaves the error chain (and this leak) alone; the two
conflict textually in `on_close` / `on_poll` but compose, whichever
lands second needs to keep the unconditional release in `on_close` and
the `Err` return in `drain_buffered_data`.

### Test

`test/js/bun/spawn/spawn-stdin-pipe-fd-leak.test.ts` runs a fixture on
the ASAN lane with `detect_leaks=1` (the setup from
shell-worker-terminate-leak.test.ts). The fixture drives both owners
through both endings: children that close their stdin, report it and
wait to be killed (so the write fails while the child is alive and the
exit path cannot be what cleans up), and children that drain it; memfd
is disabled so that the `Bun.spawn` scenarios reach the writer at all.
Unfixed it fails every time (4 of 4) with the report quoted above: the
two shell-owned writers. The two stranded `Bun.spawn`-owned writers from
the same run have their refcount stuck at 1 just the same (checked with
`BUN_DEBUG_ref_count`, and their `Subprocess` structs are freed before
exit), but LSan still finds their address somewhere and does not report
them, so those scenarios only exercise the same teardown under ASAN.
Fixed, the fixture is clean, 5 of 5 runs, about 0.7s each.

An earlier revision used a live-writer counter in
`bun:internal-for-testing`; it was removed per review in favour of LSan.

### Verification

Debug (ASAN) build: the file above, plus spawn.test.ts,
spawnSync.test.ts, spawn-empty-arrayBufferOrBlob, spawn-streaming-stdin,
spawn-stdin-readable-stream, spawn-stdin-destroy,
spawn-pipe-start-error, spawn-many-teardown, memfd-disabled,
bunshell.test.ts (418 pass), bunshell-instance, epipe,
shell-blocking-pipe, shell-write-fault, file-io, pipeline_stack,
filesink.test.ts, terminal.test.ts, terminal-spawn,
child_process.test.ts and bun-security-scanner-workspaces all pass.
`cargo check` of bun_io and bun_spawn for `x86_64-pc-windows-msvc` and
`aarch64-apple-darwin`, `cargo clippy` on both, and `cargo fmt --check`
are clean. CI on the two previous revisions (the first of which ran its
tests on every lane, Windows included) was green apart from flaky tests
that passed on retry.

</details>
springmin pushed a commit that referenced this pull request Aug 13, 2026
…n-sh#37813)

### Problem
- An HTML route served without the DevServer (`development: false` or `{
hmr: false }`) bundles on its first request. If the only client
disconnects and `server.stop(true)` is called while a `[serve.static]`
plugin still has that build parked, `stop()` settles, the next GC frees
the server, and the build then finishes against the freed server.
- Debug build: `AddressSanitizer: heap-use-after-free` in
`html_bundle::Route::on_complete` (parked in `onLoad`) or
`Route::on_plugins_resolved` (parked in the plugin's `setup()`). A
release build reads the freed `NewServer` with no report.
- Cause: while a route is building, nothing counts as keeping the server
alive. The clients waiting on the build only count as connections, so
once they drop the server's idle check sees no pending work.
- The other route kinds already count their asynchronous work in the
server's pending-request counter; the HTML build was the one piece of
in-flight work that did not.

### Fix
- Entering the building state now takes one pending request on the
server; both ways out of it (build finished, plugin load rejected)
answer the waiting clients and then release it.
- This holds the server for exactly the window in which the route will
call back into it. The release runs the server's idle pass, so a server
stopped mid-build is freed right after the build lands.
- Visible change: `server.pendingRequests` is 1 while an HTML route
bundles and `await server.stop()` waits for the bundle, as it already
does for a `fetch` handler still running. A build cancelled by VM
teardown is not covered; at exit it leaves the same state an in-flight
`fetch` handler does.
- Verification: a new test parks the route in the build, in the plugin
load, and in a plugin load that rejects. Unfixed debug build: all three
report 0 pending requests and an early-settled `stop()`, and the first
two die with the ASAN reports above. Fixed: all three pass, as do the
existing HTML-serve tests that do not need the DevServer.

### Background
- HTML routes: `Bun.serve({ routes: { "/": html } })` with an imported
`.html` file. Without the DevServer the route bundles the page once, on
the first request, registers the outputs as static routes, and holds
requests that arrive during the build.
- `[serve.static]` plugins: a bunfig entry naming bundler plugins for
these routes, loaded on the first request. Both the plugin load and the
bundle finish on later event-loop turns and complete by calling back
into the server through a raw pointer stored on the route.
- Pending requests: the server's count of in-flight work, exposed as
`server.pendingRequests`. `stop()` settles and the server can be torn
down only when the count is zero; static and file routes already raise
it when a response goes asynchronous.
- Server lifetime: stopping a server does not free it. Once nothing is
pending, the JS wrapper becomes collectable and the native server is
freed on a later GC, so a stale pointer to it only fails after a GC.

<details>
<summary>Original description</summary>

### Repro

HTML route served without the DevServer (`development: false` or `{ hmr:
false }`), with a `[serve.static]` plugin whose `onLoad` parks on a
promise. Request the route, drop the client, `server.stop(true)`, drop
the server, `Bun.gc(true)` plus a couple of event-loop turns, then let
`onLoad` resolve. Debug (ASAN) build:

```
==1165==ERROR: AddressSanitizer: heap-use-after-free on address 0x73defa800738 ...
READ of size 8 at 0x73defa800738 thread T0
    #0 in <bun_runtime::server::NewServer<false, false>>::global_this src/runtime/server/mod.rs:451
    #1 in <bun_runtime::server::AnyServer>::global_this src/runtime/server/mod.rs:3847
    #2 in <bun_runtime::server::html_bundle::Route>::on_complete src/runtime/server/HTMLBundle.rs
    #3 in JSBundleCompletionTask::on_complete src/runtime/api/js_bundle_completion_task.rs:642
freed by thread T0 here:
    ...
    #11 in <bun_runtime::server::NewServer<false, false>>::deinit src/runtime/server/mod.rs:2122
    #12 in NewServer::schedule_deinit::{closure#1} src/runtime/server/mod.rs:1957
```

Parking in the plugin's `setup()` instead (so the route is still waiting
for the plugin load when the server goes away) gives the same report one
step earlier:

```
READ of size 1 ... in <bun_runtime::server::html_bundle::Route>::on_plugins_resolved src/runtime/server/HTMLBundle.rs
    #1 in <bun_runtime::server::server_body::ServePlugins>::handle_on_resolve src/runtime/server/server_body.rs:1150
    #2 in bun_runtime::server::server_body::on_resolve_impl
```

On a release build the same sequence reads a freed `NewServer` (its
config, then `append_static_route` / `reload_static_routes` on it)
without a report.

### Cause

`html_bundle::Route` keeps a raw `server` back-pointer and bundles on
its first request. Both the plugin load and the build finish on later
event-loop turns and call back into the server through that pointer
(`on_plugins_resolved` reads the config, `on_complete` registers the
output files as static routes and reloads the route table). While the
route is in `State::Building`, nothing holds the server on its behalf:
`on_plugins_resolved` only refs the route itself, and the clients
waiting in `pending_responses` only count as connections, which they can
drop at any time. So once the last client disconnects and the server is
stopped, `deinit_if_we_can` sees no pending requests, settles `stop()`,
downgrades the wrapper, and the next GC frees the `NewServer` with the
build still in flight.

`StaticRoute` / `FileRoute` / `DirectoryRoute` already handle their
asynchronous work with the server's `pending_requests` counter
(`on_pending_request` when a response goes async,
`on_static_request_complete` when it finishes); the route's build is the
same kind of in-flight work and was the one thing not counted.

### Fix

`schedule_bundle` calls `server.on_pending_request()` whenever the route
enters `State::Building` (plugins ready, or plugins still loading), and
the two ways out of that state (`on_complete`, `on_plugins_rejected`) go
through a new `finish_building`, which answers the pending responses and
then calls `on_request_complete()`. That keeps the server allocated for
exactly the window in which the route will call back into it, and
`on_request_complete` runs the idle pass, so a server that was stopped
while building is downgraded and freed right after the build lands (the
`stop()` promise now settles then as well, matching what happens for a
`fetch` handler that is still running when `stop()` is called). With
that invariant, `on_complete` no longer needs its `Option` handling of
the back-pointer; it takes the server once at the top, the same way
`on_plugins_resolved` already did.

A visible consequence: `server.pendingRequests` is 1 while an HTML route
is bundling, and `await server.stop()` waits for the bundle. A build
whose plugin never settles therefore keeps the server allocated, as an
unsettled `fetch` handler already does. Not covered: a build cancelled
by VM teardown never reaches `Route::on_complete` (the completion task
returns early on `cancelled`), so at exit the route keeps its ref and,
now, its pending request; that is the same state an in-flight `fetch`
handler leaves a server in at exit and nothing observes it. The
DevServer's own plugin wait uses a different back-pointer and is not
changed here.

### Verification

`test/js/bun/http/bun-serve-html-build-holds-server.test.ts` (separate
small file; `bun-serve-html.test.ts` is too slow under the debug ASAN
build for a lifetime test, as `bun-serve-html-hot-reload-drop.test.ts`
notes). One fixture, parked in turn in the build (`onLoad`), in the
plugin load (`setup()`), and in a plugin load that then rejects (the
`on_plugins_rejected` exit has to release the request too). Each child
reports `server.pendingRequests` while parked, whether `stop(true)`
settled across ten event-loop turns before the route was released, and
whether the wrapper became collectable afterwards; the test expects `{
pendingRequestsWhileParked: 1, stopBeforeRelease: "pending",
collectedAfterwards: true }` plus a clean exit. If `stop()` did settle
early, the fixture lets the server get collected before releasing the
route, which is the sequence above.

Unfixed debug build: all three report `pendingRequestsWhileParked: 0,
stopBeforeRelease: "settled"`, and the first two children die with the
ASAN reports above (the rejection case has no use-after-free to hit; it
fails on the report). Fixed: the three pass in under a second each. Also
run on the fixed debug build: `bun-serve-html-405.test.ts`,
`bun-serve-html-hot-reload-drop.test.ts`,
`test/bake/serve-plugins-dev-server.test.ts` (all pass), and
`bun-serve-html.test.ts`, where everything that does not need the
DevServer passes, including `serve plugins > concurrent requests to
multiple routes during plugin load`; its `development: true` cases fail
in this container with `EMFILE while initializing file watcher for
development server` (inotify instance limit) before reaching any of this
code.


</details>
springmin pushed a commit that referenced this pull request Sep 14, 2026
…back closes the session (oven-sh#42647)

### Problem
- A server `RST_STREAM` can make the HTTP/2 fetch client read and free a
stream twice. A debug ASAN build of main (09bb546) reports
`AddressSanitizer: heap-use-after-free` in `ClientSession::handle_data`
(`src/http/h2_client/ClientSession.rs:848`, the `s.state !=
StreamState::Closed` read). The stream was freed by `drop_stream` in
`ClientSession::fail_streams` (`ClientSession.rs:943`). A release build
has no report, only heap corruption.
- It needs a request with a custom TLS context (for example `tls: {
serverName }`) whose entry left the 60-entry context cache. That request
then holds the last ref. Its terminal callback drops the context, the
drop closes the session's socket, and `on_close` runs `fail_streams`
inside the deliver loop.

### Fix
- While `delivering` is set, `fail_streams` fails each client and leaves
the streams in `streams` and `by_http_id`. The deliver loop already
removes a stream that has no client, through `remove_stream`, so each
stream is freed once.
- Outside the loop `fail_streams` behaves as before.
- Correct because the loop's tail is safe on a session that is already
closed: `maybe_release` returns at the `registry_index` sentinel, and a
write to the closed socket writes nothing.
- Verified: `test/js/web/fetch/fetch-http2-client.test.ts` (two new
ASAN-only tests, 67 pass). With `src/` at main the RST test fails. Also
`fetch-http2-leak.test.ts` (7 pass) and
`fetch-http2-adversarial.test.ts` (20 pass).

### Background
- `ClientSession` is one HTTP/2 connection of the fetch client.
`streams` maps a stream id to a heap `Stream`. Each `Stream` points at
the `HTTPClient` of one `fetch()`.
- `handle_data` parses frames, then delivers each ready stream to its
client in a loop, with `delivering` set. A terminal delivery runs the
result callback at once, on the HTTP thread.
- Custom TLS contexts are refcounted. The cache holds one ref and each
request holds one.

<details><summary>Notes</summary>

This is the part of oven-sh#31788 that main still needs. oven-sh#31788 was closed as
stale, and all five of its `src/` files conflict with main now.

- Its first defect, `abort_by_http_id` with no ref on the session, is
fixed on main: since oven-sh#37870 every entry point goes through
`ClientSession::enter`, which holds a `RefPtr`. The abort test from
oven-sh#31788 passes on main. It is included here because main has no test that
evicts a TLS context.
- Its third part, a `ctx` field on `PendingConnect`, has no reproduction
and is not included.

ASAN report on main, trimmed:

```
ERROR: AddressSanitizer: heap-use-after-free READ of size 1 thread T2 (HTTP Client)
    #0 <State as PartialEq>::eq            src/http/h2_client/Stream.rs:104
    #2 ClientSession::handle_data          src/http/h2_client/ClientSession.rs:848
    #4 ClientSession::enter                src/http/h2_client/ClientSession.rs:242
    #6 Handler<true>::on_data              src/http/HTTPContext.rs:1386
freed by thread T2 (HTTP Client) here:
    #12 drop_stream                        src/http/h2_client/ClientSession.rs:211
    #13 ClientSession::fail_streams        src/http/h2_client/ClientSession.rs:943
```

The two tests share one helper. The child holds a stream open on a
context made by `serverName`, runs 61 more TLS configs to evict it,
writes `evicted` to stderr, and then ends the held request: one test
aborts it, the other has the server send `RST_STREAM(CANCEL)`. Both
check that a later `fetch()` still works. They run only under ASAN and
carry a 30 s timeout, because each child makes 62 TLS handshakes and a
regression needs time to print its report. Without the longer timeout
the failing run on main shows a timeout at 5 s, not the report.

The tests are serial on purpose: each one fills the context cache, which
would evict the context that a concurrent test depends on.

One older test in the same file changed: `concurrent requests multiplex
on one h2 session`. Its server held each stream for 100 ms and the test
expected 8 open at once. On a loaded machine it saw 7 (2 of 9 runs of
the file). The server now answers when the eighth stream is open, so the
test uses no timer. After the change the file passed 4 of 4 runs on the
same loaded machine.
</details>

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

---

**[human-review]** gate passed · iteration 1 · 2 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/pr_gate.xml" "test/js/web/fetch/fetch-http2-client.test.ts"
bun test v1.4.3 (b993710)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [1309.07ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [1215.67ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [1497.63ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [730.67ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [2353.52ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [2514.64ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body larger than initial send window [927.77
... (truncated)

release without fix: 2 skipped
bun test v1.4.3-canary.1 (f92be71)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [110.34ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [115.04ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response trailers are consumed without breaking the body [99.30ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > connection-specific request headers are stripped before HPACK [111.10ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > cold-start: parallel requests coalesce onto one TLS connect [135.66ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [147.00ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [160.06ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > keep-alive: sequential requests reuse one h2 session [139.51ms]
(pass) fetch() over H
... (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/pr_gate.xml" "test/js/web/fetch/fetch-http2-client.test.ts"
bun test v1.4.3 (b993710)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [1532.08ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [1265.37ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [1731.76ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [501.02ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [1802.42ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [1743.84ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent ReadableStream uploads route each chunk to its own stream 
... (truncated)

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 710ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] cargo bun_runtime → libbun_runtime.a
�[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_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_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 v0.0.0 (/workspace/bun/src/brotli)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap v0.0.0 (/workspace/bun/src/clap)
�[1m�[92m   Compiling�[0m bu
... (truncated)
```

</details>

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

```
src/http/h2_client/ClientSession.rs          |  12 ++-
 test/js/web/fetch/fetch-http2-client.test.ts | 118 ++++++++++++++++++++++++---
 2 files changed, 117 insertions(+), 13 deletions(-)
```

</details>

**gate history** · 1 passed · 1 rejected · iteration 1

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

```
file                                          reads  edits  tests
src/http/h2_client/ClientSession.rs               2      1     24
test/js/web/fetch/fetch-http2-client.test.ts      1      2     24
```

</details>

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Sep 15, 2026
…ide its own socket callback (oven-sh#42693)

### Problem
- A `fetch()` with its own TLS context (for example `tls: { serverName
}`) holds the last ref to that `HTTPContext` once the 60-entry context
cache evicts it. When the server closes the connection, a debug ASAN
build reports `AddressSanitizer: heap-use-after-free` in
`us_internal_ssl_detach`
(`packages/bun-usockets/src/crypto/openssl.c:1827`). A release build
shows nothing.
- The request drops its ref in the result callback
(`src/http/AsyncHTTP.rs:745`), inside the close callback of the
context's own socket. That frees the context and the socket group
embedded in it. `us_internal_ssl_on_close` then reads `s->group->loop`
(`openssl.c:2303`).
- Found by fuzzing, no user report. It needs over 60 `tls` configs in
one process, or a request older than the 30-minute cache TTL.

### Fix
- The last deref of an `HTTPContext` no longer frees it in place.
`#[ref_count(destroy = Self::destroy_between_ticks)]` queues it on the
HTTP thread, and `process_events` frees it between `drain_events()` and
the next `tick()`.
- Correct because all work of the HTTP thread runs inside those two
calls. No socket callback is on the stack between them, whoever dropped
the last ref.
- Verified: `test/js/web/fetch/fetch-http2-client.test.ts`. Two new
ASAN-only tests (h2 and HTTP/1.1) fail with `src/` at main. A third
checks that the context is still freed.

### Background
- An `HTTPContext` embeds one uSockets socket group, and each socket
points into it (`s->group`).
- Custom TLS contexts are refcounted (oven-sh#29334). The cache holds one ref
and each request holds one. Eviction drops only the cache ref.
- `HttpThread::process_events` is the HTTP thread's loop.
`drain_events()` starts and aborts requests. `tick()` runs the socket
callbacks.
- `WindowsNamedPipeContext` defers its free with the same hook.

<details><summary>Notes</summary>

ASAN report on main (3f7f046), HTTP/1.1 run, trimmed:

```
ERROR: AddressSanitizer: heap-use-after-free READ of size 8, 72 bytes inside of 208-byte region, thread T4 (HTTP Client)
    #0 us_internal_ssl_detach          packages/bun-usockets/src/crypto/openssl.c:1827
    #1 us_internal_ssl_on_close        packages/bun-usockets/src/crypto/openssl.c:2303
    #2 us_internal_socket_close_raw    packages/bun-usockets/src/socket.c:328
    #3 us_internal_ssl_on_end          packages/bun-usockets/src/crypto/openssl.c:2393
freed by thread T4 (HTTP Client):
    #12 HTTPContext<true>::destroy
    #15 RefPtr<HTTPContext<true>>::drop
    oven-sh#19 AsyncHTTP::on_async_http_callback_raw   src/http/AsyncHTTP.rs:745
    oven-sh#21 HTTPClient::dispatch_result_and_reset    src/http/lib.rs:1618
    oven-sh#22 HTTPClient::fail                         src/http/lib.rs:3970
    oven-sh#23 HTTPClient::on_close::<true>             src/http/lib.rs:2163
    oven-sh#24 Handler<true>::on_close                  src/http/HTTPContext.rs:1341
    oven-sh#28 us_internal_ssl_on_close                 packages/bun-usockets/src/crypto/openssl.c:2301
```

The h2 run frees through `fail_from_h2`, called by
`ClientSession::fail_streams`, called by `ClientSession::on_close`.
oven-sh#42647 fixed a different defect with the same trigger (a stream freed
twice in the h2 deliver loop). With this change that trigger is gone
too: the context's `Drop`, which closes the session's socket, no longer
runs inside the deliver loop.

One more path fails on main with the same report: a keep-alive socket
that the server closes before it answers. The request dials again from
inside the close callback, and the new context displaces the ref on the
old one. A redirect, an idle timeout, a DNS failure, an abort and a
normal end of the response on an evicted context show no report on main.

Why the fix is in `HTTPContext` and not in uSockets. `HTTPContext` is
the only socket group owner in bun that freed its group from inside a
socket callback. `Listener` frees its group from a finalizer, and the
other TLS users share per-VM groups. The safety contract on
`SocketGroup::destroy` (`src/uws_sys/SocketGroup.rs`) already says that
it must not run while the loop walks the group. A C-side change that
passes `loop` into `us_internal_ssl_detach` also stops this report. It
leaves the context free to die under Rust frames that still hold its raw
pointer, and the next read of `s->group` after a dispatch brings the
report back (that read came with oven-sh#31584, and `ssl_release_batch` after
it). Two comments in uSockets said the opposite of the Rust contract:
`us_socket_group_deinit` called a deinit from `on_close` fine. They now
say what `SocketGroup::destroy` says. No C code changes.

An earlier draft of this branch routed each holder
(`AsyncHTTP::on_async_http_callback_raw`, `HttpThread::connect`) through
a queue of refs. The hook replaces that: no holder can get it wrong, and
only dead contexts are queued, not one ref per request.

No wakeup is needed. A context that dies during `tick()` is freed as
soon as that tick returns. One that dies during `drain_events()` (a
request that fails at start, a cache eviction) is freed right after it.
At process exit the queue is not drained, like the cache.

Related history: oven-sh#31660 (closed) is a crash of the same class in the Zig
implementation, where the last ref went away inside `onLongTimeout`.

Tests:
- The ASAN-only block from oven-sh#42647 now takes a protocol. The child holds
a response open on a context made by `serverName`, runs 61 more TLS
configs to evict it, and writes `evicted` to stderr. The two new tests
then destroy the server side of the held TLS socket, over h2 and over
HTTP/1.1. With `src/` at main both fail with the report above.
- `an evicted custom TLS context is freed when its last request ends`
runs on each build. It does not fail on main. It exists because the
deferral adds a way to leak: if the queue is never drained, a dead
context lives forever. The test keeps one request busy on a context,
leaves a second connection idle in that context's keep-alive pool,
evicts the context, ends the busy request, and waits for the server to
see the idle connection close. With the `heap::destroy` call removed the
test times out.

Suites run on the debug ASAN build: `fetch-http2-client.test.ts` (70
pass), `fetch-http2-leak.test.ts` (7), `fetch-http2-adversarial.test.ts`
(20), `fetch.tls.test.ts` (34), `fetch-redirect.test.ts` (30),
`fetch-tls-abortsignal-timeout.test.ts` (6),
`fetch-proxy-tls-intern-race.test.ts` (1), `tls-keepalive.test.ts` (4
pass, 2 skip), `fetch-abort-ssl-context-eviction.test.ts` (1),
`regression/issue/27358.test.ts` (2). On a release build of this branch:
`tls-keepalive.test.ts` 6 pass (same config: 20000 requests, 0 MB
growth. 200 distinct configs: 1 MB growth), `fetch-http2-client.test.ts`
66 pass and 4 skip, `fetch-abort-ssl-context-eviction.test.ts` 1 pass.
</details>

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

---

**[human-review]** gate passed · iteration 0 · 5 files touched

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

```console
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/web/fetch/fetch-http2-client.test.ts"
bun test v1.4.3 (b993710)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [1172.89ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [989.87ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [1222.39ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [467.33ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [1562.88ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [1655.77ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body larger than initial send window [960.24m
... (truncated)

release without fix: 4 skipped
bun test v1.4.3-canary.1 (722de17)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [73.34ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [65.19ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [89.92ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [102.83ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > connection-specific request headers are stripped before HPACK [74.16ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > multiple Set-Cookie response headers survive HPACK decode [74.43ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [101.86ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > abort sends RST_STREAM; siblings on the session survive [107.88ms]
(pass) fetch() over HTTP/2 (BUN_FE
... (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/pr_gate.xml" "test/js/web/fetch/fetch-http2-client.test.ts"
bun test v1.4.3 (b993710)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [1119.35ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [988.06ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [1222.20ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [531.27ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [1925.52ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [2051.61ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body larger than initial send window [837.85m
... (truncated)

release with fix: 4 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 846ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/128] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[2/128] gen cpp.rs (cppbind)
[3/128] gen JS modules (bundle-modules)
Preprocess modules (9358ms)
Bundle modules (81ms)
Postprocesss modules (248ms)
Bundle Functions (727ms)
Generate Code (35ms)

[10.46s] Bundled "src/js" for production
  2600 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[3/127] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_threading v0.0.0 (/workspace/bun/src/threading)
�[1m�[92m   Compiling�[0m bun_react_compiler v0.0.0 (/workspace/bun/src/react_compiler)
�[1m�[92m   Compiling�[0m bun_io v0.0.0 (/workspace/bun/src/io)
�[1m�[92m   Compiling�[0m bun_watcher v0.0.0 (/workspace/bun/src/watcher)
�[1m�[92m   Compiling�[0m bun_crash_handler v0.0.0 (/workspace/bun/src/crash_handler)
�[1m�[92m   Compiling�[0m bun_event_loop v0.0.0 (/workspace/bun/src/event_loop)
�[1m�[92m   Compiling�[0m bun_
... (truncated)
```

</details>

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

```
packages/bun-usockets/src/context.c          |  10 ++-
 packages/bun-usockets/src/loop.c             |  10 +--
 src/http/HTTPContext.rs                      |  17 +++-
 src/http/HTTPThread.rs                       |  18 ++++
 test/js/web/fetch/fetch-http2-client.test.ts | 125 ++++++++++++++++++++++-----
 5 files changed, 151 insertions(+), 29 deletions(-)
```

</details>

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

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

```
file                                          reads  edits  tests
packages/bun-usockets/src/context.c               1      1     28
packages/bun-usockets/src/loop.c                  1      1     28
src/http/HTTPContext.rs                           4      5     28
src/http/HTTPThread.rs                            5     15     28
test/js/web/fetch/fetch-http2-client.test.ts      4     11     28
```

</details>

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Sep 17, 2026
oven-sh#42900)

### Problem

- An HTTP/3 `fetch()` whose QUIC connection dies before the response
header can abort the process. ASan: `heap-use-after-free READ of size 8`
in `HTTPClient::fail_from_h2` (`src/http/lib.rs:2108`), from
`ClientSession::retry_or_fail`
(`src/http/h3_client/ClientSession.rs:288`). Release builds panic:
`fetch on the HTTP thread holds a ticket`.
- The retry queues the request on a new session through
`ClientContext::connect`. When no connection opens, connect fails that
session with `PendingConnect::fail_session`, which fails every request
queued on it. That dispatch frees the `AsyncHTTP` the client is part of.
Then the retry fails the same client again.

### Fix

- `connect` takes the request back off the session before it fails that
session. A `false` return leaves the request on no session, so the
caller is its only failure path, which is what the other two callers
assume.
- Correct because the session is one call old: the request `enqueue`
just queued is its only entry, so `detach` leaves `fail_session` nothing
to fail. The teardown, the registry removal and the session's last
reference do not change.
- The retried request keeps the error of the stream that closed.
`start_` still reports `ConnectionRefused` for its own failed connect.
- Verified: `test/js/web/fetch/fetch-http3-client.test.ts`, one new test
(main aborts with an empty stdout). Also the three other `fetch-http3-*`
suites, `serve-http3` and `serve-protocols`.

### Background

- The h3 fetch client pools one QUIC connection per origin.
`retry_or_fail` re-sends a stream that closed before any response
header, once, on a fresh connection.
- `ClientContext::connect` finds a pooled connection or opens one, and
queues the request. `enqueue` binds a `Stream` to the request before the
QUIC connect, because that stream has to exist when the handshake
completes.
- `HTTPClient::start_` sets
`defer_terminal_dispatch_until_connecting_is_complete` before its own
connect call, so a failure inside that frame is recorded and dispatched
later. That flag is why the two initial connect sites survived the
double failure.

<details><summary>Notes</summary>

**Fail-before.** With `src/` and `packages/` back on `55c11065f2`, the
new test gives `exitCode: 1` and an empty stdout. That run, the passing
run and the suites above were on `55c11065f2` plus this change, built
with LLVM 21. The branch has since merged main, which needs LLVM 23
(oven-sh#42851). The build environment used here does not have it, so on the
merged tree only `cargo check` and `cargo clippy` for `bun_http` were
run locally, and CI is the test run for it. The three commits that merge
brought in touch none of the files involved. The ASan frames are the
report above:

```
READ of size 8 at 0x... thread T4 (HTTP Client)
  #2 <bun_http::HTTPClient>::fail_from_h2                src/http/lib.rs:2108
  #3 <ClientSession>::retry_or_fail                      src/http/h3_client/ClientSession.rs:288
  #4 h3_client::callbacks::on_conn_close                 src/http/h3_client/callbacks.rs:151
freed by thread T4 (HTTP Client) here:
  #7 <AsyncHTTP>::on_async_http_callback_raw             src/http/AsyncHTTP.rs:783
  #10 <bun_http::HTTPClient>::fail_from_h2               src/http/lib.rs:2122
  #11 <PendingConnect>::fail_session                     src/http/h3_client/PendingConnect.rs:149
  #12 <ClientContext>::connect                           src/http/h3_client/ClientContext.rs:179
  #13 <ClientSession>::retry_or_fail                     src/http/h3_client/ClientSession.rs:287
```

A release build aborts as well, so the fault is not an ASan artifact:
`on_async_http_callback_raw` resets the client's stage before the
dealloc, so the once-only guard in `fail_from_h2` cannot stop the second
dispatch. Making that guard survive the reset is a separate change.

**How the test reaches it.** A connect to a resolved hostname probes
each address with a throwaway UDP `connect(2)`, and gives up when no
entry is reachable (`packages/bun-usockets/src/quic.c`,
`us_quic_connect_result`). An `LD_PRELOAD` shim allows the first probe
and refuses every later one, so the reconnect fails inside `connect`.
`rejectUnauthorized` against the suite's self-signed certificate fails
the handshake, which is what closes the stream before any header and
starts the retry. `localhost` answers from `is_localhost_name` as `[::1,
127.0.0.1]` without the resolver, so no connect waits for DNS, and the
shim refuses the IPv6 entry the way a host without an IPv6 route does,
which pins both connects to the same address. Linux only, and only where
a C compiler exists, like the DPLPMTUD shim test in
`fetch-http3-syscall-fault.test.ts`. 5 runs, 5 passes, about 500 ms each
on the debug ASan build.

**Other ways to reach the same failure.** Any synchronous failure of the
QUIC connect does it: a cached resolver error, an IP literal whose
family the shared client endpoint cannot serve, `lsquic_engine_connect`
returning NULL, or the shared client UDP endpoint dying on a hard
`recvmsg` error and the poll registration for its replacement failing.
The last one needs no resolver, so it reaches this path for an
IP-literal origin too. One test is enough: all of them end in the same
`return false`, and the endpoint-replacement route needs several
iterations of a loop to line up.

**Earlier shape.** The first version of this PR removed the retry's
failure call instead, and documented `connect` as owning the request.
Review pushed back: it left both `if !connect { self.fail(..) }` arms in
`start_` dead, it made the bool unusable by every caller, and it set the
opposite contract from oven-sh#40385, which removes the same double failure
from the callee side. This version fixes the callee, which also keeps
the closed stream's error in the rejection instead of replacing it with
`ECONNREFUSED`.

**Scope.** `retry_or_fail` is also edited by oven-sh#41564 (a retry budget) and
oven-sh#42579 (no replay of a non-idempotent request), and oven-sh#40598 changes which
pre-header closes retry. None of them touch this branch, so this applies
on top of any of them, and oven-sh#40385 keeps the same contract.

</details>

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

---

**no test proof** · iteration 1 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/web/fetch/fetch-http3-client.test.ts

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Sep 30, 2026
### Problem
- On Windows, a worker can use the stdin `FileSink` of a `Bun.spawn`
child after it is freed. It needs `stdin: "pipe"`, a live child, and a
script that never read `proc.stdin`. Debug build: `panic: misaligned
pointer dereference: address must be a multiple of 0x8 but is
0xdfdfdfdfdfdf`. A release build reads freed memory silently.
- `FileSink::on_close` (`src/runtime/webcore/FileSink.rs:528`) calls
`source.close()`. That reaches `Writable::on_close`
(`src/runtime/api/bun/subprocess/Writable.rs:114`), which drops the only
ref on the sink. `on_close` then continues on freed memory.

### Fix
- `FileSink::on_close` holds a ref on the sink until it returns.
`on_write` and `run_pending` already use this guard.
- A testing hook, `subprocessInternals.closeStdinWriter(proc)`, does the
same close as the Windows stop phase, on every platform.
- Verified with the hook, in `test/js/bun/util/filesink.test.ts`.
Without the guard, the Linux ASAN build reports `heap-use-after-free` in
`settle_stream_done`, and a Windows debug build panics. With the guard
both pass. The worker test in
`test/js/web/workers/worker-terminate-lifetime.test.ts` can fail only on
a Windows debug build.
- Self-reviewed: 3 concerns raised, 2 addressed (see Notes).

### Background
- A `FileSink` is a refcounted writer. For `stdin: "pipe"` the
Subprocess holds one ref. The `proc.stdin` getter moves it to a JS
wrapper. `source.close()` tells that owner that the sink closed, and the
owner drops its ref.
- The stop phase is the first step of worker teardown (oven-sh#37075). On
Windows it closes each libuv pipe of the worker through its writer,
which calls `FileSink::on_close`.

### Downsides
- None found for the guard. `on_close` never runs while the sink is
destroyed, so the guard never takes a ref from zero.
- Still open: the stream-fed stdin leak in the Notes.

<details><summary>Notes</summary>

**Repro** (windows-x64, debug build, 5 of 5 runs on main at b7ea95a):

```js
import { Worker } from "node:worker_threads";
const w = new Worker(`
  const { parentPort } = require("node:worker_threads");
  globalThis.keep = Bun.spawn({ cmd: [process.execPath, "-e", "setTimeout(() => {}, 4000)"], stdin: "pipe", stdout: "ignore", stderr: "ignore" });
  setTimeout(() => parentPort.postMessage("go"), 300);
`, { eval: true });
await new Promise(r => w.once("message", r));
await w.terminate();
console.log("survived");
```

**Symbolized stack** (llvm-symbolizer with the PDB, ASLR off, line
numbers at b7ea95a):

```
bun_jsc::strong::Impl::get                                   src/jsc/Strong.rs:179
bun_jsc::strong::Optional::get                               src/jsc/Strong.rs:92
webcore::streams::PipeCell::take_done                        src/runtime/webcore/streams.rs:908
FileSink::settle_stream_done                                 src/runtime/webcore/FileSink.rs:552
FileSink::on_close                                           src/runtime/webcore/FileSink.rs:530
<FileSink as WindowsStreamingWriterParent>::on_close         src/io/PipeWriter.rs:2734
WindowsStreamingWriter<FileSink>::on_close_source            src/io/PipeWriter.rs:2010
WindowsStreamingWriter<FileSink>::close                      src/io/PipeWriter.rs:1212
WindowsStreamingWriter<FileSink>::stop_for_vm_teardown       src/io/PipeWriter.rs:1130
open_handles::stop_all_for_vm_teardown                       src/libuv_sys/open_handles.rs:172
VirtualMachine::stop_phase_sweep                             src/jsc/VirtualMachine.rs:2554
VirtualMachine::teardown                                     src/jsc/VirtualMachine.rs:2401
WebWorker::shutdown / spin / thread_main                     src/jsc/web_worker.rs
```

The address matches. `Strong::get` masks the handle to 48 bits, and
`0xdfdfdfdfdfdfdfdf & ((1 << 48) - 1)` is `0xdfdfdfdfdfdf`. `0xdf` is
the fill that a debug mimalloc writes into a freed block.

**Linux ASAN report** (debug ASAN build with the hook and without the
guard, from the new test in `filesink.test.ts`):

```
ERROR: AddressSanitizer: heap-use-after-free ... READ of size 8
    #0 bun_jsc::strong::Optional::get                  src/jsc/Strong.rs:91
    #1 webcore::streams::PipeCell::take_done           src/runtime/webcore/streams.rs:908
    #2 FileSink::settle_stream_done                    src/runtime/webcore/FileSink.rs
    #3 FileSink::on_close                              src/runtime/webcore/FileSink.rs
    #5 PosixStreamingWriter<FileSink>::close           src/io/PipeWriter.rs:1048
    #7 PollOrFd::close_impl                            src/io/pipes.rs:118
   #10 testing_apis::close_stdin_writer                src/runtime/api/bun/subprocess.rs
freed by thread T0 here:
   #12 <FileSink as CellRefCounted>::destroy           src/ptr/ref_count.rs:448
   #15 <RefPtr<FileSink> as Drop>::drop                src/ptr/ref_count.rs:522
   oven-sh#19 JsCell<Writable>::set                           src/ptr/js_cell.rs:94
   oven-sh#20 Writable::on_close                              src/runtime/api/bun/subprocess/Writable.rs:114
   oven-sh#22 SourceHandle::close                             src/runtime/webcore/streams.rs:1043
   oven-sh#23 FileSink::on_close                              src/runtime/webcore/FileSink.rs
```

The use and the free are in the same `FileSink::on_close` call. For this
proof I removed only the guard line and kept the hook. With the `src/`
of main the new `filesink.test.ts` test also fails, but for a weaker
reason: the hook does not exist there.

**Why only this path.** Every other path that closes this sink keeps it
alive or detaches the source first:
- `Subprocess::on_process_exit` clears `source` before
`on_attached_process_exit`, which also holds a ref.
- `Writable::finalize` and `on_close_io` clear `source` before they drop
the ref.
- The `proc.stdin` getter takes the pipe out of the `stdin` slot and
clears `source`, so `Writable::on_close` is not reached.
- POSIX has no stop-phase close of the pipe. The sink dies in
`Subprocess::finalize`. A poll HUP with an empty buffer reports
`Drained`, not a close.

**Variants** (windows-x64 debug, before and after the fix):

| worker ends by | stdin | before | after |
| --- | --- | --- | --- |
| `terminate()` | `"pipe"`, never read | panic | ok |
| `process.exit()` in the worker | `"pipe"`, never read | panic | ok |
| loop drains (`proc.unref()`) | `"pipe"`, never read | panic | ok |
| `terminate()`, `stdout: "pipe"` too | `"pipe"`, never read | panic |
ok |
| `terminate()` | `"pipe"`, `proc.stdin.write()` pending | ok | ok |
| no worker, `closeStdinWriter(proc)` hook | `"pipe"`, never read |
panic | ok |

After the fix, `fileSinkInternals.liveCount()` is back at the baseline
after the `exit` event of the worker in each row.

**Suites run with the fix.**
- windows-x64 debug: `worker-terminate-lifetime.test.ts` (24 pass, 2
skip), `worker_destruction.test.ts` (5 pass), `spawn.test.ts` (126
pass), `spawn-stdin-readable-stream.test.ts` (36 pass),
`spawn-stdin-destroy.test.ts`, `spawn-stdin-pipe-fd-leak.test.ts`,
`child_process.test.ts` (53 pass), `filesink.test.ts` (33 pass, 1 fail,
see below). The hook test passes 5 of 5 runs and the worker test 3 of 3.
- linux-x64 debug ASAN: both new tests, `filesink.test.ts` (70 pass),
`spawn-stdin-readable-stream.test.ts` (37 pass),
`spawn-stdin-destroy.test.ts`, `worker_destruction.test.ts` (5 pass with
`--timeout 120000`).

**Self-review.**
- Addressed: no CI lane could fail the worker test. The Windows lanes
run release builds, where the stale reads do not crash, and Linux does
not reach the path. The hook test now fails on an ASAN build of any
platform without the guard. The worker test stays, because it is the
real trigger, and its comment says which build can fail it.
- Addressed: the comment before `clear_keep_alive_ref` said that call
can free the sink. With the guard it cannot, so the sentence is gone.
- Not changed: the sink keeps its stale `source` after the owner is
told. Nothing reads it again on this path, because a later write needs
the JS wrapper, and the getter that creates the wrapper clears `source`.
- Checked and fine: no caller of `on_close` touches the writer or the
sink after the call returns (`on_close_source`, the tail of `close`,
`stop_all_for_vm_teardown`, and `PollOrFd::close` on POSIX, where the
callback is last). Both writer `Drop` impls close without a report, so
`on_close` never runs while the sink is destroyed.

**Not in this PR.** On Windows, a worker that ends while `Bun.spawn({
stdin: new ReadableStream({ pull(c) { c.enqueue(new Uint8Array(1024));
return new Promise(() => {}); } }) })` still pumps leaves one `FileSink`
alive. `liveCount()` stays at +1, before and after this change. Linux
returns to the baseline. The cause is different: the ref that
`assign_to_js_stream` takes for the reactions of the pump promise is not
released, because the reactions never run after script is forbidden.
oven-sh#43729 adds a flag that tracks that ref.

**Local failures that this diff does not cause.**
- windows-x64 (Server 2019): `test/js/bun/util/filesink.test.ts`
"Bun.spawn stdin pipe with an unref'd child" fails 3 of 3 runs with the
`src/` of main, with this fix, and with the release canary.
- linux-x64 debug ASAN in my container: "terminate() while dns.lookup()
is in flight" in the same test file reports a 16 byte LeakSanitizer leak
from `node_fs_binding::Binding::new`. No `FileSink` is involved.

</details>

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

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/web/workers/worker-terminate-lifetime.test.ts,
test/js/bun/util/filesink.test.ts

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant