Skip to content

Navigation Menu

Sign in
Sign up

deps: update libarchive to v3.8.1 - #3

Open
github-actions[bot] wants to merge 1 commit into
main from
deps/update-libarchive-2
Open

deps: update libarchive to v3.8.1 #3
github-actions[bot] wants to merge 1 commit into
main from
deps/update-libarchive-2

Conversation

@github-actions

@github-actions github-actions Bot commented Jul 6, 2025

Copy link
Copy Markdown

What does this PR do?

Updates libarchive to version v3.8.1

Compare: libarchive/libarchive@898dc83...9525f90

Auto-updated by this workflow

mchv pushed a commit that referenced this pull request Aug 27, 2026
...ven-sh#31219)
### What does this PR do?
Fixes a fuzzer-reported SIGSEGV (fingerprint `a8d2694be898a53f`) in the
`Bun.jest()` / `expect` statics area. The reported reproducer exercises
`Bun.jest().expect`, `expect.extend()`, and `new` on an expect static:
```js
const v2 = Bun.jest().expect;
try { v2.extend(); } catch (e) {}
const t6 = v2.arrayContaining;
new t6();
Bun.gc(true);
```
**Root cause (primary fix):** matchers registered through
`expect.extend()` are wrapped in `JSWrappingFunction`.
`JSWrappingFunction::create` passed `nullptr` as the native constructor
to `VM::getHostFunction`. JSC only treats
`callHostFunctionAsConstructor` as "not constructible", so the wrapper
was considered constructible with a null native constructor — `new
expect.someCustomMatcher()` jumps straight to address 0:
```
Thread 1 received signal SIGSEGV, Segmentation fault.
#0 0x0000000000000000 in ?? ()
#3 llint_op_construct ()
```
Deterministic repro (crashes on current main, raw SIGSEGV with no output
— matching the fuzzer's crash signature):
```js
const e = Bun.jest().expect;
e.extend({ myMatcher() { return { pass: true, message: () => "" }; } });
new e.myMatcher();
```
The fix passes `callHostFunctionAsConstructor` so `new` on a wrapped
matcher throws `TypeError: function is not a constructor` like other
native functions.
**Secondary hardening (same area):** if
`Bun__Jest__createTestModuleObject` ever fails it returns an empty
`JSValue`, and the `m_lazyTestModuleObject` initializer called
`toObject()` on it — a null-cell dereference. The initializer now falls
back to a plain object, `Bun__Jest__testModuleObject` surfaces the
pending exception, `Bun.jest()` maps it to a thrown JS error, and the
`xdescribe` arm of `create_test_module` propagates its error instead of
returning an empty value as success.
### How did you verify your code works?
- `new (expect.extend-registered matcher)()` segfaults on the baked
build and throws a `TypeError` with this change.
- Regression test added to `test/js/bun/test/bun-test.test.ts`: it
spawns a subprocess that registers a custom matcher, constructs it (plus
the original fuzzer shape: `extend()` with no args, `new
expect.arrayContaining`), runs `Bun.gc(true)`, and asserts a clean exit.
The test fails on the baked build (`USE_SYSTEM_BUN=1`) with the
subprocess dying from SIGSEGV, and passes with this change.
- `bun bd test` on `bun-test.test.ts`, `expect-extend.test.js`,
`jest-extended.test.js`, `expect-extend-asymmetric-match-throw.test.ts`,
`expect-extend-preload.test.ts`, `describe.test.ts`,
`jest-each.test.ts`, `expect-symbol-toPrimitive-crash.test.ts` — all
pass.
mchv pushed a commit that referenced this pull request Aug 27, 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
 oven-sh#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.
mchv pushed a commit that referenced this pull request Aug 27, 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
 oven-sh#4 SSLWrapper<*mut HTTPClient>::handle_traffic
 oven-sh#5 SSLWrapper<*mut HTTPClient>::receive_data
 oven-sh#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>
mchv pushed a commit that referenced this pull request Aug 27, 2026
...sweep (oven-sh#32729)
### Crash
```
ASSERTION FAILED: vm().currentThreadIsHoldingAPILock() => vm().heap.mutatorState() != MutatorState::Sweeping
vendor/WebKit/Source/JavaScriptCore/runtime/JSCell.cpp(179) : bool JSC::JSCell::validateIsNotSweeping() const
```
Backtrace (from a release-asan build with asserts):
```
#3 JSC::JSCell::validateIsNotSweeping()
oven-sh#4 JSC::JSCell::classInfo() const
oven-sh#5 WTF::uncheckedDowncast<WebCore::JSResumableFetchSink>(JSValue const&)
oven-sh#6 ResumableFetchSinkPrototype__ondrainSetCachedValue
oven-sh#7 bun_runtime::webcore::fetch::fetch_tasklet::FetchTasklet::ignore_remaining_response_body
oven-sh#8 JSC::WeakBlock::sweep() <- inside GC sweep (Weak finalizer)
oven-sh#9 JSC::WeakSet::sweep()
oven-sh#10 JSC::PreciseAllocation::sweep()
oven-sh#12 JSC::Heap::finalize()
oven-sh#21 JSC::LocalAllocator::allocateSlowCase
oven-sh#23 JSC::ErrorInstance::create <- ordinary allocation kicked off GC
```
Found by the syscall fault-injection fuzzer's client-side grammar
scenario (fetch/node:http with abort + transient errno on the client
socket). Reproduces ~4/5 under `BUN_JSC_collectContinuously=1`.
### Cause
`FetchTasklet::on_response_finalize` is the
`WeakRefOwner<FetchResponse>::finalize` callback and runs inside
`WeakBlock::sweep` while `MutatorState == Sweeping`. When the response
body is `Locked` without a pending promise or stream it calls
`ignore_remaining_response_body()`, which called:
- `ResumableSink::detach_js()`: writes the sink wrapper's cached
`ondrain` / `oncancel` / `stream` slots via the generated
`ResumableFetchSinkPrototype__*SetCachedValue` helpers. Each does
`uncheckedDowncast<JSResumableFetchSink>(thisValue)`, which reaches
`JSCell::classInfo()` and then issues a write barrier on the wrapper
cell.
- `clear_stream_handlers()`: reaches `ReadableStreamTag__tagged` ->
`object->inherits<JSReadableStream>()` (guarded today, but one boolean
away).
Calling `classInfo()` on any cell while the mutator is sweeping is
forbidden: the cell's `Structure` may already have been swept. Assert
builds catch it; release builds corrupt the heap.
### Fix
Thread a `from_finalizer` flag through `ignore_remaining_response_body`.
When `true` (the `on_response_finalize` caller) skip `detach_js()` and
`clear_stream_handlers()`; only native state is touched. The sink's
JS-side detach still happens from `clear_sink()` in
`FetchTasklet::deinit()`, which runs as an event-loop `ConcurrentTask`
outside any sweep, so nothing leaks.
The `on_stream_cancelled_callback` caller (reader `.cancel()`, runs from
JS on the event loop) passes `false` and keeps the immediate detach.
Also corrects the `ResumableSink::detach_js` doc comment that claimed
finalizer safety.
### Verification
New test at `test/js/web/fetch/fetch-response-finalizer-sweep.test.ts`:
a child process under `BUN_JSC_collectContinuously=1` does 12 iterations
of `fetch()` with a user-constructed `ReadableStream` body (so the sink
takes the JS route with a Strong `js_this`) against a raw TCP server
that sends headers + a partial chunked body and never terminates it,
then drops the `Response` unconsumed and runs `Bun.gc(true)`.
Without the fix (`bun bd`, src/ stashed):
```
exitCode: 134
stderr: ASSERTION FAILED: vm().currentThreadIsHoldingAPILock() => vm().heap.mutatorState() != MutatorState::Sweeping
```
With the fix: `stdout: "ok"`, `exitCode: 0`.
`test/js/web/fetch/fetch-backpressure.test.ts` (exercises the
`on_stream_cancelled_callback` path) passes unchanged.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
mchv pushed a commit that referenced this pull request Aug 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):
 oven-sh#5 AsyncHTTP::on_async_http_callback_raw AsyncHTTP.rs:819
 oven-sh#7 HTTPClient::send_progress_update_without_stage_check
 oven-sh#9 proxy_tunnel::on_data ProxyTunnel.rs:350
 oven-sh#11 SSLWrapper::trigger_data_callback uws/lib.rs:824
 oven-sh#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.
mchv pushed a commit that referenced this pull request Aug 27, 2026
...oven-sh#32743)
## What
`ReadableStream::from_pipe` (the `proc.stdout` / `proc.stderr` path for
`Bun.spawn` and the shell subprocess) moves an already-registered pipe
poll from the subprocess `PipeReader` into a freshly allocated
`NewSource<FileReader>` and re-points the poll's owner at it. The
across-read ref that keeps that box alive (`waiting_for_on_reader_done`
+ `increment_count()`, which upgrades `this_jsvalue` to `Strong`) was
only taken in `FileReader::on_start`, i.e. the first time JS actually
pulls from the stream.
Between `from_pipe` and that first pull, the poll's owner points into a
box whose only ref is the JS wrapper's own `Weak` back-reference. If the
`Subprocess` and its cached stdout become unreachable before anyone
pulls (a fire-and-forget spawn where `proc.stdout` is touched but never
read, and the direct child exits while something else still holds the
write end), GC sweeps the `JSFileInternalReadableStreamSource` wrapper
and frees the `NewSource<FileReader>` box while the poll is still armed.
The next readability or EOF event dispatches into freed memory:
```
READ of size 8 (heap-use-after-free)
 #0 Vec::len / is_empty (freed Vec<u8>)
 #2 webcore::file_reader::FileReader::on_reader_done FileReader.rs:1008
 #3 bun_io::pipe_reader::read_socket{closure} PipeReader.rs:846
 oven-sh#4 PosixBufferedReader::read_socket PipeReader.rs:576
 oven-sh#5 file-poll dispatch <- posix_event_loop <- us_internal_dispatch_ready_polls
freed by: JSC::JSDestructibleObjectDestroyFunc <- MarkedBlock sweep <- MarkedSpace::sweepBlocks
allocated: ReadableStream::from_pipe<subprocess::PipeReader> -> NewSource<FileReader>
```
Found by a coverage-guided GC-stress fuzzer with syscall interposition
(`BUN_JSC_collectContinuously=1` plus an injected `EAGAIN` to keep the
read pending). In release builds this is silent heap corruption.
## Fix
Take the across-read ref in `from_pipe` itself, immediately after the
live reader is transferred and the JS wrapper is created, so the box is
`Strong`-rooted for as long as the poll can fire. `on_reader_done` /
`on_reader_error` release it exactly as before. `FileReader::on_start`
now checks `waiting_for_on_reader_done` before taking the ref so the
later `handle.start()` call from `lazyLoadStream` does not double-count
on this path.
## How did you verify your code works?
The test asserts the lifetime invariant directly via
`heapStats().objectTypeCounts.FileInternalReadableStreamSource` rather
than racing for the crash, since the exact UAF trigger depends on the
fuzzer's syscall interposition. A detached grandchild (`sh -c 'while [ !
-e FLAG ]; do sleep 0.02; done; echo x'`) inherits the child's stdout
and keeps the write end open past the direct child's exit, so the
`FileReader`'s poll is still armed while we force GC with nothing in JS
referencing the wrapper.
- **Before** (`git stash push -- src/` + `bun bd test`): `duringLivePipe
= 0` of 4; every wrapper swept while its poll owner still points into
the freed box.
- **After**: `duringLivePipe >= 4`; once the grandchildren exit and the
pipes EOF, `afterEof <= 1` (one may remain via a conservatively-rooted
final `Subprocess`, same caveat as `spawn-ipc-gc.test.ts`).
Also passes `spawn-streaming-stdout.test.ts`,
`spawn-unread-stdout-gc.test.ts`, `spawn-ipc-gc.test.ts`,
`spawn-stdout-iterate-leak.test.ts`, and
`readablestream-helpers.test.ts`.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
mchv pushed a commit that referenced this pull request Aug 27, 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
 oven-sh#4 <bun_io::pipe_reader::PosixBufferedReader>::on_poll io/PipeReader.rs:529
 oven-sh#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.
mchv pushed a commit that referenced this pull request Aug 27, 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>
mchv pushed a commit that referenced this pull request Aug 27, 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
 oven-sh#7 PmPkgCommand::save_package_json src/runtime/cli/pm_pkg_command.rs:909
freed by thread T0 here:
 oven-sh#7 <Box<[u8]> as Drop>::drop
 oven-sh#12 PmPkgCommand::set_value src/runtime/cli/pm_pkg_command.rs:661
previously allocated by thread T0 here:
 oven-sh#10 <Box<[u8]> as From<&[u8]>>::from
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#7 0x00000c05c270 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::save_package_json /workspace/bun/src/runtime/cli/pm_pkg_command.rs:909:25
 oven-sh#8 0x00000c060cfb in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec_set /workspace/bun/src/runtime/cli/pm_pkg_command.rs:330:13
 oven-sh#9 0x00000c05d333 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec /workspace/bun/src/runtime/cli/pm_pkg_command.rs:73:32
 oven-sh#10 0x00000bf919d0 in <bun_runtime::cli::package_manager_command::PackageManagerCommand>::exec /workspace/bun/src/runtime/cli/package_manager_command.rs:704:13
 oven-sh#11 0x00000c3fbb87 in bun_runtime::cli::command::exec_pm /workspace/bun/src/runtime/cli/mod.rs:1591:34
 oven-sh#12 0x00000c3f2b86 in bun_runtime::cli::command::start /workspace/bun/src/runtime/cli/mod.rs:1309:43
 oven-sh#13 0x00000bfad16c in bun_runtime::cli::cli::start /workspace/bun/src/runtime/cli/mod.rs:573:27
 oven-sh#14 0x00000bb3c034 in main /workspace/bun/src/bun_bin/lib.rs:230:5
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#12 0x00000c061846 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::set_value /workspace/bun/src/runtime/cli/pm_pkg_command.rs:661:5
 oven-sh#13 0x00000c061038 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec_set /workspace/bun/src/runtime/cli/pm_pkg_command.rs:325:13
 oven-sh#14 0x00000c05d333 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec /workspace/bun/src/runtime/cli/pm_pkg_command.rs:73:32
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#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
 oven-sh#11 0x00000c05a47f in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::parse_key_path /workspace/bun/src/runtime/cli/pm_pkg_command.rs:583:37
 oven-sh#12 0x00000c061608 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::set_value /workspace/bun/src/runtime/cli/pm_pkg_command.rs:643:30
 oven-sh#13 0x00000c061038 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec_set /workspace/bun/src/runtime/cli/pm_pkg_command.rs:325:13
 oven-sh#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).
mchv pushed a commit that referenced this pull request Aug 27, 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
 oven-sh#4 <bun_http::HTTPClient>::start_ src/http/lib.rs:2635
 oven-sh#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>
mchv pushed a commit that referenced this pull request Aug 27, 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).
mchv pushed a commit that referenced this pull request Aug 27, 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
 oven-sh#4 us_internal_ssl_on_data packages/bun-usockets/src/crypto/openssl.c:1847:21
 oven-sh#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.
