Skip to content

deps: update elysia to 1.4.28 - #1

Merged
springmin merged 1 commit into
mainfrom
deps/update-elysia
May 25, 2026
Merged

springmin merged 1 commit into
mainfrom
deps/update-elysia

Conversation

@github-actions

@github-actions github-actions Bot commented Apr 5, 2026

Copy link
Copy Markdown

What does this PR do?

Updates elysia to version 1.4.28

Compare: elysiajs/elysia@1.4.12...1.4.28

Auto-updated by this workflow

@github-actions
github-actions Bot force-pushed the deps/update-elysia branch from 84256fb to e4b9ed6 Compare April 12, 2026 05:56
springmin pushed a commit that referenced this pull request May 5, 2026
…ven-sh#29971)

## What

`FileSystemRouter`'s constructor (and `reload()`) initialize the error
log with the arena allocator:

```zig
const allocator = arena.allocator();
...
var log = Log.Log.init(allocator);
```

When route loading produces errors, the error paths did:

```zig
arena.deinit();
globalThis.allocator().destroy(arena);
return globalThis.throwValue(try log.toJS(...));  // reads arena-backed msgs.items
```

`log.msgs.items` is backed by the arena, so `log.toJS()` reads freed
memory. ASAN reports `use-after-poison` in `logger.Log.toJS`.

## Repro

```js
// pages/[foo.tsx — missing closing bracket
new Bun.FileSystemRouter({ style: "nextjs", dir: "./pages", fileExtensions: [".tsx"] });
```

Debug (ASAN) build:
```
AddressSanitizer: use-after-poison ...
  #1 in logger.Log.toJS (src/logger.zig:733)
  #2 in FileSystemRouter.constructor (src/bun.js/api/filesystem_router.zig:149)
```

## Fix

Build the JS error value first (while the arena is still live —
`BuildMessage.create` / `ResolveMessage.create` clone the msg into
`globalThis.allocator()`), then free the arena, then throw. Applied to
all four `log.toJS()` call sites across `constructor()` and `reload()`.

## Verification

- `git stash -- src/ && bun bd test filesystem_router.test.ts -t
'invalid route'` → **fail** (ASAN crash in subprocess)
- `git stash pop && bun bd test filesystem_router.test.ts -t 'invalid
route'` → **pass**, error message is `Route is missing a closing
bracket]`
- All 19 existing `filesystem_router.test.ts` tests pass.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
springmin pushed a commit that referenced this pull request May 5, 2026
…es (oven-sh#30077)

## What

When a chunked (or HTTP/3) request body exceeds `maxRequestBodySize`,
`onBufferedBodyChunk` writes the 413 directly on the raw uWS response:

```zig
resp.writeStatus("413 Payload Too Large");
resp.endWithoutBody(comptime !http3);
```

`internalEnd` → `markDone()` nulls `onAborted`, so when the socket
closes no abort ever fires to detach `ctx.resp` or release the base ref.
`this.resp` is left pointing at a completed response whose socket is
about to be freed by `us_internal_free_closed_sockets`.

If the fetch handler returned a pending Promise:

- **resolve**: `handleResolve` → `isAbortedOrEnded()` is false
(`this.resp != null`) → `render()` → `runCorkedWithType` corks the freed
socket → **heap-use-after-free** (ASAN trace below).
- **reject**: `handleReject` reads `resp.hasResponded()` off freed
memory, sees `true`, skips the error handler, and returns without ever
releasing the base ref → **RequestContext leaks**
(`server.pendingRequests` never returns to 0).

## Fix

Route through `this.endWithoutBody()` (the `RequestContext` wrapper)
instead of the raw `resp.endWithoutBody()`. That path does
`detachResponse()` (nulls `this.resp`, clears
`onData`/`onAborted`/`onTimeout`) and `deref()` (releases the base ref),
matching every other end path in this file.

The body promise is rejected with the specific `"Request body exceeded
maxRequestBodySize"` error *before* `endWithoutBody()` so
`endRequestStreaming()` doesn't overwrite it with a generic
`ConnectionClosed`. `has_written_status` is set so any later
`renderMissing`/`renderMetadata` knows the status line is already
committed.

## Repro

```
==ERROR: AddressSanitizer: heap-use-after-free
  #0 us_socket_group socket.c:77
  #1 uWS::AsyncSocket<false>::getLoopData() AsyncSocket.h:69
  #2 uWS::AsyncSocket<false>::isCorked() AsyncSocket.h:141
  #3 uWS::HttpResponse<false>::cork(...) HttpResponse.h:647
  #4 uws_res_cork libuwsockets.cpp:1740
  #5 ...runCorkedWithType Response.zig:299
  #6 ...doRenderBlob RequestContext.zig:1942
  ...
  #11 ...handleResolve RequestContext.zig:220
  #12 ...onResolve RequestContext.zig:154
freed by:
  #1 us_poll_free epoll_kqueue.c:73
  #2 us_internal_free_closed_sockets loop.c:305
```

## Test

`test/js/bun/http/serve-pending-promise-abort-leak.test.ts` — new case
sends a raw `Transfer-Encoding: chunked` POST exceeding
`maxRequestBodySize` with a handler that holds its resolve/reject, waits
for the socket to be reclaimed, then settles the Promise. Asserts
`pendingRequests` returns to 0 for both paths, the body was rejected
with the right message, and a follow-up request still works.

Without the fix: ASAN heap-use-after-free on the resolve path; on
release builds the reject path shows `pendingAfterReject: 1` (leak).

Co-authored-by: robobun <robobun@users.noreply.github.com>
springmin pushed a commit that referenced this pull request May 5, 2026
…ven-sh#30136)

## Repro

```js
const client = await Bun.connect({ hostname, port, tls, socket: { ... } });
// after handshake:
client.end("x");
client.flush();   // ← second markInactive frees *Handlers
// peer replies close_notify → onClose derefs freed Handlers
```

ASAN on debug build:

```
==4075==ERROR: AddressSanitizer: use-after-poison on address 0x7aff355e0469
READ of size 1 at 0x7aff355e0469 thread T0
    #0 bun.js.api.bun.socket.NewSocket(true).onClose  src/bun.js/api/bun/socket.zig:661:46
    #1 deps.uws.handlers.PtrHandler(...).onClose      src/deps/uws/handlers.zig:49:61
    ...
    #5 us_internal_ssl_on_close                       packages/bun-usockets/src/crypto/openssl.c:940:29
```

## Cause

`end()` → `internalFlush` → `canEndAfterFlush()` → `markInactive()` →
`closeAndDetach(.normal)` detaches `this.socket` and calls
`us_socket_close(code=0)`. For TLS with `code==0`,
`us_internal_ssl_close` sends close_notify and **defers** the raw close
until the peer replies (so the loop stays alive to receive it).
`markInactive` returns early without clearing `is_active`, relying on
the eventual `onClose` → `markInactive` to run `handlers.markInactive()`
and free the client-mode `*Handlers`.

`flush()` was the only `internalFlush()` caller without an
`isDetached()` guard. Calling it in that window re-enters
`canEndAfterFlush()` (still `is_active && end_after_flush`) →
`markInactive()`, which now sees the detached socket as closed and runs
the **full** teardown: `handlers.markInactive()` → `active_connections
== 0` → `vm.allocator.destroy(handlers)`. When the peer's close_notify
later arrives, `onClose` calls `this.getHandlers()` on freed memory.

## Fix

Add the same `isDetached()` early-return to `flush()` that `end()`,
`endBuffered()`, `onWritable`, and every other `internalFlush()` caller
already have.

## Verification

New test in `test/js/bun/net/socket.test.ts` spawns a TLS client that
does `end("x"); flush(); flush();` after handshake and awaits `close`.

- Without fix (`git stash -- src/`): subprocess aborts with the ASAN
trace above; test fails.
- With fix: subprocess prints `OK` and exits 0; test passes.

Co-authored-by: robobun <robobun@users.noreply.github.com>
springmin pushed a commit that referenced this pull request May 5, 2026
…0148)

## Repro

```js
const server = Bun.listen({ hostname: '127.0.0.1', port: 0, socket: { open(s){s.end()}, data(){} } });
const client = await Bun.connect({ hostname: '127.0.0.1', port: server.port, socket: { data(){}, close(){} } });
// ... after close fires and the native onClose unwinds:
client.listener; // ← reads handlers.mode through freed pointer
```

ASAN on debug build:

```
==4232==ERROR: AddressSanitizer: use-after-poison on address 0x79f744320469
READ of size 1 at 0x79f744320469 thread T0
    #0 bun.js.api.bun.socket.NewSocket(false).getListener  src/bun.js/api/bun/socket.zig:760:25
    #1 TCPSocketPrototype__listenerGetterWrap              ZigGeneratedClasses.cpp:66452:34
```

## Cause

Client-mode `Handlers` are heap-allocated per `Bun.connect`
(Listener.zig:795). When the socket closes, the socket's `markInactive`
calls `handlers.markInactive()`, which — for non-`.server` modes — drops
`active_connections` to zero, `deinit`s and `destroy`s the allocation.
`this.handlers` is never cleared, so it's left pointing at freed memory.

The `.listener` getter then does:

```zig
const handlers = this.handlers orelse return .js_undefined; // non-null, dangling
if (handlers.mode != .server or this.socket.isDetached()) { // ← UAF read
```

Same in `setServername` → `isServer()` → `getHandlers().mode`.

The reconnection path in `Listener.connect` (line 813-816) also checks
`if (prev.handlers) |h| { h.deinit(); destroy(h); }` — a double-free
when the previous connection's `markInactive` already freed it.

## Fix

In the socket's `markInactive`, capture `handlers.mode == .server`
before calling `handlers.markInactive()`, then null `this.handlers` for
non-listener-owned modes. `isServer()` now returns `false` on null
instead of panicking via `getHandlers()`.

`.server`-mode handlers are embedded in the Listener struct (not freed
here), so those pointers stay intact.

## Verification

New test in `test/js/bun/net/socket.test.ts` spawns a client, waits for
close + one `setImmediate` hop (so the deferred `markInactive` has run),
then reads `.listener`.

- Without fix (`git stash -- src/`): subprocess aborts with the ASAN
trace above; test fails on
`expect(stdout).toBe("listener:undefined\\n")`.
- With fix: subprocess prints `listener:undefined` and exits 0; test
passes.

