Add Windows and Linux release pipeline - #2
Merged
Conversation
added 17 commits
July 30, 2026 17:45
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
## What
`JSSink::assign_to_stream` now detaches the freshly created
`JSReadable*SinkController` (nulling its `m_sinkPtr`) when the C++
stream-pump setup returns an error, before returning to the caller.
## Why
The generated `${name}__assignToStream` functions create the controller
with `m_sinkPtr = sinkPtr` and then call into
`GlobalObject::assignToStream` → `readDirectStream` /
`readStreamIntoSink`. If that setup throws (for example a direct
`ReadableStream` whose `pull` getter throws), the controller is never
started, so nothing ever calls `end()`/`close()` to null `m_sinkPtr`.
The caller's error path (`Writable::init` for `Bun.spawn`) then releases
and frees the native sink. When the controller is later swept, its
destructor runs `${name}__controllerDetached` / `${name}__finalize` on
freed memory.
ASAN report:
```
heap-use-after-free on address 0x799feed81c78
READ of size 1
#0 JSSink<FileSink>::js_controller_detached Sink.rs:567
#1 FileSink__controllerDetached generated_jssink.rs:179
#2 JSReadableFileSinkController::~JSReadableFileSinkController()
freed by:
#12 FileSink::deinit FileSink.rs:1142
#16 Writable::pipe_release Writable.rs:70
#17 Writable::init Writable.rs:339
#18 spawn_maybe_sync js_bun_spawn_bindings.rs:1379
```
The fix is at the generic `JSSink::assign_to_stream` layer so it covers
every sink type (`FileSink`, `NetworkSink`, `FetchRequestBodySink`,
...), not just the spawn path.
## Repro
```js
const { openSync, closeSync } = require("node:fs");
const fd = openSync("/tmp/out.txt", "w");
let armed = false;
const stream = new ReadableStream({
type: "direct",
get pull() { if (armed) throw new Error("pull unavailable"); return () => {}; },
});
armed = true;
try {
Bun.spawn({ cmd: [process.execPath, "-e", "0"], stdio: [stream, fd, "ignore"] });
} catch {}
closeSync(fd);
Bun.gc(true); // sweep -> controller dtor -> UAF
```
## Tests
The two existing `spawn.test.ts` cases that cover the
stdin-stream-setup-throws path now force a full GC in the child fixture
so the controller destructor runs deterministically under debug+ASAN as
well. Previously they were only failing on the release-asan lane (where
the whole file has been quarantined as `[ASAN] [TIMEOUT]`), which is why
this went unnoticed.
```
bun bd test test/js/bun/spawn/spawn.test.ts -t "stdin stream setup fails"
```
fails on `main` (ASAN heap-use-after-free in the child's stderr) and
passes with this change.
`spawn-stdin-readable-stream-edge-cases.test.ts` and
`body-stream.test.ts` continue to pass.
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
… sink ends inline (#36939) ### Crash Sentry [BUN-3BZF](https://bun-p9.sentry.io/issues/?query=BUN-3BZF) (2,975 events since 2026-05-25, macOS-dominant): `Panic: called Option::unwrap() on a None value` at `FetchTasklet::callback`'s `task_ref.http.as_mut().unwrap()`, reached from the HTTP thread's result dispatch (`us_internal_ssl_on_data -> HTTPClient::fail -> dispatch_result_and_reset -> AsyncHTTP::on_async_http_callback_raw -> FetchTasklet::callback`). `http` is set once at creation and cleared only at deinit, so the panic means the callback ran against a freed `FetchTasklet`. ### Cause `start_request_stream` takes a `+1` on the tasklet that must be released exactly once by `write_end_request`. For a native `ByteStream` request body (an upstream response body piped into `fetch()`), `wire_native_sink` installs the sink's `source` handle *before* any of its `EndedInline` returns (`ReadableStream.rs:328` vs `:337/:352/:359`), so a stream that picked up an error or its last chunk between `fetch()` and the `can_stream` tick comes back `EndedInline` with a native source attached. The `EndedInline` arm released the `+1` (via `write_end_request`) but left `self.sink` installed with `ended == false`. Every terminal path then runs `cancel_request_body_sink`, which saw a "live" native sink and took its native arm: `abort_task()` plus a second `write_end_request` — releasing the same `+1` again. The double release collapses the refcount while the other owners (the JS-side initial ref and the HTTP thread's in-flight ref) still use the tasklet. Under ASAN the deterministic form is the trace below (deinit runs inside `cancel_request_body_sink`, then `on_progress_update` keeps using `self`). In release builds the same imbalance frees the tasklet while it is still in use (or double-frees, handing a live tasklet's block back to the allocator), which surfaces as downstream crashes in the fetch completion path — the BUN-3BZF unwrap is the tasklet's `http` field read from freed/recycled memory. ``` READ of size 8 ... core::mem::replace::<bun_jsc::js_promise::Strong> #2 FetchTasklet::on_progress_update FetchTasklet.rs:1158 freed by thread T0 here: #12 FetchTasklet::deinit FetchTasklet.rs:509 #16 FetchTasklet::write_end_request FetchTasklet.rs:2281 #17 FetchTasklet::cancel_request_body_sink FetchTasklet.rs:2368 #18 FetchTasklet::on_progress_update FetchTasklet.rs:1143 ``` ### Fix Leave the sink in the same state `end_from_stream` (the normal native termination) leaves it: `ended = true`, source and task detached. The terminal `cancel_request_body_sink` then hits its existing `if sink.ended { return }` guard and cannot release the ref a second time (it also no longer spuriously aborts a request whose body simply ended inline). ### Verification - New fixture `fetch-stream-body-ended-inline-fixture.ts` drives the window: an upstream server that advertises a larger `content-length` than it sends and closes a few ms later, piped as the body of a TLS `fetch()` (the handshake keeps the wire-attempt window open), 100 iterations. - Unfixed debug+ASAN build: heap-use-after-free with the trace above, 8/8 runs. - Fixed build: `bun bd test test/js/web/fetch/fetch-abort-stream-body.test.ts` passes (5 pass, 1 pre-existing skip), including the new test. - `test/js/web/fetch/body-stream.test.ts`: 9086 pass / 0 fail. `fetch.test.ts` and `fetch.stream.test.ts`: identical pass/fail counts to an unfixed baseline in the same container (the failures are pre-existing network/timeout issues). - The test is `skipIf(!isASAN)`: the release build corrupts silently, so only sanitizer lanes can observe the failure.
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…e cache (#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 #37024, whose
`test/js/bun/bun-object/deep-equals-temporal.test.ts` uses
`[u-ca=hebrew]`:
```
Direct leak of 624 byte(s) in 1 object(s) allocated from:
#1 icu_75::HebrewCalendar::clone() const
#2 icu_75::Calendar::createInstance(icu_75::TimeZone*, icu_75::Locale const&, UErrorCode&)
#3 ucal_open_75
#4 JSC::TemporalCore::buildCalendarTemplate(WTF::AbstractLocker const&, unsigned int)
#5 JSC::TemporalCore::withCalendar<JSC::TemporalCore::calendarYear(...)::$_0>(...)
```
The CI annotation titles this `direct leak of 624b in {closure#0}
(src/jsc/JSValue.rs:1664:22)` because that is the first in-repo frame
(the test-runner's `JSValue::call`); everything below it is WebKit/ICU.
### Cause
`TemporalCore::withCalendar`
(`vendor/WebKit/.../temporal/core/CalendarICUBridge.cpp`) keeps up to 8
open `UCalendar` templates in a process-lifetime `LazyNeverDestroyed`
`TinyLRUCache`, one per calendar ID (non-ISO arithmetic, plus pure-ISO
`PlainDateTime.prototype.with`, which reaches the same path unguarded);
LRU eviction `ucal_close`s them, so the set is bounded. The
`CalendarCacheEntry` that owns each `UCalendar` is
`WTF_MAKE_TZONE_ALLOCATED` (bmalloc), which LSan does not scan, so the
libc-allocated `UCalendar` (and the ICU `TimeZone` inside it) is
reported as a direct leak even though it is reachable. Whether a given
run aborts depends on whether some stale stack or register value still
points at the ICU object when LSan scans at exit, hence the
intermittence.
This is the calendar twin of the already-suppressed
`TemporalCore::withTimeZone` entry (same cache design, same
TZone-allocated owner).
### Fix
- Add a `leak:TemporalCore::buildCalendarTemplate` suppression to
`test/leaksan.supp`, mirroring the `withTimeZone` entry. The pattern
anchors on the template builder rather than `withCalendar` itself so
that a future real leak inside one of the many op lambdas `withCalendar`
runs would still be reported; every cached-template allocation carries
the builder frame. (`withTimeZone` has no such builder frame, its
`ucal_open` is inline, so that entry keeps its existing pattern.)
- Drop the `test/no-validate-leaksan.txt` escape hatch #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 #32180: that is `bun test`'s own
finalizer-owned memory, while this cache deliberately survives VM
teardown, so #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>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
<details> <summary> observed in https://buildkite.com/bun/bun/builds/22442#annotation-test/js/node/zlib/leak.test.ts </summary> ``` ==5045==ERROR: AddressSanitizer: heap-use-after-free on address 0x5220000243c0 at pc 0x00000dad671b bp 0x14f22d4a4990 sp 0x14f22d4a4988 READ of size 8 at 0x5220000243c0 thread T5 (HeapHelper) ======== Stack trace from GDB for HeapHelper-5045.core: ======== Program terminated with signal SIGABRT, Aborted. #0 0x000014f2c3672eec in ?? () from /lib/x86_64-linux-gnu/libc.so.6 [Current thread is 1 (Thread 0x14f22d4f46c0 (LWP 5050))] #0 0x000014f2c3672eec in ?? () from /lib/x86_64-linux-gnu/libc.so.6 #1 0x000014f2c3623fb2 in raise () from /lib/x86_64-linux-gnu/libc.so.6 #2 0x000014f2c360e472 in abort () from /lib/x86_64-linux-gnu/libc.so.6 #3 0x000000000e3b2ae2 in uw_init_context_1[cold] () #4 0x000000000e3b29fc in _Unwind_Backtrace () #5 0x00000000046a6bab in __sanitizer::BufferedStackTrace::UnwindSlow(unsigned long, unsigned int) () #6 0x00000000046a181d in __sanitizer::BufferedStackTrace::Unwind(unsigned int, unsigned long, unsigned long, void*, unsigned long, unsigned long, bool) () #7 0x00000000046885bd in __sanitizer::BufferedStackTrace::UnwindImpl(unsigned long, unsigned long, void*, bool, unsigned int) () #8 0x0000000004601127 in __asan::ErrorGeneric::Print() () #9 0x0000000004683180 in __asan::ScopedInErrorReport::~ScopedInErrorReport() () #10 0x0000000004686567 in __asan::ReportGenericError(unsigned long, unsigned long, unsigned long, unsigned long, bool, unsigned long, unsigned int, bool) () #11 0x0000000004686d46 in __asan_report_load8 () #12 0x000000000dad671b in ZSTD_sizeof_CCtx (cctx=<optimized out>) at ./build/release-asan/zstd/vendor/zstd/lib/compress/zstd_compress.c:210 #13 0x0000000006d2284d in bun.js.node.zlib.NativeZstd.estimatedSize () at /var/lib/buildkite-agent/builds/ip-172-31-72-121/bun/bun/src/bun.js/node/zlib/NativeZstd.zig:57 #14 ZigGeneratedClasses.JSNativeZstd.JavaScriptCoreBindings.NativeZstd__estimatedSize (thisValue=<optimized out>) at /var/lib/buildkite-agent/builds/ip-172-31-72-121/bun/bun/build/release-asan/codegen/ZigGeneratedClasses.zig:11122 #15 0x000000000852803b in WebCore::JSNativeZstd::visitChildrenImpl<JSC::SlotVisitor> (cell=0x14f22e190840, visitor=...) at ./build/release-asan/./build/release-asan/codegen/ZigGeneratedClasses.cpp:30728 #16 WebCore::JSNativeZstd::visitChildren (cell=0x14f22e190840, visitor=...) at ./build/release-asan/./build/release-asan/codegen/ZigGeneratedClasses.cpp:30734 #17 0x000000000aa99d6c in JSC::MethodTable::visitChildren (this=<optimized out>, cell=<optimized out>, visitor=...) at vendor/WebKit/Source/JavaScriptCore/runtime/ClassInfo.h:115 #18 0x000000000aa99d6c in JSC::SlotVisitor::visitChildren (this=0x14f277028300, cell=0x14f22e190840) #19 JSC::SlotVisitor::drain(WTF::MonotonicTime)::$_0::operator()(JSC::MarkStackArray&) const (this=<optimized out>, stack=...) at vendor/WebKit/Source/JavaScriptCore/heap/SlotVisitor.cpp:509 #20 0x000000000aa8f130 in JSC::SlotVisitor::forEachMarkStack<JSC::SlotVisitor::drain(WTF::MonotonicTime)::$_0>(JSC::SlotVisitor::drain(WTF::MonotonicTime)::$_0 const&) (this=0x14f277028300, func=...) at vendor/WebKit/Source/JavaScriptCore/heap/SlotVisitorInlines.h:193 #21 JSC::SlotVisitor::drain (this=this@entry=0x14f277028300, timeout=<error reading variable: That operation is not available on integers of more than 8 bytes.>, timeout@entry=...) at vendor/WebKit/Source/JavaScriptCore/heap/SlotVisitor.cpp:499 #22 0x000000000aa90590 in JSC::SlotVisitor::drainFromShared (this=0x14f277028300, sharedDrainMode=JSC::SlotVisitor::HelperDrain, timeout=<error reading variable: That operation is not available on integers of more than 8 bytes.>) at vendor/WebKit/Source/JavaScriptCore/heap/SlotVisitor.cpp:699 #23 0x000000000aa08726 in JSC::Heap::runBeginPhase(JSC::GCConductor)::$_1::operator()() const (this=<optimized out>) at vendor/WebKit/Source/JavaScriptCore/heap/Heap.cpp:1508 #24 WTF::SharedTaskFunctor<void (), JSC::Heap::runBeginPhase(JSC::GCConductor)::$_1>::run() (this=<optimized out>) at .WTF/Headers/wtf/SharedTask.h:91 #25 0x000000000aa3b596 in WTF::ParallelHelperClient::runTask(WTF::RefPtr<WTF::SharedTask<void ()>, WTF::RawPtrTraits<WTF::SharedTask<void ()> >, WTF::DefaultRefDerefTraits<WTF::SharedTask<void ()> > > const&) (this=0x14f22e000428, task=...) at vendor/WebKit/Source/WTF/wtf/ParallelHelperPool.cpp:110 #26 0x000000000aa3d976 in WTF::ParallelHelperPool::Thread::work (this=<optimized out>) at vendor/WebKit/Source/WTF/wtf/ParallelHelperPool.cpp:201 #27 0x000000000aa4210d in WTF::AutomaticThread::start(WTF::AbstractLocker const&)::$_0::operator()() const (this=<optimized out>) at vendor/WebKit/Source/WTF/wtf/AutomaticThread.cpp:225 #28 WTF::Detail::CallableWrapper<WTF::AutomaticThread::start(WTF::AbstractLocker const&)::$_0, void>::call() (this=<optimized out>) at vendor/WebKit/Source/WTF/wtf/Function.h:53 #29 0x0000000008958ada in WTF::Function<void ()>::operator()() const (this=<optimized out>) at vendor/WebKit/Source/WTF/wtf/Function.h:82 #30 WTF::Thread::entryPoint (newThreadContext=<optimized out>) at vendor/WebKit/Source/WTF/wtf/Threading.cpp:272 #31 0x0000000008a65689 in WTF::wtfThreadEntryPoint (context=0x13b5) at vendor/WebKit/Source/WTF/wtf/posix/ThreadingPOSIX.cpp:255 #32 0x000000000467d347 in asan_thread_start(void*) () #33 0x000014f2c36711f5 in ?? () from /lib/x86_64-linux-gnu/libc.so.6 #34 0x000014f2c36f189c in ?? () from /lib/x86_64-linux-gnu/libc.so.6 ``` </details> `ZSTD_sizeof_CCtx` and `ZSTD_sizeof_DCtx` can not be relied upon to be thread-safe and estimatedSize may be called from any thread
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…Worker" (#21994) Reverts oven-sh/bun#21962 `vm.ensureTerminationException` allocates a JSString, which is not safe to do from a thread that doesn't own the API lock. ```ts Bun Canary v1.2.21-canary.1 (f706382a) Linux x64 (baseline) Linux Kernel v6.12.38 | musl CPU: sse42 popcnt avx avx2 avx512 Args: "/var/lib/buildkite-agent/builds/ip-172-31-38-185/bun/bun/release/bun-linux-x64-musl-baseline-profile/bun-profile" "/var/lib/buildkite-agent/builds/ip-172-31-38-185/bun/bun/test/js/node/worker_threads"... Features: bunfig http_server jsc tsconfig(3) tsconfig_paths workers_spawned(40) workers_terminated(34) Builtins: "bun:main" "node:worker_threads" Elapsed: 362ms | User: 518ms | Sys: 63ms RSS: 0.34GB | Peak: 100.36MB | Commit: 0.34GB | Faults: 0 | Machine: 8.17GB panic(main thread): Segmentation fault at address 0x0 oh no: Bun has crashed. This indicates a bug in Bun, not your code. To send a redacted crash report to Bun's team, please file a GitHub issue using the link below: http://localhost:38809/1.2.21/Ba2f706382wNgkgUu11luEm6yX+lwy+Dgtt+oEurthoD8214mE___07+09DA2AA 6 | describe("Worker destruction", () => { 7 | const method = ["Bun.connect", "Bun.listen", "fetch"]; 8 | describe.each(method)("bun when %s is used in a Worker that is terminating", method => { 9 | // fetch: ASAN failure 10 | test.skipIf(isBroken && method == "fetch")("exits cleanly", () => { 11 | expect([join(import.meta.dir, "worker_thread_check.ts"), method]).toRun(); ^ error: Command /var/lib/buildkite-agent/builds/ip-172-31-38-185/bun/bun/test/js/node/worker_threads/worker_thread_check.ts Bun.connect failed: Spawned 10 workers RSS 79 MB Spawned 10 workers RSS 87 MB Spawned 10 workers RSS 90 MB at <anonymous> (/var/lib/buildkite-agent/builds/ip-172-31-38-185/bun/bun/test/js/node/worker_threads/worker_destruction.test.ts:11:73) ✗ Worker destruction > bun when Bun.connect is used in a Worker that is terminating > exits cleanly [597.56ms] ✓ Worker destruction > bun when Bun.listen is used in a Worker that is terminating > exits cleanly [503.47ms] » Worker destruction > bun when fetch is used in a Worker that is terminating > exits cleanly 1 pass 1 skip 1 fail 2 expect() calls Ran 3 tests across 1 file. [1125.00ms] ======== Stack trace from GDB for bun-profile-28234.core: ======== Program terminated with signal SIGILL, Illegal instruction. #0 crash_handler.crash () at crash_handler.zig:1523 [Current thread is 1 (LWP 28234)] #0 crash_handler.crash () at crash_handler.zig:1523 #1 0x0000000002db77aa in crash_handler.crashHandler (reason=..., error_return_trace=0x0, begin_addr=...) at crash_handler.zig:471 #2 0x0000000002db2b55 in crash_handler.handleSegfaultPosix (sig=<optimized out>, info=<optimized out>) at crash_handler.zig:792 #3 0x0000000004716b58 in WTF::jscSignalHandler (sig=11, info=0x7ffe54051e90, ucontext=0x0) at vendor/WebKit/Source/WTF/wtf/threads/Signals.cpp:548 #4 <signal handler called> #5 JSC::VM::currentThreadIsHoldingAPILock (this=0x148296c30000) at vendor/WebKit/Source/JavaScriptCore/runtime/VM.h:840 #6 JSC::sanitizeStackForVM (vm=...) at vendor/WebKit/Source/JavaScriptCore/runtime/VM.cpp:1369 #7 0x0000000003f4a060 in JSC::LocalAllocator::allocate(JSC::Heap&, unsigned long, JSC::GCDeferralContext*, JSC::AllocationFailureMode)::{lambda()#1}::operator()() const (this=<optimized out>) at cache/webkit-a73e665a39b281c5/include/JavaScriptCore/LocalAllocatorInlines.h:46 #8 JSC::FreeList::allocateWithCellSize<JSC::LocalAllocator::allocate(JSC::Heap&, unsigned long, JSC::GCDeferralContext*, JSC::AllocationFailureMode)::{lambda()#1}>(JSC::LocalAllocator::allocate(JSC::Heap&, unsigned long, JSC::GCDeferralContext*, JSC::AllocationFailureMode)::{lambda()#1} const&, unsigned long) (this=0x148296c38e48, cellSize=16, slowPath=...) at cache/webkit-a73e665a39b281c5/include/JavaScriptCore/FreeListInlines.h:46 #9 JSC::LocalAllocator::allocate (this=0x148296c38e30, heap=..., cellSize=16, deferralContext=0x0, failureMode=JSC::AllocationFailureMode::Assert) at cache/webkit-a73e665a39b281c5/include/JavaScriptCore/LocalAllocatorInlines.h:44 #10 JSC::GCClient::IsoSubspace::allocate (this=0x148296c38e30, vm=..., cellSize=16, deferralContext=0x0, failureMode=JSC::AllocationFailureMode::Assert) at cache/webkit-a73e665a39b281c5/include/JavaScriptCore/IsoSubspaceInlines.h:34 #11 JSC::tryAllocateCellHelper<JSC::JSString, (JSC::AllocationFailureMode)0> (vm=..., size=16, deferralContext=0x0) at cache/webkit-a73e665a39b281c5/include/JavaScriptCore/JSCellInlines.h:192 #12 JSC::allocateCell<JSC::JSString> (vm=..., size=16) at cache/webkit-a73e665a39b281c5/include/JavaScriptCore/JSCellInlines.h:212 #13 JSC::JSString::create (vm=..., value=...) at cache/webkit-a73e665a39b281c5/include/JavaScriptCore/JSString.h:204 #14 0x0000000004479ad1 in JSC::jsNontrivialString (vm=..., s=...) at vendor/WebKit/Source/JavaScriptCore/runtime/JSString.h:846 #15 JSC::VM::ensureTerminationException (this=0x148296c30000) at vendor/WebKit/Source/JavaScriptCore/runtime/VM.cpp:627 #16 JSGlobalObject__requestTermination (globalObject=<optimized out>) at ./build/release/./src/bun.js/bindings/ZigGlobalObject.cpp:3979 #17 0x0000000003405ab8 in bun.js.web_worker.notifyNeedTermination (this=0x542904f0d80) at /var/lib/buildkite-agent/builds/ip-172-31-16-28/bun/bun/src/bun.js/web_worker.zig:558 #18 0x0000000004362b6f in WebCore::Worker::terminate (this=0x984c900000000000) at ./src/bun.js/bindings/webcore/Worker.cpp:266 #19 WebCore::jsWorkerPrototypeFunction_terminateBody(JSC::JSGlobalObject*, JSC::CallFrame*, WebCore::JSWorker*)::{lambda()#1}::operator()() const (this=<optimized out>) at ./build/release/./src/bun.js/bindings/webcore/JSWorker.cpp:549 #20 WebCore::toJS<WebCore::IDLUndefined, WebCore::jsWorkerPrototypeFunction_terminateBody(JSC::JSGlobalObject*, JSC::CallFrame*, WebCore::JSWorker*)::{lambda()#1}>(JSC::JSGlobalObject&, JSC::ThrowScope&, WebCore::jsWorkerPrototypeFunction_terminateBody(JSC::JSGlobalObject*, JSC::CallFrame*, WebCore::JSWorker*)::{lambda()#1}&&) (lexicalGlobalObject=..., throwScope=..., valueOrFunctor=...) at ./src/bun.js/bindings/webcore/JSDOMConvertBase.h:174 #21 WebCore::jsWorkerPrototypeFunction_terminateBody (lexicalGlobalObject=<optimized out>, callFrame=<optimized out>, castedThis=<optimized out>) at ./build/release/./src/bun.js/bindings/webcore/JSWorker.cpp:549 #22 WebCore::IDLOperation<WebCore::JSWorker>::call<&WebCore::jsWorkerPrototypeFunction_terminateBody, (WebCore::CastedThisErrorBehavior)0> (lexicalGlobalObject=..., operationName=..., callFrame=...) at ./src/bun.js/bindings/webcore/JSDOMOperation.h:63 #23 WebCore::jsWorkerPrototypeFunction_terminate (lexicalGlobalObject=<optimized out>, callFrame=0x7ffe540536b8) at ./build/release/./src/bun.js/bindings/webcore/JSWorker.cpp:554 #24 0x000014825580c038 in ?? () #25 0x00007ffe540537b0 in ?? () #26 0x0000148255a626cb in ?? () #27 0x0000000000000000 in ?? () 1 crashes reported during this test ```
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
### What does this PR do? ### How did you verify your code works? --------- Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude <noreply@anthropic.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
## What Adds an early-return at the top of `ResumableSink.cancel()` when `status == .done`, so `onEnd` fires at most once. Fixes #20740 Fixes #21463 ## Why When a `fetch()` with a `ReadableStream` request body is aborted, `ResumableSink.cancel()` is called from `FetchTasklet.abortListener()` (FetchTasklet.zig:1203). The HTTP thread then completes with failure and `onProgressUpdate`'s reject path calls `sink.cancel()` a second time at FetchTasklet.zig:576 (and `onBodyReceived` at :342) — `this.sink` is only nulled in `clearSink←clearData←deinit`. `cancel()` (ResumableSink.zig:228) guarded against re-entry only for `status == .piped`. For the JS-route sink it relied on `#js_this.tryGet()` returning null after `detachJS()`, but `JSRef.downgrade()` (JSRef.zig:153-160) preserves the wrapper value as `.weak = <wrapper>`, and `tryGet()` (JSRef.zig:111) returns non-null for any non-empty weak. So the second `cancel()` re-enters the block and re-invokes `onEnd` → `FetchTasklet.writeEndRequest` → unconditional `defer this.deref()` (FetchTasklet.zig:1286). That second deref releases the single ref taken in `startRequestStream()` (FetchTasklet.zig:300) twice. Ref-count math: init(1) + queue(1) + startRequestStream(1) = 3 → cancel#1 deref → 2 → derefFromThread → 1 → cancel#2 deref → 0 → `deinit()`/`destroy()` runs *inside* `onProgressUpdate`, then its defer at :471-477 does `this.mutex.unlock()` + `this.deref()` on freed memory. `jsEnd()` already has an `isDetached()` guard for the same reason; `cancel()` was missing the equivalent. Using `status == .done` (rather than `isDetached()`) keeps the `.piped` branch reachable since piped sinks never set `#js_this` to `.strong`. ## Test `test/js/web/fetch/fetch-abort-stream-body.test.ts` reproduces the use-after-free in a debug/ASAN build: ``` [fetchtasklet] abortListener [fetchtasklet] writeEndRequest hasError? true <- cancel #1 [fetchtasklet] callback success=false ... [fetchtasklet] onProgressUpdate [fetchtasklet] onReject [fetchtasklet] writeEndRequest hasError? true <- cancel #2 (over-deref) [fetchtasklet] deinit ==40720==ERROR: AddressSanitizer: use-after-poison ... in onProgressUpdate ```
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…yToRoot (#29483) Fuzzilli found a use-after-poison in the runtime auto-install path. `enqueueDependencyToRoot` passed `&lockfile.buffers.dependencies.items[dep_id]` into `enqueueDependencyWithMainAndSuccessFn`. When the manifest for the requested package is already cached (on disk or in memory) but the extracted tarball is not, control reaches `getOrPutResolvedPackageWithFindResult`, which calls `Lockfile.Package.fromNPM`. That grows `buffers.dependencies` via `ensureUnusedCapacity` to make room for the package's own dependencies, reallocating the backing storage. The subsequent `.extract` branch then read `dependency.behavior.isRequired()` from the freed buffer. ``` #0 getOrPutResolvedPackageWithFindResult PackageManagerEnqueue.zig:1520 dependency.behavior.isRequired() #1 getOrPutResolvedPackage PackageManagerEnqueue.zig:1778 #2 enqueueDependencyWithMainAndSuccessFn PackageManagerEnqueue.zig:523 #3 enqueueDependencyToRoot PackageManagerEnqueue.zig:321 #4 Resolver.enqueueDependencyToResolve resolver.zig:2356 ... #14 Bun__resolveSync #15 functionImportMeta__resolveSyncPrivate (runtime require() path) ``` Two changes: - `enqueueDependencyToRoot` now copies the `Dependency` to the stack before taking its address, matching every other caller of `enqueueDependencyWithMainAndSuccessFn` (`processDependencyListItem`, `processPeerDependencyList`, etc.). - The one read that ran after `fromNPM` now uses the `behavior` parameter that was already passed by value, instead of re-dereferencing `dependency`. Repro (debug/ASAN only): auto-install a package with a warm on-disk manifest but no extracted tarball — `fromNPM` appending even a single dependency forces a realloc of the one-entry buffer. The new test warms the cache, removes the extracted tarballs, and runs `require()` via `-e` so it goes through `Bun__resolveSync` → `enqueueDependencyToRoot`.
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
`ResolveMessage.create` stored the `referrer` path via `Fs.Path.init`
without cloning. Every caller passes a temporary buffer — the `toUTF8()`
of a `bun.String` that is `deinit()`'d on return — so reading
`.referrer` after the creating frame unwound was a use-after-free.
Found by Fuzzilli as a flaky `use-after-poison` via `vi.mock()` →
`Bun__resolveSyncWithSource` → `resolveMaybeNeedsTrailingSlash`, but it
reproduces deterministically under ASAN with any non-ASCII source path:
```js
let err;
try {
Bun.resolveSync("./does-not-exist", "/tmp/café-🎉/file.js");
} catch (e) { err = e; }
Bun.gc(true);
err.referrer; // use-after-poison
```
```
==3080==ERROR: AddressSanitizer: use-after-poison on address 0x77cca9db0000 ...
READ of size 44 at 0x77cca9db0000 thread T0
#0 in __asan_memcpy
#1 in Zig::toStringCopy(ZigString) helpers.h:217
#2 in ZigString__toValueGC bindings.cpp:3402
#3 in ZigString.toJS ZigString.zig:57
#4 in ResolveMessage.getReferrer ResolveMessage.zig:221
```
In release builds the first 8 bytes of the returned referrer are
overwritten by mimalloc's free-list pointer instead of crashing.
Clone the referrer in `create()` and free it in `finalize()`. Also
`deinit()` the `toUTF8()` temporaries in `processFetchLog` now that
`create()` copies.
Co-authored-by: robobun <robobun@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…9910)
## What
`Blob.dupeWithContentType` guarded its content_type handling on
`duped.isHeapAllocated()` immediately *after* calling
`duped.setNotHeapAllocated()`, so both branches were dead. When the
source Blob's `content_type` is heap-allocated, the bitwise-copied dupe
aliased the same allocation while both sides had `content_type_allocated
== true`.
This is a regression from #23015: the pre-refactor code checked
`duped.allocator != null` *before* clearing it at the end of the
function; the refactor moved the clear to the top but left the (renamed)
guard in place.
## Repro
```js
const file = Bun.file(path, { type: "application/x-custom-type-not-in-registry-abcdefghijklm" });
const response = new Response(file); // body holds a dupe that aliases file.content_type
await file.write("hello", { type: "application/x-..." }); // frees file.content_type
response.headers.get("content-type"); // reads freed memory
```
On ASAN builds:
```
==716==ERROR: AddressSanitizer: use-after-poison on address 0x71df454301c0
#1 in Zig::toStringCopy(ZigString) helpers.h:217
#2 in WebCore__FetchHeaders__put bindings.cpp:2082
#5 in bun.js.webcore.Response.getOrCreateHeaders Response.zig:358
```
On release builds the freed slot gets reused and the read produces
garbage:
```
TypeError: Header '25' has invalid value: 'ion/x-custom-type-not-in-registry-abcdefghijklm'
```
## Fix
Drop the `isHeapAllocated()` guard and always deep-copy an allocated
`content_type` in `dupeWithContentType`. The old `!include_content_type`
branch's "resolve to static mime or fall back to empty" is gone — it
would have dropped FormData's `multipart/form-data; boundary=...` (and
any non-registry type) on `Response.clone()`, and the branch itself was
marked `// TODO: fix this / this is a bug`. The `include_content_type`
parameter is now a no-op.
Since every dupe now owns its `content_type` copy, `Blob.deinit()` frees
it. That in turn required closing a few places that held a
bitwise-copied Blob alongside the live owner:
- `fromJSWithoutDeferGC` `move=true`: deep-copy `name`/`content_type`
into the moved-out value so the source JS Blob keeps sole ownership; the
BuildArtifact arm now `dupe()`s (its "move" only nulled the store on a
local copy).
- `getSliceFrom()`: free the dupe's copy before overwriting it with the
slice's own type.
- `doWrite`/`getWriter`: clear `content_type_allocated` after the
in-place free so a registry-resolved static string isn't later freed by
`deinit()`.
- `BlobOrStringOrBuffer.deinitAndUnprotect`: only deref the store
(matching its `deinit()`) since `.blob` is a raw view of a live JS Blob.
## Verified
- `bun bd test test/js/web/fetch/blob.test.ts` — 16/16 pass
- New UAF test fails on both debug/ASAN (use-after-poison) and system
bun (garbage header) without the fix, passes with it
- New clone test guards against dropping FormData's boundary on
`Response.clone()`
- ASAN stress: 1k×
`Response.clone`/`blob.slice`/`createObjectURL`+revoke/`new
Response([blob])`/`write({type})` — no double-free
- RSS is flat across 50k × 1KB-type `Response.clone()` and
`blob.slice()`
---------
Co-authored-by: robobun <robobun@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
Closes #29925
Closes #22808
Closes #24019
## What does this PR do?
Fixes a bug where `Bun.RedisClient` would permanently reject every
command with `Connection has failed` after the client entered the failed
state (via reconnect exhaustion, manual `close()`, or a fatal socket
error). Calling `client.connect()` did not recover the client — the
process had to be restarted.
## Root cause
Pre-refactor (before #23141) `.failed` was a connection status and
`doConnect` handled it explicitly:
```zig
.failed => {
this.client.flags.is_reconnecting = true;
this.client.retry_attempts = 0;
this.reconnect();
},
```
The refactor folded `.failed` into `.disconnected` and a new
`flags.failed` boolean but never wired up the reset path. Two things
stayed sticky:
1. **`flags.failed`** — `send()` short-circuits on this and immediately
rejects with `Connection has failed`. Once set in `failWithJSValue`,
nothing ever cleared it.
2. **`flags.is_authenticated`** — kept `true` from the prior successful
session, so when the new socket's HELLO response arrived,
`handleResponse` skipped `handleHelloResponse` (which is guarded by `if
(!this.flags.is_authenticated)`) and silently discarded the response.
The client never transitioned back to `.connected` and `connect()` would
hang until the connection timeout fired.
## Fix
Two small resets:
- `doConnect` (src/valkey/js_valkey.zig) clears `flags.failed` alongside
`is_manually_closed` so an explicit `connect()` hands the client a clean
slate.
- `onOpen` (src/valkey/valkey.zig) clears `flags.failed`,
`is_authenticated`, and `is_selecting_db_internal` so a fresh socket
properly replays the HELLO handshake — matching what `onClose`'s
auto-reconnect branch already does at L502–504.
## Verification
New regression test at `test/regression/issue/29925.test.ts` spawns a
local `redis-server` on a random port, drives the client into the failed
state via `close()` (same terminal state as max-retries exhaustion),
then asserts `connect()` recovers the client and subsequent commands
complete round-trip. Gate check confirms the test times out without the
fix.
Also manually verified:
- #22808: tight `close()` + `connect()` + `send("FLUSHALL", ["SYNC"])`
loop that previously locked up on iter 1 now runs cleanly across many
iterations.
- #24019: after max-retries exhaustion during a redis restart,
`client.connect()` recovers the client instead of returning `connected:
true` while the next command still rejects.
Reproduction from #29925:
```
$ bun /tmp/repro.ts
first set: Max reconnection attempts reached
subsequent #0: Connection has failed ← forever
subsequent #1: Connection has failed
subsequent #2: Connection has failed
```
After the fix, `await client.connect()` brings the client back online
and the next `set`/`get` pair succeeds.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: robobun <robobun@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…double-free in deinit (#29988)
## Repro
Dev server with a directory watch that has two pending
resolution-failure dependencies (`./sub/a` at index 0, `./sub/b` at
index 1). Create `sub/a.ts` so dep 0 resolves; because it is not the
tail slot, `freeDependencyIndex(0)` pushes index 0 onto
`dependencies_free_list`. Shut the server down.
```
==ERROR: AddressSanitizer: negative-size-param: (size=-6148914691236517206)
#1 mem.Allocator.free
#2 bake.DevServer.deinit /workspace/bun/src/bake/DevServer.zig:686
#3 bun.js.api.server.NewServer(.http,.debug).deinitIfWeCan
Address 0xaaaaaaaaaaaaaaaa is a wild pointer
```
## Cause
`DirectoryWatchStore.freeDependencyIndex` frees `dep.specifier` and (in
debug) sets the whole slot to `undefined`, then pushes the index onto
`dependencies_free_list`. The slot stays in `dependencies.items`.
`DevServer.deinit` iterates every `dependencies.items` slot and calls
`alloc.free(watcher.specifier)` without consulting the free list, so
free-list slots are freed a second time. In debug builds the `undefined`
(0xAA…) slice trips ASAN's negative-size check; in release it is a
straight double-free. `memoryCost` has the same blind iteration and
would read `.len` from freed memory.
## Fix
After freeing, write an empty slice back into `specifier` so the slot is
safe to revisit: `alloc.free(&.{})` is a no-op and `.len == 0`.
## Verification
New test `deinit with a free-list slot in
DirectoryWatchStore.dependencies` in `test/bake/dev/bundle.test.ts`
arranges the free-list slot and lets the harness's graceful-exit call
`deinit`.
- `git stash -- src/ && bun bd test … -t 'deinit with a free-list slot'`
→ 3/3 **fail** (ASAN abort at DevServer.zig:686)
- with fix → 3/3 **pass**
- adjacent `removing 'use client' from a component with a pending
resolution failure` test still passes
---------
Co-authored-by: robobun <robobun@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…0196)
## What does this PR do?
Fixes a use-after-free in `HTMLRewriter.transform()` that caused flaky
SIGSEGV crashes found by fuzzing.
When transforming a string or ArrayBuffer, the body is buffered
synchronously and fed to lol-html via `write()` followed by `end()`. If
a document/element handler returns a rejected promise for the final
`lastInTextNode` chunk (emitted from `end()`), the `end() catch` branch
in `BufferOutputSink.runOutputSink` would call `response.finalize()`
directly on the output `Response`.
That `Response` is already owned by its JS wrapper cell (created earlier
in `init()` via `sink.response.toJS()`), so destroying it in-place left
the wrapper's `m_ctx` pointing at freed memory. When GC later swept the
wrapper, its destructor invoked `Response.finalize()` again on that
freed pointer:
```
AddressSanitizer: use-after-poison
#0 bun.js.bindings.JSRef.JSRef.deinit src/bun.js/bindings/JSRef.zig:188
#1 bun.js.bindings.JSRef.JSRef.finalize src/bun.js/bindings/JSRef.zig:200
#2 bun.js.webcore.Response.finalize src/bun.js/webcore/Response.zig:474
#3 ResponseClass__finalize codegen/ZigGeneratedClasses.zig:17250
#4 WebCore::JSResponse::~JSResponse() codegen/ZigGeneratedClasses.cpp:54979
```
The `write()` error path (just above it) already handled this correctly
by returning the error and letting the JS wrapper own the Response
lifetime. This PR makes the `end()` error path do the same — drop the
manual `response.finalize()` and `sink.response = undefined`.
## How did you verify your code works?
Minimal repro that reliably triggers the ASAN error before the fix and
passes cleanly after:
```js
const rewriter = new HTMLRewriter();
rewriter.onDocument({
text(chunk) {
if (chunk.lastInTextNode) {
return Promise.reject(new Error("boom"));
}
},
});
try {
rewriter.transform(new Uint8Array([97, 98, 99]).buffer);
} catch (e) {}
Bun.gc(true);
```
Added regression tests in `test/js/workerd/html-rewriter.test.js`
covering both ArrayBuffer and string inputs. All existing HTMLRewriter
tests pass.
---------
Co-authored-by: robobun <robobun@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
## What `RequestContext` stored `response_ptr: ?*Response` and, for plain `Blob`/`InternalBlob`/`WTFStringImpl` bodies, left the Response JSValue unprotected. `renderBytes()` → `tryEnd()` can hit backpressure and register an `onWritable` callback, unwinding with `response_ptr` still set. Nothing rooted the Response (`RequestContext` is a pool struct, not GC-visited), so GC could finalize it. If the client then aborted while the request body was still `.Locked`, `onAbort()` dereferenced a freed `*Response` — heap-use-after-free under ASAN at `RequestContext.zig:692`. ## Repro ``` POST → handler returns new Response(8MB string) sync → tryEnd() backpressure (client paused) → onWritable registered, return → Bun.gc(true) → Response collected, response_ptr dangles → client.destroy() → onAbort → deref response_ptr → UAF ``` ASAN trace (unpatched): ``` ==ERROR: AddressSanitizer: use-after-poison #0 bun.js.bindings.JSRef.JSRef.tryGet #1 bun.js.webcore.Response.getBodyReadableStream #2 RequestContext.onAbort src/bun.js/api/server/RequestContext.zig:693 #3 uWS::HttpContext<false>::onClose ``` ## Fix Give `Response` a `weak_ptr_data` field (mirroring `Request.WeakRef`) and replace `response_ptr: ?*Response` with `response_weakref: Response.WeakRef` via `bun.ptr.WeakPtr`. `Response.destroy()` now defers freeing the allocation until outstanding weak refs drop; `WeakRef.get()` returns null once the contents are gone. `onAbort` / `handleResolveStream` / `handleRejectStream` call `.get()` and simply skip the readable-stream cleanup when it's null — a no-op for in-memory bodies anyway, since the body was already extracted via `useAsAnyBlobAllowNonUTF8String()` before backpressure. File-backed and `.Locked` bodies continue to `protect()` `response_jsvalue` as before; those paths need the Response's status/headers alive across the async hop for `renderMetadata()`. The hot path (small in-memory responses) no longer needs `protect()`/`unprotect()`. The two redundant `ctx.response_ptr = response` assignments right before `ctx.render(response)` are dropped — `render()` already sets the weak ref. ## Verification `test/js/bun/http/serve-response-gc-backpressure-abort.test.ts` (ASAN/debug-only): POST with incomplete chunked body so `request_body` stays `.Locked`, handler returns a large string Response, client pauses so `tryEnd()` stalls, `Bun.gc(true)` loop, then client closes. - **without fix**: `AddressSanitizer: use-after-poison` in `onAbort` → `Response.getBodyReadableStream` - **with fix**: passes, `abortCount === iterations`, `pendingRequests === 0` --------- Co-authored-by: robobun <robobun@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…before write (#30155)
## Repro
```js
Bun.serve({
port: 0,
fetch: () =>
new Response("hello", {
headers: [
["Transfer-Encoding", "gzip"],
["Transfer-Encoding", "chunked"],
],
}),
});
// HEAD / → ASAN heap-use-after-free in uWS::HttpResponse::writeHeader
```
The duplicate entries make `FetchHeaders` combine them via
`makeString()`, producing a `StringImpl` held only by the header map —
the minimal condition for the free to actually happen.
StringImpl is allocated via bmalloc which ASAN doesn't instrument by
default; with `Malloc=1` (bmalloc → system heap) the debug build
reports:
```
AddressSanitizer: heap-use-after-free
READ of size 13
#2 uWS::HttpResponse<false>::writeHeader
#5 doRenderHeadResponse RequestContext.zig:1378
freed by:
#23 HTTPHeaderMap::remove
#28 doWriteHeaders RequestContext.zig:2303
#29 renderMetadata RequestContext.zig:2209
#30 doRenderHeadResponse RequestContext.zig:1377
```
## Cause
`doRenderHeadResponse()` calls `headers.fastGet(.TransferEncoding)`,
which returns a `ZigString` that **borrows** the header map entry's
`StringImpl` bytes (no ref taken). For an ASCII value, `toSlice()` also
borrows rather than copying. It then calls `this.renderMetadata()`,
whose `doWriteHeaders()` does `headers.fastRemove(.TransferEncoding)`
(and `renderMetadata` also `swapInitHeaders()` + `deref()`s the whole
`FetchHeaders`). When the map held the only reference to the
`StringImpl`, it's destroyed right there — and the very next line
`resp.writeHeader("transfer-encoding", transfer_encoding_str.slice())`
writes the freed bytes to the socket.
The adjacent `Content-Length` branch has the same bug:
`std.fmt.parseInt()` runs on the borrowed slice *after*
`renderMetadata()` has already `fastRemove(.ContentLength)`'d it.
## Fix
- **Transfer-Encoding**: use `toSliceClone()` instead of `toSlice()` so
the value is owned and survives `renderMetadata()`.
- **Content-Length**: parse the integer *before* `renderMetadata()` (and
drop the slice immediately), so the borrowed bytes are never touched
after the header entry is removed. No extra allocation needed since only
the parsed `usize` is used afterwards.
## Verification
New test in `test/js/bun/http/bun-server.test.ts` (inside the existing
`HEAD requests #15355` block) spawns a subprocess with `Malloc=1`
(non-Windows), serves HEAD responses whose Transfer-Encoding /
Content-Length values are `makeString()`-combined (sole-owner
StringImpl), and asserts the raw wire output.
```
git stash push -- src/ → test fails with "AddressSanitizer: heap-use-after-free" in stderr
git stash pop → test passes
```
All other tests in the `HEAD requests #15355` describe block continue to
pass.
Co-authored-by: robobun <robobun@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…s, http (#30722) Hardens 36 reachable security findings across the runtime, package manager, parsers, HTTP client/server, and SQL drivers. Three auto-applied fixes (#61 SSL exception leak, #68 YAML merge dedup, #104 archive overwrite precheck) were dropped: #61 introduced a use-after-free, #68 stored a non-`'static` byte view in a `'static` field, and #104 added dead gating that did not close the traversal. ### Memory safety / lifetime - #2 — Dangling proxy slice across reentrant JS getter — copy `process.env` proxy href to an owned `Vec` before reentrant getters can free the env map (`Blob.rs`) - #15 — Rollback restores dangling editor name pointer — preserve and restore `name_storage` on `detect_editor` failure (`BunObject.rs`) - #81 — Reentrant reconnect frees live handlers — only free previous handlers when `active_connections == 0` (`Listener.rs`) - #110 — Async randomFill uses stale resizable buffer pointer — fill a worker-owned scratch buffer; copy back on the JS thread after re-validating bounds (`node_crypto_binding.rs`) - #119 — Null zero-length slice UB in DOMJIT fast path — use `ffi::slice` which tolerates `(null, 0)` (`Crypto.rs`) - #67 — Raw serialization reads struct padding bytes — add explicit `_padding_*` fields with `offset_of!` proof asserts (`npm.rs`) - #74 — TLS rejection path leaks websocket refcount — route SSL/auth failures through `self.fail()` which clears `outgoing_websocket` (`websocket_client.rs`) - #108 — FD-backed fetch body leaks duplicated descriptor — close `opened_fd` unconditionally after `read_file` (`fetch.rs`) ### Untrusted-input bounds / panics - #10 — Invalid lockfile tag causes panic DoS — replace `unreachable!()` with logged error + `Tag::Uninitialized` (`dependency.rs`) - #20 — Unchecked lockfile string offsets cause OOB slice — bounds-check non-inline `String` pointers against `ctx.buffer` (`dependency.rs`) - #91 — Panic on unvalidated resolution tag — validate `ResolutionTag` discriminants on lockfile load (`Package.rs`) - #24 — Unwrap panic on unexpected 304 response — return `UnexpectedNotModified` when no cached manifest exists (`npm.rs`) - #44 — UDP port getter unwrap panic on transient state — return `undefined` when `socket` is `None` (`udp_socket.rs`) - #36 — Close reason length mismatch causes panic — clamp `body_len` to 125 and bail on overlong UTF-8 transcode (`websocket_client.rs`) - #100 — Windows pipe name length panic DoS — `debug_assert` → real bounds check (`Listener.rs`) - #60 / #111 — Windows shim stack buffer overflows — bounds-check argument and filename writes against `BUF1_LEN`/`BUF2_U16_LEN` before `copy_nonoverlapping` (`bun_shim_impl.rs`) - #76 / #101 — Unchecked bin name/entry name copies — bounds-check before slicing into `abs_dest_buf` (`bin.rs`) - #79 — `if` keyword misclassification causes parser panic — require a delimiter token before classifying (`shell_parser/parse.rs`) - #32 — Bounds check occurs after UTF-16 write — pre-flight key/value lengths before `convert_utf8_to_utf16_in_buffer` (`env_loader.rs`) - #95 — PBKDF2 digest validation allows panic-only algorithm — reject digests with no `EVP_MD` (`PBKDF2.rs`) ### DoS / resource caps - #17 — Unbounded recursion on deep TOML dotted keys — cap dotted-key segments at 512 (`toml.rs`) - #39 — Unbounded brace expansion preallocation — cap expansion count at 65536 in `Bun.$` and `Bun.braces` (`BunObject.rs`, `Expansion.rs`) - #31 — SCRAM PBKDF2 parameters accepted from server — clamp iteration count to `[4096, 10M]`, salt length to `[1, 1024]` (`PostgresSQLConnection.rs`) ### Auth / injection / traversal - #19 — Cleartext password sent after TLS downgrade — require `TLSStatus::SslOk`, not just `ssl_mode != Disable` (`MySQLConnection.rs`) - #83 — Strict TLS request reuses lax-verified pooled socket — track `established_with_reject_unauthorized` and refuse pool reuse for strict callers (`HTTPContext.rs`, `lib.rs`, `ClientSession.rs`) - #73 — IPv6 loopback prefix auth bypass — exact-match `::1` instead of `starts_with` (`server_body.rs`) - #56 — Unsanitized filename injects response headers — reject `\r`/`\n`/NUL/`"` in `content-disposition` filenames (`RequestContext.rs`) - #43 — Missing CRLF checks for signed host/auth headers — also validate `region`, `access_key_id`, and `host` (`s3_signing/credentials.rs`) - #34 — Bucket slash enables S3 host confusion — reject buckets containing `/` (`s3_signing/credentials.rs`) - #25 — Lexical symlink check permits extraction escape — track created symlinks during extraction and refuse paths that traverse them (`libarchive/lib.rs`) - #71 — bunx executes untrusted temp-cache binary — `lstat` cached binary; refuse symlinks and other-uid files (`bunx_command.rs`) ### Permission hygiene - #6 — Bin target chmod always sets mode 0777 — `0o777 & !umask` instead of `umask | 0o777` (`bin.rs`) - #23 — Process umask cleared and never restored — restore umask after probing it in `ensure_umask` (`bin.rs`) ### Parser correctness - #22 — Sign-prefixed scalar misparsed as infinity — fix Zig→Rust `&&`/`||` precedence transliteration (`yaml.rs`)
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
… stack pointer (#31020) ## What `resolveMaybeNeedsTrailingSlash` swaps `vm.log` / `resolver.log` to a stack-local `Log` for the duration of `_resolve`, then restores them via a drop guard. The Zig original also swaps and restores `transpiler.linker.log` and `resolver.package_manager.log`; the Rust port had those behind a `TODO(b2-cycle)` and only handled `vm.log` + `resolver.log`. When auto-install is enabled and the resolver lazily creates the `PackageManager` during `_resolve`, `Resolver::get_package_manager` seeds `pm.log` from `resolver.log` — which at that point is the **stack-local** `Log`. Because the restore guard never touched `pm.log`, it was left pointing into a dead stack frame after the function returned. The next resolve at a different stack depth that routes through the auto-install task runner dereferenced that stale pointer in `Log::add_error_fmt`, tripping ASAN's `stack-use-after-scope` (or segfaulting / executing garbage in release builds). Stack at the fault: ``` #0 bun_ast::Log::add_formatted_msg #1 bun_ast::Log::add_error_fmt #2 bun_install::…::run_tasks #7 bun_install::…::enqueue_dependency_to_root #9 bun_resolver::Resolver::enqueue_dependency_to_resolve #14 bun_resolver::Resolver::resolve_and_auto_install #15 bun_jsc::VirtualMachine::_resolve #16 bun_jsc::VirtualMachine::resolve_maybe_needs_trailing_slash::<true> ``` ## Fix Swap and restore `linker.log` and (when present) `package_manager.log` in both copies of the resolve log guard (`VirtualMachine::resolve_maybe_needs_trailing_slash` and `jsc_hooks::resolve_hook`), matching `VirtualMachine.zig`. The restore re-checks `resolver.package_manager` at drop time so a PM that was lazily created during `_resolve` is also pointed back at the VM log. Also adds the missing `<cassert>` include in `wtf-bindings.cpp`, which stopped being pulled in transitively. ## Repro ```js // run from an empty dir with // BUN_CONFIG_INSTALL=fallback BUN_CONFIG_REGISTRY=http://127.0.0.1:1 const realm = new ShadowRealm(); const variants = [ () => realm.importValue("pkg-not-found-a", "x"), () => (() => realm.importValue("pkg-not-found-b", "x"))(), () => (() => (() => realm.importValue("pkg-not-found-c", "x"))())(), () => import("pkg-not-found-f"), ]; for (let i = 0; i < 100; i++) for (const v of variants) try { v()?.catch?.(() => {}); } catch {} ``` Segfaults on `main`, clean after this change. Fixes #14432 Fixes #22407 --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…s longer than the comparand (#31264)
### What does this PR do?
Fixes an ASAN `global-buffer-overflow` found by fuzzing the CSS parser:
```
asan:global-buffer-overflow:strncasecmp|eql_case_insensitive_ascii|eql_case_insensitive_ascii|bun_core::string::immutable::eql_case_insensitive_ascii_ignore_length
```
**Repro**
```sh
BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING=1 bun -e 'require("bun:internal-for-testing").cssInternals.minifyTest(":nth-child(Nn", "")'
```
```
==ERROR: AddressSanitizer: global-buffer-overflow READ of size 2 ...
#0 strncasecmp
#1 bun_core::strings_impl::eql_case_insensitive_ascii src/bun_core/lib.rs
#2 bun_core::string::immutable::eql_case_insensitive_ascii_ignore_length src/bun_core/string/immutable.rs
#3 bun_css::css_parser::nth::parse_nth src/css/css_parser.rs
#4 bun_css::selectors::parser::parse_nth_pseudo_class src/css/selectors/parser.rs
```
**Cause**
`strings_impl::eql_case_insensitive_ascii(a, b, check_len)` defers to
`strncasecmp(a, b, a.len())`, which reads up to `a.len()` bytes from
*both* buffers. The Zig original (`strings.eqlCaseInsensitiveASCII`)
compared against NUL-terminated comptime literals, so `strncasecmp`
stopped at the sentinel and reported a mismatch whenever `a` was longer
than `b`. Rust byte-string literals carry no terminator, so the An+B
parser's ident branch (`parse_nth`), which compares an arbitrary user
ident against the keywords `"even" / "odd" / "n" / "-n" / "n-" / "-n-"`
with the ignore-length variant, reads past the end of the keyword
literal as soon as the ident is longer than the keyword and shares its
prefix (`Nn` vs `n`, `n-3` vs `n`, …). Besides the OOB read, the
comparison result depended on whatever byte happens to follow the
literal in rodata.
**Fix**
Reject `b.len() < a.len()` up front in `eql_case_insensitive_ascii`
before calling `strncasecmp` — the same result the NUL sentinel produced
in Zig, so observable behavior is unchanged for every in-bounds input
(all other callers of the ignore-length variant already pass
equal-length slices). `strncasecmp` now only ever reads within both
slices.
**Verification**
- `bun bd test test/js/bun/css/nth-anplusb-ident.test.ts` without the
fix (src/ stashed): aborts with the ASAN global-buffer-overflow above.
- With the fix: passes. The new test covers valid `n-<digits>` idents
that are longer than the `n`/`n-` keywords (`:nth-child(n-3)`,
`:nth-child(N-3)`, `:nth-last-child(n- 42)`), keyword case-insensitivity
(`:nth-child(N)`), an invalid ident (`:nth-child(NN)` → parse error),
and the exact fuzzer-minimized input run in a subprocess.
- `bun bd test test/js/bun/css/css.test.ts`: 1032 pass, 0 fail (no
behavior change for the existing suite).
- A second fuzz report hits the same overflow through `Bun.build` with a
CSS entrypoint containing `:nth-child(Nn`; that path goes through the
same `parse_nth` comparison and is covered by this fix (`Bun.build` now
reports a parse error instead of aborting).
- The `build-rust` CI failures on this PR (unused label / unnecessary
`unsafe` warnings in `src/spawn`, `src/install`, `src/crash_handler`,
`src/runtime/ffi`, `src/runtime/dns_jsc`) are present on current `main`
commits that don't include this change and come from files this PR
doesn't touch.
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…letes mid-read (#31959) [publish images] Fixes a use-after-free in the HTTP client's proxy tunnel close path (Sentry BUN-2VY8, ~10 events/day on Windows release builds; reproduces deterministically under ASAN on all platforms). ## Repro `fetch()` through an HTTP CONNECT proxy to an HTTPS origin, where the origin's final response bytes and its TLS `close_notify` reach the client in a single TCP batch (origin writes the response and immediately closes). The regression test builds exactly that: a local CONNECT proxy that holds origin-to-client bytes after the handshake and flushes session tickets + response + close_notify in one write. On an unfixed ASAN build: ``` ERROR: AddressSanitizer: heap-use-after-free READ of size 8 thread T11 (HTTP Client) #0 Option<RefPtr<ProxyTunnel>>::as_ref #1 bun_http::proxy_tunnel::on_close src/http/ProxyTunnel.rs:525 #2 SSLWrapper<*mut HTTPClient>::trigger_close_callback src/uws/lib.rs:802 #3 SSLWrapper<*mut HTTPClient>::handle_reading src/uws/lib.rs:1022 #4 SSLWrapper<*mut HTTPClient>::handle_traffic #5 SSLWrapper<*mut HTTPClient>::receive_data #6 ProxyTunnel::receive src/http/ProxyTunnel.rs:751 freed by: AsyncHTTP::on_async_http_callback_raw src/http/AsyncHTTP.rs:813 HTTPClient::send_progress_update_without_stage_check src/http/lib.rs:3793 ``` ## Cause 1. `handle_reading` processes the batch: `SSL_read` returns the body bytes, the next `SSL_read` hits `close_notify` (`SSL_ERROR_ZERO_RETURN`), which sets `received_ssl_shutdown` and `sent_ssl_shutdown` before flushing the already-decrypted bytes through the data callback. 2. The data callback completes the response. The done path runs `close_proxy_tunnel(true)` -> `ProxyTunnel::shutdown()` -> `SSLWrapper::shutdown(true)`, which hits the already-shut-down early return (`sent_ssl_shutdown || fatal_error`) and returns **without setting `closed_notified`**. The result callback then frees the `ThreadlocalAsyncHTTP` embedding the `HTTPClient`, the exact pointer stored in the wrapper's `handlers.ctx`. 3. Control returns to `handle_reading`. Its liveness guard (`ssl.is_none() || closed_notified()`) passes because neither is set, so `trigger_close_callback()` invokes `on_close(handlers.ctx)` on the freed client. When the allocation has been recycled, `on_close` can ref or close a different request's tunnel instead of faulting. ## Fix `src/uws/lib.rs`: when `SSLWrapper::shutdown(fast_shutdown=true)` takes the already-shut-down early return, fire `trigger_close_callback()` (idempotent via `closed_notified`) so the wrapper is marked closed before the owner detaches and frees `handlers.ctx`. A fast shutdown is a full teardown, and the normal fast-shutdown path already fires the close callback unconditionally; this only closes the gap where the SSL-level shutdown had already happened. Graceful `shutdown(false)` (node:tls half-close via UpgradedDuplex / WindowsNamedPipe) is unchanged, so reads after a sent `close_notify` keep working. ## Verification New test in `test/js/bun/http/proxy.test.ts` (`test.skipIf(!isASAN)`, the UAF is only deterministic under ASAN): fails on an unfixed ASAN debug build with the heap-use-after-free above, passes with the fix. Full `proxy.test.ts` (46 tests) plus `node-tls-connect`, `node-tls-upgrade`, `node-tls-duplex-close-throw-uaf`, `node-tls-socket-allow-half-open-option`, `node-tls-server`, `fetch-tls-cert`, and `node-https-checkServerIdentity` suites pass. ## Note on the asan-lane CI failure (#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 #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 (e59bc1d0) 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 #32146's guard prevents, so it was reverted (61f9e701). This PR is scoped to the proxy-tunnel UAF; its asan lane can still intermittently hit the pre-existing #32144 leak until #32146 lands. ## Related PRs - #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. - #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>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…e re-enters the event loop (#32597)
Sentry BUN-2WJA / BUN-2WKB (~290 events combined, Windows x86_64,
`http_server=True`, bun 1.2.23 through 1.3.14):
```
Segmentation fault at address 0xFFFFFFFFFFFFFFFF
endWithSink src/runtime/webcore/Sink.zig:577
endFromJS src/runtime/webcore/streams.zig:1200
finalize src/runtime/webcore/streams.zig:1301
clearAndFree src/collections/baby_list.zig:148
memset (fault at 0xFFFFFFFFFFFFFFFF)
```
## Cause
The generated `JSReadable*Controller` `end()` and `close()` host
functions (`src/codegen/generate-jssink.ts`) stash `m_sinkPtr` in a
local, call `controller->detach()`, and only afterward dereference the
stashed pointer via `endWithSink()` / `${name}__close()`:
```cpp
void *ptr = controller->wrapped();
controller->detach(); // runs onClose JS synchronously
return ${name}__endWithSink(ptr, lexicalGlobalObject); // derefs ptr
```
`detach()` invokes the stored `onClose` callback. For a `type: "direct"`
stream this is `readDirectStream`'s `close(stream, reason)`, which calls
`underlyingSource.cancel()`. That is arbitrary user code running while
`ptr` is still live on the C++ stack.
If the stream's `pull()` promise has already settled,
`RequestContext::on_resolve_stream` is sitting in the microtask queue.
Any path from `cancel()` that drains microtasks (e.g. the server-side
drain points in `on_response` / `do_render_with_body`, or an explicit
`drainMicrotasks()`) runs `handle_resolve_stream`, which calls
`destroy_sink` and frees the `HTTPServerWritable`. `endWithSink(ptr)`
then enters `end_from_js` on the freed allocation; `finalize()` reads
garbage for `pooled_buffer` / `buffer.cap` / `buffer.ptr` and faults in
the `memset` the allocator's free-scrub path performs.
The same ordering appears in the Rust port (`streams.rs` / `Sink.rs`)
unchanged.
## Fix
In `${controller}__end` and `${controller}__close`, finish the native
sink operation before any JS runs:
1. Call `${name}__controllerDetached(ptr, controller)` and null
`m_sinkPtr` up front (so `end_from_js`'s own `signal.close()` stays a
no-op, matching the previous behaviour, and so the later `detach()`
won't touch the native side again).
2. Run `endWithSink(ptr)` / `close(ptr)`.
3. Call `controller->detach()` last. With `m_sinkPtr` already null it
only clears `m_onPull` and fires `onClose`; by now we hold no reference
into the sink, so re-entrant teardown is safe.
## Verification
New ASAN-gated test in
`test/js/bun/http/serve-direct-readable-stream.test.ts` reproduces the
exact UAF deterministically by draining microtasks from the stream's
`cancel()` callback (the test uses
`require("bun:jsc").drainMicrotasks()` to force the drain that the
production crash hits via the server's own drain points).
<details>
<summary>ASAN output on the unfixed build</summary>
```
==22203==ERROR: AddressSanitizer: heap-use-after-free on address 0x6ee5f87602ca
READ of size 1 at 0x6ee5f87602ca thread T0
#0 HTTPServerWritable::end_from_js src/runtime/webcore/streams.rs:1831
#2 JSSink::js_end_with_sink src/runtime/webcore/Sink.rs:1107
#4 WebCore::JSReadableHTTPResponseSinkController__end JSSink.cpp:620
freed by thread T0 here:
#10 HTTPServerWritable::destroy src/runtime/webcore/streams.rs:1950
#11 RequestContext::destroy_sink src/runtime/server/RequestContext.rs:1930
#12 RequestContext::handle_resolve_stream src/runtime/server/RequestContext.rs:2680
#13 RequestContext::on_resolve_stream src/runtime/server/RequestContext.rs:2716
...
#24 JSC::VM::drainMicrotasks()
```
</details>
With the fix the fixture completes normally. Existing suites
(`serve.test.ts`, `bun-server.test.ts`,
`direct-readable-stream.test.tsx`, `streams.test.js`, the sink leak
tests) show no new failures against the unfixed build.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
… (#32742) ### What does this PR do? Fixes a use-after-free in the HTTP client's CONNECT proxy tunnel, caught by ASAN: ``` READ of size 8 at 0x61e00001fe80 thread T6 #0 Option<RefPtr<ProxyTunnel>>::as_ref #1 proxy_tunnel::on_close ProxyTunnel.rs:525 #2 SSLWrapper::trigger_close_callback uws/lib.rs:833 #3 SSLWrapper::handle_reading uws/lib.rs:1053 ... freed by thread T6 here (same stack, same `handle_reading` call): #5 AsyncHTTP::on_async_http_callback_raw AsyncHTTP.rs:819 #7 HTTPClient::send_progress_update_without_stage_check #9 proxy_tunnel::on_data ProxyTunnel.rs:350 #11 SSLWrapper::trigger_data_callback uws/lib.rs:824 #12 SSLWrapper::handle_reading uws/lib.rs:1046 ``` `SSLWrapper::handle_reading` flushes pending decrypted bytes to the data callback, then runs the close callback, guarded only by `closed_notified`: 1. The flushed data callback completes a keep-alive response through the tunnel. A fatal TLS record error sets only `fatal_error` — none of the shutdown flags — so the wrapper passed `tunnel_poolable`'s `!is_shutdown()` check and the tunnel was handed to the keep-alive pool. Nothing called `wrapper.shutdown()`, so `closed_notified` was never latched. Dispatching the final result then freed the `ThreadlocalAsyncHTTP` that embeds the `HTTPClient`. 2. The guard (`ssl.is_none() || closed_notified()`) passes. 3. `trigger_close_callback()` invokes `on_close(handlers.ctx)` with `ctx` pointing at the freed client. The pooling branch is the only terminal path that doesn't go through `close_proxy_tunnel(true)` → `wrapper.shutdown()` → `closed_notified`, which is the latch the read loop relies on. `SSLWrapper::shutdown` already special-cases the *close_notify* flavor of this for exactly that reason; the fatal-error flavor never reaches `shutdown()`. The fix is one predicate: a tunnel whose wrapper has a fatal error or pending unconsumed input/output is not poolable. That routes it through the orderly teardown that latches `closed_notified`, and the pending-I/O half closes the same hole for a tunnel pooled from a mid-loop data callback while more decrypted bytes or queued output remain. Both are also required for the pool to be correct on its own terms — a poisoned or dirty TLS session must not be handed to the next request. ### How did you verify your code works? New regression test in `test/js/bun/http/proxy.test.ts` (next to the existing close_notify sibling): an HTTPS keep-alive response through a CONNECT proxy with a corrupt TLS record appended to the same TCP burst, followed by a second request that can only complete if the HTTP client thread survived the first. Against an unfixed ASAN debug build the fixture aborts every run: ``` ==20981==ERROR: AddressSanitizer: heap-use-after-free on address 0x61e00001fe80 READ of size 8 at 0x61e00001fe80 thread T6 ... exit=134 ``` With this change it prints `4096 200 200` and exits 0 with no ASAN report. `test/js/bun/http/proxy.test.ts` (49/49), `fetch-proxy-connect-tunnel-split-envelope.test.ts`, `fetch-proxy-tls-intern-race.test.ts`, and `fetch-keepalive.test.ts` all pass.
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
… (#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 #4 PosixBufferedReader::read_socket PipeReader.rs:576 #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>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…ll-driven read (#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 #32754, which fixed the same failure on the eager spawn-time read path. ``` ERROR: AddressSanitizer: heap-use-after-free READ of size 1, thread T0 #0 <bun_io::pipe_reader::BufferedReaderVTable>::link io/PipeReader.rs:105 #1 <bun_io::pipe_reader::BufferedReaderVTable>::on_read_chunk io/PipeReader.rs:125 #2 <bun_io::pipe_reader::PosixBufferedReader>::read_with_fn io/PipeReader.rs:890 #3 <bun_io::pipe_reader::PosixBufferedReader>::read_socket io/PipeReader.rs:576 #4 <bun_io::pipe_reader::PosixBufferedReader>::on_poll io/PipeReader.rs:529 #5 __bun_run_file_poll runtime/dispatch.rs:677 freed by: <alloc::sync::Arc<bun_runtime::shell::subproc::PipeReader>>::drop ``` ## Repro 1. `PipeReader::start` registers the poll and the eager spawn-time `read_all()` hits `EAGAIN`, so `read_with_fn`'s `EAGAIN` arm re-registers the poll and the spawn returns. 2. The child writes to stdout and the poll fires. `__bun_run_file_poll`'s `BUFFERED_READER` arm dispatches straight into `PosixBufferedReader::on_poll` with a bare `&mut *h` and no keepalive. 3. `read_with_fn` drains the chunk, `recv()` returns a real `EAGAIN`, and `register_poll()` issues another `epoll_ctl`, which fails (`ENOMEM` in the repro). 4. `register_poll` dispatches `on_reader_error`. The shell `PipeReader::on_reader_error` signals the `Cmd`, the `Readable::Pipe` `Arc` is dropped, and the callback's own `guard_from_raw` keepalive becomes the last reference. The code already documents this: "Dropping `guard` is the matching `deref()`; may free `this`." 5. Back in `read_with_fn`, the `EAGAIN` arm still delivers the drained head: `parent.vtable.on_read_chunk(.., ReadState::Drained)` reads the freed vtable. Traced with the test's `LD_PRELOAD` shim: ``` [shim] epoll_ctl(ADD fd=13) unix call#1 -> ok PipeReader::start [shim] recv(fd=13) unix call#1 -> EAGAIN eager read, inside spawn [shim] epoll_ctl(MOD fd=13) unix call#2 -> ok re-register; spawn returns [shim] recv(fd=13) unix call#2 poll fired: the child's bytes [shim] recv(fd=13) unix call#3 real EAGAIN [shim] epoll_ctl(MOD fd=13) unix call#3 -> ENOMEM register_poll fails [shell_subproc] PipeReader(0x..250) onReaderError errno: 12 [shell_subproc] PipeReader(0x..250, stdout) detach() [shell_subproc] PipeReader(0x..250, stdout) deinit() ==ERROR: AddressSanitizer: heap-use-after-free ``` ## Cause `register_poll()`'s failure path dispatches `on_reader_error`, which the `BufferedReaderParent` contract explicitly allows to free the parent, but `register_poll` gave the caller no way to know that happened. `read_with_fn`'s `EAGAIN` arm is the only call site that touches the reader afterwards; every other `register_poll()` is in tail position. The `SAFETY` comment above the `parent` rebind claimed the parent is "never freed mid-call", which holds for `on_read_chunk` re-entry but not for `on_reader_error`. #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.
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…low-priority queue (#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>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…ed (#33016)
A backend message that fails the connection can share a TCP read with
messages that follow it. `PostgresRequest::on_data`'s message loop had
no bail-out once `fail()` had run, so the trailing messages in that read
kept being dispatched against the already-failed connection.
### Repro
A mock backend that answers the StartupMessage with one write carrying
two messages:
```
R int32(8) int32(99) Authentication, unrecognized type
Z int32(5) 'I' ReadyForQuery
```
```ts
const sql = new SQL({ url: `postgres://u@127.0.0.1:${port}/db`, max: 1, idleTimeout: 1, connectionTimeout: 5 });
await sql`select 1`.catch(() => {});
await Bun.sleep(1600);
```
### Cause
The unrecognized `Authentication` type calls `fail()`, which sets the
status to `Failed`, closes the socket, and rejects the pending requests,
but the message loop keeps going and dispatches the `ReadyForQuery` from
the same read. That calls `set_status(Status::Connected)`, which has no
guard against leaving `Failed`, so the dead connection is flipped back
to `Connected` and the `on_data` epilogue re-arms its idle timer.
uSockets frees a closed `us_socket_t` at the end of the event-loop
iteration, so when the timer later fires, `ref_and_close` reads the
freed socket:
```
ERROR: AddressSanitizer: heap-use-after-free
READ of size 1 at 0x71f2125605d2 thread T0
#0 us_socket_is_closed packages/bun-usockets/src/socket.c:143:21
#4 PostgresSQLConnection::ref_and_close src/sql_jsc/postgres/PostgresSQLConnection.rs:1528:31
#5 PostgresSQLConnection::fail_with_js_value src/sql_jsc/postgres/PostgresSQLConnection.rs:726:14
#6 PostgresSQLConnection::fail_fmt src/sql_jsc/postgres/PostgresSQLConnection.rs:749:14
#7 PostgresSQLConnection::on_connection_timeout src/sql_jsc/postgres/PostgresSQLConnection.rs:557:14
#8 __bun_fire_timer src/runtime/dispatch.rs:1020:35
0x71f2125605d2 is located 18 bytes inside of 104-byte region
freed by thread T0 here:
#2 us_internal_free_closed_sockets packages/bun-usockets/src/loop.c:305:9
```
### Fix
- `PostgresRequest::on_data`: the message loop returns once the
connection's status is `Failed`. `fail()` is terminal; nothing after it
in the same read should be handled (a `DataRow`, `CommandComplete`, or
`ErrorResponse` in that position would be just as wrong as the
`ReadyForQuery`).
- `PostgresSQLConnection::set_status`: refuses to transition out of
`Failed`. The transition function owns that invariant; every other
consumer of `Status` (the timer interval, `update_has_pending_activity`,
the idempotency check in `fail_with_js_value`) already assumes `Failed`
is terminal.
### Verification
`test/js/sql/postgres-failed-connection-resurrection.test.ts` runs a
fixture against the mock backend above and lets it outlive the
idle-timer window. Without the fix the fixture dies with the ASan report
above; with it the fixture exits 0. Gated to ASan builds because the bug
is a read of freed memory, which release lanes do not detect.
The postgres fault-injection and integration suites still pass locally
(90 tests across `test/js/sql/postgres-*.test.ts`, `sql*.test.ts`,
`tls-sql.test.ts`).
### Related
- #32861 detaches the stored socket handle in `on_close` /
`on_connect_error` so nothing can dereference the freed `us_socket_t`
regardless of how the stale read is reached. It removes the last step of
this chain from the other end; this PR stops the failed connection from
being resurrected at all.
- #30950 guards the JS pool's `handleConnected` against the reverse
ordering within one read (a legitimately queued `onconnect` microtask
arriving after a synchronous `onclose`).
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…used (#33118)
### What does this PR do?
Fixes a silent failure in `Bun.serve`: when the `fetch` handler returns
a `Response` whose body has already been used (most commonly the same
`Response` object returned for more than one request), the request is
answered with a `200` and `Content-Length: 0`. Nothing is thrown,
nothing is logged, and `error()` is never invoked.
```js
const cached = new Response("cached-route-body");
Bun.serve({ fetch() { return cached; } });
// request #1: 200, body "cached-route-body"
// request #2+: 200, Content-Length: 0, empty body, error() never fires
```
Per the fetch model a disturbed body is an error, and Deno and workerd
both reject it loudly. Bun's static routes (`routes: { "/x": new
Response(...) }`) already throw "Response body has already been used"
for a used body at registration time; the dynamic path was the only
place a disturbed body got silently dropped.
Cause: `RequestContext::do_render_with_body` has explicit arms for
`Error` bodies, in-memory bodies, and `Locked` streams, but
`Body::Value::Used` fell into the catch-all `_ => {}` arm, which renders
the never-assigned context blob: a 200 with `Content-Length: 0`. This
affected string, `Uint8Array`, stream, and file bodied Responses alike,
and also a Response whose body the handler consumed (`await
response.text()`) before returning it.
Fix: add a `Body::Value::Used` arm that builds a `TypeError` (`code:
"ERR_BODY_ALREADY_USED"`, message `Response body already used. A
Response body can only be sent once; create a new Response for each
request.`) and routes it through `run_error_handler`, exactly like the
existing body-error and locked-stream arms. With an `error()` handler,
the handler receives the TypeError and its Response is sent. Without
one, the error is logged and the request gets the standard 500, the same
as any other error thrown from `fetch`. The existing
`has_called_error_handler` guard keeps an `error()` handler that itself
returns a used Response from recursing.
Static routes, fresh Responses per request, bodiless Responses, and HEAD
rendering are unchanged; only the `Used` (disturbed) body state is
affected.
### How did you verify your code works?
`test/js/bun/http/serve-reused-response.test.ts`:
- the same string, `Uint8Array`, and stream bodied Response returned
twice: the first request gets the body, later ones invoke `error()` with
the TypeError (`code: "ERR_BODY_ALREADY_USED"`) and receive its response
- a Response consumed with `.text()` before being returned from an async
handler: same error
- no `error()` handler (`development: false`): the client gets 500
`"Something went wrong!"`, the error is printed to stderr, and the
process exit code matches other unhandled fetch errors
- fresh Responses and a static-route Response reused across requests
keep working and never call `error()`
The already-used cases fail on current `bun test` (they observe the
empty 200s and zero `error()` calls) and pass with this change; the two
no-reuse tests pass both ways by design.
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
### Repro
```sh
printf '{"name":"x","version":"1.0.0"}' > package.json
bun pm pkg set 'contributors[0]=alice'
```
On a release build (1.4.0 and current `main`) this exits 0 and writes
freed heap bytes into `package.json` as the property key:
```json
{
"name": "x",
"version": "1.0.0",
"P\x01\x00\x00\x00tors": {
"\x00": "alice"
}
}
```
Depending on what was in the freed allocation the result is often not
valid JSON at all. Any `bun pm pkg set` key path containing `[index]`
hits it.
Under ASAN it is a deterministic `heap-use-after-free`:
```
ERROR: AddressSanitizer: heap-use-after-free
READ of size 1
#0 bun_js_printer::write_pre_quoted_string_inner src/js_printer/lib.rs:1014
#7 PmPkgCommand::save_package_json src/runtime/cli/pm_pkg_command.rs:909
freed by thread T0 here:
#7 <Box<[u8]> as Drop>::drop
#12 PmPkgCommand::set_value src/runtime/cli/pm_pkg_command.rs:661
previously allocated by thread T0 here:
#10 <Box<[u8]> as From<&[u8]>>::from
#11 PmPkgCommand::parse_key_path src/runtime/cli/pm_pkg_command.rs:583
```
<details>
<summary>full ASAN report</summary>
```
=================================================================
==16563==ERROR: AddressSanitizer: heap-use-after-free on address 0x73423c7c0670 at pc 0x00000f583cc5 bp 0x7fff2667e950 sp 0x7fff2667e948
READ of size 1 at 0x73423c7c0670 thread T0
#0 0x00000f583cc4 in _RINvCs59Hqei94dXF_14bun_js_printer29write_pre_quoted_string_innerINtB2_16StdWriterAdapterQINtB2_6WriterNtB2_12BufferWriterEEKVNtNtB2_8Encoding4Utf8UECsgBGN0jRPILJ_11bun_bundler /workspace/bun/src/js_printer/lib.rs:1014:79
#1 0x00000ebda439 in <bun_js_printer::__gated_printer::Printer<&mut bun_js_printer::Writer<bun_js_printer::BufferWriter>, false, false, false, true, false>>::print_string_characters_utf8 /workspace/bun/src/js_printer/lib.rs:2641:21
#2 0x00000ebdb7a7 in <bun_js_printer::__gated_printer::Printer<&mut bun_js_printer::Writer<bun_js_printer::BufferWriter>, false, false, false, true, false>>::print_string_characters_e_string /workspace/bun/src/js_printer/lib.rs:4546:22
#3 0x00000ebdb238 in <bun_js_printer::__gated_printer::Printer<&mut bun_js_printer::Writer<bun_js_printer::BufferWriter>, false, false, false, true, false>>::print_string_literal_e_string /workspace/bun/src/js_printer/lib.rs:3018:18
#4 0x00000ebd2be2 in <bun_js_printer::__gated_printer::Printer<&mut bun_js_printer::Writer<bun_js_printer::BufferWriter>, false, false, false, true, false>>::print_property /workspace/bun/src/js_printer/lib.rs:4807:34
#5 0x00000ebc0166 in <bun_js_printer::__gated_printer::Printer<&mut bun_js_printer::Writer<bun_js_printer::BufferWriter>, false, false, false, true, false>>::print_expr /workspace/bun/src/js_printer/lib.rs:3962:38
#6 0x00000ee574c1 in bun_js_printer::print_json::<&mut bun_js_printer::Writer<bun_js_printer::BufferWriter>> /workspace/bun/src/js_printer/lib.rs:8071:13
#7 0x00000c05c270 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::save_package_json /workspace/bun/src/runtime/cli/pm_pkg_command.rs:909:25
#8 0x00000c060cfb in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec_set /workspace/bun/src/runtime/cli/pm_pkg_command.rs:330:13
#9 0x00000c05d333 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec /workspace/bun/src/runtime/cli/pm_pkg_command.rs:73:32
#10 0x00000bf919d0 in <bun_runtime::cli::package_manager_command::PackageManagerCommand>::exec /workspace/bun/src/runtime/cli/package_manager_command.rs:704:13
#11 0x00000c3fbb87 in bun_runtime::cli::command::exec_pm /workspace/bun/src/runtime/cli/mod.rs:1591:34
#12 0x00000c3f2b86 in bun_runtime::cli::command::start /workspace/bun/src/runtime/cli/mod.rs:1309:43
#13 0x00000bfad16c in bun_runtime::cli::cli::start /workspace/bun/src/runtime/cli/mod.rs:573:27
#14 0x00000bb3c034 in main /workspace/bun/src/bun_bin/lib.rs:230:5
#15 0x77223ccc7ca7 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
#16 0x77223ccc7d64 in __libc_start_main csu/../csu/libc-start.c:360:3
#17 0x0000099d1d1d in __wrap___libc_start_main /workspace/bun/build/debug/../../src/jsc/bindings/workaround-missing-symbols.cpp:487:12
0x73423c7c0670 is located 0 bytes inside of 12-byte region [0x73423c7c0670,0x73423c7c067c)
freed by thread T0 here:
#0 0x000007ae192a in free crtstuff.c
#1 0x00000bb3c5a7 in <std::alloc::System as core::alloc::global::GlobalAlloc>::dealloc /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/sys/alloc/unix.rs:48:18
#2 0x00000bb3be9a in __rustc::__rust_dealloc /workspace/bun/src/bun_bin/lib.rs:56:15
#3 0x00001258b05f in alloc::alloc::dealloc_nonnull /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:128:14
#4 0x0000125872fe in <alloc::alloc::Global>::deallocate_impl_runtime /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:229:22
#5 0x000012586364 in <alloc::alloc::Global>::deallocate_impl /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:344:9
#6 0x00001258d79c in <alloc::alloc::Global as core::alloc::Allocator>::deallocate /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:462:23
#7 0x000012582946 in <alloc::boxed::Box<[u8]> as core::ops::drop::Drop>::drop /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/boxed.rs:1956:24
#8 0x000012572e44 in core::ptr::drop_in_place::<alloc::boxed::Box<[u8]>> /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:809:1
#9 0x000011f8d429 in core::ptr::drop_in_place::<[alloc::boxed::Box<[u8]>]> /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:809:1
#10 0x00000ef6b73a in <alloc::vec::Vec<alloc::boxed::Box<[u8]>> as core::ops::drop::Drop>::drop /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:4258:13
#11 0x00000ef69e64 in core::ptr::drop_in_place::<alloc::vec::Vec<alloc::boxed::Box<[u8]>>> /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:809:1
#12 0x00000c061846 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::set_value /workspace/bun/src/runtime/cli/pm_pkg_command.rs:661:5
#13 0x00000c061038 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec_set /workspace/bun/src/runtime/cli/pm_pkg_command.rs:325:13
#14 0x00000c05d333 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec /workspace/bun/src/runtime/cli/pm_pkg_command.rs:73:32
#15 0x00000bf919d0 in <bun_runtime::cli::package_manager_command::PackageManagerCommand>::exec /workspace/bun/src/runtime/cli/package_manager_command.rs:704:13
#16 0x00000c3fbb87 in bun_runtime::cli::command::exec_pm /workspace/bun/src/runtime/cli/mod.rs:1591:34
#17 0x00000c3f2b86 in bun_runtime::cli::command::start /workspace/bun/src/runtime/cli/mod.rs:1309:43
#18 0x00000bfad16c in bun_runtime::cli::cli::start /workspace/bun/src/runtime/cli/mod.rs:573:27
#19 0x00000bb3c034 in main /workspace/bun/src/bun_bin/lib.rs:230:5
#20 0x77223ccc7ca7 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
previously allocated by thread T0 here:
#0 0x000007ae1bc8 in malloc crtstuff.c
#1 0x00000bb3c520 in <std::alloc::System as core::alloc::global::GlobalAlloc>::alloc /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/sys/alloc/unix.rs:14:22
#2 0x00000bb3be30 in __rustc::__rust_alloc /workspace/bun/src/bun_bin/lib.rs:56:15
#3 0x00001258b335 in alloc::alloc::alloc /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:101:9
#4 0x000012586b81 in <alloc::alloc::Global>::alloc_impl_runtime /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:210:73
#5 0x0000125862b6 in <alloc::alloc::Global>::alloc_impl /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:332:9
#6 0x00001258d86a in <alloc::alloc::Global as core::alloc::Allocator>::allocate /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/alloc.rs:449:14
#7 0x00001257dbd3 in <alloc::boxed::Box<[u8]>>::try_clone_from_ref_in /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/boxed.rs:881:29
#8 0x00001257da49 in <alloc::boxed::Box<[u8]>>::clone_from_ref_in /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/boxed.rs:840:15
#9 0x00001257d3f4 in <alloc::boxed::Box<[u8]>>::clone_from_ref /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/boxed.rs:793:9
#10 0x000012581e34 in <alloc::boxed::Box<[u8]> as core::convert::From<&[u8]>>::from /root/.rustup/toolchains/nightly-2026-05-06-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/boxed/convert.rs:77:9
#11 0x00000c05a47f in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::parse_key_path /workspace/bun/src/runtime/cli/pm_pkg_command.rs:583:37
#12 0x00000c061608 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::set_value /workspace/bun/src/runtime/cli/pm_pkg_command.rs:643:30
#13 0x00000c061038 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec_set /workspace/bun/src/runtime/cli/pm_pkg_command.rs:325:13
#14 0x00000c05d333 in <bun_runtime::cli::pm_pkg_command::PmPkgCommand>::exec /workspace/bun/src/runtime/cli/pm_pkg_command.rs:73:32
```
</details>
### Cause
`parse_key_path` returned a `Vec<Box<[u8]>>`, and `set_value` /
`set_nested` inserted those boxed segments into the manifest AST by
reference: `E::Object::put` constructs `EString::init(key)`, whose
documented contract is that `key` is arena-owned (it records the slice,
it does not copy it). The vector is a local of `set_value`, so it
dropped before `exec_set` reached `save_package_json`, and the JSON
printer then read the dangling keys.
The non-bracket path in `set_value` did not have the bug: it borrowed
its segments straight out of the argv key, which outlives the whole
command. The bracket path differed only by the unnecessary boxing.
### Fix
`parse_key_path` now returns `Vec<&[u8]>`. Every segment is a literal
sub-slice of the input key, so nothing ever needed owning. With the
boxing gone, `set_value`'s separate non-bracket branch and its
`set_nested_simple` helper (which existed only to avoid the allocation)
were exact duplicates of the bracket path, so they are deleted and all
keys route through `parse_key_path` + `set_nested`.
`set_nested_simple`'s trailing `root.put(current_key, nested)` was a
no-op: `ExprData::EObject` is a `StoreRef` handle, so mutating the copy
returned by `root.get()` already mutates the stored object, and the put
re-stores the same handle. Dropping it with the function changes nothing
(and the prior bracket path, `set_nested`, never had it).
Intentionally not changed here: `set 'contributors[0]=alice'` produces
`"contributors": {"0": "alice"}`, an object keyed by the digit string,
rather than the array npm's `pkg set` creates, and `set 'array[]=x'`
still errors with `InvalidPath` instead of appending. Both are the npm
compat gap tracked in #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).
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…3242)
### What
After a 3xx redirect, `handle_response_metadata` rewrites per-hop
request state on the HTTP-thread clone of the `AsyncHTTP`:
- `client.url` (and `connected_url`) become a self-borrow into
`client.redirect`, a `Vec<u8>` the clone owns and frees in the
final-callback teardown (`AsyncHTTP::on_async_http_callback_raw`).
- On a cross-origin hop,
`Authorization`/`Proxy-Authorization`/`Cookie`/`Host` are removed from
`client.header_entries` in place.
- The method may be downgraded to GET.
`NetworkTask::notify`'s bitwise copy-back (`ptr::write(real,
ptr::read(async_http))`) carries all of that into the JS-thread
`AsyncHTTP`. When `bun install` retries the task after a retryable
failure (5xx or a connection reset on the redirect target), the
re-scheduled request therefore:
1. connects through the freed redirect buffer (use after free), and
2. if the redirect was cross-origin, goes out without `Authorization`,
so an authorized registry answers 401.
ASAN (debug build), deterministic on the first try:
```
ERROR: AddressSanitizer: heap-use-after-free ... thread T1 (HTTP Client)
READ of size 1
#0 bun_core::fmt::parse_int::<u16> src/bun_core/fmt.rs:929
#1 <bun_url::URL>::get_port src/url/lib.rs:470
#2 <bun_url::URL>::get_port_auto src/url/lib.rs:474
#3 <bun_http::http_thread::HttpThread>::connect src/http/HTTPThread.rs:602
#4 <bun_http::HTTPClient>::start_ src/http/lib.rs:2635
#6 <bun_http::async_http::AsyncHTTP>::on_start src/http/AsyncHTTP.rs:893
freed by thread T1 (HTTP Client):
<bun_http::async_http::AsyncHTTP>::on_async_http_callback_raw src/http/AsyncHTTP.rs:774
previously allocated by thread T1 (HTTP Client):
<bun_http::HTTPClient>::handle_response_metadata src/http/lib.rs:5038
```
On a release build the same sequence does not crash, but the retries
never reach the server (each one connects through freed memory) and the
install fails.
### Repro
A scripted registry where the manifest URL 302-redirects and the
redirect target answers a 500 once, then the real packument:
```
GET /BaR -> 302 Location: /redirected/BaR
GET /redirected/BaR -> 500 on the first hit, then the packument
GET /BaR-0.0.2.tgz -> tarball
```
`bun install` against it aborts under ASAN and fails on release. Any
301/302/307/308 and 1- or 2-hop chains hit the same path. With an
authorized registry that redirects cross-origin (the common Artifactory
/ CodeArtifact / GitHub Packages shape), the retry also loses
`Authorization`; that variant fails with `GET <registry>/BaR - 401` even
once the URL is fixed.
### Fix
`src/http/AsyncHTTP.rs`: the `!has_more` teardown block already releases
every clone-owned allocation. Before freeing `client.redirect`, restore
the per-hop state that a re-scheduled attempt must not inherit:
- `client.url` back to the caller-owned pre-redirect URL
(`AsyncHTTP.url`, which borrows memory valid for the original's whole
lifetime), and `client.connected_url` (which `connect` derives from it)
to default.
- `client.header_entries` back to the untouched
`AsyncHTTP.request_headers`. The list is bitwise-shared with the
JS-thread original, so it must not be dropped or reallocated on the HTTP
thread; it was cloned from `request_headers` at init and only ever
shrinks, so `clear_retaining_capacity()` +
`append_list_assume_capacity()` restores it in place.
- `client.method` back to `AsyncHTTP.method`.
Nothing that crosses back to the JS thread references clone-freed memory
anymore, and a retried request restarts from the original URL with the
original headers instead of the last redirect hop's, which is what the
install-level retry is meant to do.
### Tests
`test/cli/install/bun-install-retry.test.ts`:
- `retries a manifest whose redirect target 500s once`
- `retries a tarball whose redirect target 500s once` (the sibling retry
site in `runTasks`)
- `retries an authorized manifest whose cross-origin redirect target
500s once` (also asserts the cross-origin hop itself still does NOT
carry `Authorization`, so the spec-mandated strip is unchanged)
All three fail on the unfixed build (ASAN abort under `bun bd`, install
error with `USE_SYSTEM_BUN=1`). The third additionally fails with a 401
if only the URL is restored and not the headers, so each restore is
load-bearing. `test/js/web/fetch/fetch-redirect.test.ts` and
`fetch-url-after-redirect.test.ts` still pass, so `response.url` after a
redirect is unaffected (it comes from the owned `metadata.url` copy, not
from `client.url`).
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…ipeReader::on_read_chunk fails (#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 #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`. #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).
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…buffer cannot be allocated (#33326)
Fixes a `Segmentation fault at address 0x00000040` (sometimes
`0x00000030`) reported from Windows x64 builds, crashing inside
boringssl's record copy from uSockets' TLS read loop:
```
memcpy src/vctools/crt/vcruntime/src/string/amd64/memcpy.asm
bssl::OPENSSL_memcpy vendor/boringssl/crypto/internal.h:868
SSL_peek vendor/boringssl/ssl/ssl_lib.cc:947
SSL_read vendor/boringssl/ssl/ssl_lib.cc:918
us_internal_ssl_on_data packages/bun-usockets/src/crypto/openssl.c:1797
us_internal_dispatch_ready_poll packages/bun-usockets/src/loop.c:600
uv__fast_poll_process_poll_req vendor/libuv/src/win/poll.c:208
uv_run vendor/libuv/src/win/core.c:737
```
## Cause
`us_internal_init_loop_ssl_data` (`openssl.c:677`) allocates one 512 KiB
plaintext buffer per event loop, lazily, on the loop's first TLS socket,
and never checked the result:
```c
loop_ssl_data->ssl_read_output =
us_malloc(LIBUS_RECV_BUFFER_LENGTH + LIBUS_RECV_BUFFER_PADDING * 2);
```
With `ssl_read_output == NULL`, every later `SSL_read` hands boringssl
```c
loop_ssl_data->ssl_read_output + LIBUS_RECV_BUFFER_PADDING + read
```
as its plaintext destination, so the first record of application data
memcpy's to `NULL + 32`. `SSL_peek`'s `OPENSSL_memcpy(buf, ...)` at
`ssl_lib.cc:947` is the write, and the access violation confirms it is a
write fault.
`0x30`/`0x40` rather than `0x20` is memcpy's destination-alignment
preamble (`dst += VEC_SIZE; dst &= ~(VEC_SIZE - 1)`), which moves the
first faulting store for copies larger than eight vector registers.
Measured on the copy sizes a real TLS record produces:
| memcpy variant | first faulting store for `dst = NULL + 32` |
| --- | --- |
| 32-byte vectors (AVX) | `0x40` |
| 16-byte vectors (SSE) | `0x30` |
So the two strikingly stable fault addresses are just CPU dispatch
across the affected machines, and `read` is always `0`: the crash is
always the connection's first record of application data.
Only Windows reports it because Linux and macOS overcommit, so a 512 KiB
`malloc` there effectively never returns NULL. Windows fails the commit
cleanly, and the loop's much smaller `us_calloc` still succeeds out of
an already-committed page, leaving exactly the observed shape: a valid
`loop_ssl_data` whose `ssl_read_output` is NULL.
## Fix
- Null-check the buffer allocation, the `us_calloc` of `loop_ssl_data`,
and the `BIO_meth_new`/`BIO_new` calls beside them, and route the
failure through Bun's out-of-memory crash path (`Bun__outOfMemory`, new
C entry point next to `Bun__panic`). The process now dies with `Bun ran
out of memory` and a stack trace that names the allocation, instead of
faulting on the first TLS byte.
- Apply the same check to the sibling site: `recv_buf`/`send_buf` in
`us_internal_loop_data_init` are the same unchecked
`malloc(LIBUS_RECV_BUFFER_LENGTH + LIBUS_RECV_BUFFER_PADDING * 2)`. A
NULL `recv_buf` does not fault, it makes every read on the loop fail
with `EFAULT` for the life of the process, which is worse to diagnose.
- `us_internal_free_loop_ssl_data` left `loop->data.ssl_data` dangling,
which defeats the `if (!loop->data.ssl_data)` guard the init function
relies on. It now clears the field.
A 512 KiB `malloc` effectively never returns NULL on an overcommitting
kernel, so the failure path needs the existing socket fault injector to
be reachable from a test. This adds an `ssl_loop_buffer` rule to it,
which like the rest of the injector is compiled out of release builds.
## Verification
The new test spawns a child that arms `ssl_loop_buffer` before its first
TLS socket and asserts it reports out of memory rather than reaching a
read loop.
Reverting only `if (!loop_ssl_data->ssl_read_output)
Bun__outOfMemory();` reproduces the reported crash exactly, on Linux,
from that same fixture: same fault address, same frames, same boringssl
source lines.
<details>
<summary>Reproduction on the unfixed build (<code>bun bd</code>,
ASAN)</summary>
```
==19592==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000040
==19592==The signal is caused by a WRITE memory access.
==19592==Hint: address points to the zero page.
#0 __memcpy_evex_unaligned_erms
#1 bssl::OPENSSL_memcpy(void*, void const*, unsigned long) vendor/boringssl/crypto/internal.h:868:10
#2 SSL_peek vendor/boringssl/ssl/ssl_lib.cc:947:3
#3 SSL_read vendor/boringssl/ssl/ssl_lib.cc:918:13
#4 us_internal_ssl_on_data packages/bun-usockets/src/crypto/openssl.c:1847:21
#5 us_internal_dispatch_ready_poll packages/bun-usockets/src/loop.c:625:38
```
With the fix:
```
panic(main thread): Bun ran out of memory
Bun__outOfMemory src/bun_bin/phase_c_exports.rs:81:5
us_internal_init_loop_ssl_data packages/bun-usockets/src/crypto/openssl.c:696:42
us_internal_ssl_attach packages/bun-usockets/src/crypto/openssl.c:1275:3
```
</details>
`test/js/node/tls/tls-syscall-fault.test.ts` (11 pass),
`test/js/bun/util/socket-fault-injection.test.ts` (15 pass), plus
`socket-syscall-fault`, `serve-syscall-fault` and `fetch-syscall-fault`
(19 pass) are green. The three failures in `test/js/node/tls/` on this
machine are pre-existing: two also fail on an unmodified 1.4.0, and
`tls.connect should ignore invalid NODE_EXTRA_CA_CERTS` takes 5.75s,
just over the 5s local default (CI triples the per-test timeout for ASAN
builds).
## Teardown audit
The report also asked whether a socket can reach
`us_internal_ssl_on_data` after its loop's SSL data has been freed.
`us_internal_free_loop_ssl_data` is only reachable from `us_loop_free`,
and the only loop Bun frees today is `SpawnSyncEventLoop`'s, which never
has a TLS socket attached (its `loop->data.ssl_data` is always NULL, so
the free is a no-op). So that is not the cause here.
It is worth noting separately that the libuv `us_loop_free`
(`eventing/libuv.c:201-206`) calls `us_internal_loop_data_free(loop)`
and *then* runs `uv_run(loop->uv_loop, UV_RUN_NOWAIT)`, which is a full
libuv iteration that can dispatch socket poll callbacks into the
just-freed `recv_buf` and `ssl_data`. The POSIX `us_loop_free`
(`eventing/epoll_kqueue.c:56-60`) has no such window. It is unreachable
today for the reason above, so it is left out of this PR rather than
changing loop teardown ordering without a test that can exercise it.
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…lementation (#33849)
### What
Five self-contained fixes (one commit each) for bugs in the C++
WebStreams implementation introduced by #33193, found while auditing the
rewrite. Two are memory-safety issues reachable from a few lines of user
JS in release builds; three are hangs/data loss via reentrancy. Each
commit ships regression tests verified to fail before the fix and pass
after.
---
### 1. Foreign-realm `newTarget` type-confuses the global object (all 10
constructors)
Every stream constructor's subclass slow path did
`uncheckedDowncast<JSDOMGlobalObject>(newTargetGlobalObject)`. A
`node:vm` context's global is a *sibling* class, so the downcast is an
invalid `static_cast` in release builds — `getDOMStructure` then reads
and writes structure caches past the end of the smaller allocation.
```js
const vm = require("node:vm");
const foreignFn = vm.runInContext("(function F(){})", vm.createContext({}));
Reflect.construct(ReadableStream, [], foreignFn); // asserts in debug; heap type confusion in release
```
The ten copy-pasted `structureForNewTarget` statics are replaced with
one shared template in `StreamConstructor.h` that `dynamicDowncast`s and
falls back to the constructor's own realm's cached Structure (per-VM
correct, unlike a process-global fallback).
### 2. `TransferArrayBuffer` left transferred buffers resizable
The spec defines TransferArrayBuffer as `ArrayBufferCopyAndDetach(O,
undefined, fixed-length)`, but `transferArrayBufferImpl` used
`ArrayBuffer::transferTo`, which carries `maxByteLength` across the
transfer. User JS reaching the stream-internal buffer through
`byobRequest.view.buffer` could `resize()` it, invalidating every byte
length the controller recorded:
```js
const rs = new ReadableStream({
type: "bytes",
pull(c) {
c.byobRequest.view.buffer.resize(0); // succeeded; must throw TypeError
c.enqueue(new Uint8Array(10)); // RELEASE_ASSERT → process abort, release builds included
},
});
await rs.getReader({ mode: "byob" }).read(
new Uint8Array(new ArrayBuffer(64, { maxByteLength: 1024 })),
);
```
A second variant (`resize(2)` + `respond()` with a remainder) made the
remainder-clone path `subspan` past the live length — an out-of-bounds
heap read whose bytes were delivered to a subsequent `read()`. Fix
mirrors JSC's own `arrayBufferCopyAndDetach` FixedLength slow path:
resizable sources are copied into a fixed-length block, then detached;
non-resizable buffers keep the zero-copy transfer. (The WPT streams
suite has no resizable-ArrayBuffer coverage, so tests are added.)
### 3. Bulk drain ran the user `pull()` before `ResetQueue`
`drainQueueEntriesInto` — behind `reader.readMany()` and the buffered
consumers (`text()`, `bytes()`, `Bun.readableStreamTo*`) — removed every
queue entry, ran the close/pull step, and only then reset the queue. A
chunk enqueued synchronously by that pull landed in the still-live queue
and was wiped by the reset; a `close()` in the same pull saw a
momentarily non-empty queue and never re-evaluated:
```js
let pulls = 0;
const rs = new ReadableStream({
start(c) { c.enqueue("A"); },
pull(c) { if (++pulls >= 2) { c.enqueue("B"); c.close(); } },
}, { highWaterMark: 2 });
await Bun.sleep(0);
await rs.text(); // hung forever (and "B" was silently destroyed); now resolves "AB"
```
The queue is now reset before the close/pull step. The pull *decision*
still runs against the pre-drain `[[queueTotalSize]]`, preserving the
existing readMany batching cadence (the `readMany batches the pipelined
pull's chunk` test still passes byte-for-byte).
### 4. Async iterator: reentrant `next()`/`return()` from a synchronous
`pull()`
The iterator published `m_ongoingPromise` only *after* running steps
that invoke the user `pull()` synchronously. A `return()` called from
inside that pull saw a stale non-pending ongoing promise, skipped the
chaining path, and released the reader under an in-flight read
(`ASSERT(reader->m_readRequests.isEmpty())` in debug):
```js
let it, phase = 0;
const rs = new ReadableStream({
pull(c) {
if (++phase === 2) { it.return("bye"); return new Promise(() => {}); }
},
}, { highWaterMark: 1 });
it = rs.values();
await null; await null; await null;
it.next(); // pull #2 fires synchronously and reenters via it.return() → assert/double release
```
The result promise is now published before any user JS can run — but
only when the current ongoing promise is not pending, so ongoing-settled
reactions never rewind the chain tail (queued `next()` calls still
resolve in call order; tests cover both properties).
### 5. `TextEncoderStream`/`TextDecoderStream` let transform failures
escape synchronously
The codec transform/flush algorithms wrapped only the encode/decode call
in the completion-record catch. Both run user JS (`ToString` of the
chunk; the patchable `TextDecoder.prototype.decode`), which can cancel
the readable mid-transform — the subsequent enqueue's TypeError then
escaped synchronously out of `writer.write()`/`writer.close()`, and the
in-flight operation never settled:
```js
const tes = new TextEncoderStream();
const reader = tes.readable.getReader();
const writer = tes.writable.getWriter();
reader.read();
await null; await null; await null;
try {
writer.write({ toString() { reader.cancel(); return "x"; } }); // threw synchronously (spec: never throws)
} catch {}
await writer.abort("bye"); // never settled — wedged forever
```
The catch now covers the enqueue at all sites (encoder transform+flush
unified into one helper, mirroring the decoder), converting abrupt
completions into rejected promises that flow through
`transformStreamError` — write rejects, abort settles, matching Node.
---
### Test notes
- All 9 regression tests fail on the unfixed implementation (crash /
5s-timeout hang / sync throw) and pass with the fixes; verified by
reverting `src/jsc/bindings/webcore/streams/` to main and re-running.
- Full `test/js/web/streams/` + WPT streams + encoding suites: 1,421
tests, no regressions (the one pre-existing failure, `streams-leak`
absolute-RSS floor under ASAN, fails identically on main with a negative
RSS delta).
<!-- 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/streams/streams.test.js
<!-- robobun:evidence:end -->
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…#33931)
## Symptom
Intermittent `EBADF: bad file descriptor, fstat` in unrelated
`Bun.file(path).text()` calls on Windows. The most visible CI victim is
`test/cli/install/bun-install.test.ts` (15 flaky hits across the last 40
PR builds, spread across many different test cases), because its dummy
registry serves every tarball via `new Response(Bun.file(path))`.
```
EBADF: bad file descriptor, fstat
syscall: "fstat",
errno: -9,
code: "EBADF"
at async <anonymous> (test/cli/install/bun-install.test.ts:6461)
```
## Cause
`FileResponseStream::start` clears `ReaderFlags::CLOSE_HANDLE` on its
`BufferedReader` so it can close the fd itself in `Drop`
(src/runtime/server/FileResponseStream.rs:182). `PosixBufferedReader`
checks that flag before closing (src/io/PipeReader.rs:283/356/374/385).
`WindowsBufferedReader` defines the flag (:1143) and sets it in
`Default` (:1155) but never reads it, so `close_impl`'s `Source::File`
arm unconditionally calls `File::detach()` which queues `uv_fs_close` on
the same CRT fd that `FileResponseStream::Drop` already queued a
`Closer::close` for (:547).
Both closes are async on the libuv threadpool. Between close #1 freeing
the CRT slot and close #2 running, an unrelated `uv_fs_open` (from
another `Response(Bun.file)` open, or a `Bun.file().text()`) can be
handed the recycled slot; close #2 then closes the wrong fd, and its
next `fstat` or `read` sees EBADF.
## Fix
Honor `WindowsFlags::CLOSE_HANDLE` in
`WindowsBufferedReader::close_impl` via a new
`File::detach_borrowed_fd()` that mirrors `detach()` but leaves
`close_after_operation` unset, so no `uv_fs_close` is scheduled for a
parent-owned fd:
- `close_impl` with `CLOSE_HANDLE` set: unchanged (`detach()` schedules
`uv_fs_close` now or after the pending read).
- `close_impl` with `CLOSE_HANDLE` cleared: `detach_borrowed_fd()`. If
idle, drop the `Box<File>` there; if a read is in flight, null `fs.data`
and let `on_file_read`'s detached branch reclaim the Box after
`complete()`.
- `on_file_read`'s `parent_ptr.is_null()` path now handles both shapes
(state `Closing`: `on_close_complete` frees; otherwise: free here).
`BaseWindowsPipeWriter::close`'s `!owns_fd()` branch already open-coded
the same sequence and now routes through the shared
`detach_borrowed_fd()`, so the reader and writer close paths share one
contract.
## Verification
The double-close only exists on Windows (POSIX honors the flag), so the
Linux gate cannot observe fail-before. Windows x64-baseline at
91675d0:
- fail-before: 5/15 runs of the new test fail under the system bun with
`EBADF: bad file descriptor, fstat 'served.bin'`
- pass-after: 8/8 under the debug build with this patch
The new test is gated behind `isWindows`.
<!-- robobun:evidence:begin -->
---
**no test proof** · iteration 2 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/http/bun-serve-file.test.ts
<!-- robobun:evidence:end -->
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…078)
### 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>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…ose_slave_fd (#34225)
### What does this PR do?
Fixes a regression from #33882 where a script that spawns a subprocess
with an inline `terminal:` option and awaits `proc.exited` hangs forever
if it never calls `terminal.close()`.
### Repro
```js
const proc = Bun.spawn([process.execPath, "-e", "console.log('hi')"], {
terminal: {},
});
await proc.exited;
// process hangs here
```
### Root cause
#33882 deferred closing the parent's pty slave fd to
`Subprocess::on_process_exit` and added a synchronous drain of the
master before the close. That drain calls `reader.read()`, which hits
`EAGAIN` (our slave is still open) and re-arms the reader poll via
`register_poll()`. Only then do we `close_slave_fd()`, so the reader's
EOF (macOS) / `EIO` (Linux) arrives on the *next* epoll tick instead of
in the same batch as the pidfd.
When that next tick runs after the script's top-level await has already
resolved, `on_reader_error` downgrades the Terminal's `JsRef`, but the
reader and writer `FilePoll`s are still counted in `loop.active`.
Nothing triggers a GC between that downgrade and the next `epoll_wait`,
so `Terminal::finalize` (the only path that unregisters those polls)
never runs and the process blocks in `epoll_wait` forever.
Before #33882 the parent's slave fd was closed right after `spawn`, so
when the child exited the last slave was already gone and the reader
observed `EIO` in the *same* epoll batch as the pidfd. `on_reader_error`
downgraded the `JsRef` before the script ended, and the next
`onBeforeWait` safepoint finalized the Terminal.
Kernel probe on Linux (nonblocking read on master after the last slave
closes returns `-1/EIO`, not `0`):
```
=== parent keeps slave until EAGAIN then closes ===
read #0: n=18 data=[hello from child\n]
read #1: n=-1 errno=11 (EAGAIN)
(closing parent slave now)
read #2: n=-1 errno=5 (EIO)
```
### Fix
In `drain_and_close_slave_fd`, after draining and closing `slave_fd`:
- call `reader.read()` again so the exit callback fires now (EOF on
macOS, `EIO` on Linux) instead of on a later tick when nothing may wake
the loop. A grandchild holding the slave keeps this at `EAGAIN` and
re-arms, matching the stdout/stderr policy in the same `on_process_exit`
body.
- `update_ref(false)` on both polls so the event loop can exit
regardless; the polls stay registered so grandchild output still arrives
while anything else keeps the loop running.
- bracket the body with a `ref_()`/`deref_()` scopeguard since both
reader callbacks re-enter user JS.
`on_reader_done` / `on_reader_error` now gate the exit-callback branch
on `READER_DONE` as well as `FINALIZED`, so the second `EIO` dispatch
(the still-armed one-shot from the first drain) is a no-op and the exit
callback fires exactly once.
`#[cfg(unix)]` only; Windows already delivers EOF via
`close_pseudoconsole` off-thread. `BufferedReader` / `FileReader` are
untouched.
### How did you verify your code works?
New test `process exits after subprocess with inline terminal (no
terminal.close)` spawns a `bun -e` that runs the repro, sleeps after
`child.exited` to give the stale poll a chance to fire, and asserts
`{gotOutput: true, exitedSync: true, exitCount: 1, exitCode: 0}`.
Fail-before (origin/main `src/` + this test):
```
(fail) ... process exits after subprocess with inline terminal (no terminal.close) [5004.58ms]
^ this test timed out after 5000ms.
```
With the fix: 91 pass / 1 todo / 0 fail.
`bun bd test test/js/bun/spawn/spawn.test.ts`: 368 pass, 0 fail. `cargo
check -p bun_runtime` clean for `aarch64-apple-darwin` and
`x86_64-pc-windows-msvc`.
<!-- robobun:evidence:begin -->
---
**no test proof** · iteration 1 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/terminal/terminal.test.ts
<!-- robobun:evidence:end -->
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…n worker terminate (#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 #34441), on the debian 13 x64-asan lane:
```
==11356==ERROR: AddressSanitizer: heap-use-after-free on address 0x12ce0a3af168
READ of size 4 at 0x12ce0a3af168 thread T6 (Worker)
#0 FilePoll::unregister src/io/posix_event_loop.rs:951
#1 FilePoll::deinit_possibly_defer src/io/posix_event_loop.rs:428
#2 FilePoll::deinit_with_vm src/io/posix_event_loop.rs:448
#3 Resolver::on_dns_socket_state src/runtime/dns_jsc/dns.rs:4894
#6 ares_conn_sock_state_cb_update vendor/cares/src/lib/ares_conn.c:36
freed by thread T6 (Worker):
drop_in_place<Box<posix_event_loop::Store>> (RareData field drop)
VirtualMachine::destroy src/jsc/VirtualMachine.rs:4453
WebWorker::shutdown src/jsc/web_worker.rs:1299
```
## Repro
```js
const { Worker } = require('worker_threads');
const w = new Worker(`
const dns = require('dns');
dns.lookup('nonexistent.org', () => {});
require('worker_threads').parentPort.postMessage('0');
`, { eval: true });
w.on('message', () => w.terminate());
```
## Cause
`WebWorker::shutdown()` runs, in order: `WebWorker__teardownJSCVM`
(frees the `JSGlobalObject`), then `VirtualMachine::destroy()` which
drops `rare_data` (frees the `FilePoll` hive `Store`) and finally calls
`deinit_runtime_state` which drops `RuntimeState`. That last drop runs
`GlobalData::drop` which calls `ares_destroy()` on the per-VM c-ares
channel.
`ares_destroy()` synchronously fires every pending query callback with
`ARES_EDESTRUCTION` and then the socket-state callback for each fd it
closes. Those callback chains re-enter:
- `Resolver::on_dns_socket_state` -> `FilePoll::deinit_with_vm` on the
already-freed hive slot (the ASAN trace above)
- `GetAddrInfoRequest::on_cares_complete` ->
`DNSLookup::process_get_addr_info` -> `reject_later(global_this)` on the
freed `JSGlobalObject` (bmalloc-backed so ASAN misses it)
- `ResolveInfoRequest::on_cares_complete` -> `request_completed()` ->
`remove_timer()` -> `(*runtime_state()).timer` with the TLS already
nulled (null deref)
## Fix
Add a `RuntimeHooks::close_dns_for_terminate` slot that runs
`Resolver::close_channel_for_terminate()` from `WebWorker::shutdown()`
(and the `BUN_DESTRUCT_VM_ON_EXIT` main-thread path) right after
`close_all_socket_groups`, while JSC, `RareData.file_polls`, the event
loop, and `runtime_state` are all still live. The method also removes
the resolver's c-ares timeout timer, which `GetAddrInfoRequest`'s
EDESTRUCTION path never unwinds. `GlobalData::drop` still handles the
channel if the early hook never ran (it sees `channel == None` when it
did).
This matches Node's model: `Worker::Exit` -> `CleanupHandles()` closes
every handle wrap (including `ChannelWrap`) before disposing the
Isolate.
## Verification
New ASAN-gated test in
`test/js/web/workers/worker-terminate-lifetime.test.ts` spawns four
workers that each start a `dns.lookup()` + `dns.resolve4()` and
terminates them mid-flight.
- **fail-before** (`git stash -- src/ && bun bd test ...`): null-deref
panic / ASAN heap-use-after-free
- **pass-after**: clean exit 0 across 10 consecutive runs
Also verified `test/js/node/dns/` and `test/js/bun/dns/` pass/fail
counts are unchanged vs. main, and `bun run rust:check-all` is clean on
all targets.
<!-- robobun:evidence:begin -->
---
**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/web/workers/worker-terminate-lifetime.test.ts
<!-- robobun:evidence:end -->
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
### What
`test/expected-durations.json` drives the LPT bin-packing in
`scripts/runner.node.mjs` that assigns test files to `--max-shards`
bins. It was last generated 2026-07-07 (builds 69636/69628/69627) and
has drifted far enough that the alpine (musl) lanes now have one shard
on the critical path roughly 2x the rest: across builds 75488 / 75513 /
75517 / 75562, `:alpine: 3.23 x64` shard 14 runs 8.0-8.4 min while every
other shard sits at 3.7-5.5 min.
Two things rotted:
- #33622 moved ~3k `js/{node,bun}/test/parallel/` files into a
concurrent phase that logs `[N/M] <path>` without the `--- ` Buildkite
group prefix, so `update-test-durations.mjs`' header regex `^---
\[\d+\/\d+\] (.+)$` no longer sees them. The scheduled regen would have
silently dropped those ~3k entries.
- The three musl lanes have no column of their own and fall back to the
debian timings, which are wrong enough on a handful of files to pile
them onto one shard.
### Changes
**`scripts/update-test-durations.mjs`**
- Capture a `musl` column from `linux-x64-musl-alpine-323-test-bun`
alongside `default` / `asan` / `windows`.
- Match both `[N/M] <path>` and `--- [N/M] <path>` headers, and treat
the `--- Running N parallel-safe` banner (#34463) as a span delimiter so
the last serial test's span does not absorb the concurrent phase.
- Concurrent-phase spans are inter-*dispatch* gaps, not wall clock.
Clamp spans from bare `[N/M]` headers to 500 ms so the last-dispatched
file on each shard cannot absorb the N-wide tail drain or a sibling's
5-15 s retry backoff (without the clamp, nine alphabetically-last
`test-zlib-*` files landed at 3-10 s on one lane and ~20 ms on every
other).
- Reject retry/error headers (`... - code 1`, `... [attempt #2]`) that
end after something other than a file extension. The previous table
already carried 14 of those as keys; they are harmless to the packer
(never matched) but noise in the diff.
- Retry 429/5xx from `api.buildkite.com` with `Retry-After` backoff. A
burst of 429s mid-run previously aborted the whole regen.
- Refresh the stale `release and asan linux-x64 lanes` doc comment.
**`scripts/runner.node.mjs`**
- Lane selection now maps `--step` values containing `musl` to the new
column (the alpine lanes pass
`--step=linux-{x64,aarch64}-musl[-baseline]-build-bun`). The existing
`entry[lane] ?? default ?? asan ?? windows` fallback chain is extended
with `musl` so an entry that only carries a subset of columns still
resolves.
**`test/expected-durations.json`**
- Regenerated from builds 75604 / 75603 / 75596 / 75595 / 75592: 5119
entries, 4 lanes each. (Was 4776 entries, 3 lanes.)
**`test/internal/expected-durations.test.ts`** (new)
- Guards the table's shape so a future broken regen fails loudly:
`_meta.lanes` contains every lane the runner selects and each has >1000
populated entries, every key is a forward-slash relative path ending at
a test file extension, the parallel-safe set is present and every one of
its values is ≤500 ms, and every value is `{lane: non-negative ms}`.
Fails 3/4 against the previous table.
### Verification
Replayed the packer (same LPT as `runner.node.mjs`) against the actual
per-file timestamps from three builds, two of which (75488, 75517) are
not in the source set:
| lane | old max/min (75562 / 75517 / 75488) | new max/min |
| --- | --- | --- |
| musl | 3.08 / 2.74 / 2.90 | **1.30 / 1.40 / 1.55** |
| asan | 1.32 / 1.32 / 1.38 | 1.09 / 1.18 / 1.17 |
| windows | 1.53 / 1.32 / 1.36 | 1.47 / 1.31 / 1.13 |
| default | 1.78 / 2.05 / 1.92 | 1.23 / 1.80 / 1.71 |
The musl critical path drops from ~350 s to ~210 s of test wall time.
The residual `default` spread on 75517/75488 is one serial test per
build running 20-90 s over its median (socket.test.ts at 93 s vs 4.3 s;
bundler_compile_splitting at 47 s vs 13 s); no static table can absorb a
one-off outlier.
This touches only `scripts/` and `test/`, so there is no `src/` stash
for the automated fail-before check to exercise; the table-shape test
above is the equivalent evidence.
### Follow-up
`.buildkite/update-test-durations.yml` uploads the regenerated file as a
build artifact and relies on someone committing it. Wiring that schedule
up to open a PR (or commit directly) would stop it rotting again; not
done here because it needs a write-scoped token on that agent.
#34552 separately notes the Windows column will want another refresh
once the ~1850 re-enabled `itBundled` tests land.
<!-- robobun:evidence:begin -->
---
**[stamp-90s]** gate passed · iteration 1 · 4 files touched
<details><summary>passes on PR (with fix)</summary>
```console
Test-only change.
Debug/ASAN (expected pass):
$ bun bd test 'test/internal/expected-durations.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/internal/expected-durations.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (5b373f388)
test/internal/expected-durations.test.ts:
(pass) test/expected-durations.json > every lane the runner selects is declared and populated [173.57ms]
(pass) test/expected-durations.json > keys are relative test paths, not runner retry/error labels [228.22ms]
(pass) test/expected-durations.json > covers the parallel-safe phase and clamps its spans [279.15ms]
(pass) test/expected-durations.json > every entry is {lane: non-negative ms} [436.90ms]
4 pass
0 fail
15 expect() calls
Ran 4 tests across 1 file. [3.57s]
Exit: 0
```
</details>
<details><summary>diff hotspot</summary>
```
scripts/runner.node.mjs | 10 +-
scripts/update-test-durations.mjs | 74 +-
test/expected-durations.json | 34536 +++++++++++++++++------------
test/internal/expected-durations.test.ts | 65 +
4 files changed, 20942 insertions(+), 13743 deletions(-)
```
</details>
**gate history** · 1 passed · 1 rejected · iteration 1
<details><summary>evidence per changed file</summary>
```
file reads edits tests
scripts/runner.node.mjs 5 1 0
scripts/update-test-durations.mjs 2 10 0
test/expected-durations.json 0 0 0
test/internal/expected-durations.test.ts 3 6 0
```
</details>
<!-- robobun:evidence:end -->
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…#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 #4 NewSocket<true>::on_writable socket_body.rs:894 #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 #4 NativeCallbacks::on_writable socket_body.rs:3960:20 #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>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…ed (#36247)
## What
`test/js/bun/http/bun-serve-html.test.ts` segfaults on `windows-aarch64`
after #36175 landed (builds 84162, 84194; one earlier sighting in
83933):
```
panic(main thread): Segmentation fault at address 0x48
Features: ... dev_server(14) ...
```
Symbolicated in #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. #36175 exposed it because the
CI runner now spawns the napi addon prebuild in the background while
serial tests run; that writes under the watched project root, so the
`jsx-runtime` DevServers in this test file now reliably receive a
hot-reload event between the last `await fetch` and `using server`
disposal.
## Fix
`watcher_atomics` is now a `NonNull<WatcherAtomics>` owned via
`bun_core::heap::into_raw`, so the allocation can outlive `DevServer`
and every queued pointer keeps allocation-root provenance.
`watcher_acquire_event`, `watcher_release_and_submit_event` and
`recycle_event_from_dev_server` take `*mut Self` and derive the returned
`*mut HotReloadEvent` (and the linked `concurrent_task` node) from that
root pointer via raw place projections rather than from a `&mut
WatcherAtomics` reborrow.
`Drop for DevServer` reads `next_event` after `Watcher::shutdown` has
serialised out the watcher thread (which guarantees it is stable):
- `DONE`: nothing is queued; clear and `heap::destroy` as before.
- otherwise: a `concurrent_task` is still linked (or its `Task` is
already in the drain FIFO). Null `owner` on every event and leave the
allocation alive.
`HotReloadEvent::run` checks `owner.is_null()` first; when set it
reclaims the allocation via the new `atomics` backref and returns
without touching the dead `DevServer`. The `# Safety` contracts on `run`
and the `BakeHotReloadEvent` dispatch arm are updated to describe the
null-owner case.
## Test
`test/js/bun/http/bun-serve-html-hot-reload-drop.test.ts` creates a
development server, bundles once so `app.js` is watched, synchronously
rewrites `app.js`, spins briefly without yielding so the watcher thread
can enqueue, disposes the server, then yields. Ten iterations. In a
separate file because the React-bundling cases in
`bun-serve-html.test.ts` already exceed the default per-test timeout
under a debug+ASAN build on `main`.
<details><summary>fail-before (debug+ASAN, src/ at main)</summary>
```
==25521==ERROR: AddressSanitizer: heap-use-after-free on address 0x79315e4743e8
READ of size 8 at 0x79315e4743e8 thread T0
#2 <ConcurrentTask as Node>::get_next unbounded_queue.rs:82
#3 BatchIterator<ConcurrentTask>::next unbounded_queue.rs:135
#4 EventLoop::tick_concurrent_with_count event_loop.rs:507
0x79315e4743e8 is located 488 bytes inside of 16512-byte region
freed by thread T0 here:
#9 Box<DevServer>::drop
#12 NewServer<false,true>::deinit_if_we_can mod.rs:1770
#13 NewServer<false,true>::stop mod.rs:1665
#14 NewServer<false,true>::dispose_from_js server_body.rs:2584
```
</details>
Passes with the fix in ~2.4s under debug+ASAN (also on a local
`windows-aarch64` debug build, where the original
`bun-serve-html.test.ts` is now 19/19);
`test/bake/deinitialization.test.ts` still green.
Supersedes the producer half of #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 (5f6622ff8)
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 (96ff7ec83)
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 (5f6622ff8)
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>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…s (#36310) ### What does this PR do? Fixes `test/js/node/net/node-net-server.test.ts > should listen on unix domain socket` going red on the alpine 3.23 lanes since #36175 (seen on main builds 84293, 84503, 84549, 84601 and ~60 branch builds). Every test in the `net.createServer listen` block armed `setTimeout(closeAndFail, 100)` next to `server.listen()`. That timer was never testing `listen()` itself: `Bun.listen` binds synchronously and `'listening'` is scheduled via `setTimeout(emitListeningNextTick, 1, this)`, so the 100 ms race was against the test process's own scheduling latency. The runner already bounds each test, so the extra timer only added a flake surface (and hid the real error behind `function should not have been called`). #36175 didn't touch `net` or this file, but it moved the allowlisted files into a single batch, so the handful of remaining serial files (this one is in `excludeFiles`) now run much earlier in the shard. On alpine that lands while the docker-service coordinator is still bringing up the mysql containers in the background: ``` t=233226 [9/282] node-net-server.test.ts t=233436 ✗ should listen on unix domain socket [144.58ms] ← 100 ms timer fired t=234907 coordinator: mysql_native_password ready ← docker init finished 1.5 s later t=242361 [attempt #2] node-net-server.test.ts 21 pass ← same file green once docker is idle ``` (from build 84601, alpine 3.23 x64 shard `019fab6d-27b7-4c39`) ### Change Remove the 100 ms `setTimeout(closeAndFail, ...)` from the nine listen tests and route `server.on('error', ...)` to `done(err)` so a real bind failure reports its actual error. Same assertions, same code paths (`listen()` → `'listening'` → `server.address()` checks); only the hand-rolled deadline that duplicated the test runner's timeout is gone. The 500 ms timers in the `events` block are untouched; they guard real client↔server round trips, have `is_done` guards, and haven't flaked. ### How did you verify your code works? - `bun bd test test/js/node/net/node-net-server.test.ts` → 21 pass / 0 fail. - Reproduced the race locally by running the file under background CPU+disk load (4× `yes`, 2 GB `dd`): with the old timer the listen block failed 1/5 runs at `function should not have been called`; with this change 5/5 runs pass under the same load (including a 246 ms `'listening'` that would have tripped the old 100 ms timer). `node-tls-server.test.ts` has the same 100 ms pattern and is also a serial `excludeFiles` entry; happy to fold it in here if preferred, but it hasn't been observed red so I kept this scoped to the reported file. <!-- robobun:evidence:begin --> --- **[stamp-90s]** gate passed · iteration 1 · 1 files touched <details><summary>passes on PR (with fix)</summary> ```console Test-only change. Debug/ASAN (expected pass): $ bun bd test 'test/js/node/net/node-net-server.test.ts' $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/node/net/node-net-server.test.ts bun test v1.4.0 (6b920f8b9) test/js/node/net/node-net-server.test.ts: (pass) net.createServer listen > should throw when no port or path when using options [27.65ms] (pass) net.createServer listen > should listen on IPv6 by default [153.42ms] (pass) net.createServer listen > should listen on IPv4 [26.37ms] (pass) net.createServer listen > should call listening [19.34ms] (pass) net.createServer listen > should provide listening property [22.93ms] (pass) net.createServer listen > should listen on localhost [17.90ms] (pass) net.createServer listen > should listen on localhost [17.45ms] (pass) net.createServer listen > should listen without port or host [24.19ms] (pass) net.createServer listen > should listen on unix domain socket [19.40ms] (pass) net.createServer listen > should bind IPv4 0.0.0.0 when listen on 0.0.0.0, issue#7355 [31.32ms] (pass) net.createServer events > should receive data [159.16ms] (pass) net.createServer events > should call end [155.39ms] (pass) net.createServer events > should call close [19.65ms] (pass) net.createServer events > should call connection and drop [70.90ms] (pass) net.createServer events > should error on an invalid port [23.08ms] (pass) net.createServer events > should call abort with signal [25.98ms] (pass) net.createServer events > should echo data [111.91ms] (pass) net.createServer events > #8374 [72.98ms] (pass) accepted socket event-loop hold matches Node (per-connection KeepAlive) > server.stop() + accepted socket.unref() lets the process exit [318.84ms] (pass) accepted socket event-loop hold matches Node (per-connection KeepAlive) > server.unref() alone does not drop a ref'd accepted connection's hold [1548.16ms] (pass) accepted socket event-loop hold matches Node (per-connection KeepAlive) > half-open accepted sockets after peer FIN do not busy-poll the event loop (Windows AFD DISCONNECT) [4 ... (truncated) Exit: 0 ``` </details> <details><summary>diff hotspot</summary> ``` test/js/node/net/node-net-server.test.ts | 117 +++++++------------------------ 1 file changed, 25 insertions(+), 92 deletions(-) ``` </details> **gate history** · 2 passed · 0 rejected · iteration 1 <details><summary>evidence per changed file</summary> ``` file reads edits tests test/js/node/net/node-net-server.test.ts 2 3 0 ``` </details> <!-- robobun:evidence:end -->
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
## What
`JSSink::assign_to_stream` now detaches the freshly created
`JSReadable*SinkController` (nulling its `m_sinkPtr`) when the C++
stream-pump setup returns an error, before returning to the caller.
## Why
The generated `${name}__assignToStream` functions create the controller
with `m_sinkPtr = sinkPtr` and then call into
`GlobalObject::assignToStream` → `readDirectStream` /
`readStreamIntoSink`. If that setup throws (for example a direct
`ReadableStream` whose `pull` getter throws), the controller is never
started, so nothing ever calls `end()`/`close()` to null `m_sinkPtr`.
The caller's error path (`Writable::init` for `Bun.spawn`) then releases
and frees the native sink. When the controller is later swept, its
destructor runs `${name}__controllerDetached` / `${name}__finalize` on
freed memory.
ASAN report:
```
heap-use-after-free on address 0x799feed81c78
READ of size 1
#0 JSSink<FileSink>::js_controller_detached Sink.rs:567
#1 FileSink__controllerDetached generated_jssink.rs:179
#2 JSReadableFileSinkController::~JSReadableFileSinkController()
freed by:
#12 FileSink::deinit FileSink.rs:1142
#16 Writable::pipe_release Writable.rs:70
#17 Writable::init Writable.rs:339
#18 spawn_maybe_sync js_bun_spawn_bindings.rs:1379
```
The fix is at the generic `JSSink::assign_to_stream` layer so it covers
every sink type (`FileSink`, `NetworkSink`, `FetchRequestBodySink`,
...), not just the spawn path.
## Repro
```js
const { openSync, closeSync } = require("node:fs");
const fd = openSync("/tmp/out.txt", "w");
let armed = false;
const stream = new ReadableStream({
type: "direct",
get pull() { if (armed) throw new Error("pull unavailable"); return () => {}; },
});
armed = true;
try {
Bun.spawn({ cmd: [process.execPath, "-e", "0"], stdio: [stream, fd, "ignore"] });
} catch {}
closeSync(fd);
Bun.gc(true); // sweep -> controller dtor -> UAF
```
## Tests
The two existing `spawn.test.ts` cases that cover the
stdin-stream-setup-throws path now force a full GC in the child fixture
so the controller destructor runs deterministically under debug+ASAN as
well. Previously they were only failing on the release-asan lane (where
the whole file has been quarantined as `[ASAN] [TIMEOUT]`), which is why
this went unnoticed.
```
bun bd test test/js/bun/spawn/spawn.test.ts -t "stdin stream setup fails"
```
fails on `main` (ASAN heap-use-after-free in the child's stderr) and
passes with this change.
`spawn-stdin-readable-stream-edge-cases.test.ts` and
`body-stream.test.ts` continue to pass.
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
… sink ends inline (#36939) ### Crash Sentry [BUN-3BZF](https://bun-p9.sentry.io/issues/?query=BUN-3BZF) (2,975 events since 2026-05-25, macOS-dominant): `Panic: called Option::unwrap() on a None value` at `FetchTasklet::callback`'s `task_ref.http.as_mut().unwrap()`, reached from the HTTP thread's result dispatch (`us_internal_ssl_on_data -> HTTPClient::fail -> dispatch_result_and_reset -> AsyncHTTP::on_async_http_callback_raw -> FetchTasklet::callback`). `http` is set once at creation and cleared only at deinit, so the panic means the callback ran against a freed `FetchTasklet`. ### Cause `start_request_stream` takes a `+1` on the tasklet that must be released exactly once by `write_end_request`. For a native `ByteStream` request body (an upstream response body piped into `fetch()`), `wire_native_sink` installs the sink's `source` handle *before* any of its `EndedInline` returns (`ReadableStream.rs:328` vs `:337/:352/:359`), so a stream that picked up an error or its last chunk between `fetch()` and the `can_stream` tick comes back `EndedInline` with a native source attached. The `EndedInline` arm released the `+1` (via `write_end_request`) but left `self.sink` installed with `ended == false`. Every terminal path then runs `cancel_request_body_sink`, which saw a "live" native sink and took its native arm: `abort_task()` plus a second `write_end_request` — releasing the same `+1` again. The double release collapses the refcount while the other owners (the JS-side initial ref and the HTTP thread's in-flight ref) still use the tasklet. Under ASAN the deterministic form is the trace below (deinit runs inside `cancel_request_body_sink`, then `on_progress_update` keeps using `self`). In release builds the same imbalance frees the tasklet while it is still in use (or double-frees, handing a live tasklet's block back to the allocator), which surfaces as downstream crashes in the fetch completion path — the BUN-3BZF unwrap is the tasklet's `http` field read from freed/recycled memory. ``` READ of size 8 ... core::mem::replace::<bun_jsc::js_promise::Strong> #2 FetchTasklet::on_progress_update FetchTasklet.rs:1158 freed by thread T0 here: #12 FetchTasklet::deinit FetchTasklet.rs:509 #16 FetchTasklet::write_end_request FetchTasklet.rs:2281 #17 FetchTasklet::cancel_request_body_sink FetchTasklet.rs:2368 #18 FetchTasklet::on_progress_update FetchTasklet.rs:1143 ``` ### Fix Leave the sink in the same state `end_from_stream` (the normal native termination) leaves it: `ended = true`, source and task detached. The terminal `cancel_request_body_sink` then hits its existing `if sink.ended { return }` guard and cannot release the ref a second time (it also no longer spuriously aborts a request whose body simply ended inline). ### Verification - New fixture `fetch-stream-body-ended-inline-fixture.ts` drives the window: an upstream server that advertises a larger `content-length` than it sends and closes a few ms later, piped as the body of a TLS `fetch()` (the handshake keeps the wire-attempt window open), 100 iterations. - Unfixed debug+ASAN build: heap-use-after-free with the trace above, 8/8 runs. - Fixed build: `bun bd test test/js/web/fetch/fetch-abort-stream-body.test.ts` passes (5 pass, 1 pre-existing skip), including the new test. - `test/js/web/fetch/body-stream.test.ts`: 9086 pass / 0 fail. `fetch.test.ts` and `fetch.stream.test.ts`: identical pass/fail counts to an unfixed baseline in the same container (the failures are pre-existing network/timeout issues). - The test is `skipIf(!isASAN)`: the release build corrupts silently, so only sanitizer lanes can observe the failure.
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…e cache (#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 #37024, whose
`test/js/bun/bun-object/deep-equals-temporal.test.ts` uses
`[u-ca=hebrew]`:
```
Direct leak of 624 byte(s) in 1 object(s) allocated from:
#1 icu_75::HebrewCalendar::clone() const
#2 icu_75::Calendar::createInstance(icu_75::TimeZone*, icu_75::Locale const&, UErrorCode&)
#3 ucal_open_75
#4 JSC::TemporalCore::buildCalendarTemplate(WTF::AbstractLocker const&, unsigned int)
#5 JSC::TemporalCore::withCalendar<JSC::TemporalCore::calendarYear(...)::$_0>(...)
```
The CI annotation titles this `direct leak of 624b in {closure#0}
(src/jsc/JSValue.rs:1664:22)` because that is the first in-repo frame
(the test-runner's `JSValue::call`); everything below it is WebKit/ICU.
### Cause
`TemporalCore::withCalendar`
(`vendor/WebKit/.../temporal/core/CalendarICUBridge.cpp`) keeps up to 8
open `UCalendar` templates in a process-lifetime `LazyNeverDestroyed`
`TinyLRUCache`, one per calendar ID (non-ISO arithmetic, plus pure-ISO
`PlainDateTime.prototype.with`, which reaches the same path unguarded);
LRU eviction `ucal_close`s them, so the set is bounded. The
`CalendarCacheEntry` that owns each `UCalendar` is
`WTF_MAKE_TZONE_ALLOCATED` (bmalloc), which LSan does not scan, so the
libc-allocated `UCalendar` (and the ICU `TimeZone` inside it) is
reported as a direct leak even though it is reachable. Whether a given
run aborts depends on whether some stale stack or register value still
points at the ICU object when LSan scans at exit, hence the
intermittence.
This is the calendar twin of the already-suppressed
`TemporalCore::withTimeZone` entry (same cache design, same
TZone-allocated owner).
### Fix
- Add a `leak:TemporalCore::buildCalendarTemplate` suppression to
`test/leaksan.supp`, mirroring the `withTimeZone` entry. The pattern
anchors on the template builder rather than `withCalendar` itself so
that a future real leak inside one of the many op lambdas `withCalendar`
runs would still be reported; every cached-template allocation carries
the builder frame. (`withTimeZone` has no such builder frame, its
`ucal_open` is inline, so that entry keeps its existing pattern.)
- Drop the `test/no-validate-leaksan.txt` escape hatch #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 #32180: that is `bun test`'s own
finalizer-owned memory, while this cache deliberately survives VM
teardown, so #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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
v*tag workflow that publishes only after both platform builds passBUILDINFO.json, generateSHA256SUMS, and upload GitHub Release assetsvendor/lolhtmlbefore Cargo resolves the combined workspaceWhy
The first release must support both Windows x64 and Linux x64. A Linux-only artifact is not an acceptable first-version release boundary.
Validation
actionlintpassed for all workflow filescargo fmt --all -- --checkcargo test --workspace --lockedThe PR intentionally triggers the full Windows and Linux Bun integration build so the native release paths can be verified before merge.