mchv pushed a commit that referenced this pull request Aug 27, 2026
...n-sh#34078)
### What does this PR do?
`bsd_create_unix_socket_address()` takes the caller's path as `(const
char *path, size_t path_len)` and, on Linux, works around `sun_path`'s
108-byte limit by opening the parent directory and binding to
`/proc/self/fd/<dirfd>/<basename>` instead. The basename was being
copied with
```c
snprintf(sun_path, sizeof sun_path, "/proc/self/fd/%d/%s", fd, path + dirname_len);
```
but `path` is a ptr+len pair coming from a Rust `&[u8]` with no NUL
terminator. `%s` walks past the end of the allocation. On ASan builds
this aborts with `heap-buffer-overflow`; on release builds `sun_path` is
assembled from whatever heap bytes follow the path buffer, so the kernel
sees an address built from out-of-bounds memory (sometimes the right
one, sometimes `EINVAL`, sometimes something else).
The trigger window is any pathname unix socket with `108 <= path_len`
whose basename still fits inside `/proc/self/fd/N/`, reachable from
`net.createServer().listen(path)`, `net.connect(path)`,
`Bun.listen({unix})` and `Bun.connect({unix})`. Node binds a full
108-byte `sun_path` here, so this is also a parity break at exactly
length 108.
Fix: use `%.*s` with `(int)(path_len - dirname_len)` so the copy is
bounded by the known basename length.
### Repro
```js
import * as net from "node:net";
import * as fs from "node:fs";
const dir = fs.mkdtempSync("/tmp/sun108-");
const path = dir + "/" + "l".repeat(108 - dir.length - 1); // exactly 108 bytes
net.createServer().listen(path, () => { console.log("LISTENING"); process.exit(0); });
```
Before (debug/ASan):
```
==510==ERROR: AddressSanitizer: heap-buffer-overflow ... READ of size 90 at 0x7339f260062c thread T0
 #0 ... in printf_common
 #2 ... in snprintf
 #3 ... in bsd_create_unix_socket_address packages/bun-usockets/src/bsd.c
