Add project website and GitHub Pages - #3
Merged
Merged
Conversation
liooil
marked this pull request as ready for review
July 30, 2026 10:18
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
added a commit
that referenced
this pull request
Aug 7, 2026
Co-authored-by: xiteng <liuxiteng@baosight.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
Fixes a segfault when reading `.fd` on the result of `Bun.listen({ tls:
{ ... } })`.
`Listener.getFD` was calling `uws_listener.socket(true).fd()` for TLS
listeners. For `is_ssl=true`, the uSockets wrapper
`us_internal_ssl_socket_get_native_handle` returns `s->ssl`, and `fd()`
then calls `SSL_get_fd()` on it. But a listen socket has no SSL object —
SSL is per-connection — so `s->ssl` is uninitialized memory (ASAN poison
`0xbebebe...`) and the call segfaults.
Listen sockets always have a plain poll fd regardless of TLS, so get it
via the non-SSL path.
```
#3 SSL_get_rfd (ssl=0xbebebe0000000018)
#4 SSL_get_fd (ssl=0xbebebe0000000018)
#5 deps.uws.socket.NewSocketHandler(true).fd () at src/deps/uws/socket.zig:283
bun.js.api.bun.socket.Listener.getFD at src/bun.js/api/bun/socket/Listener.zig:532
```
Repro (also triggered when `console.log()` introspects the listener):
```js
const s = Bun.listen({
hostname: "localhost", port: 0,
socket: { data(){}, open(){}, close(){} },
tls: { passphrase: "abc" },
});
console.log(s.fd);
```
Found by Fuzzilli.
---------
Co-authored-by: robobun <robobun@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
## Problem
Fuzzilli hit a flaky SIGSEGV (fingerprint `2519cad1804eace1`) from:
```js
const v13 = Bun.jest().vi;
try { v13.mock("function f2() {\n const v6 = new ArrayBuffer();\n ...\n}"); } catch (e) {}
Bun.gc(true);
```
`JSMock__jsModuleMock` calls `Bun__resolveSyncWithSource` on the
specifier before validating the callback, which sends the garbage string
through the resolver. The resolver's auto-install gate at
`loadNodeModules` only checks `esm_ != null`; `ESModule.Package.parse`
accepts anything that doesn't start with `.` or contain `\` / `%`, so
the whole function source is treated as a package name.
`enqueueDependencyToRoot` then calls `PackageManager.sleepUntil`, which
re-enters `EventLoop.tick()` from inside a call that is itself running
inside an event-loop tick:
```
#0 ConcurrentTask.PackedNextPtr.atomicLoadPtr
#1 UnboundedQueue(ConcurrentTask).popBatch
#3 event_loop.tickConcurrentWithCount
#7 AnyEventLoop.tick
#8 PackageManager.sleepUntil
#9 PackageManager.enqueueDependencyToRoot
#10 Resolver.resolveAndAutoInstall
#16 Bun__resolveSyncWithSource
#17 JSMock__jsModuleMock
```
The same path is reachable from `Bun.resolveSync`, `import()`, and
`require.resolve` with any user-provided string.
## Fix
Gate the auto-install branch on `strings.isNPMPackageName(esm_.?.name)`.
That validator already exists and is used by `bun link`, `bun pm view`,
and the bundler; it rejects newlines, spaces, braces, and anything else
that could never be a registry package. Specifiers failing the check
fall straight through to `.not_found` — the same result the registry
fetch would eventually produce — without initializing the package
manager or ticking the event loop.
This is a resolver-level fix, so it covers every entry point (not just
`mock.module`). It also avoids spurious network requests for garbage
specifiers; on this container a single resolve of a multi-line specifier
dropped from ~275ms to ~16ms.
## Tests
- `test/js/bun/resolve/resolve-autoinstall-invalid-name.test.ts` stands
up a local registry and verifies zero manifest requests for a set of
invalid names with `--install=force`, plus a positive control that a
valid name still hits the registry.
- `test/js/bun/test/mock/mock-module-non-string.test.ts` gains a case
for `mock.module` with newline / whitespace / bracket specifiers (with
and without a callback).
- Existing `test/cli/run/run-autoinstall.test.ts` (11 tests) and
`test/js/bun/test/mock/mock-module.test.ts` all pass.
Related: #28945, #28956, #28500, #28511.
Fingerprint: `2519cad1804eace1`
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
…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
…ion getters (#30078)
## Problem
`server.upgrade(req, opts)` reads `Sec-WebSocket-Key` /
`Sec-WebSocket-Protocol` / `Sec-WebSocket-Extensions` from
`request.getFetchHeaders()` via `FetchHeaders.fastGet`, which returns a
`ZigString` that **borrows** directly from the header map entry's
`StringImpl` (`bindings.cpp` `WebCore__FetchHeaders__fastGet_` →
`Zig::toZigString(StringView)`, no ref taken).
It then invokes the `opts.data` / `opts.headers` getters — arbitrary
user JS — and only afterwards passes those borrowed slices to
`resp.upgrade()`.
A getter that mutates `req.headers` (e.g.
`req.headers.set('sec-websocket-key', ...)`) drops the sole ref on the
original `StringImpl` (`HTTPHeaderMap::set` does a `RefPtr` assignment),
freeing it. `resp.upgrade()` then reads freed memory for the
key/protocol/extensions.
```js
Bun.serve({
fetch(req, server) {
req.headers; // materialize FetchHeaders
server.upgrade(req, {
get data() {
req.headers.set('sec-websocket-key', 'x'); // frees the borrowed StringImpl
return undefined;
},
});
},
websocket: { message() {} },
});
```
The re-entrancy guard after the getters only checks `isAbortedOrEnded()
/ didUpgradeWebSocket()`, not header mutation. The `opts.headers` path
was already defensively cloning with `toSliceClone` (because
`fastRemove` there frees the backing); the `request.headers` path was
missed.
## Fix
Clone `sec_websocket_key` / `protocol` / `extensions` into owned
`ZigString.Slice` storage immediately after reading them from
`request.getFetchHeaders()`, so the bytes stay valid across the option
getters and `resp.upgrade()`. The `opts.headers` override path reuses
the same owned slots (freeing the previous clone first).
## Verification
New test in
`test/js/bun/websocket/websocket-server-upgrade-reentrant.test.ts`
spawns a subprocess with `Malloc=1` (routes bmalloc → system heap so
ASAN observes `StringImpl` frees) and has an `opts.data` getter
overwrite all three `Sec-WebSocket-*` headers.
**Before** (src/ stashed, `bun bd test`):
```
==ERROR: AddressSanitizer: heap-use-after-free
#3 uWS::HttpResponse<false>::upgrade ... HttpResponse.h:269
#6 server.zig:1076 (resp.upgrade call)
(fail) server.upgrade() clones Sec-WebSocket-* from request.headers before running option getters
```
**After**: all three tests in the file pass.
Also fails on `USE_SYSTEM_BUN=1` (release, no ASAN) — with `Malloc=1`
the system allocator reuses the freed slot and the WebSocket client
rejects the handshake (bad `Sec-WebSocket-Accept` / mismatched
protocol).
Co-authored-by: robobun <robobun@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…es (#30077)
## What
When a chunked (or HTTP/3) request body exceeds `maxRequestBodySize`,
`onBufferedBodyChunk` writes the 413 directly on the raw uWS response:
```zig
resp.writeStatus("413 Payload Too Large");
resp.endWithoutBody(comptime !http3);
```
`internalEnd` → `markDone()` nulls `onAborted`, so when the socket
closes no abort ever fires to detach `ctx.resp` or release the base ref.
`this.resp` is left pointing at a completed response whose socket is
about to be freed by `us_internal_free_closed_sockets`.
If the fetch handler returned a pending Promise:
- **resolve**: `handleResolve` → `isAbortedOrEnded()` is false
(`this.resp != null`) → `render()` → `runCorkedWithType` corks the freed
socket → **heap-use-after-free** (ASAN trace below).
- **reject**: `handleReject` reads `resp.hasResponded()` off freed
memory, sees `true`, skips the error handler, and returns without ever
releasing the base ref → **RequestContext leaks**
(`server.pendingRequests` never returns to 0).
## Fix
Route through `this.endWithoutBody()` (the `RequestContext` wrapper)
instead of the raw `resp.endWithoutBody()`. That path does
`detachResponse()` (nulls `this.resp`, clears
`onData`/`onAborted`/`onTimeout`) and `deref()` (releases the base ref),
matching every other end path in this file.
The body promise is rejected with the specific `"Request body exceeded
maxRequestBodySize"` error *before* `endWithoutBody()` so
`endRequestStreaming()` doesn't overwrite it with a generic
`ConnectionClosed`. `has_written_status` is set so any later
`renderMissing`/`renderMetadata` knows the status line is already
committed.
## Repro
```
==ERROR: AddressSanitizer: heap-use-after-free
#0 us_socket_group socket.c:77
#1 uWS::AsyncSocket<false>::getLoopData() AsyncSocket.h:69
#2 uWS::AsyncSocket<false>::isCorked() AsyncSocket.h:141
#3 uWS::HttpResponse<false>::cork(...) HttpResponse.h:647
#4 uws_res_cork libuwsockets.cpp:1740
#5 ...runCorkedWithType Response.zig:299
#6 ...doRenderBlob RequestContext.zig:1942
...
#11 ...handleResolve RequestContext.zig:220
#12 ...onResolve RequestContext.zig:154
freed by:
#1 us_poll_free epoll_kqueue.c:73
#2 us_internal_free_closed_sockets loop.c:305
```
## Test
`test/js/bun/http/serve-pending-promise-abort-leak.test.ts` — new case
sends a raw `Transfer-Encoding: chunked` POST exceeding
`maxRequestBodySize` with a handler that holds its resolve/reject, waits
for the socket to be reclaimed, then settles the Promise. Asserts
`pendingRequests` returns to 0 for both paths, the body was rejected
with the right message, and a follow-up request still works.
Without the fix: ASAN heap-use-after-free on the resolve path; on
release builds the reject path shows `pendingAfterReject: 1` (leak).
Co-authored-by: robobun <robobun@users.noreply.github.com>
liooil
pushed a commit
that referenced
this pull request
Aug 7, 2026
…to prevent UAF (#30057)
## What
`UDPSocket.sendMany()` and `UDPSocket.send()` both captured raw pointers
into the payload's ArrayBuffer backing store (or borrowed
`WTFStringImpl` storage for Latin-1 strings) and then hit JSC safepoints
before handing those pointers to `bsd_sendmmsg`:
- **`sendMany`**: subsequent loop iterations call `iter.next()` (slow
path → `JSObject.getIndex`), `coerceToInt32` on the port, and
`toBunString` on the address
- **`send`**: `parseAddr` calls `coerceToInt32` on the port and
`toBunString` on the address after the payload is captured
Any of these can run user JS that detaches an earlier payload's
ArrayBuffer via `.transfer(newLen)` (which synchronously frees the old
backing store) or drops the last reference to a JSString, leaving the
captured pointer dangling.
## Repro
```js
const buf = new ArrayBuffer(4096);
const payload = new Uint8Array(buf);
const evilPort = {
valueOf() {
buf.transfer(0); // synchronously frees the 4096-byte backing store
return server.port;
},
};
client.sendMany([payload, evilPort, "127.0.0.1"]); // or client.send(payload, evilPort, "127.0.0.1")
// bsd_sendmmsg reads 4096 bytes from the freed region
```
Under ASAN (with `Malloc=1` so bmalloc routes through the system heap):
```
==…==ERROR: AddressSanitizer: heap-use-after-free on address … at pc …
READ of size 4096 at … thread T0
#0 … in read_iovec(…)
#2 … in sendmmsg
#3 … in bsd_sendmmsg packages/bun-usockets/src/bsd.c:123
freed by thread T0 here:
…
#14 … in JSC::arrayBufferCopyAndDetach(…) JSArrayBufferPrototype.cpp:365
…
#30 … in JSC::JSValue::toInt32(…) ← parseAddr's coerceToInt32
```
## Fix
- **`sendMany`**: root every payload JSValue in a `MarkedArgumentBuffer`
for the duration of the call and split the loop into two phases. Phase 1
collects/validates payload JSValues and runs all user-JS re-entrance
(`iter.next`, `parseAddr`). Phase 2 borrows byte slices from the rooted
JSValues once no more user JS sits between capture and `socket.send`. GC
cannot collect a rooted payload; an ArrayBuffer that was detached during
phase 1 reports a zero-length slice instead of a dangling pointer. No
payload bytes are copied.
- **`send`**: reorder so `parseAddr` runs before the payload pointer is
captured. `payload_arg` stays rooted in the callframe, and nothing
between capture and `socket.send` hits a JSC safepoint — so no copy is
needed.
## Verification
- **Without fix:** `bun bd test test/js/bun/udp/udp_socket.test.ts -t
'detaching an ArrayBuffer'` → ASAN heap-use-after-free in `read_iovec` →
`bsd_sendmmsg` for both `send` and `sendMany`, tests fail
- **With fix:** both tests pass; received bytes match the original
payload
- Full `test/js/bun/udp/` suite (207 tests) passes
- `zig:check-all` passes on all targets
---------
Co-authored-by: robobun <robobun@users.noreply.github.com>
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
## Problem
`MarkedArrayBuffer.destroy()` did two things:
```zig
allocator.free(content.buffer.slice()); // free the bytes
allocator.destroy(this); // free *this
```
Every constructor that is actually used (`fromString`, `fromBytes`,
`fromJS`, `fromTypedArray`, `fromArrayBuffer`) returns
`MarkedArrayBuffer` **by value**, so `this` is never an individually
heap-allocated struct — it's a stack local, an embedded field, or an
ArrayList slot. The `allocator.destroy(this)` call passes that interior
pointer to mimalloc.
In the readdir Buffer error-cleanup path (`readdirWithEntries` /
`readdirInner`), entries are appended by value via
`Buffer.fromString()`:
- `allocator.destroy(&entries.items[0])` frees `entries.items.ptr`
- the next loop iteration reads `this.*` from poisoned memory
- `entries.deinit()` frees the same pointer again
## Repro
```js
const fs = require('fs');
// dir contains regular files + a self-referential symlink 'loop -> loop'
fs.readdirSync(dir, { encoding: 'buffer', recursive: true });
```
The recursive walk collects Buffer entries for the root, then fails with
`ELOOP` opening the symlink (not in the swallowed `NOENT/NOTDIR/PERM`
set), and enters the cleanup loop. Under ASAN:
```
==3593==ERROR: AddressSanitizer: use-after-poison on address 0x737ec6e50040
READ of size 64 at 0x737ec6e50040 thread T0
#1 MarkedArrayBuffer.destroy array_buffer.zig:591
#2 NodeFS.readdirInner node_fs.zig:5013
#3 NodeFS.readdir node_fs.zig:4518
```
## Fix
- Drop `allocator.destroy(this)` from `MarkedArrayBuffer.destroy()`. The
struct is passed/stored by value; callers own its storage.
- Remove the unused `MarkedArrayBuffer.init()` (the only function that
heap-allocated the struct, zero callers) so there's no pairing that
would leak.
- The readdir call sites keep calling `.destroy()`, which still checks
`this.allocator` before freeing bytes — JS-owned buffers remain
untouched.
Also fixed the adjacent `Dirent` arm of the recursive-sync error
cleanup: `result.name.deref()` → `result.deref()` so `Dirent.path` is
released too (matching the non-recursive and async cleanup sites).
## Verification
New test in `test/js/node/fs/fs.test.ts` creates a temp dir with files +
a self-referential symlink, spawns a subprocess that calls
`readdirSync({encoding:'buffer', recursive:true})`, and asserts it
throws `ELOOP` and exits 0.
```
# without fix
(fail) readdirSync({encoding: 'buffer', recursive: true}) frees entries safely ...
{ exitCode: 134, stdout: "" } # SIGABRT from ASAN
# with fix
(pass) readdirSync({encoding: 'buffer', recursive: true}) frees entries safely ... [1.5s]
{ exitCode: 0, stdout: "ELOOP" }
```
`zig:check-all` passes on all targets.
---------
Co-authored-by: robobun <robobun@users.noreply.github.com>
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
…worker panic, never retry (#30216) ## What `bun test --isolate` / `--parallel` crashes when a test file loads a native addon whose deferred napi finalizers outlive the file. The `--parallel` coordinator then silently retries the file once, which masks the panic and lets the run exit 0. Fixes #30205, #30191. Supersedes #30214 (same NapiEnv fix, but without the coordinator change, the `cleanup_hooks` retarget, or a test that actually reproduces on unpatched `main`). ## Reproduction ```sh git clone https://github.com/workglow-dev/libs && cd libs bun i && bun run build:packages bun test --timeout=30000 --parallel=4 packages/test/src/test/{util,task}/*.test.ts ``` On `main` (d484fd6), 3–4 workers crash per run with either ``` ASSERTION FAILED: isMarked(cell) JavaScriptCore/heap/Heap.cpp:1232 : void JSC::Heap::addToRememberedSet(const JSCell *) ``` or (when the slot is already being reallocated) ``` ASSERTION FAILED: m_cellState == CellState::DefinitelyWhite JavaScriptCore/JSCellInlines.h:69 : JSC::JSCell::JSCell(VM &, Structure *) ``` and in release builds the segfaults at `0x68` / `0xD0` reported in #30205. ## Root cause Frame-pointer walk from the assertion: ``` #3 Bun::NapiHandleScope::open(Zig::GlobalObject*, bool) #4 NapiHandleScope__open #6 napi.Finalizer.run #7 napi.NapiFinalizerTask.runOnJSThread #10 event_loop.tick #11 event_loop.waitForPromise #13 VirtualMachine.loadEntryPointForTestRunner ← next test file ``` `NapiEnv::m_globalObject` is a raw `Zig::GlobalObject*`. For non-experimental addons (`nm_version != NAPI_VERSION_EXPERIMENTAL`, which is ~every real-world addon — sharp, better-sqlite3, etc.), `napi_wrap`/`napi_create_external` finalizers are **deferred** to the event loop as `NapiFinalizerTask` rather than run inside GC sweep. Objects rooted on the old global (module graph, `globalThis.*`) only become collectable when `Zig__GlobalObject__createForTestIsolation` runs `gcUnprotect(oldGlobal)`. The `DeferGC` from #29573 ends at that function's `}`, so the next GC runs there, collects those objects, and enqueues their finalizers. Those tasks then run on the very next `eventLoop().tick()` — inside `loadEntryPointForTestRunner`'s `waitForPromise` for file N+1. `Finalizer.run` opens a `NapiHandleScope` via `env->globalObject()`, which reads `NapiHandleScopeImplStructure()` off the dead cell and writes `m_currentNapiHandleScopeImpl` on it → write barrier on an unmarked cell. The `--parallel` coordinator's `reapWorker` then re-queued the file once (`retries[idx] < 1`) into a fresh worker with no stale `NapiEnv`, which passed — so the run reported 0 fail despite multiple Bun panics in the log. ## Fix **NapiEnv retarget** (`ZigGlobalObject.cpp`, `napi.h`): `Zig__GlobalObject__createForTestIsolation` now calls `newGlobal->adoptNapiEnvsForTestIsolation(oldGlobal)` before `gcUnprotect`. Each `NapiEnv::m_globalObject` is repointed at the new global and the `Ref<NapiEnv>`s are moved over, so late finalizers open handle scopes on a live global and the envs stay owned after the old global is swept. `VirtualMachine.swapGlobalForTestIsolation` also repoints `rare_data.cleanup_hooks[*].globalThis` so `CleanupHook.eql()` stays accurate. **No retry, abort on panic** (`Coordinator.zig`): removed the per-file retry. A worker that dies mid-file is counted as one failure. If it died by a fatal signal (SIGILL/SIGTRAP/SIGABRT/SIGBUS/SIGFPE/SIGSEGV/SIGSYS — Bun's own `@trap()`, a JSC/WTF assertion, or native-addon crash), the whole run aborts with `error: a test worker process crashed with <SIG> while running <file>`. `process.exit()` / SIGKILL are still just a per-file failure and the run continues. ## Verification - `test/regression/issue/30205.test.ts` — 4 tests. Adds a tiny non-experimental addon (`isolate_finalizer_addon.c`) and a fixture pattern (`Bun.gc(true)` + module-scope `await 0` + objects rooted on `globalThis`) that crashes **8/8** on unpatched `main` and passes 8/8 with this change. - `workglow-dev/libs` full 201-file unit suite: 3× clean `--parallel=4` runs (was 3–4 crashes/run). - Gate: `git stash -- src/ && bun bd test test/regression/issue/30205.test.ts` → 3/4 fail; with fix → 4/4 pass. - `test/cli/test/isolation.test.ts`, `test/regression/issue/29519.test.ts` → pass (one pre-existing unrelated timeout in isolation.test.ts, same as #29573). - `test/cli/test/parallel.test.ts` → all tests I touched pass; the 3 timing-sensitive scale-up/work-steal tests that fail in this container fail identically on unmodified `main`. --------- Co-authored-by: robobun <robobun@users.noreply.github.com> 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
…31219)
### What does this PR do?
Fixes a fuzzer-reported SIGSEGV (fingerprint `a8d2694be898a53f`) in the
`Bun.jest()` / `expect` statics area. The reported reproducer exercises
`Bun.jest().expect`, `expect.extend()`, and `new` on an expect static:
```js
const v2 = Bun.jest().expect;
try { v2.extend(); } catch (e) {}
const t6 = v2.arrayContaining;
new t6();
Bun.gc(true);
```
**Root cause (primary fix):** matchers registered through
`expect.extend()` are wrapped in `JSWrappingFunction`.
`JSWrappingFunction::create` passed `nullptr` as the native constructor
to `VM::getHostFunction`. JSC only treats
`callHostFunctionAsConstructor` as "not constructible", so the wrapper
was considered constructible with a null native constructor — `new
expect.someCustomMatcher()` jumps straight to address 0:
```
Thread 1 received signal SIGSEGV, Segmentation fault.
#0 0x0000000000000000 in ?? ()
#3 llint_op_construct ()
```
Deterministic repro (crashes on current main, raw SIGSEGV with no output
— matching the fuzzer's crash signature):
```js
const e = Bun.jest().expect;
e.extend({ myMatcher() { return { pass: true, message: () => "" }; } });
new e.myMatcher();
```
The fix passes `callHostFunctionAsConstructor` so `new` on a wrapped
matcher throws `TypeError: function is not a constructor` like other
native functions.
**Secondary hardening (same area):** if
`Bun__Jest__createTestModuleObject` ever fails it returns an empty
`JSValue`, and the `m_lazyTestModuleObject` initializer called
`toObject()` on it — a null-cell dereference. The initializer now falls
back to a plain object, `Bun__Jest__testModuleObject` surfaces the
pending exception, `Bun.jest()` maps it to a thrown JS error, and the
`xdescribe` arm of `create_test_module` propagates its error instead of
returning an empty value as success.
### How did you verify your code works?
- `new (expect.extend-registered matcher)()` segfaults on the baked
build and throws a `TypeError` with this change.
- Regression test added to `test/js/bun/test/bun-test.test.ts`: it
spawns a subprocess that registers a custom matcher, constructs it (plus
the original fuzzer shape: `extend()` with no args, `new
expect.arrayContaining`), runs `Bun.gc(true)`, and asserts a clean exit.
The test fails on the baked build (`USE_SYSTEM_BUN=1`) with the
subprocess dying from SIGSEGV, and passes with this change.
- `bun bd test` on `bun-test.test.ts`, `expect-extend.test.js`,
`jest-extended.test.js`, `expect-extend-asymmetric-match-throw.test.ts`,
`expect-extend-preload.test.ts`, `describe.test.ts`,
`jest-each.test.ts`, `expect-symbol-toPrimitive-crash.test.ts` — all
pass.
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
…sweep (#32729) ### Crash ``` ASSERTION FAILED: vm().currentThreadIsHoldingAPILock() => vm().heap.mutatorState() != MutatorState::Sweeping vendor/WebKit/Source/JavaScriptCore/runtime/JSCell.cpp(179) : bool JSC::JSCell::validateIsNotSweeping() const ``` Backtrace (from a release-asan build with asserts): ``` #3 JSC::JSCell::validateIsNotSweeping() #4 JSC::JSCell::classInfo() const #5 WTF::uncheckedDowncast<WebCore::JSResumableFetchSink>(JSValue const&) #6 ResumableFetchSinkPrototype__ondrainSetCachedValue #7 bun_runtime::webcore::fetch::fetch_tasklet::FetchTasklet::ignore_remaining_response_body #8 JSC::WeakBlock::sweep() <- inside GC sweep (Weak finalizer) #9 JSC::WeakSet::sweep() #10 JSC::PreciseAllocation::sweep() #12 JSC::Heap::finalize() #21 JSC::LocalAllocator::allocateSlowCase #23 JSC::ErrorInstance::create <- ordinary allocation kicked off GC ``` Found by the syscall fault-injection fuzzer's client-side grammar scenario (fetch/node:http with abort + transient errno on the client socket). Reproduces ~4/5 under `BUN_JSC_collectContinuously=1`. ### Cause `FetchTasklet::on_response_finalize` is the `WeakRefOwner<FetchResponse>::finalize` callback and runs inside `WeakBlock::sweep` while `MutatorState == Sweeping`. When the response body is `Locked` without a pending promise or stream it calls `ignore_remaining_response_body()`, which called: - `ResumableSink::detach_js()`: writes the sink wrapper's cached `ondrain` / `oncancel` / `stream` slots via the generated `ResumableFetchSinkPrototype__*SetCachedValue` helpers. Each does `uncheckedDowncast<JSResumableFetchSink>(thisValue)`, which reaches `JSCell::classInfo()` and then issues a write barrier on the wrapper cell. - `clear_stream_handlers()`: reaches `ReadableStreamTag__tagged` -> `object->inherits<JSReadableStream>()` (guarded today, but one boolean away). Calling `classInfo()` on any cell while the mutator is sweeping is forbidden: the cell's `Structure` may already have been swept. Assert builds catch it; release builds corrupt the heap. ### Fix Thread a `from_finalizer` flag through `ignore_remaining_response_body`. When `true` (the `on_response_finalize` caller) skip `detach_js()` and `clear_stream_handlers()`; only native state is touched. The sink's JS-side detach still happens from `clear_sink()` in `FetchTasklet::deinit()`, which runs as an event-loop `ConcurrentTask` outside any sweep, so nothing leaks. The `on_stream_cancelled_callback` caller (reader `.cancel()`, runs from JS on the event loop) passes `false` and keeps the immediate detach. Also corrects the `ResumableSink::detach_js` doc comment that claimed finalizer safety. ### Verification New test at `test/js/web/fetch/fetch-response-finalizer-sweep.test.ts`: a child process under `BUN_JSC_collectContinuously=1` does 12 iterations of `fetch()` with a user-constructed `ReadableStream` body (so the sink takes the JS route with a Strong `js_this`) against a raw TCP server that sends headers + a partial chunked body and never terminates it, then drops the `Response` unconsumed and runs `Bun.gc(true)`. Without the fix (`bun bd`, src/ stashed): ``` exitCode: 134 stderr: ASSERTION FAILED: vm().currentThreadIsHoldingAPILock() => vm().heap.mutatorState() != MutatorState::Sweeping ``` With the fix: `stdout: "ok"`, `exitCode: 0`. `test/js/web/fetch/fetch-backpressure.test.ts` (exercises the `on_stream_cancelled_callback` path) passes unchanged. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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
### 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
…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
…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
…#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
…e (#35255) `test/js/bun/http/serve-protocols.test.ts` has been going red on main (build 78445 darwin-x64 hard, 78462 debian-11-aarch64 hard, plus 78300/78419 with retries), always as ``` error: HTTP3StreamReset fetching "https://127.0.0.1:<port>/echo" ✗ Bun.serve over http/3 > POST echo 1000000 bytes [20169.37ms] ``` Reproduced on Linux by looping the file: the h3 subset alone fails about 17 of 100 runs. ## Cause The ten concurrent h3 tests share one lsquic client engine and one unconnected UDP socket. `bsd_create_udp_socket()` sets `IP_RECVERR` on every UDP socket (for `node:dgram`'s error surfacing, #28827), including QUIC's. When a finished test `proc.kill()`s its server, the client session is still in the engine and keeps scheduling retransmits / `NEW_CONNECTION_ID` to an unbound port. With `IP_RECVERR` on, the resulting ICMP port-unreachable is queued on the shared socket and the next `sendmmsg` returns `-1 ECONNREFUSED`, even though that call is sending a datagram to a live peer. `us_quic_packets_out` reports that as a short return, lsquic clears `ENPUB_CAN_SEND` for the whole engine and only its one-second `resume_sending_at` failsafe re-enables it. With several dead sessions generating ICMPs, every failsafe retry fails the same way and the live 1MB upload never advances; the 20s in CI is two idle-timeout rounds through `retry_or_fail`. While tracing that I also found an unsigned underflow in lsquic's `send_batch` requeue loop: when the first unsent spec in a batch coalesces multiple packets (`pack_off[0] == 0`, `iovlen > 1`), `end = &batch->packets[off - 1]` indexes with `UINT_MAX` and only the last packet of the coalesced group is returned to the connection. The earlier ones are the INIT ACK and the HSK CRYPTO carrying the client Finished, so the peer can never complete the handshake. This is the same hang reached from a different direction (real EAGAIN backpressure instead of stale ICMP). ## Fix - `IP_RECVERR` is now opt-in via `LIBUS_UDP_LINUX_RECVERR`, set by `us_create_udp_socket` when a `recv_error_cb` is provided. `node:dgram` always passes one and keeps the option; QUIC passes `NULL` and no longer gets it. This matches libuv's `UV_UDP_LINUX_RECVERR` gating that `bsd.c` already cited. - `us_quic_packets_out()` retries once on a non-`EAGAIN`/`ENOBUFS` send failure before reporting a short return, so a stale `sk_err` that does surface cannot pause the engine. Both the `sendmmsg` and per-packet paths now go through `US_FAULT_CHECK(US_FAULT_SENDMSG, ...)` so the short-return path is reachable from tests. - `patches/lsquic/requeue-unsent-coalesced.patch` rewrites the requeue loop's bounds as `[off, off+count)` so every packet in an unsent coalesced datagram is returned to the connection. The same underflow is present in upstream lsquic master; I will open a PR there separately. - `serve-protocols.test.ts` now stops each fixture server gracefully on stdin close (`server.stop(true)`), matching `serve-http3.test.ts`, so the pooled client session sees `CONNECTION_CLOSE` instead of leaving the engine retransmitting to unbound ports. - `test/js/web/fetch/fetch-http3-syscall-fault.test.ts` injects `EAGAIN` on the coalesced handshake datagram (the `pack_off[0]==0`, `iovlen>1` spec the lsquic patch fixes), a one-shot `ECONNREFUSED` that the retry-once branch consumes, and a burst of `EAGAIN` that the `on_drain` path recovers from. ## Verification Release build, looped: | | before | after | | --- | --- | --- | | `serve-protocols -t "http/3"` | 17/100 fail | 2/100 fail | | `serve-protocols` (full) | 7/100 fail | 4/200 fail | Debug+ASAN: `serve-protocols`, `serve-http3` (46), `fetch-http3-client` (52), `fetch-http3-adversarial` (29), `fetch-http3-syscall-fault` (3) and `dgram.test.ts` all pass, 211 tests total. The residual ~1-2% is a separate pre-existing bug (the client's 36-byte HSK CRYPTO is buffered but never flushed when `drain_send_body` writes the whole 1MB body synchronously from `on_stream_open`); I've handed that off as its own issue. With CI's retry it is well under the flake threshold. ### Gate note The fault-injection hook that makes the new test deterministic lives in `packages/bun-usockets/src/quic.c`, so `git stash -- src/ packages/` removes it along with the fix and the fault never fires. The lsquic piece lives in `patches/` and `scripts/`, which the stash does not touch. That means a single stashed run passes (no fault, no stall) and a single unstashed run passes (fault fires, fix handles it), and the gate cannot distinguish them mechanically. The 300-iteration probe above is the evidence; the fault-injection tests pin the behavior going forward. <details> <summary>lsquic debug trace of the stall</summary> ``` engine: packets out returned 0 (out of 1) [C919…] event: unsent packet #15 ACK_FREQUENCY, size 36 [C919…] sendctl: packet #15 has been delayed engine: send_packets_out: sent 0 packets … <- no "can send again"; nothing for 1s engine: failsafe activated: resume sending packets again after timeout engine: packets out returned 0 (out of 10) <- fails again, live conn's #207 included ``` and for the underflow, a batch with `pack_off[0]=0`, `iovlen[0]=3`: ``` engine: packets out returned 0 (out of 2) event: unsent packet #3 ACK PADDING, size 1059 event: unsent packet #4 ACK CRYPTO, size 87 event: unsent packet #5 NEW_CONNECTION_ID, size 54 event: unsent packet #6 STREAM, size 114 sendctl: packet #6 has been delayed sendctl: packet #5 has been delayed … <- #3 and #4 never requeued [WARN] sendctl: send history gap 2 - 5 ``` </details> <!-- robobun:evidence:begin --> --- **no test proof** · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/fetch/fetch-http3-syscall-fault.test.ts <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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
…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
website/website/to GitHub PagesWhy
Poly needs a public project page at
https://liooil.github.io/poly/whose content and deployment configuration live with the runtime source.Validation
actionlintgit diff --checkpassed