Co-authored-by: robobun <robobun@users.noreply.github.com>
springmin pushed a commit that referenced this pull request May 5, 2026
…sh (oven-sh#30162)

## What

Fixes a use-after-free in `fs.promises.cp(src, dest, { recursive: true
})` when one file copy fails while sibling `SingleTask`s are still
running on the thread pool.

## Repro

Recursive `fs.promises.cp` of a directory where copying one file fails
(e.g. its destination path is already a directory → `EISDIR`) while ~100
sibling file copies are in flight. Under ASAN:

```
==4475==ERROR: AddressSanitizer: use-after-poison on address 0x79676fca08b0
WRITE of size 8 at 0x79676fca08b0 thread T22 (Bun Pool 11)
    #0 atomic.Value(usize).fetchSub
    #1 NewAsyncCpTask(false).SingleTask.workPoolCallback src/bun.js/node/node_fs.zig:528
```

## Cause

`finishConcurrently()` used the `has_result` cmpxchg only to ensure the
**result** was set once, then immediately enqueued `runFromJSThread` →
`deinit()` → `bun.destroy(this)`. It did not wait for `subtask_count` to
reach zero, so:

- A `SingleTask` that errored called `finishConcurrently(err)` and
returned **without** decrementing `subtask_count`. The JS thread then
freed the parent while other `SingleTask`s were still dereferencing
`cp_task->args` / `cp_task->subtask_count`.
- `cpAsync` decremented `subtask_count` after `_cpAsyncDirectory`
returned an error, by which time `runFromJSThread` could already have
freed `this`.

## Fix

- `finishConcurrently(result)` now only records the result (first caller
wins).
- New `onSubtaskDone()` decrements `subtask_count` with `.acq_rel`
ordering; only the caller that drops it to zero enqueues
`runFromJSThread`. If no one recorded a result, it defaults to
`.success`.
- `cpAsync` drops its initial reference via `defer
this.onSubtaskDone()`, covering every early return (Windows,
non-directory, EISDIR, and the recursive path).
- `SingleTask.workPoolCallback` always ends with `this.deinit();
parent.onSubtaskDone();` on both success and error paths.

This matches the pattern already used by `AsyncReaddirRecursiveTask`.

## Verification

New test in `test/js/node/fs/cp.test.ts` creates a source dir with 128
files plus one whose destination is a pre-existing directory, and runs
`fs.promises.cp` 50× in a subprocess.

- **Without fix** (`git stash -- src/ && bun bd test`): subprocess
aborts with the ASAN `use-after-poison` shown above → test fails.
- **With fix** (`bun bd test`): subprocess rejects with `EISDIR` every
iteration, exits 0 → test passes.
- Full `cp.test.ts` suite: 38 pass, 3 skip (Windows-only), 0 fail.
- `zig:check-all` passes on all targets.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
springmin pushed a commit that referenced this pull request May 5, 2026
## Problem

`MarkedArrayBuffer.destroy()` did two things:
```zig
allocator.free(content.buffer.slice()); // free the bytes
allocator.destroy(this);                 // free *this
```

Every constructor that is actually used (`fromString`, `fromBytes`,
`fromJS`, `fromTypedArray`, `fromArrayBuffer`) returns
`MarkedArrayBuffer` **by value**, so `this` is never an individually
heap-allocated struct — it's a stack local, an embedded field, or an
ArrayList slot. The `allocator.destroy(this)` call passes that interior
pointer to mimalloc.

In the readdir Buffer error-cleanup path (`readdirWithEntries` /
`readdirInner`), entries are appended by value via
`Buffer.fromString()`:
- `allocator.destroy(&entries.items[0])` frees `entries.items.ptr`
- the next loop iteration reads `this.*` from poisoned memory
- `entries.deinit()` frees the same pointer again

## Repro

```js
const fs = require('fs');
// dir contains regular files + a self-referential symlink 'loop -> loop'
fs.readdirSync(dir, { encoding: 'buffer', recursive: true });
```

The recursive walk collects Buffer entries for the root, then fails with
`ELOOP` opening the symlink (not in the swallowed `NOENT/NOTDIR/PERM`
set), and enters the cleanup loop. Under ASAN:

```
==3593==ERROR: AddressSanitizer: use-after-poison on address 0x737ec6e50040
READ of size 64 at 0x737ec6e50040 thread T0
    #1 MarkedArrayBuffer.destroy          array_buffer.zig:591
    #2 NodeFS.readdirInner                node_fs.zig:5013
    #3 NodeFS.readdir                     node_fs.zig:4518
```

## Fix

- Drop `allocator.destroy(this)` from `MarkedArrayBuffer.destroy()`. The
struct is passed/stored by value; callers own its storage.
- Remove the unused `MarkedArrayBuffer.init()` (the only function that
heap-allocated the struct, zero callers) so there's no pairing that
would leak.
- The readdir call sites keep calling `.destroy()`, which still checks
`this.allocator` before freeing bytes — JS-owned buffers remain
untouched.

Also fixed the adjacent `Dirent` arm of the recursive-sync error
cleanup: `result.name.deref()` → `result.deref()` so `Dirent.path` is
released too (matching the non-recursive and async cleanup sites).

## Verification

New test in `test/js/node/fs/fs.test.ts` creates a temp dir with files +
a self-referential symlink, spawns a subprocess that calls
`readdirSync({encoding:'buffer', recursive:true})`, and asserts it
throws `ELOOP` and exits 0.

```
# without fix
(fail) readdirSync({encoding: 'buffer', recursive: true}) frees entries safely ...
  { exitCode: 134, stdout: "" }  # SIGABRT from ASAN

# with fix
(pass) readdirSync({encoding: 'buffer', recursive: true}) frees entries safely ... [1.5s]
  { exitCode: 0, stdout: "ELOOP" }
```

`zig:check-all` passes on all targets.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
springmin pushed a commit that referenced this pull request May 5, 2026
…ss (oven-sh#30181)

## What

Surveyed ~40 recent CI builds for bake test flakes and fixed the
underlying causes.

### Flake #1 (93 hits): `dev-and-prod-12: hmr handles rapid consecutive
edits`

Two modes:
- **Windows**: `Bun.write` is `open(O_TRUNC)` then async write with a
JS-thread round-trip in between. The watcher fires on the 0-byte
truncation, bundles an empty module that never calls `accept()`, and the
next update falls through to `fullReload()` → client exits
`unexpectedReload`.
- **All platforms**: after the final drain `client.messages.length = 0`,
a late hot_update lands during the following `await client.js\`…\`` and
trips the unread-messages disposal check.

**Fix (test):** use `fs.writeFileSync` for the rapid burst (microsecond
truncate window), write identical content so same-`sourceMapId`
duplicates are deterministic on every platform, and follow with a
synchronized sentinel write — once the sentinel arrives over the ordered
WS, every prior hot_update has been applied and nothing can leak into
disposal.

### Flake #2 (29 hits): `Timeout waiting for line "… socket connected"`

Across `react-spa`, `html`, `ssg-pages-router`, `bundle`, `hot`, `esm`,
`css`, `incremental-graph-edge-deletion`. `waitForLine()`'s default
timeout was **1 second** on non-Windows release builds — the Node client
has to start, import happy-dom, fetch, parse HTML, run the bundle, and
open a WebSocket in that window. The `ASAN_TIMEOUT_MULTIPLIER` constant
existed but was never applied.

**Fix (harness):** raise the base and apply a unified `WAIT_MULTIPLIER`
(debug × ASAN × CI). Apply the same multiplier to `expectMessage` /
`expectReload` / `getStringMessage` / `getMostRecentHmrChunk` (all
hardcoded 1000 ms), and raise the per-test base accordingly. Also make
`waitForLine` scan already-buffered lines via the previously-dead
`cursor` field so an `await` between stream creation and the call can't
drop the match.

### Underlying DevServer bugs found while stress-testing

- **`IncrementalGraph.invalidate` use-after-poison**: the incoming
`path` (a slice into `HotReloadEvent.extra_files`) was stored in
`entry_points`, but the event is reset — and its `extra_files` may be
reallocated by the watcher thread — before `entry_points` is consumed by
`startAsyncBundle` / `TestingBatch`. Since `getIndex(path)` already
succeeded, store the graph-owned `keys[index]` instead.
- **`TestingBatch.append`** stored the same borrowed slices as
persistent keys across multiple `HotReloadEvent.run` calls. Dupe keys on
insert; free them in `TestingBatch.deinit`.
- **`onFileUpdate` (Linux)** indexed only
`changed_files[event.name_off]` for a merged directory `WatchEvent`.
When an atomic-save editor (vim/emacs/IntelliJ) lands `CREATE tmp` +
`MOVED_TO target` in one coalesced inotify batch, the rename target was
dropped and never re-watched. Forward every name via `event.names()`.

### Harness robustness

- `waitForHotReload` used `clientWaits === connectedClients.size`;
straggler HMR events from prior unsynchronized writes could push the
count past, so it never matched. Use `>=`.
- `waitForHotReload` now rejects on dev-server panic instead of hanging
to the test timeout.
- Detect `AddressSanitizer` / `ThreadSanitizer` / `==ABORTING` in
subprocess output as a panic.

## How verified

- New `hot.test.ts` case floods a watched directory (32 decoy creates +
unlink + rename-over) to force inotify coalescing:
- **without** `src/` changes → 3/3 fail under ASAN (use-after-poison in
`TestingBatch.append` via `wyhash`)
  - **with** `src/` changes → 10/10 pass
- `dev-and-prod.test.ts -t "rapid consecutive edits"` → 10/10 pass
- Full runs of `hot`, `dev-and-prod`, `bundle`, `css`, `html`, `esm`,
`stress`, `ssg-pages-router`, `incremental-graph-edge-deletion`,
`plugins`, `sourcemap`, `server-sourcemap`, `vfile`, `framework-router`,
`deinitialization` → all green (esm-11 is a pre-existing `skip: ["ci"]`)
- `zig:check-all` passes on all targets

Supersedes oven-sh#29575 and oven-sh#28211.

Fixes oven-sh#19732

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
springmin pushed a commit that referenced this pull request May 5, 2026
…en-sh#30196)

## What does this PR do?

Fixes a use-after-free in `HTMLRewriter.transform()` that caused flaky
SIGSEGV crashes found by fuzzing.

When transforming a string or ArrayBuffer, the body is buffered
synchronously and fed to lol-html via `write()` followed by `end()`. If
a document/element handler returns a rejected promise for the final
`lastInTextNode` chunk (emitted from `end()`), the `end() catch` branch
in `BufferOutputSink.runOutputSink` would call `response.finalize()`
directly on the output `Response`.

That `Response` is already owned by its JS wrapper cell (created earlier
in `init()` via `sink.response.toJS()`), so destroying it in-place left
the wrapper's `m_ctx` pointing at freed memory. When GC later swept the
wrapper, its destructor invoked `Response.finalize()` again on that
freed pointer:

```
AddressSanitizer: use-after-poison
    #0 bun.js.bindings.JSRef.JSRef.deinit        src/bun.js/bindings/JSRef.zig:188
    #1 bun.js.bindings.JSRef.JSRef.finalize      src/bun.js/bindings/JSRef.zig:200
    #2 bun.js.webcore.Response.finalize          src/bun.js/webcore/Response.zig:474
    #3 ResponseClass__finalize                   codegen/ZigGeneratedClasses.zig:17250
    #4 WebCore::JSResponse::~JSResponse()        codegen/ZigGeneratedClasses.cpp:54979
```

The `write()` error path (just above it) already handled this correctly
by returning the error and letting the JS wrapper own the Response
lifetime. This PR makes the `end()` error path do the same — drop the
manual `response.finalize()` and `sink.response = undefined`.

## How did you verify your code works?

Minimal repro that reliably triggers the ASAN error before the fix and
passes cleanly after:

```js
const rewriter = new HTMLRewriter();
rewriter.onDocument({
  text(chunk) {
    if (chunk.lastInTextNode) {
      return Promise.reject(new Error("boom"));
    }
  },
});
try {
  rewriter.transform(new Uint8Array([97, 98, 99]).buffer);
} catch (e) {}
Bun.gc(true);
```

Added regression tests in `test/js/workerd/html-rewriter.test.js`
covering both ArrayBuffer and string inputs. All existing HTMLRewriter
tests pass.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
springmin pushed a commit that referenced this pull request May 5, 2026
…0174)

## What

`RequestContext` stored `response_ptr: ?*Response` and, for plain
`Blob`/`InternalBlob`/`WTFStringImpl` bodies, left the Response JSValue
unprotected. `renderBytes()` → `tryEnd()` can hit backpressure and
register an `onWritable` callback, unwinding with `response_ptr` still
set. Nothing rooted the Response (`RequestContext` is a pool struct, not
GC-visited), so GC could finalize it. If the client then aborted while
the request body was still `.Locked`, `onAbort()` dereferenced a freed
`*Response` — heap-use-after-free under ASAN at
`RequestContext.zig:692`.

## Repro

```
POST → handler returns new Response(8MB string) sync
  → tryEnd() backpressure (client paused) → onWritable registered, return
  → Bun.gc(true) → Response collected, response_ptr dangles
  → client.destroy() → onAbort → deref response_ptr → UAF
```

ASAN trace (unpatched):
```
==ERROR: AddressSanitizer: use-after-poison
  #0 bun.js.bindings.JSRef.JSRef.tryGet
  #1 bun.js.webcore.Response.getBodyReadableStream
  #2 RequestContext.onAbort src/bun.js/api/server/RequestContext.zig:693
  #3 uWS::HttpContext<false>::onClose
```

## Fix

Give `Response` a `weak_ptr_data` field (mirroring `Request.WeakRef`)
and replace `response_ptr: ?*Response` with `response_weakref:
Response.WeakRef` via `bun.ptr.WeakPtr`. `Response.destroy()` now defers
freeing the allocation until outstanding weak refs drop; `WeakRef.get()`
returns null once the contents are gone.

`onAbort` / `handleResolveStream` / `handleRejectStream` call `.get()`
and simply skip the readable-stream cleanup when it's null — a no-op for
in-memory bodies anyway, since the body was already extracted via
`useAsAnyBlobAllowNonUTF8String()` before backpressure.

File-backed and `.Locked` bodies continue to `protect()`
`response_jsvalue` as before; those paths need the Response's
status/headers alive across the async hop for `renderMetadata()`. The
hot path (small in-memory responses) no longer needs
`protect()`/`unprotect()`.

The two redundant `ctx.response_ptr = response` assignments right before
`ctx.render(response)` are dropped — `render()` already sets the weak
ref.

## Verification

`test/js/bun/http/serve-response-gc-backpressure-abort.test.ts`
(ASAN/debug-only): POST with incomplete chunked body so `request_body`
stays `.Locked`, handler returns a large string Response, client pauses
so `tryEnd()` stalls, `Bun.gc(true)` loop, then client closes.

- **without fix**: `AddressSanitizer: use-after-poison` in `onAbort` →
`Response.getBodyReadableStream`
- **with fix**: passes, `abortCount === iterations`, `pendingRequests
=== 0`

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
@github-actions
github-actions Bot force-pushed the deps/update-elysia branch from e4b9ed6 to 9af609e Compare May 10, 2026 06:35
springmin pushed a commit that referenced this pull request May 19, 2026
… stack pointer (oven-sh#31020)

## What

`resolveMaybeNeedsTrailingSlash` swaps `vm.log` / `resolver.log` to a
stack-local `Log` for the duration of `_resolve`, then restores them via
a drop guard. The Zig original also swaps and restores
`transpiler.linker.log` and `resolver.package_manager.log`; the Rust
port had those behind a `TODO(b2-cycle)` and only handled `vm.log` +
`resolver.log`.

When auto-install is enabled and the resolver lazily creates the
`PackageManager` during `_resolve`, `Resolver::get_package_manager`
seeds `pm.log` from `resolver.log` — which at that point is the
**stack-local** `Log`. Because the restore guard never touched `pm.log`,
it was left pointing into a dead stack frame after the function
returned. The next resolve at a different stack depth that routes
through the auto-install task runner dereferenced that stale pointer in
`Log::add_error_fmt`, tripping ASAN's `stack-use-after-scope` (or
segfaulting / executing garbage in release builds).

Stack at the fault:

```
#0  bun_ast::Log::add_formatted_msg
#1  bun_ast::Log::add_error_fmt
#2  bun_install::…::run_tasks
#7  bun_install::…::enqueue_dependency_to_root
#9  bun_resolver::Resolver::enqueue_dependency_to_resolve
#14 bun_resolver::Resolver::resolve_and_auto_install
#15 bun_jsc::VirtualMachine::_resolve
oven-sh#16 bun_jsc::VirtualMachine::resolve_maybe_needs_trailing_slash::<true>
```

## Fix

Swap and restore `linker.log` and (when present) `package_manager.log`
in both copies of the resolve log guard
(`VirtualMachine::resolve_maybe_needs_trailing_slash` and
`jsc_hooks::resolve_hook`), matching `VirtualMachine.zig`. The restore
re-checks `resolver.package_manager` at drop time so a PM that was
lazily created during `_resolve` is also pointed back at the VM log.

Also adds the missing `<cassert>` include in `wtf-bindings.cpp`, which
stopped being pulled in transitively.

## Repro

```js
// run from an empty dir with
// BUN_CONFIG_INSTALL=fallback BUN_CONFIG_REGISTRY=http://127.0.0.1:1
const realm = new ShadowRealm();
const variants = [
  () => realm.importValue("pkg-not-found-a", "x"),
  () => (() => realm.importValue("pkg-not-found-b", "x"))(),
  () => (() => (() => realm.importValue("pkg-not-found-c", "x"))())(),
  () => import("pkg-not-found-f"),
];
for (let i = 0; i < 100; i++)
  for (const v of variants) try { v()?.catch?.(() => {}); } catch {}
```

Segfaults on `main`, clean after this change.

Fixes oven-sh#14432
Fixes oven-sh#22407

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
springmin pushed a commit that referenced this pull request May 23, 2026
…s longer than the comparand (oven-sh#31264)

### What does this PR do?

Fixes an ASAN `global-buffer-overflow` found by fuzzing the CSS parser:

```
asan:global-buffer-overflow:strncasecmp|eql_case_insensitive_ascii|eql_case_insensitive_ascii|bun_core::string::immutable::eql_case_insensitive_ascii_ignore_length
```

**Repro**

```sh
BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING=1 bun -e 'require("bun:internal-for-testing").cssInternals.minifyTest(":nth-child(Nn", "")'
```

```
==ERROR: AddressSanitizer: global-buffer-overflow READ of size 2 ...
    #0 strncasecmp
    #1 bun_core::strings_impl::eql_case_insensitive_ascii src/bun_core/lib.rs
    #2 bun_core::string::immutable::eql_case_insensitive_ascii_ignore_length src/bun_core/string/immutable.rs
    #3 bun_css::css_parser::nth::parse_nth src/css/css_parser.rs
    #4 bun_css::selectors::parser::parse_nth_pseudo_class src/css/selectors/parser.rs
```

**Cause**

`strings_impl::eql_case_insensitive_ascii(a, b, check_len)` defers to
`strncasecmp(a, b, a.len())`, which reads up to `a.len()` bytes from
*both* buffers. The Zig original (`strings.eqlCaseInsensitiveASCII`)
compared against NUL-terminated comptime literals, so `strncasecmp`
stopped at the sentinel and reported a mismatch whenever `a` was longer
than `b`. Rust byte-string literals carry no terminator, so the An+B
parser's ident branch (`parse_nth`), which compares an arbitrary user
ident against the keywords `"even" / "odd" / "n" / "-n" / "n-" / "-n-"`
with the ignore-length variant, reads past the end of the keyword
literal as soon as the ident is longer than the keyword and shares its
prefix (`Nn` vs `n`, `n-3` vs `n`, …). Besides the OOB read, the
comparison result depended on whatever byte happens to follow the
literal in rodata.

**Fix**

Reject `b.len() < a.len()` up front in `eql_case_insensitive_ascii`
before calling `strncasecmp` — the same result the NUL sentinel produced
in Zig, so observable behavior is unchanged for every in-bounds input
(all other callers of the ignore-length variant already pass
equal-length slices). `strncasecmp` now only ever reads within both
slices.

**Verification**

- `bun bd test test/js/bun/css/nth-anplusb-ident.test.ts` without the
fix (src/ stashed): aborts with the ASAN global-buffer-overflow above.
- With the fix: passes. The new test covers valid `n-<digits>` idents
that are longer than the `n`/`n-` keywords (`:nth-child(n-3)`,
`:nth-child(N-3)`, `:nth-last-child(n- 42)`), keyword case-insensitivity
(`:nth-child(N)`), an invalid ident (`:nth-child(NN)` → parse error),
and the exact fuzzer-minimized input run in a subprocess.
- `bun bd test test/js/bun/css/css.test.ts`: 1032 pass, 0 fail (no
behavior change for the existing suite).
- A second fuzz report hits the same overflow through `Bun.build` with a
CSS entrypoint containing `:nth-child(Nn`; that path goes through the
same `parse_nth` comparison and is covered by this fix (`Bun.build` now
reports a parse error instead of aborting).
- The `build-rust` CI failures on this PR (unused label / unnecessary
`unsafe` warnings in `src/spawn`, `src/install`, `src/crash_handler`,
`src/runtime/ffi`, `src/runtime/dns_jsc`) are present on current `main`
commits that don't include this change and come from files this PR
doesn't touch.
@springmin
springmin merged commit e0c8303 into main May 25, 2026
springmin pushed a commit that referenced this pull request Jun 18, 2026
…letes mid-read (oven-sh#31959) [publish images]

Fixes a use-after-free in the HTTP client's proxy tunnel close path
(Sentry BUN-2VY8, ~10 events/day on Windows release builds; reproduces
deterministically under ASAN on all platforms).

## Repro

`fetch()` through an HTTP CONNECT proxy to an HTTPS origin, where the
origin's final response bytes and its TLS `close_notify` reach the
client in a single TCP batch (origin writes the response and immediately
closes). The regression test builds exactly that: a local CONNECT proxy
that holds origin-to-client bytes after the handshake and flushes
session tickets + response + close_notify in one write.

On an unfixed ASAN build:

```
ERROR: AddressSanitizer: heap-use-after-free
READ of size 8 thread T11 (HTTP Client)
  #0 Option<RefPtr<ProxyTunnel>>::as_ref
  #1 bun_http::proxy_tunnel::on_close                    src/http/ProxyTunnel.rs:525
  #2 SSLWrapper<*mut HTTPClient>::trigger_close_callback src/uws/lib.rs:802
  #3 SSLWrapper<*mut HTTPClient>::handle_reading         src/uws/lib.rs:1022
  #4 SSLWrapper<*mut HTTPClient>::handle_traffic
  #5 SSLWrapper<*mut HTTPClient>::receive_data
  #6 ProxyTunnel::receive                                src/http/ProxyTunnel.rs:751
freed by:
  AsyncHTTP::on_async_http_callback_raw                  src/http/AsyncHTTP.rs:813
  HTTPClient::send_progress_update_without_stage_check   src/http/lib.rs:3793
```

## Cause

1. `handle_reading` processes the batch: `SSL_read` returns the body
bytes, the next `SSL_read` hits `close_notify`
(`SSL_ERROR_ZERO_RETURN`), which sets `received_ssl_shutdown` and
`sent_ssl_shutdown` before flushing the already-decrypted bytes through
the data callback.
2. The data callback completes the response. The done path runs
`close_proxy_tunnel(true)` -> `ProxyTunnel::shutdown()` ->
`SSLWrapper::shutdown(true)`, which hits the already-shut-down early
return (`sent_ssl_shutdown || fatal_error`) and returns **without
setting `closed_notified`**. The result callback then frees the
`ThreadlocalAsyncHTTP` embedding the `HTTPClient`, the exact pointer
stored in the wrapper's `handlers.ctx`.
3. Control returns to `handle_reading`. Its liveness guard
(`ssl.is_none() || closed_notified()`) passes because neither is set, so
`trigger_close_callback()` invokes `on_close(handlers.ctx)` on the freed
client. When the allocation has been recycled, `on_close` can ref or
close a different request's tunnel instead of faulting.

## Fix

`src/uws/lib.rs`: when `SSLWrapper::shutdown(fast_shutdown=true)` takes
the already-shut-down early return, fire `trigger_close_callback()`
(idempotent via `closed_notified`) so the wrapper is marked closed
before the owner detaches and frees `handlers.ctx`. A fast shutdown is a
full teardown, and the normal fast-shutdown path already fires the close
callback unconditionally; this only closes the gap where the SSL-level
shutdown had already happened.

Graceful `shutdown(false)` (node:tls half-close via UpgradedDuplex /
WindowsNamedPipe) is unchanged, so reads after a sent `close_notify`
keep working.

## Verification

New test in `test/js/bun/http/proxy.test.ts` (`test.skipIf(!isASAN)`,
the UAF is only deterministic under ASAN): fails on an unfixed ASAN
debug build with the heap-use-after-free above, passes with the fix.
Full `proxy.test.ts` (46 tests) plus `node-tls-connect`,
`node-tls-upgrade`, `node-tls-duplex-close-throw-uaf`,
`node-tls-socket-allow-half-open-option`, `node-tls-server`,
`fetch-tls-cert`, and `node-https-checkServerIdentity` suites pass.

## Note on the asan-lane CI failure (oven-sh#32144)

The intermittent LeakSanitizer failure on the x64-asan shard (deferred
napi finalizers parked on a never-drained cleanup-hook list at `bun
test` exit) is being fixed in oven-sh#32146, which carries the same
`global_exit()` drain plus a hooks-only guard that skips pending
`napi_wrap` finalizers on undrained-loop exits. A subset version of that
fix was briefly on this branch (e59bc1d) but without the hooks-only
guard it made `test/js/third_party/duckdb/duckdb-basic-usage.test.ts`
SEGV at exit on the asan lane (build 62135), exactly the failure mode
oven-sh#32146's guard prevents, so it was reverted (61f9e70). This PR is
scoped to the proxy-tunnel UAF; its asan lane can still intermittently
hit the pre-existing oven-sh#32144 leak until oven-sh#32146 lands.

## Related PRs

- oven-sh#30606 addresses the same crash signature but patches only the `.zig`
reference files, which are no longer compiled; this PR fixes the
shipping Rust implementation.
- oven-sh#31952 fixes the same UAF by calling a new `mark_close_notified()`
helper from `ProxyTunnel::shutdown` (silently setting the flag at one
call site, with `close_raw` exempted). This PR instead closes the gap
inside `SSLWrapper::shutdown(true)` itself, so every fast-shutdown
caller (`ProxyTunnel::shutdown`, `ProxyTunnel::close_raw`,
`UpgradedDuplex::close`, `WebSocketProxyTunnel::shutdown`) gets the same
"no callbacks after teardown" guarantee without new wrapper API or a
shutdown/close_raw asymmetry. The close callback is fired rather than
suppressed, so the error teardown path keeps delivering `on_close` ->
`close_and_fail` exactly once (idempotent via `closed_notified`). Test
here is a deterministic single-shot repro (the test proxy reassembles
TLS records and flushes tickets + response + close_notify in one write)
rather than an iteration loop.

---------

Co-authored-by: Ciro Spaciari MacBook <ciro@anthropic.com>
springmin pushed a commit that referenced this pull request Jun 25, 2026
…AF through tunnel (oven-sh#32635)

## What

Comprehensive stress testing of the HTTP client proxy code paths (fetch
and WebSocket, both `ProxyTunnel` and `WebSocketProxyTunnel`): **661
tests** across 5 new files plus a shared adversarial proxy/origin helper
and a subprocess memory-probe fixture.

| File | Tests | Covers |
| --- | --- | --- |
| `proxy-stress-helpers.ts` | n/a | Adversarial CONNECT + absolute-form
proxy (http/https outer) with per-stage RST/kill/trickle/split hooks;
adversarial origin with content-length / chunked / close-delimited ×
identity/gzip/deflate/br/zstd, truncation at arbitrary byte offset,
redirect, echo. |
| `proxy-stress-matrix.test.ts` | 335 | `{http,https}` proxy ×
`{http,https}` origin × framing × encoding × body-size × keepalive
response matrix; upload matrix across
string/Uint8Array/Blob/FormData/ReadableStream/async-iterator; streamed
response via `getReader()`; trickled 1-byte-per-tick downstream; split
CONNECT envelope; RFC 9110 §9.3.6 ignored-header handling; hop-by-hop
stripping; method matrix; cross-scheme redirects. |
| `proxy-stress-lifecycle.test.ts` | 93 | Proxy RSTs client at every
tunnel stage (request-received / upstream-connected / connect-replied /
first-client-byte / first-upstream-byte) × both origins × both
keepalive; same during upload (string + stream body); proxy drops
upstream at every stage; origin RST at 0/10/60/200 response bytes;
close-delimited × compression; abort at every stage; abort after
headers; abort churn (200× under ASAN); ASAN-only
TLS-alert-during-handshake UAF repro. |
| `proxy-stress-errors.test.ts` | 51 | CONNECT
400/403/407/500/502/503/504/301/302; proxy unreachable; upstream
unreachable (502 via CONNECT and absolute-form); proxy auth
(missing/wrong/correct, URL and header) for all 4 combos; inner-TLS
verify (CA match, no-CA fail, checkServerIdentity reject); unsupported
scheme (ftp/socks4/socks5/socks5h/ws); h2-capable origin through proxy
stays HTTP/1.1. |
| `proxy-stress-concurrent.test.ts` | 31 | 32× parallel per combo ×
keepalive; tunnel reuse (sequential + auth-keyed); 12 origins through
one proxy; `reject_unauthorized` pool gate (lax → strict forces fresh
CONNECT); 4× concurrent 4MB echo; idle pooled tunnel receiving stray
data is evicted; 12 subprocess memory probes (6 modes × 2 proxy schemes,
300-1200 iterations each, RSS-growth bounded). |
| `proxy-stress-adversarial.test.ts` | 151 | Stacked split-CONNECT ×
chunked × compression × keepalive; origin status 200-503 × all combos;
origin-facing Host/header shape; `checkServerIdentity`
approve/reject/GC-loop; path/query edges; `verbose:true`;
`AbortSignal.timeout`; interleaved proxy/direct to same origin; CONNECT
target shape; **WebSocket proxy matrix** (ws/wss × http/https proxy)
with echo, 256KB binary, RST at CONNECT, 407 without auth, rapid close,
wss-via-https-proxy open/close churn under GC. |

Full suite runs in ~5 min under debug+ASAN.

## Bugs found and fixed

Two pre-existing bugs surfaced by the suite (both reproduce on main
without any test-helper changes). Fixed here in `src/http/lib.rs`.

### 1. Streamed request body through a CONNECT tunnel never sent

`fetch()` to an `https://` origin through any proxy with a
`ReadableStream` / async-iterator body hangs forever: CONNECT succeeds,
inner TLS handshake completes, the `Transfer-Encoding: chunked` request
head is written, then nothing. String/Uint8Array/Blob/FormData bodies
are fine; `http://` origins (absolute-form, no tunnel) are fine.

Cause (two layers):

- The `RequestStage::ProxyHeaders` arm of `on_writable` computes
`has_sent_body = self.request_body().is_empty()`. For
`HTTPRequestBody::Stream` the bytes buffer is always empty here, so the
request jumps straight to `RequestStage::Done` and the
`is_streaming_request_body` signal path a few lines below never runs.
The non-proxy `send_initial_request_payload` already gates this on
`matches!(original_request_body, HTTPRequestBody::Bytes(_))`.
- Even once `ProxyBody` is reached, its `Stream` case calls
`flush_stream` → `write_to_stream_using_buffer` →
`write_to_socket(socket, …)`, which writes plaintext chunked bytes to
the outer proxy socket instead of through the inner TLS session. The
`Bytes` case right next to it correctly routes through
`ProxyTunnel::write`.

Fix: mirror the non-proxy path's `Bytes`-only `has_sent_body` check (and
matching `debug_assert`) in the `ProxyHeaders` arm; in
`write_to_stream_using_buffer`, route through `ProxyTunnel::write` when
`self.proxy_tunnel.is_some()`, treating `WantRead`/`WantWrite` from the
inner SSL as backpressure. Encrypted output reaches the outer socket via
the existing `write_encrypted` callback, same as the `Bytes` path. This
also covers the `HTTPThread` drain loop which calls the same
`flush_stream`.

### 2. `heap-use-after-free` in `HTTPClient::on_writable` when a TLS
alert is buffered with the inner handshake flight

```
READ of size 1 thread T12 (HTTP Client)
  #0 HTTPClient::on_writable::<true, false>    src/http/lib.rs:~2856
  #1 bun_http::proxy_tunnel::on_handshake      src/http/ProxyTunnel.rs:450
freed by:
  AsyncHTTP::on_async_http_callback_raw        src/http/AsyncHTTP.rs:813
  HTTPClient::close_and_fail::<false>
  bun_http::proxy_tunnel::on_close             src/http/ProxyTunnel.rs:577
  SSLWrapper::handle_reading                   src/uws/lib.rs:1053
  SSLWrapper::flush                            src/uws/lib.rs:669
  ProxyTunnel::on_writable::<false>            src/http/ProxyTunnel.rs:740
  HTTPClient::on_writable::<true, false>       src/http/lib.rs:~2850
  bun_http::proxy_tunnel::on_handshake         src/http/ProxyTunnel.rs:450
```

If the origin closes or sends a TLS alert in the same buffer as its
ServerHello flight (origin rejecting client cert, corrupted stream via
misbehaving proxy, origin crash), the client's `on_handshake →
on_writable → proxy.on_writable → SSLWrapper::flush → handle_reading`
chain processes the alert, fires `on_close → close_and_fail`, which runs
the result callback and frees the `ThreadlocalAsyncHTTP` embedding
`*self`. Control returns to `on_writable`, which immediately reads
`self.state.flags.is_waiting_for_cert_check` on freed memory. Same bug
class already documented in `start_proxy_handshake`'s comment.

Fix: `close_and_fail` → `terminate_socket` synchronously marks the outer
socket closed, and the socket handle is owned by the event loop
(outlives the client). Check `socket.is_closed()` immediately after
`proxy.on_writable()` and return before touching `self`.

Deterministic ASAN repro in `proxy-stress-lifecycle.test.ts` ("TLS alert
in same buffer as inner handshake"): a CONNECT proxy that double-writes
every client→upstream byte, making the origin's TLS stack abort with an
alert. Looped 20× with `ASAN_OPTIONS=…:abort_on_error=1` so the
HTTP-thread UAF aborts the subprocess before the main thread's clean
exit wins the race.

## Verification

```
bun bd test test/js/bun/http/proxy-stress-*.test.ts
# 661 pass, 0 fail (debug+ASAN, ~5min)
```

Fail-before (src/ stashed, `bun bd`):
- `proxy-stress-matrix.test.ts -t "https-origin POST
ReadableStream|https-origin POST async-iterator"` → 8 fail
(hang/timeout)
- `proxy-stress-lifecycle.test.ts -t "TLS alert in same buffer"` → 1
fail (subprocess aborts with `heap-use-after-free`)

Existing `test/js/bun/http/proxy.test.ts` (48 tests) still passes.

## Related

- oven-sh#31959 fixed a sibling UAF on the shutdown side of the same
`SSLWrapper` callback chain.
- oven-sh#30606 touches the same `ProxyTunnel` close path but only in the
`.zig` reference files.

---------

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 Jun 29, 2026
…ll-driven read (oven-sh#32986)

## Problem

Heap use-after-free in Bun Shell when `epoll_ctl` fails while
re-registering a pipe's `FilePoll` from a poll-driven read. Found by
syscall-fault-injection fuzzing against `origin/main`. Follow-up to
oven-sh#32754, which fixed the same failure on the eager spawn-time read path.

```
ERROR: AddressSanitizer: heap-use-after-free
READ of size 1, thread T0
  #0 <bun_io::pipe_reader::BufferedReaderVTable>::link            io/PipeReader.rs:105
  #1 <bun_io::pipe_reader::BufferedReaderVTable>::on_read_chunk   io/PipeReader.rs:125
  #2 <bun_io::pipe_reader::PosixBufferedReader>::read_with_fn     io/PipeReader.rs:890
  #3 <bun_io::pipe_reader::PosixBufferedReader>::read_socket      io/PipeReader.rs:576
  #4 <bun_io::pipe_reader::PosixBufferedReader>::on_poll          io/PipeReader.rs:529
  #5 __bun_run_file_poll                                          runtime/dispatch.rs:677
freed by:
  <alloc::sync::Arc<bun_runtime::shell::subproc::PipeReader>>::drop
```

## Repro

1. `PipeReader::start` registers the poll and the eager spawn-time
`read_all()` hits `EAGAIN`, so `read_with_fn`'s `EAGAIN` arm
re-registers the poll and the spawn returns.
2. The child writes to stdout and the poll fires.
`__bun_run_file_poll`'s `BUFFERED_READER` arm dispatches straight into
`PosixBufferedReader::on_poll` with a bare `&mut *h` and no keepalive.
3. `read_with_fn` drains the chunk, `recv()` returns a real `EAGAIN`,
and `register_poll()` issues another `epoll_ctl`, which fails (`ENOMEM`
in the repro).
4. `register_poll` dispatches `on_reader_error`. The shell
`PipeReader::on_reader_error` signals the `Cmd`, the `Readable::Pipe`
`Arc` is dropped, and the callback's own `guard_from_raw` keepalive
becomes the last reference. The code already documents this: "Dropping
`guard` is the matching `deref()`; may free `this`."
5. Back in `read_with_fn`, the `EAGAIN` arm still delivers the drained
head: `parent.vtable.on_read_chunk(.., ReadState::Drained)` reads the
freed vtable.

Traced with the test's `LD_PRELOAD` shim:

```
[shim] epoll_ctl(ADD fd=13) unix call#1 -> ok       PipeReader::start
[shim] recv(fd=13)          unix call#1 -> EAGAIN   eager read, inside spawn
[shim] epoll_ctl(MOD fd=13) unix call#2 -> ok       re-register; spawn returns
[shim] recv(fd=13)          unix call#2             poll fired: the child's bytes
[shim] recv(fd=13)          unix call#3             real EAGAIN
[shim] epoll_ctl(MOD fd=13) unix call#3 -> ENOMEM   register_poll fails
[shell_subproc] PipeReader(0x..250) onReaderError errno: 12
[shell_subproc] PipeReader(0x..250, stdout) detach()
[shell_subproc] PipeReader(0x..250, stdout) deinit()
==ERROR: AddressSanitizer: heap-use-after-free
```

## Cause

`register_poll()`'s failure path dispatches `on_reader_error`, which the
`BufferedReaderParent` contract explicitly allows to free the parent,
but `register_poll` gave the caller no way to know that happened.
`read_with_fn`'s `EAGAIN` arm is the only call site that touches the
reader afterwards; every other `register_poll()` is in tail position.
The `SAFETY` comment above the `parent` rebind claimed the parent is
"never freed mid-call", which holds for `on_read_chunk` re-entry but not
for `on_reader_error`.

oven-sh#32754 covered this exact sequence on the eager spawn-time entry by
holding an `Arc<PipeReader>` across `start()` and `read_all()` in
`Readable::start_pipe_reader`. The epoll dispatch has no equivalent
keepalive, so the poll-driven entry was still exposed.

## Fix

`PosixBufferedReader::register_poll()` now returns whether registration
succeeded. `false` means `on_reader_error` was dispatched and `self`
must not be touched again, so `read_with_fn`'s `EAGAIN` arm returns
there instead of delivering the drained head to a possibly freed parent.
The stream has already been completed with the registration error at
that point, so nothing is lost. All other `register_poll()` call sites
are tail calls and discard the result.

## Test

Two new modes in `test/js/bun/shell/shell-pipe-read-fault.test.ts`'s
`LD_PRELOAD` fault shim:

- `SHELL_RECV_EAGAIN_FIRST=1`: the first `recv()` on each `AF_UNIX`
socket returns `EAGAIN`, pushing the first successful read off the eager
spawn-time `read_all()` and onto the epoll dispatch.
- `SHELL_FAIL_EPOLL_FROM=N`: the Nth and later `epoll_ctl` `ADD`/`MOD`
on each `AF_UNIX` socket fail with `ENOMEM`. `N=3` lets the initial
registration and the eager read's re-registration succeed, then fails
the first poll-driven one.

The new test is `skipIf(!isASAN)` because the use-after-free is only
reliably observable under ASAN. With `src/io/PipeReader.rs` reverted to
`main` it fails in ~1.1s with the `heap-use-after-free` above; with the
fix all 6 tests in the file pass.

## Out of scope

Shell `PipeReader::on_read_chunk` also calls
`self.reader.register_poll()` from a `&mut self` method whose stated
contract is that it never frees `self`. If that inner registration
fails, the same free can happen under `read_with_fn`'s mid-loop flush
instead of its `EAGAIN` arm. Reaching it needs a large (>32 KB) burst in
one poll wake; I have not reproduced it, so it is not changed here.
springmin pushed a commit that referenced this pull request Jun 29, 2026
…low-priority queue (oven-sh#33006)

### Symptom

AddressSanitizer reports a heap-use-after-free (READ of size 8 and WRITE
of size 8 variants) in uSockets' listener bookkeeping while a TLS server
accepts connections under load:

```
==ERROR: AddressSanitizer: heap-use-after-free (WRITE of size 8)
  #0 us_internal_socket_group_unlink_socket   bun-usockets/src/context.c:223
  #1 us_internal_socket_close_raw             bun-usockets/src/socket.c:291
  #2 us_internal_ssl_close                    bun-usockets/src/crypto/openssl.c
  #3 close<true>                              src/uws_sys/socket.rs
```

A second manifestation site is the low-priority queue walker,
`us_internal_handle_low_priority_sockets`. The trigger is an ordinary
`Bun.serve({tls})` / `node:tls` server whose clients connect, handshake,
and disconnect at inopportune times. No unusual client behavior is
required.

### Cause

uSockets throttles concurrent TLS handshakes. When the 5-per-tick budget
runs out, the readable dispatch parks the socket in the loop-wide
low-priority queue (`loop->data.low_prio_head`): it is unlinked from
`group->head_sockets` and READABLE is removed from its poll. The two
lists share the same `prev`/`next` fields, so a socket lives in exactly
one at a time.

A parked socket can still get a WRITABLE dispatch. When its handshake
flight is backpressured (`send` returned short or 0),
`us_internal_ssl_on_writable` retries the BIO write, and
`us_socket_raw_write` unconditionally runs `us_poll_change(READABLE |
WRITABLE)`. READABLE is now re-enabled on a socket that is still in the
low-priority queue.

The next readable dispatch on that socket, with the budget exhausted,
parked it a second time. That path ran
`us_internal_socket_group_unlink_socket(g, s)` on a socket whose
`prev`/`next` are low-priority-queue links, not group links:

- If the socket was the queue head, `group->head_sockets` gets pointed
at the next low-priority socket. When that socket is later closed
through `us_internal_socket_close_raw`'s low-priority branch, nothing
repairs `head_sockets`, and the group list reaches freed memory.
`us_internal_socket_group_unlink_socket`'s `next->prev = prev` for a
neighbor is the WRITE of size 8.
- `loop->data.low_prio_head` can be left pointing at the re-prepended
socket as a self-cycle; the queue walker then reads through entries the
close path has already freed. That is the READ of size 8 in
`us_internal_handle_low_priority_sockets`.
- `group->low_prio_count` is incremented a second time for a socket that
was already counted. It never returns to zero, which is also what
`us_socket_group_deinit`'s `low_prio_count == 0` assertion catches in
debug/ASan builds.

### Fix

In the parking branch, if `low_prio_state == 1` the socket is already in
`loop->data.low_prio_head` and not in `group->head_sockets`. Re-disable
READABLE (done just above) and leave it where it is instead of
group-unlinking and re-counting it.

`us_connecting_socket_close` also calls
`us_internal_socket_group_unlink_socket` without checking
`low_prio_state`, but it only runs before any candidate leg has opened,
when every socket in `connecting_head` is still a `SEMI_SOCKET` and
cannot have been parked, so it is not affected.

### Test

`test/js/bun/net/socket-syscall-fault.test.ts` drives the exact sequence
with the in-tree socket fault injection: a `Bun.listen({tls})` server
whose every `send` returns 0, and bursts of raw TLS 1.2 clients from a
child process. Without the fix the fixture aborts:

```
bun-debug: packages/bun-usockets/src/context.c:68: void us_socket_group_deinit(struct us_socket_group_t *):
Assertion `group->low_prio_count == 0' failed.
```

<details>
<summary>Verification runs</summary>

- Without the fix: 2/2 runs fail with `exitCode: 134`, `signalCode:
"SIGABRT"`, and the assertion above.
- With the fix: 3/3 runs pass.
- `test/js/bun/util/socket-fault-injection.test.ts`,
`test/js/node/tls/tls-syscall-fault.test.ts`,
`test/js/node/tls/node-tls-server.test.ts`, and
`test/js/bun/net/socket.test.ts` produce identical results before and
after the change. The two pre-existing environment failures in the last
two (plain TCP `ECONNREFUSED` to the just-bound port) reproduce
identically on unmodified `main`.

</details>

---------

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

### What does this PR do?

Fixes a silent failure in `Bun.serve`: when the `fetch` handler returns
a `Response` whose body has already been used (most commonly the same
`Response` object returned for more than one request), the request is
answered with a `200` and `Content-Length: 0`. Nothing is thrown,
nothing is logged, and `error()` is never invoked.

```js
const cached = new Response("cached-route-body");
Bun.serve({ fetch() { return cached; } });
// request #1: 200, body "cached-route-body"
// request #2+: 200, Content-Length: 0, empty body, error() never fires
```

Per the fetch model a disturbed body is an error, and Deno and workerd
both reject it loudly. Bun's static routes (`routes: { "/x": new
Response(...) }`) already throw "Response body has already been used"
for a used body at registration time; the dynamic path was the only
place a disturbed body got silently dropped.

Cause: `RequestContext::do_render_with_body` has explicit arms for
`Error` bodies, in-memory bodies, and `Locked` streams, but
`Body::Value::Used` fell into the catch-all `_ => {}` arm, which renders
the never-assigned context blob: a 200 with `Content-Length: 0`. This
affected string, `Uint8Array`, stream, and file bodied Responses alike,
and also a Response whose body the handler consumed (`await
response.text()`) before returning it.

Fix: add a `Body::Value::Used` arm that builds a `TypeError` (`code:
"ERR_BODY_ALREADY_USED"`, message `Response body already used. A
Response body can only be sent once; create a new Response for each
request.`) and routes it through `run_error_handler`, exactly like the
existing body-error and locked-stream arms. With an `error()` handler,
the handler receives the TypeError and its Response is sent. Without
one, the error is logged and the request gets the standard 500, the same
as any other error thrown from `fetch`. The existing
`has_called_error_handler` guard keeps an `error()` handler that itself
returns a used Response from recursing.

Static routes, fresh Responses per request, bodiless Responses, and HEAD
rendering are unchanged; only the `Used` (disturbed) body state is
affected.

### How did you verify your code works?

`test/js/bun/http/serve-reused-response.test.ts`:

- the same string, `Uint8Array`, and stream bodied Response returned
twice: the first request gets the body, later ones invoke `error()` with
the TypeError (`code: "ERR_BODY_ALREADY_USED"`) and receive its response
- a Response consumed with `.text()` before being returned from an async
handler: same error
- no `error()` handler (`development: false`): the client gets 500
`"Something went wrong!"`, the error is printed to stderr, and the
process exit code matches other unhandled fetch errors
- fresh Responses and a static-route Response reused across requests
keep working and never call `error()`

The already-used cases fail on current `bun test` (they observe the
empty 200s and zero `error()` calls) and pass with this change; the two
no-reuse tests pass both ways by design.
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 2, 2026
…en-sh#33242)

### What

After a 3xx redirect, `handle_response_metadata` rewrites per-hop
request state on the HTTP-thread clone of the `AsyncHTTP`:

- `client.url` (and `connected_url`) become a self-borrow into
`client.redirect`, a `Vec<u8>` the clone owns and frees in the
final-callback teardown (`AsyncHTTP::on_async_http_callback_raw`).
- On a cross-origin hop,
`Authorization`/`Proxy-Authorization`/`Cookie`/`Host` are removed from
`client.header_entries` in place.
- The method may be downgraded to GET.

`NetworkTask::notify`'s bitwise copy-back (`ptr::write(real,
ptr::read(async_http))`) carries all of that into the JS-thread
`AsyncHTTP`. When `bun install` retries the task after a retryable
failure (5xx or a connection reset on the redirect target), the
re-scheduled request therefore:

1. connects through the freed redirect buffer (use after free), and
2. if the redirect was cross-origin, goes out without `Authorization`,
so an authorized registry answers 401.

ASAN (debug build), deterministic on the first try:

```
ERROR: AddressSanitizer: heap-use-after-free ... thread T1 (HTTP Client)
READ of size 1
    #0 bun_core::fmt::parse_int::<u16>              src/bun_core/fmt.rs:929
    #1 <bun_url::URL>::get_port                     src/url/lib.rs:470
    #2 <bun_url::URL>::get_port_auto                src/url/lib.rs:474
    #3 <bun_http::http_thread::HttpThread>::connect src/http/HTTPThread.rs:602
    #4 <bun_http::HTTPClient>::start_               src/http/lib.rs:2635
    #6 <bun_http::async_http::AsyncHTTP>::on_start  src/http/AsyncHTTP.rs:893
freed by thread T1 (HTTP Client):
    <bun_http::async_http::AsyncHTTP>::on_async_http_callback_raw src/http/AsyncHTTP.rs:774
previously allocated by thread T1 (HTTP Client):
    <bun_http::HTTPClient>::handle_response_metadata src/http/lib.rs:5038
```

On a release build the same sequence does not crash, but the retries
never reach the server (each one connects through freed memory) and the
install fails.

### Repro

A scripted registry where the manifest URL 302-redirects and the
redirect target answers a 500 once, then the real packument:

```
GET /BaR              -> 302 Location: /redirected/BaR
GET /redirected/BaR   -> 500 on the first hit, then the packument
GET /BaR-0.0.2.tgz    -> tarball
```

`bun install` against it aborts under ASAN and fails on release. Any
301/302/307/308 and 1- or 2-hop chains hit the same path. With an
authorized registry that redirects cross-origin (the common Artifactory
/ CodeArtifact / GitHub Packages shape), the retry also loses
`Authorization`; that variant fails with `GET <registry>/BaR - 401` even
once the URL is fixed.

### Fix

`src/http/AsyncHTTP.rs`: the `!has_more` teardown block already releases
every clone-owned allocation. Before freeing `client.redirect`, restore
the per-hop state that a re-scheduled attempt must not inherit:

- `client.url` back to the caller-owned pre-redirect URL
(`AsyncHTTP.url`, which borrows memory valid for the original's whole
lifetime), and `client.connected_url` (which `connect` derives from it)
to default.
- `client.header_entries` back to the untouched
`AsyncHTTP.request_headers`. The list is bitwise-shared with the
JS-thread original, so it must not be dropped or reallocated on the HTTP
thread; it was cloned from `request_headers` at init and only ever
shrinks, so `clear_retaining_capacity()` +
`append_list_assume_capacity()` restores it in place.
- `client.method` back to `AsyncHTTP.method`.

Nothing that crosses back to the JS thread references clone-freed memory
anymore, and a retried request restarts from the original URL with the
original headers instead of the last redirect hop's, which is what the
install-level retry is meant to do.

### Tests

`test/cli/install/bun-install-retry.test.ts`:
- `retries a manifest whose redirect target 500s once`
- `retries a tarball whose redirect target 500s once` (the sibling retry
site in `runTasks`)
- `retries an authorized manifest whose cross-origin redirect target
500s once` (also asserts the cross-origin hop itself still does NOT
carry `Authorization`, so the spec-mandated strip is unchanged)

All three fail on the unfixed build (ASAN abort under `bun bd`, install
error with `USE_SYSTEM_BUN=1`). The third additionally fails with a 401
if only the URL is restored and not the headers, so each restore is
load-bearing. `test/js/web/fetch/fetch-redirect.test.ts` and
`fetch-url-after-redirect.test.ts` still pass, so `response.url` after a
redirect is unaffected (it comes from the owned `metadata.url` copy, not
from `client.url`).

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
springmin pushed a commit that referenced this pull request Jul 4, 2026
…ipeReader::on_read_chunk fails (oven-sh#33269)

## Problem

Heap use-after-free in Bun Shell when `epoll_ctl(MOD)` fails while the
shell `PipeReader::on_read_chunk` callback re-registers the poll from
inside the read loop. Found by syscall-fault-injection fuzzing against
`origin/main`.

This is the path oven-sh#32986 called out as out of scope: that PR fixed
`read_with_fn`'s own `EAGAIN`-arm re-registration, but the shell
`PipeReader::on_read_chunk` still called `self.reader.register_poll()`
itself.

```
==ERROR: AddressSanitizer: heap-use-after-free
READ of size 8
  #0 <PosixBufferedReader>::read_with_fn   src/io/PipeReader.rs:837:43
  #1 <PosixBufferedReader>::read_socket    src/io/PipeReader.rs:581:9
  #2 <PosixBufferedReader>::on_poll        src/io/PipeReader.rs:534:17
  #3 __bun_run_file_poll                   src/runtime/dispatch.rs:677:22
```

<details>
<summary>Freed-by stack (the re-entrant callback chain)</summary>

```
freed by thread T0 here:
  core::ptr::drop_in_place::<Arc<shell::subproc::PipeReader>>
  <shell::subproc::PipeReader>::on_reader_error                      src/runtime/shell/subproc.rs:2363
  <PosixBufferedReader>::register_poll                               src/io/PipeReader.rs:433
  <shell::subproc::PipeReader>::on_read_chunk                        src/runtime/shell/subproc.rs:2062
  <PosixBufferedReader>::read_with_fn                                src/io/PipeReader.rs:875
  <PosixBufferedReader>::read_socket
  <PosixBufferedReader>::on_poll
  __bun_run_file_poll
```

</details>

## Repro

1. A shell pipe's `FilePoll` fires and `__bun_run_file_poll` dispatches
into `PosixBufferedReader::on_poll` -> `read_with_fn` with a bare `&mut`
and no keepalive.
2. `recv()` drains more than half of the 256 KB scratch buffer in one
call, so `read_with_fn`'s streaming inner loop flushes the head
mid-loop: `parent.vtable.on_read_chunk(.., Progress)`.
3. Shell `PipeReader::on_read_chunk` re-arms the poll itself:
`self.reader.register_poll()`. The `epoll_ctl(MOD)` fails (`ENOMEM` in
the repro; fd/watch pressure in the wild).
4. `register_poll` dispatches `on_reader_error`. The shell
`PipeReader::on_reader_error` signals the `Cmd` and drops the
`Readable::Pipe` `Arc`; its own `guard_from_raw` keepalive becomes the
last reference, and dropping it frees the `PipeReader` (and the
`PosixBufferedReader` embedded in it).
5. `register_poll` returns `false`, but `on_read_chunk` is not a direct
caller of the read loop, so the `false` never reaches it. The inner loop
keeps going and reads `parent._offset` from the freed reader on the next
`recv`.

## Cause

`BufferedReaderParent`'s contract (and the `SAFETY` comments in
`read_with_fn` / `read_blocking_pipe`) is that `on_read_chunk` never
frees the reader; only `on_reader_error` may. The shell
`PipeReader::on_read_chunk` broke that transitively by calling
`register_poll()`, whose failure path dispatches `on_reader_error`.

oven-sh#32986's `register_poll() -> bool` return value only protects direct
callers in the read loop. It cannot protect a caller that reaches
`register_poll` through the `on_read_chunk` vtable dispatch two frames
down.

## Fix

Delete the re-arm from shell `PipeReader::on_read_chunk`. It was
redundant on both platforms and the codebase already documents why:

- POSIX: every exit of `read_with_fn` / `read_blocking_pipe` that wants
more data already calls `register_poll()` itself, driven by the `bool`
`on_read_chunk` returns.
- Windows: `WindowsBufferedReader::on_read` notes "the re-arm is already
handled by `on_file_read`'s epilogue / `uv_read_start`", and it already
performs the `_buffer.clear()` that used to be
`start_with_current_pipe()`'s second side effect.
- The sibling shell reader, `IOReader::on_read_chunk_cb`, already
dropped its identical re-arm for the same two reasons (redundancy, plus
re-deriving `&mut` to the embedded reader while the read loop holds
one).

Removing it also removes the only `&mut self.reader` re-derivation
inside the callback, and the `Output::panic("TODO: ...")` that was the
Windows branch's only error handling.

## Test

New `SHELL_RECV_BULK=N` mode in
`test/js/bun/shell/shell-pipe-read-fault.test.ts`'s `LD_PRELOAD` shim:
the first N real `recv()`s on each `AF_UNIX` socket instead return the
caller's whole buffer filled with `'A'`. Combined with the existing
`SHELL_RECV_EAGAIN_FIRST=1` and `SHELL_FAIL_EPOLL_FROM=3`, one
fabricated bulk recv deterministically pushes `head_start` past the
half-buffer cutoff so the mid-loop flush (and therefore the failing
re-registration) happens from `on_read_chunk`.

With the epoll failure count unchanged, the same `epoll_ctl` #3 that
used to be issued by `on_read_chunk` is now the read loop's own `EAGAIN`
re-registration, whose failure path already returns without touching the
reader, so the command just reports `ENOMEM`.

- Before the fix: the new test fails in ~750 ms with the
`heap-use-after-free` above; the other 6 tests in the file pass.
- After the fix: all 7 pass.

The test is `skipIf(!isASAN)` like its sibling.

Also ran the rest of `test/js/bun/shell/` (`bunshell*.test.ts`: 394 pass
/ 0 fail; `commands/` and the remaining files: every failure reproduces
identically with `src/runtime/shell/subproc.rs` reverted to `main`, so
they are pre-existing in this environment, not caused by this change).
springmin pushed a commit that referenced this pull request Jul 4, 2026
…buffer cannot be allocated (oven-sh#33326)

Fixes a `Segmentation fault at address 0x00000040` (sometimes
`0x00000030`) reported from Windows x64 builds, crashing inside
boringssl's record copy from uSockets' TLS read loop:

```
memcpy                            src/vctools/crt/vcruntime/src/string/amd64/memcpy.asm
bssl::OPENSSL_memcpy              vendor/boringssl/crypto/internal.h:868
SSL_peek                          vendor/boringssl/ssl/ssl_lib.cc:947
SSL_read                          vendor/boringssl/ssl/ssl_lib.cc:918
us_internal_ssl_on_data           packages/bun-usockets/src/crypto/openssl.c:1797
us_internal_dispatch_ready_poll   packages/bun-usockets/src/loop.c:600
uv__fast_poll_process_poll_req    vendor/libuv/src/win/poll.c:208
uv_run                            vendor/libuv/src/win/core.c:737
```

## Cause

`us_internal_init_loop_ssl_data` (`openssl.c:677`) allocates one 512 KiB
plaintext buffer per event loop, lazily, on the loop's first TLS socket,
and never checked the result:

```c
loop_ssl_data->ssl_read_output =
    us_malloc(LIBUS_RECV_BUFFER_LENGTH + LIBUS_RECV_BUFFER_PADDING * 2);
```

With `ssl_read_output == NULL`, every later `SSL_read` hands boringssl

```c
loop_ssl_data->ssl_read_output + LIBUS_RECV_BUFFER_PADDING + read
```

as its plaintext destination, so the first record of application data
memcpy's to `NULL + 32`. `SSL_peek`'s `OPENSSL_memcpy(buf, ...)` at
`ssl_lib.cc:947` is the write, and the access violation confirms it is a
write fault.

`0x30`/`0x40` rather than `0x20` is memcpy's destination-alignment
preamble (`dst += VEC_SIZE; dst &= ~(VEC_SIZE - 1)`), which moves the
first faulting store for copies larger than eight vector registers.
Measured on the copy sizes a real TLS record produces:

| memcpy variant | first faulting store for `dst = NULL + 32` |
| --- | --- |
| 32-byte vectors (AVX) | `0x40` |
| 16-byte vectors (SSE) | `0x30` |

So the two strikingly stable fault addresses are just CPU dispatch
across the affected machines, and `read` is always `0`: the crash is
always the connection's first record of application data.

Only Windows reports it because Linux and macOS overcommit, so a 512 KiB
`malloc` there effectively never returns NULL. Windows fails the commit
cleanly, and the loop's much smaller `us_calloc` still succeeds out of
an already-committed page, leaving exactly the observed shape: a valid
`loop_ssl_data` whose `ssl_read_output` is NULL.

## Fix

- Null-check the buffer allocation, the `us_calloc` of `loop_ssl_data`,
and the `BIO_meth_new`/`BIO_new` calls beside them, and route the
failure through Bun's out-of-memory crash path (`Bun__outOfMemory`, new
C entry point next to `Bun__panic`). The process now dies with `Bun ran
out of memory` and a stack trace that names the allocation, instead of
faulting on the first TLS byte.
- Apply the same check to the sibling site: `recv_buf`/`send_buf` in
`us_internal_loop_data_init` are the same unchecked
`malloc(LIBUS_RECV_BUFFER_LENGTH + LIBUS_RECV_BUFFER_PADDING * 2)`. A
NULL `recv_buf` does not fault, it makes every read on the loop fail
with `EFAULT` for the life of the process, which is worse to diagnose.
- `us_internal_free_loop_ssl_data` left `loop->data.ssl_data` dangling,
which defeats the `if (!loop->data.ssl_data)` guard the init function
relies on. It now clears the field.

A 512 KiB `malloc` effectively never returns NULL on an overcommitting
kernel, so the failure path needs the existing socket fault injector to
be reachable from a test. This adds an `ssl_loop_buffer` rule to it,
which like the rest of the injector is compiled out of release builds.

## Verification

The new test spawns a child that arms `ssl_loop_buffer` before its first
TLS socket and asserts it reports out of memory rather than reaching a
read loop.

Reverting only `if (!loop_ssl_data->ssl_read_output)
Bun__outOfMemory();` reproduces the reported crash exactly, on Linux,
from that same fixture: same fault address, same frames, same boringssl
source lines.

<details>
<summary>Reproduction on the unfixed build (<code>bun bd</code>,
ASAN)</summary>

```
==19592==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000040
==19592==The signal is caused by a WRITE memory access.
==19592==Hint: address points to the zero page.
    #0 __memcpy_evex_unaligned_erms
    #1 bssl::OPENSSL_memcpy(void*, void const*, unsigned long) vendor/boringssl/crypto/internal.h:868:10
    #2 SSL_peek vendor/boringssl/ssl/ssl_lib.cc:947:3
    #3 SSL_read vendor/boringssl/ssl/ssl_lib.cc:918:13
    #4 us_internal_ssl_on_data packages/bun-usockets/src/crypto/openssl.c:1847:21
    #5 us_internal_dispatch_ready_poll packages/bun-usockets/src/loop.c:625:38
```

With the fix:

```
panic(main thread): Bun ran out of memory
Bun__outOfMemory              src/bun_bin/phase_c_exports.rs:81:5
us_internal_init_loop_ssl_data packages/bun-usockets/src/crypto/openssl.c:696:42
us_internal_ssl_attach        packages/bun-usockets/src/crypto/openssl.c:1275:3
```

</details>

`test/js/node/tls/tls-syscall-fault.test.ts` (11 pass),
`test/js/bun/util/socket-fault-injection.test.ts` (15 pass), plus
`socket-syscall-fault`, `serve-syscall-fault` and `fetch-syscall-fault`
(19 pass) are green. The three failures in `test/js/node/tls/` on this
machine are pre-existing: two also fail on an unmodified 1.4.0, and
`tls.connect should ignore invalid NODE_EXTRA_CA_CERTS` takes 5.75s,
just over the 5s local default (CI triples the per-test timeout for ASAN
builds).

## Teardown audit

The report also asked whether a socket can reach
`us_internal_ssl_on_data` after its loop's SSL data has been freed.
`us_internal_free_loop_ssl_data` is only reachable from `us_loop_free`,
and the only loop Bun frees today is `SpawnSyncEventLoop`'s, which never
has a TLS socket attached (its `loop->data.ssl_data` is always NULL, so
the free is a no-op). So that is not the cause here.

It is worth noting separately that the libuv `us_loop_free`
(`eventing/libuv.c:201-206`) calls `us_internal_loop_data_free(loop)`
and *then* runs `uv_run(loop->uv_loop, UV_RUN_NOWAIT)`, which is a full
libuv iteration that can dispatch socket poll callbacks into the
just-freed `recv_buf` and `ssl_data`. The POSIX `us_loop_free`
(`eventing/epoll_kqueue.c:56-60`) has no such window. It is unreachable
today for the reason above, so it is left out of this PR rather than
changing loop teardown ordering without a test that can exercise it.
springmin pushed a commit that referenced this pull request Jul 11, 2026
…ven-sh#33931)

## Symptom

Intermittent `EBADF: bad file descriptor, fstat` in unrelated
`Bun.file(path).text()` calls on Windows. The most visible CI victim is
`test/cli/install/bun-install.test.ts` (15 flaky hits across the last 40
PR builds, spread across many different test cases), because its dummy
registry serves every tarball via `new Response(Bun.file(path))`.

```
EBADF: bad file descriptor, fstat
 syscall: "fstat",
   errno: -9,
    code: "EBADF"
      at async <anonymous> (test/cli/install/bun-install.test.ts:6461)
```

## Cause

`FileResponseStream::start` clears `ReaderFlags::CLOSE_HANDLE` on its
`BufferedReader` so it can close the fd itself in `Drop`
(src/runtime/server/FileResponseStream.rs:182). `PosixBufferedReader`
checks that flag before closing (src/io/PipeReader.rs:283/356/374/385).
`WindowsBufferedReader` defines the flag (:1143) and sets it in
`Default` (:1155) but never reads it, so `close_impl`'s `Source::File`
arm unconditionally calls `File::detach()` which queues `uv_fs_close` on
the same CRT fd that `FileResponseStream::Drop` already queued a
`Closer::close` for (:547).

Both closes are async on the libuv threadpool. Between close #1 freeing
the CRT slot and close #2 running, an unrelated `uv_fs_open` (from
another `Response(Bun.file)` open, or a `Bun.file().text()`) can be
handed the recycled slot; close #2 then closes the wrong fd, and its
next `fstat` or `read` sees EBADF.

## Fix

Honor `WindowsFlags::CLOSE_HANDLE` in
`WindowsBufferedReader::close_impl` via a new
`File::detach_borrowed_fd()` that mirrors `detach()` but leaves
`close_after_operation` unset, so no `uv_fs_close` is scheduled for a
parent-owned fd:

- `close_impl` with `CLOSE_HANDLE` set: unchanged (`detach()` schedules
`uv_fs_close` now or after the pending read).
- `close_impl` with `CLOSE_HANDLE` cleared: `detach_borrowed_fd()`. If
idle, drop the `Box<File>` there; if a read is in flight, null `fs.data`
and let `on_file_read`'s detached branch reclaim the Box after
`complete()`.
- `on_file_read`'s `parent_ptr.is_null()` path now handles both shapes
(state `Closing`: `on_close_complete` frees; otherwise: free here).

`BaseWindowsPipeWriter::close`'s `!owns_fd()` branch already open-coded
the same sequence and now routes through the shared
`detach_borrowed_fd()`, so the reader and writer close paths share one
contract.

## Verification

The double-close only exists on Windows (POSIX honors the flag), so the
Linux gate cannot observe fail-before. Windows x64-baseline at
91675d0:

- fail-before: 5/15 runs of the new test fail under the system bun with
`EBADF: bad file descriptor, fstat 'served.bin'`
- pass-after: 8/8 under the debug build with this patch

The new test is gated behind `isWindows`.

<!-- 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-file.test.ts

<!-- 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 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
…ose_slave_fd (oven-sh#34225)

### What does this PR do?

Fixes a regression from oven-sh#33882 where a script that spawns a subprocess
with an inline `terminal:` option and awaits `proc.exited` hangs forever
if it never calls `terminal.close()`.

### Repro

```js
const proc = Bun.spawn([process.execPath, "-e", "console.log('hi')"], {
  terminal: {},
});
await proc.exited;
// process hangs here
```

### Root cause

oven-sh#33882 deferred closing the parent's pty slave fd to
`Subprocess::on_process_exit` and added a synchronous drain of the
master before the close. That drain calls `reader.read()`, which hits
`EAGAIN` (our slave is still open) and re-arms the reader poll via
`register_poll()`. Only then do we `close_slave_fd()`, so the reader's
EOF (macOS) / `EIO` (Linux) arrives on the *next* epoll tick instead of
in the same batch as the pidfd.

When that next tick runs after the script's top-level await has already
resolved, `on_reader_error` downgrades the Terminal's `JsRef`, but the
reader and writer `FilePoll`s are still counted in `loop.active`.
Nothing triggers a GC between that downgrade and the next `epoll_wait`,
so `Terminal::finalize` (the only path that unregisters those polls)
never runs and the process blocks in `epoll_wait` forever.

Before oven-sh#33882 the parent's slave fd was closed right after `spawn`, so
when the child exited the last slave was already gone and the reader
observed `EIO` in the *same* epoll batch as the pidfd. `on_reader_error`
downgraded the `JsRef` before the script ended, and the next
`onBeforeWait` safepoint finalized the Terminal.

Kernel probe on Linux (nonblocking read on master after the last slave
closes returns `-1/EIO`, not `0`):

```
=== parent keeps slave until EAGAIN then closes ===
read #0: n=18 data=[hello from child\n]
read #1: n=-1 errno=11 (EAGAIN)
  (closing parent slave now)
read #2: n=-1 errno=5 (EIO)
```

### Fix

In `drain_and_close_slave_fd`, after draining and closing `slave_fd`:

- call `reader.read()` again so the exit callback fires now (EOF on
macOS, `EIO` on Linux) instead of on a later tick when nothing may wake
the loop. A grandchild holding the slave keeps this at `EAGAIN` and
re-arms, matching the stdout/stderr policy in the same `on_process_exit`
body.
- `update_ref(false)` on both polls so the event loop can exit
regardless; the polls stay registered so grandchild output still arrives
while anything else keeps the loop running.
- bracket the body with a `ref_()`/`deref_()` scopeguard since both
reader callbacks re-enter user JS.

`on_reader_done` / `on_reader_error` now gate the exit-callback branch
on `READER_DONE` as well as `FINALIZED`, so the second `EIO` dispatch
(the still-armed one-shot from the first drain) is a no-op and the exit
callback fires exactly once.

`#[cfg(unix)]` only; Windows already delivers EOF via
`close_pseudoconsole` off-thread. `BufferedReader` / `FileReader` are
untouched.

### How did you verify your code works?

New test `process exits after subprocess with inline terminal (no
terminal.close)` spawns a `bun -e` that runs the repro, sleeps after
`child.exited` to give the stale poll a chance to fire, and asserts
`{gotOutput: true, exitedSync: true, exitCount: 1, exitCode: 0}`.

Fail-before (origin/main `src/` + this test):

```
(fail) ... process exits after subprocess with inline terminal (no terminal.close) [5004.58ms]
  ^ this test timed out after 5000ms.
```

With the fix: 91 pass / 1 todo / 0 fail.

`bun bd test test/js/bun/spawn/spawn.test.ts`: 368 pass, 0 fail. `cargo
check -p bun_runtime` clean for `aarch64-apple-darwin` and
`x86_64-pc-windows-msvc`.

<!-- 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/terminal/terminal.test.ts

<!-- 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 Jul 18, 2026
…n worker terminate (oven-sh#34455)

## What

Fixes a heap-use-after-free when a Worker with an in-flight
`dns.lookup()` / `dns.resolve*()` is terminated.

Surfaced by Node's upstream `test/parallel/test-worker-dns-terminate.js`
(being vendored in oven-sh#34441), on the debian 13 x64-asan lane:

```
==11356==ERROR: AddressSanitizer: heap-use-after-free on address 0x12ce0a3af168
READ of size 4 at 0x12ce0a3af168 thread T6 (Worker)
    #0 FilePoll::unregister            src/io/posix_event_loop.rs:951
    #1 FilePoll::deinit_possibly_defer src/io/posix_event_loop.rs:428
    #2 FilePoll::deinit_with_vm        src/io/posix_event_loop.rs:448
    #3 Resolver::on_dns_socket_state   src/runtime/dns_jsc/dns.rs:4894
    #6 ares_conn_sock_state_cb_update  vendor/cares/src/lib/ares_conn.c:36

freed by thread T6 (Worker):
    drop_in_place<Box<posix_event_loop::Store>>   (RareData field drop)
    VirtualMachine::destroy                       src/jsc/VirtualMachine.rs:4453
    WebWorker::shutdown                           src/jsc/web_worker.rs:1299
```

## Repro

```js
const { Worker } = require('worker_threads');
const w = new Worker(`
  const dns = require('dns');
  dns.lookup('nonexistent.org', () => {});
  require('worker_threads').parentPort.postMessage('0');
`, { eval: true });
w.on('message', () => w.terminate());
```

## Cause

`WebWorker::shutdown()` runs, in order: `WebWorker__teardownJSCVM`
(frees the `JSGlobalObject`), then `VirtualMachine::destroy()` which
drops `rare_data` (frees the `FilePoll` hive `Store`) and finally calls
`deinit_runtime_state` which drops `RuntimeState`. That last drop runs
`GlobalData::drop` which calls `ares_destroy()` on the per-VM c-ares
channel.

`ares_destroy()` synchronously fires every pending query callback with
`ARES_EDESTRUCTION` and then the socket-state callback for each fd it
closes. Those callback chains re-enter:

- `Resolver::on_dns_socket_state` -> `FilePoll::deinit_with_vm` on the
already-freed hive slot (the ASAN trace above)
- `GetAddrInfoRequest::on_cares_complete` ->
`DNSLookup::process_get_addr_info` -> `reject_later(global_this)` on the
freed `JSGlobalObject` (bmalloc-backed so ASAN misses it)
- `ResolveInfoRequest::on_cares_complete` -> `request_completed()` ->
`remove_timer()` -> `(*runtime_state()).timer` with the TLS already
nulled (null deref)

## Fix

Add a `RuntimeHooks::close_dns_for_terminate` slot that runs
`Resolver::close_channel_for_terminate()` from `WebWorker::shutdown()`
(and the `BUN_DESTRUCT_VM_ON_EXIT` main-thread path) right after
`close_all_socket_groups`, while JSC, `RareData.file_polls`, the event
loop, and `runtime_state` are all still live. The method also removes
the resolver's c-ares timeout timer, which `GetAddrInfoRequest`'s
EDESTRUCTION path never unwinds. `GlobalData::drop` still handles the
channel if the early hook never ran (it sees `channel == None` when it
did).

This matches Node's model: `Worker::Exit` -> `CleanupHandles()` closes
every handle wrap (including `ChannelWrap`) before disposing the
Isolate.

## Verification

New ASAN-gated test in
`test/js/web/workers/worker-terminate-lifetime.test.ts` spawns four
workers that each start a `dns.lookup()` + `dns.resolve4()` and
terminates them mid-flight.

- **fail-before** (`git stash -- src/ && bun bd test ...`): null-deref
panic / ASAN heap-use-after-free
- **pass-after**: clean exit 0 across 10 consecutive runs

Also verified `test/js/node/dns/` and `test/js/bun/dns/` pass/fail
counts are unchanged vs. main, and `bun run rust:check-all` is clean on
all 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/web/workers/worker-terminate-lifetime.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 6, 2026
…e cache (oven-sh#37034)

### Problem

On the `13 x64-asan` lane, a test that exercises non-ISO Temporal
calendars from a test callback can abort after a fully green run with a
LeakSanitizer report. Seen in build 89504 on oven-sh#37024, whose
`test/js/bun/bun-object/deep-equals-temporal.test.ts` uses
`[u-ca=hebrew]`:

```
Direct leak of 624 byte(s) in 1 object(s) allocated from:
    #1 icu_75::HebrewCalendar::clone() const
    #2 icu_75::Calendar::createInstance(icu_75::TimeZone*, icu_75::Locale const&, UErrorCode&)
    #3 ucal_open_75
    #4 JSC::TemporalCore::buildCalendarTemplate(WTF::AbstractLocker const&, unsigned int)
    #5 JSC::TemporalCore::withCalendar<JSC::TemporalCore::calendarYear(...)::$_0>(...)
```

The CI annotation titles this `direct leak of 624b in {closure#0}
(src/jsc/JSValue.rs:1664:22)` because that is the first in-repo frame
(the test-runner's `JSValue::call`); everything below it is WebKit/ICU.

### Cause

`TemporalCore::withCalendar`
(`vendor/WebKit/.../temporal/core/CalendarICUBridge.cpp`) keeps up to 8
open `UCalendar` templates in a process-lifetime `LazyNeverDestroyed`
`TinyLRUCache`, one per calendar ID (non-ISO arithmetic, plus pure-ISO
`PlainDateTime.prototype.with`, which reaches the same path unguarded);
LRU eviction `ucal_close`s them, so the set is bounded. The
`CalendarCacheEntry` that owns each `UCalendar` is
`WTF_MAKE_TZONE_ALLOCATED` (bmalloc), which LSan does not scan, so the
libc-allocated `UCalendar` (and the ICU `TimeZone` inside it) is
reported as a direct leak even though it is reachable. Whether a given
run aborts depends on whether some stale stack or register value still
points at the ICU object when LSan scans at exit, hence the
intermittence.

This is the calendar twin of the already-suppressed
`TemporalCore::withTimeZone` entry (same cache design, same
TZone-allocated owner).

### Fix

- Add a `leak:TemporalCore::buildCalendarTemplate` suppression to
`test/leaksan.supp`, mirroring the `withTimeZone` entry. The pattern
anchors on the template builder rather than `withCalendar` itself so
that a future real leak inside one of the many op lambdas `withCalendar`
runs would still be reported; every cached-template allocation carries
the builder frame. (`withTimeZone` has no such builder frame, its
`ucal_open` is inline, so that entry keeps its existing pattern.)
- Drop the `test/no-validate-leaksan.txt` escape hatch oven-sh#37024 added for
`deep-equals-temporal.test.ts`, re-enabling leak validation for it; that
file exercises the suppressed path on the asan lane.

### Verification

On a debug ASAN build, running `bun test
test/js/bun/bun-object/deep-equals-temporal.test.ts` under the CI
leak-validation env (`BUN_DESTRUCT_VM_ON_EXIT=1`,
`detect_leaks=1:abort_on_error=1`, repo suppression file):

- with the new entry: clean exit, 5/5 runs
- without it: LSan abort with the calendar-template stacks above, 3/3
runs

A standalone probe exercising 8 non-ISO calendars plus pure-ISO
`PlainDateTime.with` from a timer callback shows the same split (10/10
aborts without, 10/10 clean with; `print_suppressions=1` attributes
exactly the ICU template allocations to the new entry). Top-level module
code cannot reproduce this: its allocation stacks carry
`JSC::JSModuleLoader::evaluateNonVirtual`, which the suppression file
already covers wholesale. An ASAN-gated test pinning the entry was part
of an earlier revision and was dropped per review; the re-enabled
`deep-equals-temporal.test.ts` covers the path in CI instead.

The Expect-wrapper shutdown leak mentioned in the dropped no-validate
comment is a separate issue tracked in oven-sh#32180: that is `bun test`'s own
finalizer-owned memory, while this cache deliberately survives VM
teardown, so oven-sh#32180 would not prevent this report.

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

---

**no test proof** · iteration 1 · docs-only change; test-proof not
applicable

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

---------

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
springmin pushed a commit that referenced this pull request Aug 8, 2026
…-comparison (oven-sh#37168)

### Problem

`Bun__deepEquals` has heap-use-after-free when a getter on a nested
object mutates one of the objects being compared. All entry points are
affected: `Bun.deepEquals`, `expect().toEqual` / `toStrictEqual`,
`assert.deepStrictEqual` / `deepEqual`, and `util.isDeepStrictEqual`.

```js
// Malloc=1 <bun-asan> repro.mjs
const p1 = {}, p2 = {};
for (let i = 0; i < 8; i++) { p1['k'+i] = i; p2['k'+i] = i; }
let f = 0;
p1.a = { get x() { if (!f++) for (let i = 0; i < 2000; i++) p1['n'+i] = i; return 1; } };
p2.a = { get x() { return 1; } };
p1.z = 1; p2.z = 1;
Bun.deepEquals(p1, p2, true);
```

ASAN (with `Malloc=1` so JSC's bmalloc routes through the system
allocator):

```
heap-use-after-free READ of size 8
  #0 CompactPropertyTableEntry::key() Structure.h
  #1 PropertyTable::forEachProperty
  #2 Structure::forEachProperty
  #3 Bun__deepEquals<...> bindings.cpp
freed by:
  PropertyTable::destroyIndexVector <- PropertyTable::rehash <- PropertyTable::add
  <- Structure::addNewPropertyTransition <- JSObject::putDirectInternal
```

### Cause

The object fast path walks the structure's `PropertyTable` with
`Structure::forEachProperty` and recurses into `Bun__deepEquals` from
inside the lambda. Comparing a nested value can run a user getter; if
that getter adds (or deletes) properties on the parent object, JSC takes
the shared table off the old structure and rehashes it, freeing the
index vector the outer walk is iterating. Every remaining sibling
property is then read from freed memory and its stale offset fed to
`getDirect()`. In release builds this shows up as a SEGV at a forged
address or a wrong verdict.

### Fix

Collect the (left, right) value pairs into a `MarkedArgumentBuffer`
under `forEachProperty` with no side effects, then run `sameValue` and
the recursive comparisons after the walk finishes. This is the same
shape as `Object.assign`'s fast path (snapshot under `forEachProperty`,
side-effectful work after). The buffer keeps the snapshotted values
visible to GC, so allocation churn in a getter cannot collect them
either. The reverse `o2` walk already did only direct structure reads
and now also completes before any user code can run.

Verdicts are unchanged for non-mutating comparisons (existing suites
pass); a comparison whose getter mutates the object now
deterministically compares the snapshot, which matches Node's behavior
for the repro above (`true`).

### Verification

- New test in `test/js/bun/bun-object/deep-equals.test.ts` (renamed from
`deep-equals.spec.ts` to match the test naming convention): spawns an
ASAN child with `Malloc=1` (bmalloc routed through the system allocator
so ASAN can see the freed table) covering same-structure,
mixed-structure, delete, right-side mutation, and GC-churn variants
across all entry points. Fails before the fix (ASAN heap-use-after-free
abort), passes after.
- `test/js/bun/bun-object/`, `test/js/node/assert/deep-equal.test.ts`,
`assert-typedarray-deepequal.test.ts`: 542 pass.
- `test/js/bun/test/expect.test.js`: 415 pass.
- `test/js/node/test/parallel/test-assert-deep-with-error.js`: 2 pass.


### Scope

The same pattern exists in `JSC__JSValue__forEachPropertyImpl` in this
file (the `Bun.inspect` / `console.log` property walk), where the
formatter callback can run a nested value's `inspect.custom` mid-walk.
Verified with ASAN to hit the same free/read pair. That is a
pre-existing bug in the console/inspect subsystem and is intentionally
excluded here; a follow-up fix for that site is in progress. The other
`forEachProperty` sites (CommonJS export enumeration, HTTP header
writing, the ordered/non-indexed iteration variants) run no user code
inside the walk.

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

---

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

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Aug 8, 2026
…id-format (oven-sh#37169)

### Problem

`Bun.inspect` and `console.log` have a heap-use-after-free when
formatting a value runs user code that mutates the object being
formatted. The default-enabled
`Symbol.for("nodejs.util.inspect.custom")` hook on a nested value is
enough to trigger it:

```js
// Malloc=1 <bun-asan> repro.mjs
const p = {};
for (let i = 0; i < 8; i++) p['k'+i] = i;
let f = 0;
p.a = { [Symbol.for('nodejs.util.inspect.custom')]() {
  if (!f++) for (let i = 0; i < 256; i++) p['n'+i] = i;
  return 'a';
} };
p.z = 1;
console.log(Bun.inspect(p).length);
```

ASAN (with `Malloc=1` so JSC's bmalloc routes through the system
allocator):

```
heap-use-after-free READ of size 8
  #0 CompactPropertyTableEntry::key() Structure.h
  #1 PropertyTable::forEachProperty
  #2 Structure::forEachProperty
  #3 JSC__JSValue__forEachPropertyImpl bindings.cpp
freed by:
  PropertyTable::destroyIndexVector <- PropertyTable::rehash <- PropertyTable::add
  <- Structure::addNewPropertyTransition <- JSObject::putDirectInternal
```

### Cause

The fast path of `JSC__JSValue__forEachPropertyImpl` walks the
structure's `PropertyTable` with `Structure::forEachProperty` and
invokes the formatter callback from inside the walk. The callback
recursively formats the property value, which can run user code: a
nested value's `inspect.custom`, or a getter on a built-in subclass (for
example an overridden `Map.prototype.size`). If that code adds or
deletes properties on the parent object, JSC rehashes the shared table,
freeing the index vector the outer walk is iterating, and every
remaining entry is read from freed memory.

The fast-path guard only inspects the parent's structure, and a parent
with plain data properties passes it; the hostile hook lives on a nested
value. Same bug class as the deepEquals fix in oven-sh#37168, which
deliberately excluded this site.

### Fix

Collect the entries (key, attributes, direct value) under
`forEachProperty` with no side effects, then resolve remaining values
and invoke the callback on the snapshot after the walk finishes. The
values go in a `MarkedArgumentBuffer` so GC in a callback cannot collect
them; keys are retained as `Identifier`s. The snapshot is per structure
walk, so the prototype-chain restart loop still re-reads each
prototype's live structure.

Properties added to the object while it is being formatted are no longer
printed: the walk now reflects the object as it was when formatting
started. That matches Node, which collects the key list before
formatting values. The other `forEachProperty` sites are unaffected: the
non-indexed and ordered variants never take this fast path, and the
remaining callers run no user code in the callback.

### Verification

- New test in `test/js/bun/util/inspect.test.js` (ASAN-only, child
spawned with `Malloc=1`) covering: `inspect.custom` adding properties
via `Bun.inspect` and `console.log`, deleting properties, a `Map`
subclass `size` getter, the prototype fast-walk of an own-property-less
object, and GC churn inside the hook with object-valued siblings
formatted afterwards. Fails before the fix (ASAN abort, empty stdout),
passes after.
- `test/js/bun/util/inspect.test.js`: 74 pass. `test/js/bun/console/`:
85 pass, 1 skip.
- `inspect-error.test.js` minified-file snapshots and
`inspect-error-leak.test.js` fail identically with and without this diff
locally (pre-existing, unrelated to property enumeration).
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
…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 Aug 22, 2026
oven-sh#39947)

### Problem
- A worker whose entry point goes through a package.json `imports` or
`exports` map leaks 12 KiB (3 `PathBuffer`s, more on Windows) when its
thread exits. On an ASAN build LeakSanitizer reports `Direct leak of
12288 byte(s)` allocated in `module_bufs`
(`src/resolver/package_json.rs`), reached from
`resolve_entry_point_specifier` on the worker thread.
- Cause: `MODULE_BUFS` is a thread local `Cell<*mut ModuleBufs>` with
nothing that frees the box. The resolver's other per thread buffers
(`BufsSlot` in `resolver.rs`, `LazyPathBuf` in `bun_paths`) got a
destructor in oven-sh#30875. This one did not.

### Fix
- Wrap the pointer in `ModuleBufsSlot`, whose `Drop` destroys the box
when the thread exits. Same shape as `BufsSlot`. Access is unchanged, so
the recursion notes on the thread local still hold, and the static TLS
template is still one pointer.
- Correct because the destructor runs when the thread's TLS is torn
down, after every resolver frame on that thread has returned. The main
thread's box lives for the process, as before.
- Verified: `test/js/web/workers/worker-entry-point.test.ts` (new file)
runs a worker through an `imports` alias in a child with
`detect_leaks=1`. It fails on main with the report above and passes with
this change (checked both ways with a debug build).
`test/js/bun/binary/tls-segment-size.test.ts` still passes.

### Background
- The resolver keeps a few large scratch buffers per thread instead of
on the stack. They are boxed on first use and only a pointer sits in
TLS, so the TLS segment stays small on every platform.
- A worker thread resolves its own entry point and preloads, so it is
the common short lived thread that touches these buffers. The bundler's
pool threads live as long as the pool.
- The ASAN CI lanes run test children with `detect_leaks=1`. The test
sets that itself (plus the repo's `test/leaksan.supp`) so that a local
ASAN build checks it too. A build without ASAN ignores the options and
checks the behaviour only.

<details><summary>Notes</summary>

Found through oven-sh#39811, whose worker test resolves an `imports` alias and
failed on the ASAN lanes because of this leak. oven-sh#39811 carries this
change until this lands and is otherwise independent of it. oven-sh#35060
(overflow bundle threads, open) includes the same change as one of its
hunks, because its threads are short lived too.

The case is in its own file, for the worker entry point resolution
cases, rather than in `worker.test.ts`: three of that file's stress
cases go over their budget on a debug build on a slow machine, which
would hide whether this case itself flips. oven-sh#39811 adds its worker case
to the same file.

Without `print_suppressions=0` LeakSanitizer prints a "Suppressions
used" table to stderr on exit when an unrelated, suppressed allocation
exists in the process, so the test passes that along with the
suppressions file when the environment does not already set
`LSAN_OPTIONS`.
</details>

<!-- 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/web/workers/worker-entry-point.test.ts
bun test v1.4.0 (4199361)

test/js/web/workers/worker-entry-point.test.ts:
41 |         LSAN_OPTIONS:
42 |           bunEnv.LSAN_OPTIONS ??
43 |           `print_suppressions=0:suppressions=${path.join(import.meta.dir, "..", "..", "..", "leaksan.supp")}`,
44 |       },
45 |     );
46 |     expect(stderr).toBe("");
                        ^
error: expect(received).toBe(expected)

- ""
+ "
+ =================================================================
+ ==385090==ERROR: LeakSanitizer: detected memory leaks
+ 
+ Direct leak of 12288 byte(s) in 1 object(s) allocated from:
+     #0 0x000007dd95c8 in malloc crtstuff.c
+     #1 0x00000be19934 in std::sys::alloc::unix::alloc /root/.rustup/toolchains/nightly-2026-07-20-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/sys/alloc/unix.rs:31:18
+     #2 0x00000be184b9 in <std::alloc::System>::alloc_impl /root/.rustup/toolchains/nightly-2026-07-20-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/alloc.rs:149:78
+     #3 
... (truncated)

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

test/js/web/workers/worker-entry-point.test.ts:
(pass) package.json imports alias as the entry point > the worker runs and its thread exits without leaking [11.86ms]

 1 pass
 0 fail
 3 expect() calls
Ran 1 test across 1 file. [218.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/web/workers/worker-entry-point.test.ts
bun test v1.4.0 (4199361)

test/js/web/workers/worker-entry-point.test.ts:
(pass) package.json imports alias as the entry point > the worker runs and its thread exits without leaking [3743.31ms]

 1 pass
 0 fail
 3 expect() calls
Ran 1 test across 1 file. [6.05s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 667ms (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_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_outpu
... (truncated)
```

</details>

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

```
src/resolver/package_json.rs                   | 23 +++++++++---
 test/js/web/workers/worker-entry-point.test.ts | 50 ++++++++++++++++++++++++++
 2 files changed, 68 insertions(+), 5 deletions(-)
```

</details>

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

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

```
file                                            reads  edits  tests
src/resolver/package_json.rs                        2      2      0
test/js/web/workers/worker-entry-point.test.ts      1      2      0
```

</details>

**root cause** · written by the author bot

With --target bun or node, the resolver short-circuits node:, bun: and
hardcoded builtin specifiers into an external result whose primary path
is the bare specifier rather than an absolute file path, and entry-point
resolution passed that through, so enqueue_entry_item either tripped the
absolute-path assert, reported a misleading "File not found", or for
bun:wrap collided with the runtime's pre-registered source and left the
build with no entry points. The fix marks entry-point resolutions with
their own ImportKind so the resolver no longer applies externalization
rules to them, and resolv…

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

### Problem
- GitHub closes only the first reference after a keyword, so "Fixes #1,
#2" leaves #2 open. "Supersedes #3" links nothing, and no reference
closes a pull request.
- The last 1000 merged PRs name 274 such references. PR oven-sh#32292 is open
although merged oven-sh#36135 says "Supersedes oven-sh#32292".

### Fix
- `.github/workflows/close-linked-issues.yml` runs on
`pull_request_target` `closed` (a merge into the default branch of
`oven-sh/bun`) and on `workflow_dispatch` with a PR number and
`dry_run`. Everything is inline in one `actions/github-script` step,
with no checkout.
- Each open target is closed as `completed` with the comment "Closed as
completed by #N." or "Superseded by #N.". Closed or missing targets, the
PR itself and other repositories are skipped.
- The parser has no regex. A closing keyword (close, fix, resolve,
supersede, replace, any tense) must lead the reference, alone or in a
list. A negated, hedged or noun keyword, or one whose subject is another
reference, does not count ("may fix", "the rm fix #1", "oven-sh#100 supersedes
#1").
- Verified: `test/internal/close-linked-issues.test.ts` (333 cases) runs
the YAML's script against fake `github`, `context` and `core`. Also the
1000-PR parse (Notes).

### Background
- GitHub's own keywords are close, fix and resolve (-s, -ed). Each links
one reference, and only a merge into the default branch closes it.
- `pull_request_target` runs in the base repository with a write token,
also for fork PRs. That is safe only when no PR-controlled code runs.
Here the description is the only PR input, parsed as text.

<details><summary>Notes</summary>

A close through the API does not create the "closed this in #N" timeline
link that GitHub makes for its own closes. The comment carries the PR
number instead.

How the parser was calibrated. I pulled the descriptions of the last
1000 merged PRs and listed every line with a keyword next to a
reference. The keyword families, list shapes and reference forms in the
script are the ones that appear there. A reference is `#1`,
`owner/repo#1`, an issue or pull URL (bare or in `<>`), or a markdown
link. Four lines would have been wrong with a plain
keyword-then-reference rule, and each led to a rule:

- "the open `rm` fix oven-sh#37521" (oven-sh#38379): "fix" as a noun. Base forms (fix,
close, resolve, supersede, replace) count only at the start of a
sentence or line, or after will, should, does, and, and a few similar
words. "to" is not one of them ("unable to fix #1", "how to fix #1").
- "May also fix oven-sh#12318 / oven-sh#10046, untested" (oven-sh#38242): hedged. may, might,
could, would, partially and the negations disqualify the keyword,
looking past adverbs such as "also".
- "Supersedes the closed oven-sh#26040" (oven-sh#36289) and "a comment on closed
oven-sh#35351" (oven-sh#35365): "closed" as an adjective. A determiner or preposition
before the keyword disqualifies it.
- "supersedes oven-sh#33130's optimisation" (oven-sh#35843): a number that continues
into a word is not a reference.

Review added: a reference before the keyword is the subject ("oven-sh#100
supersedes #1"), also through "which" or "that" ("reverts oven-sh#100, which
fixed #1") and across a removed span ("oven-sh#100 ~~also~~ fixes #1"). A hedge
two words before the keyword disqualifies it ("hopefully this fixes #1",
"could this fix #1?"). A clause that starts with if, when, once, until
or unless is not a statement. The tokenizer keeps a line break as a
token so that "Fixes #1" on one line and "Fixes #2" on the next stay two
statements. Code spans, fences, indented code, blockquotes, HTML
comments and strikethrough are skipped. The block stripping follows
CommonMark for fences (also inside a blockquote), indented code,
blockquotes with lazy continuation, setext underlines and HTML comments,
and GFM for `~~` flanking.

Result over the 1000 descriptions: 274 distinct references in 135 PRs. I
checked the current state of all of them through GraphQL. All but one
are closed (202 issues completed, 5 duplicates, 66 pull requests). The
one open target is PR oven-sh#32292, superseded by merged oven-sh#36135. No open
target is a false positive. Every review change kept this result.

Patterns that are deliberately not handled: a bulleted list under
"Closes:" on its own line (not seen in the sample), references separated
by whitespace only ("#1 #2"), "fix for #1", and GH-1 style references. A
`?` after the list is not treated as a question. The block parser tracks
no list containers, so a second paragraph of a list item indented by
four spaces is read as an indented code block and skipped. A removed
span or inline comment reads as one word, so "Fixes <!-- n --> #1" finds
nothing.

The test suite covers: the phrases above, stopping at the right place in
real sentences, CRLF descriptions, URLs with fragments or a `/files`
suffix, case-insensitive `Owner/Repo#1`, the fake API where a lookup, an
update or a comment fails, the `dry_run` input, an invalid `pr_number`
input, an unmerged PR, a PR merged into a non-default branch, the merge
event body against a later edit, and a description with no closing
statement.

The first revision of this PR checked out the repository and ran
`scripts/close-linked-issues.ts`. Jarred asked for no checkout and no
script file, so the script moved inline into the workflow and the test
now reads it out of the YAML.
</details>

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

---

**[stamp-90s]** gate passed · iteration 9 · 2 files touched

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

```console
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/internal/close-linked-issues.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/internal/close-linked-issues.test.ts
bun test v1.4.1 (4448a2e)

test/internal/close-linked-issues.test.ts:
(pass) finds "Fixes oven-sh#39852" [176.21ms]
(pass) finds "Closes oven-sh#31772. Fixes oven-sh#31771." [22.28ms]
(pass) finds "- Fixes oven-sh#39930" [12.28ms]
(pass) finds "Fixes: oven-sh#30429" [10.46ms]
(pass) finds "FIXES #1" [7.86ms]
(pass) finds "(Fixes #1)" [8.97ms]
(pass) finds "**Fixes #1**" [10.20ms]
(pass) finds "__Fixes #1__" [9.83ms]
(pass) finds "_Fixes #1_" [11.25ms]
(pass) finds "Fixes **#1**" [9.72ms]
(pass) finds "**Fixes** #1" [7.13ms]
(pass) finds "**Fixes:** #1" [8.11ms]
(pass) finds "Fixes #1 and **#2**" [11.47ms]
(pass) finds "Fixes **#1**, **#2**" [9.13ms]
(pass) finds "## Why (fixes oven-sh#13771, closes oven-sh#30543)" [16.08ms]
(pass) finds "Closes oven-sh#11418" [19.46ms]
(pass) finds "Resolves #1. Resolved #2. Resolve #3." [12.09ms]
(pass) finds "Fixes oven-sh#34055, oven-sh#30327, oven-sh#24394, oven-sh#20816, oven-sh#32403, oven-sh#11898, oven-sh#10056." [17.11ms]
(pass) finds "Fixes oven-sh#18192 and oven-sh#31675 as a consequence" [10.45ms]
(pass) finds "Fixes #1, #2, and #3" [10.96ms]
(pass) finds "Fixes #1 & #2" [7.63ms]
(pass) finds "Closes oven-sh#33280,  Closes oven-sh#32864 and Closes oven-sh#29696 (the timer in oven-sh#32949 is orthogonal)" [20.29ms]
(pass) finds "Closes oven-sh#33182 and oven-sh#32947 on top of current main (which already has oven-sh#36304 for catalogs)." [16.12ms]
(pass) finds "Fixes #1,\n#2" [7.76ms]
(pass) finds "Fixes #1, #2,\nand #3" [9.27ms]
(pass) finds "Fixes #1\nand #2" [8.57ms]
(pass) finds "Fixes #1\n& #2" [6.80ms]
(pass) finds "Fixes #1 and\n#2" [7.31ms]
(pass) finds "Supersedes oven-sh#39908 (same change, moved from a fork branch)" [13.21ms]
(pass) finds "Supersedes oven-sh#38778 and oven-sh#38391. Carries the entry point arm of oven-sh#35053." [14.43ms]
(pass) finds "Supersedes oven-sh#39193 and keeps its three tests." [11.48ms]
(pass) finds "This supersedes oven-sh#33306 and oven-sh#32803. Their tests are kept here." [13.73ms]
(pass) finds "- This replaces oven-sh#33793. Its 
... (truncated)
Exit: 0
```

</details>

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

```
.github/workflows/close-linked-issues.yml | 950 ++++++++++++++++++++++++++++++
 test/internal/close-linked-issues.test.ts | 598 +++++++++++++++++++
 2 files changed, 1548 insertions(+)
```

</details>

**gate history** · 29 passed · 0 rejected · iteration 9

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

```
file                                       reads  edits  tests
.github/workflows/close-linked-issues.yml      6     12      0
test/internal/close-linked-issues.test.ts      3     11      0
```

</details>

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Sep 1, 2026
…safe functions (oven-sh#39848)

### Problem
- An addon's `napi_finalize` segfaults under
`napi_body::Finalizer::run`: Sentry BUN-4MXA and BUN-4NJZ on 1.4.0, and
oven-sh#41055 (libsql under `bun test --parallel`).
- `NapiRef::callFinalizer` (`napi.h`) copied the finalizer into the task
that runs after the GC, so `napi_delete_reference` in between did not
cancel it and it ran on freed memory. Node dequeues it.
- `ThreadSafeFunction::destroy` (`napi_body.rs`) freed the function
before its finalizer ran. After `napi_tsfn_abort` it also waited for
every other thread's reference (oven-sh#40671), so a holder that never releases
pinned the process.

### Fix
- `NapiEnv::m_pendingRefFinalizers` (Node's `pending_finalizers`): the
GC adds the ref and queues a drain if the set was empty. Each drain
takes the first ref, requeues if any remain, then runs its finalizer.
`~NapiRef` removes itself, so a deleted reference never finalizes.
- `ThreadSafeFunction::finalize` (was `destroy`) runs from the closing
dispatch whatever `thread_count` is: the finalizer first, then the
JS-thread release under the lock. It frees the allocation if no thread
reference is left, else the last release does (Node's `Finalize` +
`MaybeDelete`).
- Verified: four new tests in `test/napi/napi.test.ts`, compared with
Node 26.

### Background
- A `NapiRef` backs a `napi_ref`. `napi_wrap` creates one that holds the
JS object weakly, and JSC calls it when it sweeps the object.
- Outside `NAPI_EXPERIMENTAL` a finalizer may not run inside the GC, so
it is queued on the event loop. Node never finalizes a reference deleted
before then.
- Node finalizes a threadsafe function on the JS thread when it closes
and deletes it after the callback, or at the last `thread_count`
release.

<details><summary>Notes</summary>

Branch history. The first version kept a `HashSet` of refs with a queued
task each; the maintainer push on this branch replaced it with the
`ListHashSet` drain (`NapiEnv::drainOneRefFinalizer`, one ref per
event-loop task, the next drain queued before the finalizer runs so a
finalizer that deletes or enqueues other refs is plain) and merged
oven-sh#40671 (threadsafe function: finalize on abort without waiting for the
other threads, `finalize` replaces `destroy`, `env_teardown_done`
renamed `resources_released`). That PR is closed in favour of this one.
A `napi_wrap` without a result keeps the copying `callFinalizer()` path:
its runtime-owned reference (`NapiRefSelfDeletingWeakHandleOwner`) is
deleted right after, so nothing can delete it while the copy is queued.

The four tests: `napi_wrap` and `napi_add_finalizer` references deleted
in the same turn as the GC, a parent finalizer that deletes children
collected by the same GC (in both creation orders), a tsfn finalizer
that uses its handle, and (from oven-sh#40671) a tsfn aborted while another
thread still holds a reference. The last one waits for the holder thread
by deadline (3 s, the holder gives up after 2 s) so it stays under the
default test timeout.

Fail before, on release 1.4.0 and on an unfixed ASAN debug build. The
fixture's native objects are static and record a finalizer that runs
after the delete instead of reading freed memory, so the failure is a
clean output mismatch with Node:

```
- napi_wrap: collected before delete: true, finalized after delete: 0
- napi_add_finalizer: collected before delete: true, finalized after delete: 0
+ napi_wrap: collected before delete: true, finalized after delete: 1
+ napi_add_finalizer: collected before delete: true, finalized after delete: 2
```

Parent and children (the shape from the report): with the parent created
after the children, JSC sweeps the parent's weak handle first, so the
parent's queued finalizer ran first and then all 8 child finalizers ran
on deleted children (`children finalized after delete: 8`). Created
before the children, the children's finalizers ran first and the
parent's deletes were plain. The fixture runs both orders.

`Bun.gc(true)` is `collectNow(Sync)`, which sweeps synchronously, so the
task is queued before `Bun.gc` returns and the same-turn delete is
deterministic. A conservative scan can keep an object alive, so each
attempt uses a fresh object and the output does not depend on the
attempt count.

BUN-4NJZ (Windows x64, `bun test`): eight frames inside `index.node`,
then `napi_body::Finalizer::run` (`napi_body.rs:2405`, the return
address after the callback, which symbolizes as the inlined
`napi_internal_remove_finalizer` / `NapiEnv::removeFinalizer` /
`BoundFinalizer::BoundFinalizer`),
`NapiFinalizerTask::run_on_js_thread`, `dispatch::run_task`. Same shape
as BUN-4MXA: the addon's finalizer is what is executing.

Drains queued during VM shutdown become cleanup hooks
(`NapiFinalizerTask::schedule`); `VirtualMachine::run_cleanup_hooks`
repeats while hooks push more, so a chain of drains still runs at exit.

The tsfn test on an unfixed ASAN build:

```
ERROR: AddressSanitizer: heap-use-after-free
  #0 napi_get_threadsafe_function_context src/runtime/napi/napi_body.rs:3290
  #1 napitests::tsfn_finalizer_uses_handle standalone_tests.cpp
  #2 <napi_body::Finalizer>::run napi_body.rs:2403
  #3 <NapiFinalizerTask>::run_on_js_thread
  #4 bun_runtime::dispatch::run_task
freed by: ThreadSafeFunction::destroy
```

On a release build the stale read still returns the old context, so that
test only fails under ASAN. The two reference tests fail on every build.
Experimental modules are unchanged: `callFinalizerFromGC` runs the
finalizer during the GC for them, as before. The runtime-owned reference
is `NapiRefSelfDeletingWeakHandleOwner` in `napi.cpp`.

Other paths checked:

- Finalizer deletes its own reference (node-addon-api `ObjectWrap`):
`runQueuedFinalizer` takes the ref out of the set before calling, so the
delete inside the callback is plain.
`test/napi/napi-finalizer-delete-ref.test.ts` covers the experimental
variant.
- Env cleanup while a finalizer is queued: `wrap_cleanup` runs it at
once and clears it. The task later finds the cleared callback and does
nothing, so it still runs once.
- Task dropped at VM teardown (`has_run_cleanup_hooks`): the entry stays
in the set until `~NapiRef` or the env goes away. The set holds the
pointer as a key only.
- A reused address: set membership means a finalizer is owed, so a drain
that reaches a new ref at the same address runs a finalizer that is owed
anyway, and the set holds each ref once.
- `~NapiRef` does one `ListHashSet::remove`, which returns at once while
the env never queued anything. `napi_create_reference` refs never enter
the set.
- Re-entry from the tsfn finalizer into the function it belongs to:
`destroy` holds no borrow and no lock while the callback runs.
`napi_release_threadsafe_function` returns `napi_invalid_arg`
(`thread_count` is 0) and `napi_unref_threadsafe_function` returns
`napi_ok` in both runtimes (asserted by the test).
`napi_acquire_threadsafe_function` returns `napi_closing` (`closing` is
`Closed`), `napi_call_threadsafe_function` returns `napi_invalid_arg`
for the same reason, and `napi_ref_threadsafe_function` is a no-op
because `maybe_queue_finalizer` disabled the keepalive. None of them
frees the function or schedules it, and `finalizer_fun` was taken, so
nothing runs the finalizer twice.

Suites run with the debug build: `test/napi/napi.test.ts`,
`napi-finalizer-delete-ref.test.ts`, `napi-value-ffi.test.ts`, and the
node-napi-tests suites `6_object_wrap`, `7_factory_wrap`,
`8_passing_wrapped`, `test_finalizer`, `test_reference`,
`test_reference_double_free`, `test_general` (both),
`test_instance_data` (both), `test_threadsafe_function`,
`test_reference_by_node_api_version`, `test_env_teardown_gc`,
`test_worker_terminate_finalization`, `test_buffer`. All pass (a CI-like
`--timeout` is needed locally, the default 5 s is too short for the ASAN
build).

oven-sh#38506 changes `NapiEnv::inGC()`. `callFinalizerFromGC` calls it, so the
two compose. Closes oven-sh#41055.
</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/napi/napi.test.ts

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

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
springmin pushed a commit that referenced this pull request Sep 3, 2026
…p_until (oven-sh#41149)

Fuzzilli found an ASAN stack-buffer-overflow in `run_tasks` ->
`Log::add_msg` -> `Vec::push` during runtime auto-install.

## What happened

`enqueue_dependency_to_root` blocks in `sleep_until`. `sleep_until`
ticks the JS event loop between `is_done` polls. Each poll calls
`run_tasks`, which reads `manager.log` to log manifest 4xx errors.

The event loop tick can run module transpilation or
`AsyncModule::resume_loading_module`. Both swap the VM's log pointers
(`jsc_vm.log`, `transpiler.log`, `resolver.log`, `linker.log`, `pm.log`)
and restore them on exit. They do not save from the same source:

- `resolve_maybe_needs_trailing_slash` saves `jsc_vm.log` and swaps
every pointer except `transpiler.log`.
- `transpile_source_code_inner` saves `transpiler.log` and restores
`pm.log` to it.
- `AsyncModule::resume_loading_module` saves `jsc_vm.log` but restores
`transpiler.log` to it.

When a module transpile runs during a resolve's `sleep_until` tick, its
restore sets `pm.log` to the old `transpiler.log`, not the resolve's
scoped log. The 404 diagnostics from `run_tasks` then land in the wrong
log. With more interleaving across calls `pm.log` ends up at a dead
stack `Log`, and the next `run_tasks` poll reads it.

```
READ of size 8 at 0x7ffd98a149c0 thread T0
    #0  RawVecInner::capacity
    #1  Log::add_msg (lib.rs:2369)
    #3  Log::add_error_fmt (lib.rs:2032)
    #4  run_tasks (runTasks.rs:494)
    #5  Closure::is_done (PackageManagerEnqueue.rs:534)
    #7  AnyEventLoop::tick_raw (AnyEventLoop.rs:148)
    #8  PackageManager::sleep_until (PackageManager.rs:1063)
    #9  enqueue_dependency_to_root (PackageManagerEnqueue.rs:571)
    ...
    oven-sh#18 resolve_maybe_needs_trailing_slash (VirtualMachine.rs:4259)

Address is located in stack of thread T0 in frame
    #0  to_js_host_call (host_fn.rs:677)
  [144, 152) 'scope' <== Memory access at offset 160 overflows this variable
  [176, 240) 'scope_storage'
```

## Fix

- Swap and restore `transpiler.log` with the other log pointers in
`resolve_maybe_needs_trailing_slash`. It can no longer drift from
`jsc_vm.log` across a resolve.
- Snapshot `pm.log` on entry to `enqueue_dependency_to_root`'s
`sleep_until`. Re-assert it before each `run_tasks` poll. `run_tasks`
always sees the caller's log, whatever the event loop tick did to it.

## Test

The test runs a 404 registry in the parent process on `port: 0`. The
child queues `require()` calls with `setImmediate`, so they run during
`sleep_until`'s event-loop tick and trigger
`transpile_source_code_inner`'s log swap. Without the fix, the 404
errors land in the VM log and print to stderr at exit. With the fix,
stderr is empty.

Verified on current `main` (6f27257): the test fails without the
source change and passes with it.

Supersedes oven-sh#31120, which the stale bot closed after 90 days. Same
change, rebased onto current main; moved to this branch so the fix can
be tracked.

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

---

**[human-review]** gate passed · iteration 6 · 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/pr_gate.xml" test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts
bun test v1.4.1 (a6c4cc2)

test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts:
(pass) repeated failing auto-install resolves at varying stack depth don't read a dangling pm.log [1075.94ms]
114 |     stderr: "pipe",
115 |   });
116 | 
117 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
118 | 
119 |   expect(stderr).toBe("");
                       ^
error: expect(received).toBe(expected)

- ""
+ "error: GET http://localhost:37987/autoinstall-missing-pkg-0 - 404
+ 
+ error: GET http://localhost:37987/autoinstall-missing-pkg-1 - 404
+ 
+ error: GET http://localhost:37987/autoinstall-missing-pkg-2 - 404
+ 
+ error: GET http://localhost:37987/autoinstall-missing-pkg-3 - 404
+ 
+ error: GET http://localhost:37987/autoinstall-missing-pkg-4 - 404
+ 
+ error: GET http://localhost:37987/autoinstall-missing-pkg-5 - 404
+ 
+ error: GET http://localhost:37987/autoinstall-missing-pkg-6 - 404
+ 
+ error: GET http://
... (truncated)

release without fix: 1 FAILED
bun test v1.4.1-canary.1 (a6c4cc2)

test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts:
(pass) repeated failing auto-install resolves at varying stack depth don't read a dangling pm.log [22.63ms]
114 |     stderr: "pipe",
115 |   });
116 | 
117 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
118 | 
119 |   expect(stderr).toBe("");
                       ^
error: expect(received).toBe(expected)

- ""
+ "error: GET http://localhost:35697/autoinstall-missing-pkg-0 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-1 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-2 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-3 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-4 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-5 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-6 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-7 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-8 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-9 - 
... (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/bun/resolve/resolve-autoinstall-log-dangling.test.ts
bun test v1.4.1 (a6c4cc2)

test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts:
(pass) repeated failing auto-install resolves at varying stack depth don't read a dangling pm.log [1104.72ms]
(pass) module transpile during auto-install's event-loop tick doesn't desync pm.log [689.71ms]

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

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 590ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/141] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 244 extern-C blocks audited
[2/141] gen JS modules (bundle-modules)
Preprocess modules (8046ms)
Bundle modules (666ms)
Postprocesss modules (969ms)
Bundle Functions (629ms)
Generate Code (40ms)

[10.36s] Bundled "src/js" for production
  2594 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[2/141] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_output_tags v0.0.0 (/workspace/bun/src/bun_output_tags)
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_parsers v0.0.0 (/workspace/bun/src/parsers)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_dispatch v0.0.0 (/workspace/bun/src/dispatch)
�[1m�[92m   Compiling�[0m bun_jsc_macros v0.0.0 (/workspace/bun/src/jsc_macros)

... (truncated)
```

</details>

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

```
.../PackageManager/PackageManagerEnqueue.rs        |  6 ++
 src/jsc/VirtualMachine.rs                          |  5 ++
 .../resolve-autoinstall-log-dangling.test.ts       | 65 ++++++++++++++++++++++
 3 files changed, 76 insertions(+)
```

</details>

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

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

```
file                                                      reads  edits  tests
src/install/PackageManager/PackageManagerEnqueue.rs           1      2     19
src/jsc/VirtualMachine.rs                                     5      3     19
…js/bun/resolve/resolve-autoinstall-log-dangling.test.ts      3      9     18
```

</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 15, 2026
…text (oven-sh#42520)

### Problem
- `class Upload extends File {}` inside a `node:vm` context crashes on
`new Upload(...)`: `panic(main thread): Segmentation fault at address
0x28`. `class S extends fs.Stats {}`, `BigIntStats`, and
`Reflect.construct(File, args, fnFromContext)` crash the same way. Node
v26.3.0 constructs the object.
- These constructors do
`static_cast<Zig::GlobalObject*>(getFunctionRealm(lexicalGlobalObject,
newTarget))` (`JSDOMFile.cpp:75`, `NodeFSStatBinding.cpp:843`,
`NodeFSStatFSBinding.cpp:372`). For a function from a context the realm
is a `NodeVMGlobalObject`, which is not a `Zig::GlobalObject`.
`JSBlobStructure()` and `getStructure<isBigInt>()` then read a
`LazyClassStructure` past the end of the cell.

### Fix
- The three sites call `defaultGlobalObject(getFunctionRealm(...))`. It
returns the realm when it is a Bun global, and the default Bun global
when it is not.
- The other native constructors already do this
(`generate-classes.ts:2387`, `JSDOMWrapperCache.cpp:42`,
`NodeDirent.cpp:177`).
- The `StatFs` constructor is not reachable from JS, so that site has no
test.
- Verified: `test/js/node/vm/vm.test.ts`, four new cases. All four exit
with SIGSEGV on 1.4.3-canary.1. Also ran the `fs.Stats`, `Blob`, and
globals suites.

### Background
- `newTarget` is the constructor that `new` was applied to. In `new
Upload()`, `File` runs with `newTarget === Upload`.
- `getFunctionRealm(newTarget)` returns the global object the function
was created in. A native constructor takes its base `Structure` from
that global. `createSubclassStructure` then applies
`newTarget.prototype`.
- A `node:vm` context has its own global, `NodeVMGlobalObject`. It and
`Zig::GlobalObject` (the Bun global) are sibling subclasses of
`Bun::GlobalScope`. Only `Zig::GlobalObject` holds the structures of Bun
classes.
- A ShadowRealm global is a `Zig::GlobalObject`. The comment at these
sites names that case, and the cast was valid for it.

<details><summary>Notes</summary>

Repro (`bun f.js file`, `bun f.js stats`, `bun f.js bigint`):

```js
const vm = require("vm"), fs = require("fs");
const ctx = vm.createContext({ File, Stats: fs.Stats });
const which = process.argv[2] || "file";
try {
  if (which === "file") console.log("ok", vm.runInContext(`class Upload extends File {}; new Upload(["a"], "a.txt")`, ctx).name);
  if (which === "stats") console.log("ok", typeof vm.runInContext(`class S extends Stats {}; new S()`, ctx).isFile);
  if (which === "bigint") console.log("ok", typeof Reflect.construct(fs.statSync(".", { bigint: true }).constructor, [], vm.runInContext("(function(){})", ctx)));
} catch (e) { console.log("threw", e.message); }
console.log("survived");
```

| | `file` | `stats` | `bigint` |
| --- | --- | --- | --- |
| 1.4.3-canary.1 | segfault at 0x28 | segfault at 0x28 | segfault at
0x28 |
| this branch | `ok a.txt` | `ok function` | `threw Invalid argument
type in ToBigInt operation` |
| node v26.3.0 | `ok a.txt` | `ok function` | `threw Cannot mix BigInt
and other types` |

ASAN on main with `Malloc=1` in the environment. `heap-buffer-overflow`,
`READ of size 8`, "located 3776 bytes after 4184-byte region" (`File`),
896 bytes after (`Stats`), 912 bytes after (`BigIntStats`). The region
is the `NodeVMGlobalObject` cell.

```
#0 JSC::LazyProperty<JSC::JSGlobalObject, JSC::Structure>::getInitializedOnMainThread LazyProperty.h:95
#1 JSC::LazyClassStructure::getInitializedOnMainThread LazyClassStructure.h:104
#2 Zig::GlobalObject::JSBlobStructure() ZigGeneratedClasses+lazyStructureHeader.h:7
#3 JSDOMFile::construct src/jsc/bindings/JSDOMFile.cpp:79
#5 llint_op_super_construct_varargs
```

```
#1 JSC::LazyClassStructure::getInitializedOnMainThread LazyClassStructure.h:104
#2 Bun::getStructure<false>(Zig::GlobalObject*) src/jsc/bindings/NodeFSStatBinding.cpp:167   (<true>: line 164)
#3 Bun::constructJSStatsObject<false>(JSGlobalObject*, CallFrame*) src/jsc/bindings/NodeFSStatBinding.cpp:847
#4 Bun::constructStats src/jsc/bindings/NodeFSStatBinding.cpp:915
```

Without `Malloc=1` the debug build of main reads zero there and stops at
`Structure.h:415: runtime error: member call on null pointer of type
'const JSC::Structure *'`.

Fail-before and pass-after, same test file:
- `USE_SYSTEM_BUN=1 bun test test/js/node/vm/vm.test.ts -t "belongs to a
context"`: 0 pass, 4 fail (`exitCode: 139`, `signalCode: "SIGSEGV"`).
- Debug ASAN build of main with only the test added: 0 pass, 4 fail
(`exitCode: 1`).
- Debug ASAN build of this branch: 4 pass. The whole file: 285 pass, 0
fail.

Other cases checked by hand on this branch:
- A newTarget that is a plain function, a bound function, or a Proxy
from the context. The new test covers these. A bound function has no
`prototype`, so the object gets the prototype of the constructor.
- A revoked Proxy from the context throws `TypeError: Cannot get
function realm from revoked Proxy`.
- A newTarget whose `prototype` is not an object falls back to
`File.prototype` or `fs.Stats.prototype`.
- `class X extends File {}` inside a `ShadowRealm` still works. `File`
and `X` both belong to the ShadowRealm global there.
- An instance of the context subclass works with `FormData`, `.text()`,
and a forced GC.

Suites run on the debug build: `test/js/node/vm/vm.test.ts`,
`test/js/node/fs/fs-stats-constructor.test.ts`,
`test/js/node/fs/fs-stats-truncate.test.ts`, `test/js/node/fs/fs.test.ts
-t Stats`, `test/js/bun/globals.test.js`,
`test/js/web/fetch/blob.test.ts`, and the Node tests `test-fs-stat.js`,
`test-fs-stat-bigint.js`, `test-fs-statfs.js`, `test-fs-watchfile.js`.

Related:
- oven-sh#32434 changes the `File` cast to `defaultGlobalObject` as one line of
a larger `File.prototype` change. It does not touch `fs.Stats`.
- oven-sh#42514 fixes the same kind of cast at another site
(`UtilInspect.cpp`).
- `new BigIntStats(...)` stores `atimeMs` as a Number where Node stores
a BigInt, so `.atime` throws. That is a separate bug and is not part of
this PR.

</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/node/vm/vm.test.ts

<!-- robobun:evidence:end -->
springmin pushed a commit that referenced this pull request Sep 21, 2026
…peline is uploaded as JSON (oven-sh#43639)

## What

Two changes to CI's machine images and the pipeline that bakes them.

### The image's record is lines the bake script writes

The record a bake writes about the machine (what arrived on the day of
the bake, for keying caches of build outputs) becomes `bun-image.txt`,
in place of `bun-image.json`.

It was JSON, assembled by a JavaScript program kept as text in
`scripts/build/ci-images/spec.ts` and run under the image's Node.js.
JSON is what needed a program: neither `sh` nor Windows PowerShell 5.1
can write it safely without one. The only reader hashes the file's
bytes, so the layout is free, and lines can be written by ordinary steps
in either shell:

```
name: linux-aarch64-debian-8e6e635fb2cbd2fd
image: {"os":"linux","arch":"aarch64","distro":"debian",…}
tool nodejs: pinned 26.3.0
tool llvm: packageDatabase
tool glibc-sysroot: observed
tool prefetch: notRecorded decided by the commit being built, not by this file
observed glibc-sysroot: b8f58b00c18b…
package adduser 3.134
package libc6:amd64 2.41-12+deb13u4
```

- The generator knows the facts and every tool's identity, so the
`image:` and `tool` lines are literal in the script. The `observed`
lines and the `package` lines are read on the machine.
- Nothing is best-effort. The package query is a statement of its own
into a scratch file, because `sh` has no pipefail and a query piped
straight into `sort` could fail unnoticed; on Windows `scoop export`
runs through `run`, so its exit code is checked. On every path a query
that listed nothing, or an observed tool that left nothing, fails the
bake.
- `recordImage` is a tool made of steps like every other tool;
`record-image.mjs` is no longer written into the bake directories, and
`image.json` no longer carries the list of tools that only that program
read. `recordedTool` is the one place that turns a tool's identity into
its line.
- dpkg's packages are listed as `${binary:Package}`. On the arm64 build
image, which also has amd64 packages, `libc6` and `libc6:amd64` used to
be the same line twice.
- The separate sha256 of the package list is gone: the file is what gets
hashed.

### The pipeline is uploaded as JSON

`.buildkite/ci.ts` wrote its pipeline with a hand-written YAML
serializer, `toYaml()`, which quoted a string only when it held certain
punctuation. A string of digits was written bare and YAML read it as a
number: Canonical's AWS account id, `"099720109477"`, became the agent
tag `base-image-owner=99720109477`, and both Ubuntu bakes were cancelled
with "Failed to create agent". A release such as `"3.20"` would have
become `3.2` the same way.

`buildkite-agent pipeline upload` parses every file it is given with a
YAML parser, whatever its name, and JSON is YAML. So the pipeline is now
`JSON.stringify(pipeline)`, written to `.buildkite/ci.json`, and
`toYaml()` is deleted. In JSON every string is quoted, so a string
cannot lose its type. Read by a YAML parser, the generated pipeline is
the same tree as before, except for the values that were being misread
and an empty `depends_on`, which is now an empty list where it used to
be written as nothing and read as null.

## What it does to the images

All eight baked images get new names, so this PR's build bakes them all.
Every Linux bake script is otherwise unchanged; the Windows scripts
differ in the record section only.

## Verified

- The generated record section run for real in Debian 13 and Alpine 3.23
containers: name, facts, tool lines, observed values, sorted packages,
no duplicate lines; a `dpkg-query` that exits 3 stops the script with 3;
an empty or missing observation stops it.
- The Windows record lines run under PowerShell against a `scoop` that
fails (the script throws on its exit code), one that lists no apps (it
throws), and one that prints its JSON over several lines (recorded).
- The pipeline generated as YAML by the old serializer and as JSON, for
a PR build and a `main` build with the release step: the JSON file reads
the same through a YAML parser as through `JSON.parse`, and the trees
are equal except where the old serializer misread a string or dropped an
empty list. On this PR's build the agent reports `Successfully parsed
and uploaded pipeline #1 from "ci.json"`, and both Ubuntu bakes got
their machines.
- Tests: `test/internal/source-lints/ci-images.test.ts` asserts the
exact record section for Debian and for Windows, the Alpine package
query, and a literal record line for each kind of identity.
- shellcheck on the generated sh, PowerShell's parser on both Windows
scripts, `tsc` for both script projects.
springmin pushed a commit that referenced this pull request Sep 22, 2026
…ed settles (oven-sh#43752)

### Problem
- On a `type: "direct"` stream, `reader.read()` settles two microtasks
after the chunk is delivered. If the source ends the stream right after
the chunk, `reader.closed` settles first. `Duplex.fromWeb` destroys on
that rejection and drops the chunk: it emits only `["error:source
failed"]`.
- The cause: `readableStreamDefaultReaderRead`
(`src/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cpp:139`)
resolved the read promise with the promise that `onPull` allocated. That
adoption costs two microtasks. `handleError` rejects `reader.closed`
directly.

### Fix
- `JSDirectStreamController::onPull` takes the caller's read promise and
settles it on every path. `m_pendingRead` holds that promise.
`readMany()` passes its own inner promise and keeps its timing.
- Correct because the JS implementation before oven-sh#33193 did the same:
`read()` returned the pump's promise.
- Verified: `test/js/web/streams/streams.test.js`. The 10 new tests fail
without the fix. All 624 pass with it.
- Self-reviewed: 17 concerns raised, 3 survived, all 3 addressed with
tests.

### Background
- A direct stream has no queue. `pull(controller)` writes bytes and
hands them to the reader with `flush()`, `end()` or `close()`.
- The controller keeps the head-of-line `read()` in `m_pendingRead`, not
in the reader's `[[readRequests]]` list. `pipeTo`, `tee` and `for await`
use that list and do not change.

### Downsides
- A read loop calls `pull()` again two microtasks sooner. A sync
`pull()` that keeps the controller and is not idempotent can run twice
when its `close()` lands within two microtasks of its last `flush()`.
With chunks paced by the event loop it already ran twice.
- A read loop sees one chunk for each `flush()`. Before, two flushes
could arrive as one chunk. The bytes are the same.

<details><summary>Notes</summary>

**Repro (order).** Bun 1.4.3-canary (367d939) and main a2b69f7
print the direct line reversed.

```js
async function order(rs) {
  const log = [];
  const reader = rs.getReader();
  reader.closed.catch(() => log.push("closed rejected"));
  await reader.read().then(() => log.push("read #1 fulfilled"));
  await new Promise(r => setImmediate(r));
  return log;
}
const def = new ReadableStream({ async pull(c) { c.enqueue(new TextEncoder().encode("a")); await undefined; c.error(new Error("source failed")); } }, { highWaterMark: 0 });
const direct = new ReadableStream({ type: "direct", async pull(c) { c.write("a"); await c.flush(); c.close(new Error("source failed")); } });
console.log("default:", JSON.stringify(await order(def)));   // ["read #1 fulfilled","closed rejected"]
console.log("direct: ", JSON.stringify(await order(direct))); // before: ["closed rejected","read #1 fulfilled"]
```

The order is also reversed when the chunk arrives later and
`close(error)` follows `flush()` in the same tick, with `error()` in
place of `close(error)`, when `pull()` rejects after the flush, and when
`reader.closed` fulfils (`flush()` then `close()`, or `end()` with a
pending read). The `test.each` covers these six producers.

**Overlapping reads.** The delay also let a later read pass an earlier
one. With `pull(c) { c.write("a"); c.close(); }` and two `read()` calls,
read 2 reported `done` before read 1 delivered `a`. The same happened
when `end()` armed a final chunk with nobody reading: the read after it
reported `done` first. A consumer that stops at the first `done` lost
the chunk. Three tests under "overlapping read()s are observed in the
order they were issued" cover this, and the path where read 2 waits in
`[[readRequests]]` until `onFlush` makes it the pending read.

**What a reader observes differently.** I ran one probe script of 22
reader scenarios on the build before and after. Seven lines differ. No
scenario loses or gains data.

| scenario | before | after |
| --- | --- | --- |
| microtasks from `read()` to its reaction, chunk ready | 2 | 0 |
| flush, then `close()` in the same tick | `closed`, `a`, done | `a`,
`closed`, done |
| async pull: flush `a`, `b`, `c`, then `close()` | `a`, `closed`, `bc`,
done | `a`, `b`, `c`, `closed`, done |
| 4 concurrent reads, 3 chunks, then `close()` | ..., `closed`, read3
done | ..., read3 done, `closed` |
| 3 concurrent reads, `close(error)` after chunk 1 | `closed`, read0
`a`, read1 rejects, read2 rejects | read0 `a`, read1 rejects, `closed`,
read2 rejects |
| second `pull()` throws with read0 pending | `closed`, read0 rejects,
read1 rejects | read0 rejects, `closed`, read1 rejects |
| `cancel()` with a pending read | `closed`, cancel resolves, read done
| `closed`, read done, cancel resolves |

The last row now matches the order of the spec (ReadableStreamCancel
closes the stream and runs the close steps before the cancel promise
resolves). Unhandled-rejection reports, `for await`, `pipeTo`,
`Response.text()` and the `readMany()` result shapes are the same on
both builds.

**The first Downsides bullet, measured.** Source: `pull(c) { pulls++;
queueMicrotask(() => { c.write("a"); c.flush(); /* close() N microtasks
later */ }); }`, read with a `read()` loop.

| `close()` lands after | before | after |
| --- | --- | --- |
| 0 microtasks | `a`, 1 pull | `a`, 1 pull |
| 1 | `a`, 1 pull | `a`, 2 pulls |
| 2 | `a`, 1 pull | `aa`, 2 pulls |
| 3 | `a`, 2 pulls | `aa`, 3 pulls |
| 4 | `aa`, 2 pulls | `aaa`, 3 pulls |

A reader calls `pull()` again for each `read()` once a sync `pull()` has
returned. That contract does not change. Only the moment of the next
`read()` does. An async `pull()` is not affected: the controller does
not call it again while its promise is pending. An idempotent source
that keeps the controller gets extra calls that do nothing. A sync
`pull()` that starts an async loop over `x`, `y` and does not return its
promise gives `xyx` on both builds when `setImmediate` paces the chunks.
With microtask pacing it gave `xy` before and gives `xyx` now.

**`readMany()`.** It maps the pump's `{value, done}` through one
internal reaction, for every controller kind. A `reader.closed` handler
still runs before a `readMany()` result, on a started default stream
too. A `read()` issued behind a pending `readMany()` can now settle
first, as on a default stream. This PR leaves `readMany()` as it is.

**No `then` lookup in the pump.** The pump settles the read with
`JSPromise::fulfill`, as it did for its own promise. Before, the last
adoption step resolved the read promise with the `{value, done}` object,
so a patched `Object.prototype.then` ran for it. Now it does not. I kept
`fulfill`: a `then` getter would run user code in the middle of
`finishClose` and `cancelSteps`. A patched `Promise.prototype.then` also
no longer receives the pump's internal promise.

**Removed branch.** `onPull` returned `m_pendingRead` when `pull()` left
the stream not readable. `handleError`, `finishClose` and `cancelSteps`
all clear `m_pendingRead` before the stream leaves the readable state,
so that branch could not run. The new read settles from the stream
state.

**Review concerns I did not act on.**
- If a source hook throws after the read was registered, `read()`
returns a promise rejected with that error, and the registered promise
is dropped. Before, the pump's promise was dropped the same way.
- A review proposed a timing window that delays the next sync `pull()`,
to hide the first Downsides bullet. That window would put back the delay
that this PR removes.

**Found outside this PR.** Both builds do these. No open PR covers them.
- A sync `pull()` that calls `write("a")`, `flush()` and then fails
before it returns (`close(error)`, `error()` or a throw) loses `a` for
`read()`, `Readable.fromWeb` and `Duplex.fromWeb`. `for await` and
`pipeTo` get `a` and then the error. Their read request is queued before
`pull()` runs, so `flush()` delivers at once. A `read()` promise is
registered after `pull()` returns, and the failure frees the buffer
first. The same producer with an `await` before the failure delivers `a`
to every consumer.
- If `pull()` calls `reader.releaseLock()` before it returns, the
`read()` that started it stays registered on the controller. It then
takes the first chunk of the next reader, and that reader's `read()`
stays pending.

Related to oven-sh#43748, which makes `Readable.fromWeb` watch `reader.closed`.

**Local runs (debug + ASAN build).** `streams.test.js` 624 pass, also
with `BUN_JSC_validateExceptionChecks=1`. The 10 new tests fail on
1.4.3-canary and on a debug build with `src/` from main. They pass 100
of 100 with `--rerun-each 10`. `serve-direct-readable-stream` 169,
`async-iterator-stream` 95, `body.test.ts` 774, `body-async-iterator` 6,
`body-stream-excess` 4, `node-stream` 104, `web-stream-state` 46,
`spawn-stdin-readable-stream` 37, `html-rewriter` 186,
`sync-pull-fast-path` 7, `streams-string-limit` 8, and
`test-stream-duplex-from.js`,
`test-whatwg-webstreams-adapters-to-streamduplex.js`,
`test-whatwg-webstreams-adapters-to-readablewritablepair.js`,
`test-webstreams-finished.js`, `test-webstreams-pipeline.js`,
`test-whatwg-readablestream.mjs`,
`test-readable-from-web-enqueue-then-close.js`,
`test-stream-readable-from-web-termination.js`. Three tests time out at
5 s on this debug build with and without the change:
`fetch.stream.test.ts` "Content-Length response works (multiple parts)",
`AsyncLocalStorage.test.ts` "re-entering a storage inside run() does not
grow the context", `bun-write.test.js` "on large files". None of them
reads a direct stream through a reader.

</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/streams/streams.test.js

<!-- 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