```
After: `LISTENING`, exit 0.
### How did you verify your code works?
`bun bd test test/js/bun/net/unix-socket-long-path.test.ts` passes
(4/4). With the `packages/` change stashed out, all four cases fail with
the ASan `heap-buffer-overflow` header in the subprocess stderr.
<!-- robobun:evidence:begin -->
---
**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/net/unix-socket-long-path.test.ts
<!-- robobun:evidence:end -->
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
mchv pushed a commit that referenced this pull request Aug 27, 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
 oven-sh#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 -->
mchv pushed a commit that referenced this pull request Aug 27, 2026
...ven-sh#34693)
## Use-after-free in `H2FrameParser::on_native_writable`
Fleet ASAN fuzz hit (p-h2c cleartext harness, seed 1,
`server-conn.recv.*:A8`):
```
use-after-poison READ 8 (shadow f7 = user poison, HiveArray slot re-poison)
 #0 Vec::len (write_buffer)
 #2 has_backpressure h2_frame_parser.rs:3276
 #3 on_native_writable h2_frame_parser.rs:9605
 oven-sh#4 NewSocket<true>::on_writable socket_body.rs:894
 oven-sh#8 us_internal_ssl_on_writable bun-usockets openssl.c:1851
allocated by: HiveArray Fallback<H2FrameParser,256>, H2FrameParser::constructor
```
### Cause
`on_native_writable` loops `flush()` and checks `has_backpressure()`
between iterations. `flush()` re-enters JS via `flush_stream_queue` ->
`dispatch_write_callback` / `onStreamEnd` / `onWantTrailers`. A callback
that destroys the session reaches `detach_native_callback`, dropping the
socket's `+1` on the parser. If that was the last external ref,
`flush()`'s own keepalive is all that remains and drops on return, so
the next `has_backpressure()` reads a HiveArray slot that was just
`drop_in_place`'d and re-poisoned by `POOL.put`.
`on_native_read` already takes a `keepalive()` for exactly this reason
(h2_frame_parser.rs:9590); `on_native_writable` did not.
In release builds there is no poison: the same ordering is a silent
use-after-free in every `node:http2` server/client on a native socket.
The read of a stale `write_buffer.len()` can satisfy the loop condition
and send the next `flush()` into UAF writes on the freed parser.
### Fix
- Take a `keepalive()` for the extent of `on_native_writable`, mirroring
`on_native_read`.
- `NativeCallbacks::on_data`/`on_writable`: copy the raw `*mut
H2FrameParser` out of the enum before dispatching, so the
`JsCell<NativeCallbacks>` borrow does not span a re-entrant
`detach_native_callback` that overwrites the cell.
### Test
`test/js/node/http2/node-http2-writable-destroy-fixture.ts` reproduces
the exact fleet stack under ASAN by faulting `send`/`writev` to 0
(backpressure, arms WRITABLE), queuing a DATA frame whose write callback
runs `session.destroy()` + `Bun.gc(true)`, then clearing the fault so
the writable event drains the queue inside `on_native_writable`. Added
to `node-http2-syscall-fault.test.ts` as an ASAN-gated subprocess test.
<details><summary>Fail-before ASAN report (matches the fleet
hit)</summary>
```
==ERROR: AddressSanitizer: use-after-poison on address 0x... 
READ of size 8 at 0x... thread T0
 #2 H2FrameParser::has_backpressure h2_frame_parser.rs:3276:33
 #3 H2FrameParser::on_native_writable h2_frame_parser.rs:9605:21
 oven-sh#4 NativeCallbacks::on_writable socket_body.rs:3960:20
 oven-sh#5 NewSocket<false>::on_writable socket_body.rs:894:39
allocated by thread T0 here:
 ... Fallback<H2FrameParser, 256>::new_boxed hive_array.rs:668
 ... H2FrameParser::constructor h2_frame_parser.rs:9800
SUMMARY: AddressSanitizer: use-after-poison ... Vec<u8>::len
```
</details>
<!-- 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/node/http2/node-http2-syscall-fault.test.ts
<!-- robobun:evidence:end -->
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
mchv pushed a commit that referenced this pull request Aug 27, 2026
oven-sh#35255)
`test/js/bun/http/serve-protocols.test.ts` has been going red on main
(build 78445 darwin-x64 hard, 78462 debian-11-aarch64 hard, plus
78300/78419 with retries), always as
```
error: HTTP3StreamReset fetching "https://127.0.0.1:<port>/echo"
✗ Bun.serve over http/3 > POST echo 1000000 bytes [20169.37ms]
```
Reproduced on Linux by looping the file: the h3 subset alone fails about
17 of 100 runs.
## Cause
The ten concurrent h3 tests share one lsquic client engine and one
unconnected UDP socket. `bsd_create_udp_socket()` sets `IP_RECVERR` on
every UDP socket (for `node:dgram`'s error surfacing, oven-sh#28827), including
QUIC's. When a finished test `proc.kill()`s its server, the client
session is still in the engine and keeps scheduling retransmits /
`NEW_CONNECTION_ID` to an unbound port. With `IP_RECVERR` on, the
resulting ICMP port-unreachable is queued on the shared socket and the
next `sendmmsg` returns `-1 ECONNREFUSED`, even though that call is
sending a datagram to a live peer.
`us_quic_packets_out` reports that as a short return, lsquic clears
`ENPUB_CAN_SEND` for the whole engine and only its one-second
`resume_sending_at` failsafe re-enables it. With several dead sessions
generating ICMPs, every failsafe retry fails the same way and the live
1MB upload never advances; the 20s in CI is two idle-timeout rounds
through `retry_or_fail`.
While tracing that I also found an unsigned underflow in lsquic's
`send_batch` requeue loop: when the first unsent spec in a batch
coalesces multiple packets (`pack_off[0] == 0`, `iovlen > 1`), `end =
&batch->packets[off - 1]` indexes with `UINT_MAX` and only the last
packet of the coalesced group is returned to the connection. The earlier
ones are the INIT ACK and the HSK CRYPTO carrying the client Finished,
so the peer can never complete the handshake. This is the same hang
reached from a different direction (real EAGAIN backpressure instead of
stale ICMP).
## Fix
- `IP_RECVERR` is now opt-in via `LIBUS_UDP_LINUX_RECVERR`, set by
`us_create_udp_socket` when a `recv_error_cb` is provided. `node:dgram`
always passes one and keeps the option; QUIC passes `NULL` and no longer
gets it. This matches libuv's `UV_UDP_LINUX_RECVERR` gating that `bsd.c`
already cited.
- `us_quic_packets_out()` retries once on a non-`EAGAIN`/`ENOBUFS` send
failure before reporting a short return, so a stale `sk_err` that does
surface cannot pause the engine. Both the `sendmmsg` and per-packet
paths now go through `US_FAULT_CHECK(US_FAULT_SENDMSG, ...)` so the
short-return path is reachable from tests.
- `patches/lsquic/requeue-unsent-coalesced.patch` rewrites the requeue
loop's bounds as `[off, off+count)` so every packet in an unsent
coalesced datagram is returned to the connection. The same underflow is
present in upstream lsquic master; I will open a PR there separately.
- `serve-protocols.test.ts` now stops each fixture server gracefully on
stdin close (`server.stop(true)`), matching `serve-http3.test.ts`, so
the pooled client session sees `CONNECTION_CLOSE` instead of leaving the
engine retransmitting to unbound ports.
- `test/js/web/fetch/fetch-http3-syscall-fault.test.ts` injects `EAGAIN`
on the coalesced handshake datagram (the `pack_off[0]==0`, `iovlen>1`
spec the lsquic patch fixes), a one-shot `ECONNREFUSED` that the
retry-once branch consumes, and a burst of `EAGAIN` that the `on_drain`
path recovers from.
## Verification
Release build, looped:
| | before | after |
| --- | --- | --- |
| `serve-protocols -t "http/3"` | 17/100 fail | 2/100 fail |
| `serve-protocols` (full) | 7/100 fail | 4/200 fail |
Debug+ASAN: `serve-protocols`, `serve-http3` (46), `fetch-http3-client`
(52), `fetch-http3-adversarial` (29), `fetch-http3-syscall-fault` (3)
and `dgram.test.ts` all pass, 211 tests total.
The residual ~1-2% is a separate pre-existing bug (the client's 36-byte
HSK CRYPTO is buffered but never flushed when `drain_send_body` writes
the whole 1MB body synchronously from `on_stream_open`); I've handed
that off as its own issue. With CI's retry it is well under the flake
threshold.
### Gate note
The fault-injection hook that makes the new test deterministic lives in
`packages/bun-usockets/src/quic.c`, so `git stash -- src/ packages/`
removes it along with the fix and the fault never fires. The lsquic
piece lives in `patches/` and `scripts/`, which the stash does not
touch. That means a single stashed run passes (no fault, no stall) and a
single unstashed run passes (fault fires, fix handles it), and the gate
cannot distinguish them mechanically. The 300-iteration probe above is
the evidence; the fault-injection tests pin the behavior going forward.
<details>
<summary>lsquic debug trace of the stall</summary>
```
engine: packets out returned 0 (out of 1)
[C919...] event: unsent packet oven-sh#15 ACK_FREQUENCY, size 36
[C919...] sendctl: packet oven-sh#15 has been delayed
engine: send_packets_out: sent 0 packets
... <- no "can send again"; nothing for 1s
engine: failsafe activated: resume sending packets again after timeout
engine: packets out returned 0 (out of 10) <- fails again, live conn's oven-sh#207 included
```
and for the underflow, a batch with `pack_off[0]=0`, `iovlen[0]=3`:
```
engine: packets out returned 0 (out of 2)
event: unsent packet #3 ACK PADDING, size 1059
event: unsent packet oven-sh#4 ACK CRYPTO, size 87
event: unsent packet oven-sh#5 NEW_CONNECTION_ID, size 54
event: unsent packet oven-sh#6 STREAM, size 114
sendctl: packet oven-sh#6 has been delayed
sendctl: packet oven-sh#5 has been delayed
... <- #3 and oven-sh#4 never requeued
[WARN] sendctl: send history gap 2 - 5
```
</details>
<!-- robobun:evidence:begin -->
---
**no test proof** · iteration 1 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/web/fetch/fetch-http3-syscall-fault.test.ts
<!-- robobun:evidence:end -->
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
mchv pushed a commit that referenced this pull request Aug 27, 2026
...ed (oven-sh#36247)
## What
`test/js/bun/http/bun-serve-html.test.ts` segfaults on `windows-aarch64`
after oven-sh#36175 landed (builds 84162, 84194; one earlier sighting in
83933):
```
panic(main thread): Segmentation fault at address 0x48
Features: ... dev_server(14) ...
```
Symbolicated in oven-sh#36214 as `AsyncFSTask<Access>::run_from_js_thread` with
`self = null`, i.e. a zeroed `ConcurrentTask` was dispatched.
## Cause
`DevServer.watcher_atomics.events[*].concurrent_task` is the intrusive
MPSC node the watcher thread links into `EventLoop.concurrent_tasks`
when it submits a hot-reload event. It was an inline field of
`DevServer`, so `server.stop()` → `drop(Box<DevServer>)` freed it while
it was still linked. The next `tick_concurrent` then read
`.next`/`.task`/`.auto_delete` from freed memory. ASAN on Linux
confirms:
```
heap-use-after-free: ConcurrentTask::get_next (unbounded_queue.rs)
 ← BatchIterator::next ← EventLoop::tick_concurrent_with_count
freed by: Box<DevServer>::drop ← NewServer::deinit_if_we_can
 ← NewServer::stop ← dispose_from_js (using server)
```
On release builds the freed block reads back as zeros, so the copied
`Task` is `{tag: 0, ptr: null}`; tag 0 is `task_tag::Access`, whose
`run_from_js_thread` loads `self.result` at offset `0x48`.
The bug is latent and platform-agnostic. oven-sh#36175 exposed it because the
CI runner now spawns the napi addon prebuild in the background while
serial tests run; that writes under the watched project root, so the
`jsx-runtime` DevServers in this test file now reliably receive a
hot-reload event between the last `await fetch` and `using server`
disposal.
## Fix
`watcher_atomics` is now a `NonNull<WatcherAtomics>` owned via
`bun_core::heap::into_raw`, so the allocation can outlive `DevServer`
and every queued pointer keeps allocation-root provenance.
`watcher_acquire_event`, `watcher_release_and_submit_event` and
`recycle_event_from_dev_server` take `*mut Self` and derive the returned
`*mut HotReloadEvent` (and the linked `concurrent_task` node) from that
root pointer via raw place projections rather than from a `&mut
WatcherAtomics` reborrow.
`Drop for DevServer` reads `next_event` after `Watcher::shutdown` has
serialised out the watcher thread (which guarantees it is stable):
- `DONE`: nothing is queued; clear and `heap::destroy` as before.
- otherwise: a `concurrent_task` is still linked (or its `Task` is
already in the drain FIFO). Null `owner` on every event and leave the
allocation alive.
`HotReloadEvent::run` checks `owner.is_null()` first; when set it
reclaims the allocation via the new `atomics` backref and returns
without touching the dead `DevServer`. The `# Safety` contracts on `run`
and the `BakeHotReloadEvent` dispatch arm are updated to describe the
null-owner case.
## Test
`test/js/bun/http/bun-serve-html-hot-reload-drop.test.ts` creates a
development server, bundles once so `app.js` is watched, synchronously
rewrites `app.js`, spins briefly without yielding so the watcher thread
can enqueue, disposes the server, then yields. Ten iterations. In a
separate file because the React-bundling cases in
`bun-serve-html.test.ts` already exceed the default per-test timeout
under a debug+ASAN build on `main`.
<details><summary>fail-before (debug+ASAN, src/ at main)</summary>
```
==25521==ERROR: AddressSanitizer: heap-use-after-free on address 0x79315e4743e8
READ of size 8 at 0x79315e4743e8 thread T0
 #2 <ConcurrentTask as Node>::get_next unbounded_queue.rs:82
 #3 BatchIterator<ConcurrentTask>::next unbounded_queue.rs:135
 oven-sh#4 EventLoop::tick_concurrent_with_count event_loop.rs:507
0x79315e4743e8 is located 488 bytes inside of 16512-byte region
freed by thread T0 here:
 oven-sh#9 Box<DevServer>::drop
 oven-sh#12 NewServer<false,true>::deinit_if_we_can mod.rs:1770
 oven-sh#13 NewServer<false,true>::stop mod.rs:1665
 oven-sh#14 NewServer<false,true>::dispose_from_js server_body.rs:2584
```
</details>
Passes with the fix in ~2.4s under debug+ASAN (also on a local
`windows-aarch64` debug build, where the original
`bun-serve-html.test.ts` is now 19/19);
`test/bake/deinitialization.test.ts` still green.
Supersedes the producer half of oven-sh#36214 (which adds a sentinel for the
same zeroed-task symptom).
<!-- robobun:evidence:begin -->
---
**[review]** gate passed · iteration 4 · 6 files touched
<details><summary>fails on main (without fix)</summary>
```console
ASAN without fix: 1 failed, 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/bun-serve-html.test.ts test/js/bun/http/bun-serve-html-hot-reload-drop.test.ts
bun test v1.4.0 (5f6622f)
test/js/bun/http/bun-serve-html.test.ts:
waitForServer /tmp/html-css-js_ObkyZk {
 "/": "/tmp/html-css-js_ObkyZk/index.html",
 "/dashboard": "/tmp/html-css-js_ObkyZk/dashboard.html",
}
[0.12ms] bundle index.html 1.09 KB
[0.05ms] bundle dashboard.html 1.27 KB
(pass) serve html [630.67ms]
waitForServer /tmp/bun-serve-html-txt_5C6B7a {
 "/": "/tmp/bun-serve-html-txt_5C6B7a/index.html",
}
[0.15ms] bundle index.html 0.40 KB
HASH efbnbska
(pass) serve plugins > basic plugin [556.20ms]
waitForServer /tmp/html-css-js-failing-plugin_OPRhwb {
 "/": "/tmp/html-css-js-failing-plugin_OPRhwb/index.html",
}
error: Plugin failed intentionally
 at /tmp/html-css-js-failing-plugin_OPRhwb/styles.css:0
error: Plugin failed intentionally
 at /tmp/html-css-js-failing-plugin_OPRhwb/styles.css:0
(pass) serve plugins > serve html with failing plugin [491.35ms]
waitForServer /tmp/html-css-js-empty-plugins_biqnN6 {
 "/": "/tmp/htm
... (truncated)
release without fix: all passed
bun test v1.4.0-canary.1 (96ff7ec)
test/js/bun/http/bun-serve-html.test.ts:
waitForServer /tmp/html-css-js_ZmgkBG {
 "/": "/tmp/html-css-js_ZmgkBG/index.html",
 "/dashboard": "/tmp/html-css-js_ZmgkBG/dashboard.html",
}
[0.00ms] bundle index.html 1.09 KB
[0.00ms] bundle dashboard.html 1.27 KB
(pass) serve html [25.17ms]
waitForServer /tmp/bun-serve-html-txt_uWNogT {
 "/": "/tmp/bun-serve-html-txt_uWNogT/index.html",
}
[0.00ms] bundle index.html 0.40 KB
HASH efbnbska
(pass) serve plugins > basic plugin [17.25ms]
waitForServer /tmp/html-css-js-failing-plugin_Kd1a8p {
 "/": "/tmp/html-css-js-failing-plugin_Kd1a8p/index.html",
}
error: Plugin failed intentionally
 at /tmp/html-css-js-failing-plugin_Kd1a8p/styles.css:0
error: Plugin failed intentionally
 at /tmp/html-css-js-failing-plugin_Kd1a8p/styles.css:0
(pass) serve plugins > serve html with failing plugin [16.33ms]
waitForServer /tmp/html-css-js-empty-plugins_Ecz7qJ {
 "/": "/tmp/html-css-js-empty-plugins_Ecz7qJ/index.html",
}
[0.00ms] bundle index.html 0.71 KB
(pass) serve plugins > empty plugin array [13.23ms]
Waiting for server
waitForServer /tmp/html-css-js-concurrent-plugins_l7wQg5 {
 "/": "/tmp/
... (truncated)
```
</details>
<details><summary>passes on PR (with fix)</summary>
```console
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/bun-serve-html.test.ts test/js/bun/http/bun-serve-html-hot-reload-drop.test.ts
bun test v1.4.0 (5f6622f)
test/js/bun/http/bun-serve-html.test.ts:
waitForServer /tmp/html-css-js_CpXhx4 {
 "/": "/tmp/html-css-js_CpXhx4/index.html",
 "/dashboard": "/tmp/html-css-js_CpXhx4/dashboard.html",
}
[0.09ms] bundle index.html 1.09 KB
[0.05ms] bundle dashboard.html 1.27 KB
(pass) serve html [588.64ms]
waitForServer /tmp/bun-serve-html-txt_rjZL4e {
 "/": "/tmp/bun-serve-html-txt_rjZL4e/index.html",
}
[0.15ms] bundle index.html 0.40 KB
HASH efbnbska
(pass) serve plugins > basic plugin [552.11ms]
waitForServer /tmp/html-css-js-failing-plugin_D8NJ2O {
 "/": "/tmp/html-css-js-failing-plugin_D8NJ2O/index.html",
}
error: Plugin failed intentionally
 at /tmp/html-css-js-failing-plugin_D8NJ2O/styles.css:0
error: Plugin failed intentionally
 at /tmp/html-css-js-failing-plugin_D8NJ2O/styles.css:0
(pass) serve plugins > serve html with failing plugin [505.55ms]
waitForServer /tmp/html-css-js-empty-plugins_vK0DVI {
 "/": "/tmp/htm
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 673ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/7] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[2/7] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
 nightly-2026年07月20日-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026年07月19日)
�[1m�[92m Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m Compiling�[0m
... (truncated)
```
</details>
<details><summary>diff hotspot</summary>
```
src/runtime/bake/DevServer.rs | 63 +++-
 src/runtime/bake/dev_server/lifecycle.rs | 12 +-
 src/runtime/bake/dev_server/mod.rs | 401 +++++++++++----------
 src/runtime/dispatch.rs | 10 +-
 .../http/bun-serve-html-hot-reload-drop.test.ts | 82 +++++
 test/js/bun/http/bun-serve-html.test.ts | 10 +-
 6 files changed, 374 insertions(+), 204 deletions(-)
```
</details>
**gate history** · 3 passed · 2 rejected · iteration 4
<details><summary>evidence per changed file</summary>
```
file reads edits tests
src/runtime/bake/DevServer.rs 10 13 0
src/runtime/bake/dev_server/lifecycle.rs 5 9 0
src/runtime/bake/dev_server/mod.rs 13 12 0
src/runtime/dispatch.rs 3 2 0
test/js/bun/http/bun-serve-html-hot-reload-drop.test.ts 1 5 0
test/js/bun/http/bun-serve-html.test.ts 6 9 0
```
</details>
<!-- robobun:evidence:end -->
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
mchv pushed a commit that referenced this pull request Aug 27, 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
 oven-sh#4 JSC::TemporalCore::buildCalendarTemplate(WTF::AbstractLocker const&, unsigned int)
 oven-sh#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>
mchv pushed a commit that referenced this pull request Aug 27, 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 -->
mchv pushed a commit that referenced this pull request Aug 27, 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).
mchv pushed a commit that referenced this pull request Aug 27, 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:
 oven-sh#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>
mchv pushed a commit that referenced this pull request Aug 27, 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:
 ...
 oven-sh#11 in <bun_runtime::server::NewServer<false, false>>::deinit src/runtime/server/mod.rs:2122
 oven-sh#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>
mchv pushed a commit that referenced this pull request Aug 27, 2026
...wo parallel vecs (oven-sh#39145)
### Problem
- `LOLHTMLContext` in `src/runtime/api/html_rewriter.rs` keeps two vecs,
`selectors` and `element_handlers`, that describe one thing: entry `i`
of each is the selector and the handler object from the same
`rewriter.on(selector, handlers)` call.
- The pairing is only held up by convention: `on_()` pushes to both,
`build_settings()` zips them back together, and a doc comment plus an
invariant comment explain it. The mordant `parallel_vecs` lint flags
this (the one baselined finding for this file).
### Fix
- Add `ElementHandlerEntry { selector, handler: Box<ElementHandler> }`
and store `element_handlers: Vec<ElementHandlerEntry>`. One vec, one
push in `on_()`, and `build_settings()` destructures each entry instead
of zipping.
- No behavior change: the same values are pushed in the same order, the
handler is still boxed (the lol-html closures built in
`build_settings()` hold raw pointers into the box, so it must not move
when the vec reallocates), and the body of the `build_settings()` loop
is unchanged. The `#[expect(clippy::vec_box)]` comes off
`element_handlers` because it is no longer a `Vec<Box<_>>`;
`document_handlers` keeps its own.
- Remove the `parallel_vecs:src/runtime/api/html_rewriter.rs` line from
`mordant-baseline.toml`.
- Tests, in `test/js/workerd/html-rewriter.test.js` (`on()
registrations`), pin down the two things this storage has to get right.
They pass before and after this change, since it is a refactor:
- Many selectors registered on one rewriter, with two rejected `on()`
calls in the middle, each still run the handlers they were registered
with, on two transforms of the same rewriter.
- `on()` called from inside a handler, often enough to reallocate the
registry while lol-html is still calling the handlers registered before
the transform started: the running transform is unaffected and the next
one picks the additions up. With the `Box` removed from
`ElementHandlerEntry` this test fails under ASAN with a
heap-use-after-free (report in the details below), so the boxing is now
covered rather than only commented.
- Verified:
- `bun bd test` on `test/js/workerd/html-rewriter.test.js` (165 tests,
including the new ones), `html-rewriter-end-error.test.ts`,
`html-rewriter-leak.test.ts`,
`test/js/web/html/html-rewriter-doctype.test.ts` and the HTMLRewriter
regression tests: all pass.
 - `cargo clippy -p bun_runtime --no-deps`: clean.
- `cargo dylint --all -p bun_runtime` with this baseline: nothing over
the baseline. The same command with the baseline line removed but the
source change stashed reports exactly the one `parallel_vecs` finding
for this file, so the removed line is the one this change fixes.
- Regenerating the baseline with `MORDANT_BASELINE_WRITE=1` also drops
two entries this PR does not touch
(`always_unwrapped_option:src/install/PackageInstall.rs`,
`narrowed_two_ways:src/runtime/node/node_crypto_binding.rs`); those
findings were already fixed on main by other changes and are left for a
separate cleanup.
### Background
- `HTMLRewriter.on(selector, handlers)` parses the CSS selector with
lol-html and wraps the JS handler object in an `ElementHandler` (the
protected `element`/`comments`/`text` callbacks). Nothing is handed to
lol-html at that point; registrations are collected in `LOLHTMLContext`,
which is shared by the rewriter and every transform it starts, because
`transform()` can run more than once.
- `build_settings()` runs at transform time and turns each registration
into a `(selector, ElementContentHandlers)` pair for lol-html. Its
closures capture a `NonNull<ElementHandler>` pointing into the heap
allocation owned by the `Box`, which is why the handler has to stay
boxed even though clippy would normally suggest otherwise. An `on()`
call after a transform has started (for example from inside a handler)
pushes onto the same vec, which is what makes the reallocation case
reachable from JS.
- `mordant-baseline.toml` is the ratchet for the mordant lint pack run
by the Rust lints workflow: it records the accepted number of findings
per (lint, file), and CI reports anything above those counts. Removing
the line here means a reintroduction of the pattern in this file would
be reported.
<details>
<summary>ASAN report from the new test with the Box removed from
ElementHandlerEntry</summary>
```
ERROR: AddressSanitizer: heap-use-after-free
READ of size 8
 #3 <ElementHandler as HandlerLike>::global src/runtime/api/html_rewriter.rs
 oven-sh#4 handler_callback::<ElementHandler, Element, ...> src/runtime/api/html_rewriter.rs
 oven-sh#5 ElementHandler::on_element src/runtime/api/html_rewriter.rs
 oven-sh#6 build_settings::{closure#0} src/runtime/api/html_rewriter.rs
 oven-sh#8 lol_html ContentHandlersDispatcher::handle_start_tag
freed by thread T0 here:
 oven-sh#13 RawVec<ElementHandlerEntry>::grow_one
 oven-sh#15 Vec<ElementHandlerEntry>::push
 oven-sh#16 HTMLRewriter::on_ src/runtime/api/html_rewriter.rs
```
</details>
<!-- 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/workerd/html-rewriter.test.js
<!-- robobun:evidence:end -->
---------
Co-authored-by: Alistair Smith <hi@alistair.sh>
mchv pushed a commit that referenced this pull request Aug 27, 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 -->
mchv pushed a commit that referenced this pull request Aug 27, 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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

No reviews

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

1 participant

AltStyle によって変換されたページ (->オリジナル) /