You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
191edc image: preserve ICC profile through WebP decode/encode (#30211)
Closes #30197. Follow-up to #30201, which added ICC carry-through for
JPEG and PNG but left WebP dropping the profile because libwebpmux/libwebpdemux weren't linked.
Repro
// any JPEG/PNG with an embedded ICC profile — P3, Adobe RGB, Jpegli XYBawaitBun.file("p3.png").image().webp().write("out.webp");// out.webp had no ICCP chunk → viewers reinterpret as sRGB → colours shift
And the reverse direction: a WebP carrying an ICCP chunk lost it on
decode, so webp → png/jpeg also shifted colour.
Cause
WebP stores ICC profiles in an ICCP chunk inside a VP8X RIFF container
that wraps the VP8/VP8L bitstream. WebPDecodeRGBA/WebPEncodeRGBA
only touch the bitstream chunk; reading or writing sibling chunks needs
the separate demux/mux APIs, and Bun only compiled src/{dec,enc,dsp,utils}.
Fix
Build (scripts/build/deps/libwebp.ts): add src/demux/*.c and src/mux/*.c from the same libwebp checkout. Plain C, no new deps, same
include paths.
Decode (src/image/codec_webp.zig): after WebPDecodeRGBA, run WebPDemux on the original bytes, check WEBP_FF_FORMAT_FLAGS & ICCP_FLAG, and WebPDemuxGetChunk("ICCP") the profile into Decoded.icc_profile (duped into bun.default_allocator to match
JPEG/PNG ownership). A plain VP8/VP8L WebP with no VP8X wrapper falls
through with null.
Encode: webp.encode now takes icc_profile: ?[]const u8. When null/empty, keep the existing one-shot WebPEncodeRGBA fast path
(bare VP8/VP8L, no VP8X). When set, pass the bitstream through WebPMuxSetImage + WebPMuxSetChunk("ICCP") + WebPMuxAssemble to
produce a VP8X-wrapped file and hand the assembled buffer to JS with WebPFree as the finaliser.
codecs.zig / Image.zig / bun.d.ts comments updated to drop the
"WebP loses the profile" caveat.
Verification
New tests in the existing describe("ICC profile") block of test/js/bun/image/image.test.ts walk the output RIFF container to find
the ICCP fourcc and compare the payload byte-for-byte:
PNG iCCP → WebP lossy → ICCP chunk present, VP8X flag bit set
When a specifier contains non-ASCII characters, specifier.toUTF8() in resolveMaybeNeedsTrailingSlash heap-allocates a UTF-8 buffer (because
the underlying WTF string is Latin-1 or UTF-16 and needs converting).
For http://, https://, and // prefixes the resolver marks the
specifier as external and returns a Path.init(import_path) that points
directly into that temporary buffer.
resolveMaybeNeedsTrailingSlash then wrapped that slice in a borrowing bun.String.init(result.path) and freed the buffer via defer specifier_utf8.deinit() before returning. Callers in both Zig
(doResolveWithArgs) and C++ (moduleLoaderResolve, moduleLoaderImportModule) subsequently read poisoned memory when
formatting or converting the result to a JS string.
The query_string out-param had already been fixed to clone in the same
way; result.path needed the same treatment.
How
Clone result.path into an owned bun.String via bun.String.cloneUTF8.
The hardcoded-builtin branch that returned specifier now returns specifier.dupeRef() so all success paths return an owned string.
All callers (doResolveWithArgs, NodeModuleModule.findPath, and the
two C++ Zig__GlobalObject__resolve call sites) now deref() the
successful result after use.
This also fixes a pre-existing leak where onResolveJSC (plugin
onResolve) returned an owned WTFStringImpl that was never deref'd.
b34c77 Shrink Windows binary: lazy-heap threadlocal PathBuffers + /OPT:SAFEICF (#30219)
Windows bun.exe is ~15 MB larger than Linux bun and ~40 MB larger
than macOS. Section-contribution analysis of the canary PDB shows where
it goes:
Section
Windows
Linux
Delta
Cause
.text
60.0 MB
55.8 MB
+4.2 MB
/OPT:NOICF (Linux uses
-icf=safe)
.rdata
36.6 MB
32.4 MB
+4.2 MB
ICU data + no tail merge
.pdata
1.0 MB
—
+1.0 MB
x64 SEH unwind (required)
.tls
4.8 MB
280 B
+4.8 MB
this PR
.reloc
0.2 MB
—
+0.2 MB
ASLR
.tls — 4.8 MB of literal zeros
5,069,287 of 5,069,312 bytes (99.9995%) of the .tls section are 0x00. Of that, 4,998,432 bytes come from bun-zig.o.
Root cause: bun.PathBuffer is [std.fs.max_path_bytes]u8. On Windows
that's 32767*3+1 = 98302 bytes (vs 4096 on POSIX). There are ~50 threadlocal var x: bun.PathBuffer = undefined declarations — resolver.zig alone has 25 of them in the bufs struct (~2.5 MB).
PE/COFF has no TLS-BSS equivalent and lld-link doesn't use IMAGE_TLS_DIRECTORY.SizeOfZeroFill, so every zero-initialized
threadlocal is written into the .tls section as raw zeros in the file and copied into every thread's TLS block at creation whether or not
that thread ever touches the resolver.
Fix: new bun.ThreadlocalBuffers(T) wraps a struct of large buffers
behind a single lazily-heap-allocated per-thread pointer. 8 bytes on
disk per instantiation; backing memory allocated on first get().
Applied to:
resolver.zigbufs() (25 PathBuffers + [2*MAX_PATH_BYTES] win32
buf) — the accessor signature is unchanged so callers don't move
Expected .tls after: ~8 KB (pointers + the few small non-PathBuffer
threadlocals).
Secondary benefit: threads that never hit the resolver/installer (e.g. Workers running pure compute) no longer pay ~5 MB of TLS-block copy at
spawn.
Linker flags (Windows release)
/OPT:NOICF → /OPT:SAFEICF. The previous attempt (commit
d7c6d59f02) used aggressive /OPT:ICF, which folded callBigIntConstructor with constructWithBigIntConstructor
(byte-identical bodies that both throw) → JSC's InternalFunction
pointer-identity check broke → "BigInt is not a constructor" and expect.any(Ctor) failures → reverted in 218430c731. /OPT:SAFEICF
(lld-specific) skips address-taken functions, which is exactly what
those ClassInfo function pointers are, so the identity checks survive.
This is the same guarantee Linux already gets from -Wl,-icf=safe.
/OPT:lldtailmerge — lld-specific string-literal tail merging; no
MSVC link.exe equivalent.
/FILEALIGN:0x200 — was in the old CMake config (kept alongside
the /OPT:NOICF revert), lost in the ninja migration.
What this does NOT touch
Debug symbols: PDB generation unchanged (/DEBUG:FULL still set;
PDB is a separate file).
napi / libuv: src/symbols.def unchanged; no exports removed.
ICU data (24.6 MB of .rdata): also present on Linux; macOS uses
system libicucore. Windows icu.dll isn't ABI-compatible with what
WebKit needs without upstream changes, so it stays for now. The existing icupkg -r filter already removes ~6.8 MB of converters/translit/rbnf.
.pdata (1.0 MB): Windows x64 SEH unwind tables are required for
structured exception handling and can't be stripped.
test/js/bun/jsc/native-constructor-identity.test.ts added as a
trip-wire for the ICF constructor-identity regression (BigInt/Symbol not
constructable, expect.any across Map/Set/WeakMap/WeakSet, all 11
typed-array constructors distinct, Request/Response/Blob distinct)
Expected Windows x64 reduction: ~5 MB from .tls alone; SAFEICF +
tailmerge + FILEALIGN should recover another ~2–4 MB from .text/.rdata. Actual numbers from Windows CI artifacts.
bab007 socket: set Handlers.mode=.client for Windows named-pipe Bun.connect (#30150)
Repro
Windows only:
awaitBun.connect({unix: '\\\\.\\pipe\\x',socket: {data(){},open(){},close(){}},});// then close (or fail) the connection
On close, Handlers.markInactive() hits active_connections == 0 with .mode == .server and does @​fieldParentPtr("handlers", this)
expecting an enclosing Listener — but the handlers live in a
standalone allocator.create(Handlers) block, so reading listen_socket.listener falls past the allocation. Under ASAN that's a
heap-buffer-overflow; on release it reads garbage and — because the .client branch is skipped — leaks the block.
Cause
connectInner() calls SocketConfig.fromJS(vm, opts, globalObject, true) at Listener.zig:564. The last argument is is_server, which
feeds handlers.mode. It was false until 4a06991d3b (#23755) flipped
it during a bindings-generator refactor.
The non-pipe path at :797 has always had an explicit handlers_ptr.mode = .client after copying into the heap block (it was handlers_ptr.is_server = false before #26539), which masked the flip
everywhere except the Windows named-pipe early-return at :655–656, which
never had one.
is_server is only used to set handlers.mode; nothing else in SocketConfig.fromGenerated / Handlers.fromGenerated branches on it.
Fix
Restore is_server=false at the connectInner call site (this is the
client connect path).
Add the same defensive handlers_ptr.mode = .client on the named-pipe
branch to mirror the non-pipe branch, so the two copies into a
standalone Handlers block look the same.
Audited the other standalone allocator.create(Handlers) sites:
socket.zig:1557 — sourced from Handlers.fromJS(..., false),
already .client.
socket.zig:2062 — explicit .mode = if (is_server) .duplex_server else .client.
Verification
bun run zig:check-all passes (all targets, including both Windows
arches).
New Windows-only tests in test/js/bun/net/socket.test.ts:
Listen on a named pipe, Bun.connect to it, close → clean exit.
Bun.connect to a non-existent pipe → rejects, clean exit.
Both are spawned in a subprocess so an ASAN crash surfaces as a non-zero
exit instead of killing the test runner. Skipped on non-Windows (the if (Environment.isWindows) branch is unreachable there, and the non-pipe
path's :797 override already covers it).
4f13b9 bun -p: return module completion value, not first yielded await (#30208)
Repro
$ bun -p '(await 1) + 1'1
$ bun -p 'await Promise.resolve("hello") + " world"'hello
Expected: 2 and hello world.
Cause
--print uses ESM module evaluation and captures the last expression
value via EvalGlobalObject::moduleLoaderEvaluate in src/bun.js/bindings/ZigGlobalObject.cpp. For a module with top-level await, JSC generator-ifies the body; the first call into moduleLoader->evaluate() yields the awaited value (1), not the
module's final completion value (2). That yielded value was stored as
the eval result.
The async resume path (asyncModuleExecutionResume in vendor/WebKit/.../JSMicrotask.cpp) calls module->evaluate()
directly and bypasses the moduleLoaderEvaluate hook, so the hook
could never observe the final value and correct itself.
Fix
After the initial evaluateNonVirtual call, inspect the module
record's generator state. If it yielded (state is a number other than Executing), the module still has work left and result is the
awaited value. Store the module's asyncCapability() promise instead
— its eventual resolution is the module's actual completion value.
The bun -p loop in src/bun.js.zig already unwraps promises via asAnyPromise + Bun__onResolveEntryPointResult, so no Zig-side
changes are needed. For non-TLA modules, behavior is unchanged (state
is Executing, result stored as before).
Verification
USE_SYSTEM_BUN=1 bun test test/cli/run/run-eval.test.ts -t 'bun -p'
→ 3 fail, 1 pass
bun bd test test/cli/run/run-eval.test.ts -t 'bun -p' → 4 pass
Full test/cli/run/run-eval.test.ts (33 tests) and TLA regression
tests still pass.
Fixes #30207
Co-authored-by: robobun <robobun@bun.sh>
6acb78 Make it easy to compare canary vs previous release build size
31c494 socket: balance ref on synchronous doConnect failure for reused sockets (#30168)
Listener.connectInner unconditionally socket.ref()s before calling doConnect, for both freshly-allocated sockets and reused ones passed
as prev (the node:net path — _handle is a detached native socket
from newDetachedSocket).
When doConnect fails synchronously (ENOENT unix path, bad fd, EMFILE),
the socket never leaves .detached, so handleConnectError's needs_deref = !this.socket.isDetached() is false and its own deref
does not fire. The caller is responsible for balancing the ref — but the
existing line only did so when maybe_previous == null:
if (maybe_previous==null) socket.deref();
That guard was added in #23936 to fix the Bun.connect({fd: badFd})
leak (fresh-socket case) but left the reused-socket case unbalanced: one
native TCPSocket struct + its connection string leak per failed
reconnect.
Fix
Drop the guard. The ref() at :849 is unconditional, so the balancing deref() on sync failure must be too.
Verification
New test in test/js/node/net/node-net.test.ts does 20k failed unix
connects in a subprocess and samples RSS after equal-sized work units. A
real leak grows linearly; noise plateaus.
RSS growth over 12.5k post-warmup iterations
before (debug+ASAN)
~14 MB
before (release)
~6 MB
after (debug+ASAN)
±1 MB
Threshold 3 MB. The original #23936 test (Bun.connect with bad fd) and socket-retention.test.ts still pass.
a47ccf socket: null handlers pointer after client-mode Handlers are freed (#30176)
What
Follow-up to #30148, which nulls this.handlers in the socket's markInactive() after Handlers.markInactive() frees the client-mode
allocation. That covers the onClose → This.markInactive() path, but
not the paths where scope.exit() is the decrement that frees the
handlers — most notably handleConnectError, where the socket never
reaches markActive() so is_active == false and the deferred this.markInactive() is a no-op.
Handlers.markInactive() (client mode, active_connections → 0) does this.deinit(); vm.allocator.destroy(this). Any caller that still holds
the pointer — the socket's handlers field — must clear it, otherwise:
Listener.connectInner at :664 / :728 / :814 — a reconnect through
the same native socket as prev calls prev_handlers.deinit() then allocator.destroy(prev_handlers) on freed memory (UAF + double-free).
socket.ziggetListener — reads handlers.mode on freed memory.
Repro
constnet=require('node:net');consts=newnet.Socket();lethandle;s.on('error',()=>{});s.once('connectionAttemptFailed',()=>{handle=s._handle;});s.on('close',()=>{// handleConnectError's scope.exit() freed the Handlers; the// socket-level markInactive() never ran (is_active == false).handle.listener;// ← UAF on current main});s.connect(1,'127.0.0.1');
Under debug+ASAN on current main (after #30148):
AddressSanitizer: use-after-poison
READ of size 1 ...
#0 NewSocket(false).getListener src/bun.js/api/bun/socket.zig:769
Fix
Handlers.markInactive() and Scope.exit() now return whether the
allocation was destroyed. This.markInactive() and every scope.exit()
site in socket.zig null this.handlers when it was. This replaces
#30148's mode-based check with the precise destroyed signal and extends
it to the handleConnectError / handleError paths.
Verification
Three tests in test/js/node/net/node-net.test.ts (gated to
debug/ASAN):
main (incl. #30148)
with this PR
handle.listener after connectError
ASAN use-after-poison @
getListener
undefined
handle.listener after close
passes (covered by #30148)
passes
reconnect via saved native handle
passes (covered by #30148)
passes
The first test is the one that demonstrates this PR's incremental fix.
797dee crypto: fix f32 precision loss and unit mismatch in randomFill bounds checks (#30134)
What does this PR do?
Fixes two bounds-checking bugs in crypto.randomFill / crypto.randomFillSync:
Heap overflow via f32 precision loss in the size + offset > length check
Unit mismatch in the 3-arg randomFill(buf, offset, cb)
default-size computation causing integer underflow or silent under-fill
for multi-byte typed arrays
Reproduction
// (1) writes 1 byte past the end of the allocation instead of throwingrequire('crypto').randomFillSync(newArrayBuffer(16777218),16777217,2);// (2a) panics in debug / throws spurious ERR_OUT_OF_RANGE in releaserequire('crypto').randomFill(newFloat64Array(10),2,()=>{});// (2b) leaves bytes 744..800 un-randomizedrequire('crypto').randomFill(newFloat64Array(100),1,()=>{});
Node.js throws ERR_OUT_OF_RANGE for (1) and fills the full tail for
(2).
Root cause
(1) In assertSize, the u32offset was cast to f32 before
being added to the f64size:
if (size+@​as(f32, @​floatFromInt(offset)) >@​as(f64, @​floatFromInt(length))) {
f32 only represents integers exactly up to 2²⁴ = 16777216. An offset
of 16777217 rounds down to 16777216, so with length = 16777218 and size = 2 the check evaluates 2 + 16777216 > 16777218 → false, when
the true sum 16777219 exceeds length. The bogus offset/size are then
used to slice the buffer (sync) or handed to the threadpool as a raw [*]u8 span (async), producing an OOB write.
(2) In randomFill's 3-arg branch, the default size was computed as buf.len - offset where buf.len is the element count but offset
had already been scaled to a byte offset by assertOffset. For Float64Array(10) with offset 2, that's 10 - 16 → usize underflow.
Fix
Change all four f32 casts in assertOffset / assertSize to f64
(exact for all integers up to 2⁵³, well beyond max_possible_length).
In the 3-arg branch, set size_value = .js_undefined to fall through
to the existing buf.byte_len - offset default, keeping both operands
in byte units.
Verification
bun bd test test/js/node/crypto/crypto-random.test.ts — 14 pass, 0
fail
6d73f5 fs: deref Dirent.path in readdirSync recursive error cleanup (#30167)
What
When fs.readdirSync(dir, { recursive: true, withFileTypes: true })
fails partway through (e.g. a subdirectory returns ELOOP/EACCES on
open), the error-path cleanup in readdirInner was only calling result.name.deref() on each collected Dirent, leaking the ref on Dirent.path that was taken via dirent_path_prev.ref() in readdirWithEntriesRecursiveSync.
The async recursive path (AsyncReaddirRecursiveTask.performWork) and
the non-recursive path (readdirWithEntries) already call Dirent.deref() which releases both name and path. This brings the
sync-recursive error path in line.
Repro
constfs=require('fs');// dir contains a self-referential symlink at depth 2, so the BFS walker// collects a bunch of Dirents before hitting ELOOP and unwinding.for(leti=0;i<30000;i++){try{fs.readdirSync(dir,{recursive: true,withFileTypes: true});}catch{}}// RSS grows linearly with iteration count
Verification
The new test builds a wide tree under a long path with a symlink loop at
depth 2, warms up to saturate ASAN quarantine, then runs 20k failing readdirSync calls and asserts RSS growth stays under 64 MB.
d971e4 webcore/Blob: free allocations on truncated structured-clone deserialize (#30152)
Problem
_onStructuredCloneDeserialize in src/bun.js/webcore/Blob.zig reads a
Blob/File from untrusted bytes — reachable via require('bun:jsc').deserialize, require('node:v8').deserialize, and
cross-process IPC advanced serialization. It allocates at several points
along the way:
readSlice allocates a buffer, reads into it, and returns error.TooSmall on a short read — without freeing the buffer.
content_type is allocated with no errdefer; every subsequent try leaks it.
The bytes payload is allocated and wrapped in a stack Blob (owning
a Store) with no errdefer; the following stored_name
length/payload reads leak the whole thing on truncation.
The stack Blob is heap-promoted via Blob.new; the trailer reads
(is_jsdom_file, last_modified, v3 File name) then leak the heap *Blob, its Store, and its bytes.
The stored_name slice is leaked when the store is null
(zero-length bytes payload).
This is distinct from #30072, which fixed the out-of-bounds offset
clamp in the same function; this is the error-path cleanup.
readSlice: errdefer allocator.free(slice) so a short read releases
the buffer.
After content_type allocation: errdefer allocator.free(content_type) — it isn't attached to the blob until the
very end of the success path.
Inside the .bytes arm: errdefer blob.deinit() on the stack blob so
the Store (and its bytes) are released when the stored_name reads
fail; free name explicitly when there is no store to own it.
After the switch: errdefer blob.deinit() on the heap *Blob so
the trailer reads release the heap object, its Store, and its bytes.
Verification
Two new tests in test/js/web/structured-clone-blob-file.test.ts:
truncated payload at every byte boundary throws cleanly —
serializes a File, slices it at every byte offset, and asserts each deserialize throws rather than crashing or returning a half-built
Blob. Sweeps every error edge in the decoder.
truncated payload does not leak ... — serializes a File with
64 KiB of content-type and 64 KiB of body, truncates at five points
chosen to land after each allocation site, and loops deserialize on
them. Measures RSS across 1500 iterations after a warmup.
d6f215 fix(Bun.serve): HEAD response Transfer-Encoding/Content-Length freed before write (#30155)
Repro
Bun.serve({port: 0,fetch: ()=>newResponse("hello",{headers: [["Transfer-Encoding","gzip"],["Transfer-Encoding","chunked"],],}),});// HEAD / → ASAN heap-use-after-free in uWS::HttpResponse::writeHeader
The duplicate entries make FetchHeaders combine them via makeString(), producing a StringImpl held only by the header map —
the minimal condition for the free to actually happen.
StringImpl is allocated via bmalloc which ASAN doesn't instrument by
default; with Malloc=1 (bmalloc → system heap) the debug build
reports:
doRenderHeadResponse() calls headers.fastGet(.TransferEncoding),
which returns a ZigString that borrows the header map entry's StringImpl bytes (no ref taken). For an ASCII value, toSlice() also
borrows rather than copying. It then calls this.renderMetadata(),
whose doWriteHeaders() does headers.fastRemove(.TransferEncoding)
(and renderMetadata also swapInitHeaders() + deref()s the whole FetchHeaders). When the map held the only reference to the StringImpl, it's destroyed right there — and the very next line resp.writeHeader("transfer-encoding", transfer_encoding_str.slice())
writes the freed bytes to the socket.
The adjacent Content-Length branch has the same bug: std.fmt.parseInt() runs on the borrowed slice after renderMetadata() has already fastRemove(.ContentLength)'d it.
Fix
Transfer-Encoding: use toSliceClone() instead of toSlice() so
the value is owned and survives renderMetadata().
Content-Length: parse the integer beforerenderMetadata() (and
drop the slice immediately), so the borrowed bytes are never touched
after the header entry is removed. No extra allocation needed since only
the parsed usize is used afterwards.
Verification
New test in test/js/bun/http/bun-server.test.ts (inside the existing HEAD requests #15355 block) spawns a subprocess with Malloc=1
(non-Windows), serves HEAD responses whose Transfer-Encoding /
Content-Length values are makeString()-combined (sole-owner
StringImpl), and asserts the raw wire output.
git stash push -- src/ → test fails with "AddressSanitizer: heap-use-after-free" in stderr
git stash pop → test passes
All other tests in the HEAD requests #15355 describe block continue to
pass.
096a24 sql(mysql): pin ArrayBuffer backing store while binding BLOB parameters (#30159)
Repro
constbuf=newArrayBuffer(64);constta=newUint8Array(buf);for(leti=0;i<ta.length;i++)ta[i]=i;constvalues=[1,ta,"placeholder"];letcalls=0;Object.defineProperty(values,"2",{get(){if(++calls>=2&&buf.byteLength>0){// zero-copy transfer: same backing pointernewUint8Array(buf.transfer()).fill(0xff);}return"evil";},});awaitsql.unsafe(`INSERT INTO t (id, data, name) VALUES (?, ?, ?)`,values);// stored `data`: 64 × 0xff — should be 0x00..0x3f
Cause
Value.fromJS for MYSQL_TYPE_*BLOB returned ZigString.Slice.fromUTF8NeverFree(array_buffer.slice()), borrowing the
backing store without protecting it.
MySQLQuery.bind() collects every parameter into a []Value first and
only then calls execute.write(). Converting later parameters can run
user JS — array index getters via QueryBindingIterator.next(), toJSON via jsonStringifyFast, toString via bun.String.fromJS —
and that JS can transfer()/detach an earlier buffer, or drop the last
JS reference to it and force GC. execute.write() then reads bytes the
caller no longer owns.
For a non-resizable ArrayBuffer, buf.transfer() with no arguments is
zero-copy in JSC: the new buffer takes ownership of the same backing
pointer, so overwriting the new buffer mutates exactly what the borrowed
slice still points at. With a resizing transferToFixedLength(n) the
old backing store is freed outright.
(The Postgres path doesn't have this window: PostgresRequest.writeBind
writes each parameter to the wire inside the loop before touching the
next one.)
Fix
bindAndExecute now runs inside a stack-scoped MarkedArgumentBuffer
(same pattern as udp_socket.zigsendMany) that Value.fromJS
appends borrowed buffer/Blob wrappers to, and the backing ArrayBuffer
is pinned via JSC__JSValue__borrowBytesForOffThread (the same helper Bun.Image uses):
Oversize/Wasteful/DataView/JSArrayBuffer → ArrayBuffer::pin()
makes it non-detachable — transfer() hands the user a copy and
leaves the original backing store intact. The wrapper is appended to the MarkedArgumentBuffer so GC can't sweep the cell whose RefPtr<ArrayBuffer> keeps the storage alive (params lives on the
malloc heap and isn't scanned).
FastTypedArray (≤ ~1 KB, GC-movable vector) → bytes are duped.
Pinning would force slowDownAndWasteMemory() which copies anyway.
Blob → plain borrow (immutable store, no detach); wrapper appended
to the MarkedArgumentBuffer so the store survives GC.
Value.bytes now carries the JSValue to unpin alongside the slice:
Value.deinit() — already run via Execute.deinit() after execute.write(), inside the MarkedArgumentBuffer scope — calls JSC__JSValue__unpinArrayBuffer(pinned) and frees the dupe via slice.deinit().
Verification
test/js/sql/sql-mysql-bind-blob-borrow.test.ts primes the
prepared-statement cache so the second call goes straight to bindAndExecute, then binds [id, Uint8Array(buf), <getter>] where the
getter transfer()s buf and fills the result with 0xff during the
bind loop. It also asserts buf is detachable again after the query
resolves (pin released).
Fail-before (src/ reverted, test kept):
{
"detachableAfter": true,
"detached": true,
- "gotHex": "000102…3f",
- "match": true,
+ "gotHex": "ffffff…ff",
+ "match": false,
"originalHex": "000102…3f",
}
(fail) mysql (local) > BLOB param backing store is pinned across the bind loop
a50b47 fix(ipc): run SendQueue.deinit() from IPCInstance.deinit on getIPCInstance failure (#30177)
What
Follow-up to #30051, which added SendQueue.after_close_task tracking
so SendQueue.deinit() can cancel a pending _onAfterIPCClosed task
before the owner frees it.
IPCInstance.deinit was still TrivialDeinit → bun.destroy, so on
the getIPCInstance error path the embedded SendQueue was never
deinited and the tracked task was never cancelled.
On Windows, windowsConfigureClient sets data.socket = .open before calling uv_read_start. If uv_read_start fails, it calls closeSocket() which queues the _onAfterIPCClosed task (socket was .open), returns an error, and getIPCInstance then calls instance.deinit() — freeing the IPCInstance and its embedded SendQueue with the task still queued.
Fix
Replace TrivialDeinit with an explicit deinit that runs this.data.deinit() before bun.destroy(this), so the after_close_task cancel path added in #30051 actually fires for this
owner too.
Test
Added a case in spawn.ipc.test.ts that drives a child through the getIPCInstance error path with an unusable NODE_CHANNEL_FD and
verifies clean teardown. The specific uv_read_start-fails-after-uv_pipe_open-succeeds trigger is
Windows-only and not deterministically reproducible from userland; the
test covers the surrounding error-path teardown on both platforms.
570653 server: hold Response via WeakPtr instead of a raw pointer (#30174)
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.
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
JSC::Strong<T> has no move constructor. Capturing it by value
copy-constructs it, which calls HandleSet::allocate() + m_strongList.push(); destroying it calls HandleSet::deallocate() + NodeList::remove(). Both happen on the worker thread against the parent VM'sHandleSet, without the parent VM's lock.
HandleSet::m_strongList is a SentinelLinkedList<HandleNode> — not
thread-safe. push/remove transiently null m_next/m_prev. The
parent VM's "Sh" (Strong Handles) marking constraint
(Heap::addCoreConstraints) iterates that list during GC; when it
follows a null m_next it reads *((HandleNode*)nullptr)->slot() → *(0x0 + 0x10).
The heapHelperPool() is process-global, so the crashing helper thread
belongs to the parent VM's collector even though the worker VM's BunV8HeapSnapshotBuilder full GC is in progress at the same time.
This has been there since getHeapSnapshot was added — the recent
worker lifetime rewrites (#29957, #29937) didn't introduce it.
Fix
Heap-allocate the Strong<JSPromise> once on the parent thread and pass
only the raw pointer through the cross-thread lambdas. The worker thread
never dereferences it, so it never touches the parent VM's HandleSet.
The parent-side completion lambda resolves the promise and frees the
handle.
Worker::postTaskToWorkerGlobalScope now returns bool so a lost race
to Closing/Closed (worker exited between isOnline() and the post)
rejects with ERR_WORKER_NOT_RUNNING instead of silently leaking the
handle. If postTaskTo(parentId, …) on the return trip fails (parent
context gone), the handle intentionally leaks — deleting a parent-VM Strong from the worker thread is exactly the bug we're fixing, and the
parent VM is tearing down anyway.
Verification
Stress fixture (heap-snapshot-gc-race-fixture.js, 300 iterations of await worker.getHeapSnapshot(); Bun.gc(true)), 40 runs each on
linux-x64 release:
build
segfault at 0x10
52bdf47 (CI artifact, no fix)
15 / 40
this branch
0 / 40
The new worker_heap_snapshot_gc.test.ts runs the fixture — 300 iters
in release, 5 in debug (a single debug heap snapshot takes ~1.6s so the
race window, which is a handful of instructions after each snapshot, is
impractical to hit there; the debug pass is a functional check).
While reproducing I hit a second, unrelated crash in locally-built
(non-LTO) release binaries: stripFlags removed .eh_frame_hdr on
linux-gnu unconditionally, but the linker only passes --no-eh-frame-hdr when LTO is on. GNU strip doesn't rewrite the
program header table, so the PT_GNU_EH_FRAME phdr was left pointing at
unmapped memory and any stack unwind (e.g. WTF::Thread teardown after a
worker exits) faulted. CI release builds always have LTO on so they
weren't affected. Gated the section removal on c.lto to match the
linker flag.
0561f8 Fix HTMLRewriter use-after-free when handler rejects during end() (#30196)
What does this PR do?
Fixes a use-after-free in HTMLRewriter.transform() that caused flaky
SIGSEGV crashes found by fuzzing.
When transforming a string or ArrayBuffer, the body is buffered
synchronously and fed to lol-html via write() followed by end(). If
a document/element handler returns a rejected promise for the final lastInTextNode chunk (emitted from end()), the end() catch branch
in BufferOutputSink.runOutputSink would call response.finalize()
directly on the output Response.
That Response is already owned by its JS wrapper cell (created earlier
in init() via sink.response.toJS()), so destroying it in-place left
the wrapper's m_ctx pointing at freed memory. When GC later swept the
wrapper, its destructor invoked Response.finalize() again on that
freed pointer:
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:
d0a0bc Preserve ICC colour profile through Bun.Image JPEG/PNG encode (#30201)
Fixes #30197.
Repro
// photo.jpg is a Jpegli XYB or Display-P3 JPEGBun.file("photo.jpg").image().png().write("out.png");// out.png looks washed-out / shifted — the JPEG's APP2 ICC chunk is gone
Cause
src/image/codecs.zig's Decoded struct carried { rgba, width, height }
— no slot for a colour profile. Each codec decoded into a bare RGBA
buffer and the JPEG APP2 ICC_PROFILE marker / PNG iCCP chunk was
dropped on the floor. When the RGBA was then re-encoded, whatever
decoder looks at the output reinterprets it as sRGB and shifts the
colours. Visible on any non-sRGB input (P3, Adobe RGB, XYB).
Fix
Wire the profile through the pipeline:
Decoded.icc_profile: ?[]u8 (bun.default_allocator-owned) plus a deinit() for the new two-field ownership.
codec_jpeg decode sets TJPARAM_SAVEMARKERS=2 before header parse
and pulls the marker via tj3GetICCProfile; encode embeds via tj3SetICCProfile.
codec_png decode pulls the iCCP chunk via spng_get_iccp
(no-profile case is SPNG_ECHUNKAVAIL, not an error); encode embeds
via spng_set_iccp. Indexed PNG drops the profile — quantisation
invalidates the ICC mapping.
applyPipeline/applyOrientation used to assign the whole Decoded
returned from rotate() (which has icc_profile: null), which would
wipe the source's profile. They now swap only the pixel + dim slots;
the profile attaches to the decode, not the geometry.
On encode, PipelineTask.run copies decoded.icc_profile into the EncodeOptions.icc_profile it hands to the codec. Unchanged
when the caller (future .withIccProfile()) already set one.
Scope
JPEG + PNG encode: preserved.
WebP encode: drops the profile. Preserving it requires libwebpmux/libwebpdemux, neither of which is in the build today;
kept out of this PR to limit churn. Opening it separately.
System backend decode (backend == "system", CoreGraphics/WIC on
macOS/Windows): already applies colour management during decode — the
RGBA it emits is in the display space, so dropping the profile is the
correct thing there. No change.
Verification
test/js/bun/image/image.test.ts — new describe("ICC profile"):
PNG iCCP round-trips byte-for-byte through PNG re-encode.
iCCP survives resize and rotate (catches the d.* = next bug).
PNG → JPEG transfers the profile into an APP2 ICC_PROFILE marker
(reassembled across multi-segment splits).
Absence case: PNG/JPEG without a profile don't synthesise one.
JPEG → JPEG round-trip preserves the marker.
All 211 image tests pass. Gate: git stash the codec changes + rebuild
reproduces 5/7 new-test failures (the two "no profile stays no profile"
tests correctly pass before and after).
Windows: Bun.write is open(O_TRUNC) then async write with a
JS-thread round-trip in between. The watcher fires on the 0-byte
truncation, bundles an empty module that never calls accept(), and the
next update falls through to fullReload() → client exits unexpectedReload.
All platforms: after the final drain client.messages.length = 0,
a late hot_update lands during the following await client.js\…`` and
trips the unread-messages disposal check.
Fix (test): use fs.writeFileSync for the rapid burst (microsecond
truncate window), write identical content so same-sourceMapId
duplicates are deterministic on every platform, and follow with a
synchronized sentinel write — once the sentinel arrives over the ordered
WS, every prior hot_update has been applied and nothing can leak into
disposal.
Across react-spa, html, ssg-pages-router, bundle, hot, esm, css, incremental-graph-edge-deletion. waitForLine()'s default
timeout was 1 second on non-Windows release builds — the Node client
has to start, import happy-dom, fetch, parse HTML, run the bundle, and
open a WebSocket in that window. The ASAN_TIMEOUT_MULTIPLIER constant
existed but was never applied.
Fix (harness): raise the base and apply a unified WAIT_MULTIPLIER
(debug × ASAN × CI). Apply the same multiplier to expectMessage / expectReload / getStringMessage / getMostRecentHmrChunk (all
hardcoded 1000 ms), and raise the per-test base accordingly. Also make waitForLine scan already-buffered lines via the previously-dead cursor field so an await between stream creation and the call can't
drop the match.
Underlying DevServer bugs found while stress-testing
IncrementalGraph.invalidate use-after-poison: the incoming path (a slice into HotReloadEvent.extra_files) was stored in entry_points, but the event is reset — and its extra_files may be
reallocated by the watcher thread — before entry_points is consumed by startAsyncBundle / TestingBatch. Since getIndex(path) already
succeeded, store the graph-owned keys[index] instead.
TestingBatch.append stored the same borrowed slices as
persistent keys across multiple HotReloadEvent.run calls. Dupe keys on
insert; free them in TestingBatch.deinit.
onFileUpdate (Linux) indexed only changed_files[event.name_off] for a merged directory WatchEvent.
When an atomic-save editor (vim/emacs/IntelliJ) lands CREATE tmp + MOVED_TO target in one coalesced inotify batch, the rename target was
dropped and never re-watched. Forward every name via event.names().
Harness robustness
waitForHotReload used clientWaits === connectedClients.size;
straggler HMR events from prior unsynchronized writes could push the
count past, so it never matched. Use >=.
waitForHotReload now rejects on dev-server panic instead of hanging
to the test timeout.
Detect AddressSanitizer / ThreadSanitizer / ==ABORTING in
subprocess output as a panic.
How verified
New hot.test.ts case floods a watched directory (32 decoy creates +
unlink + rename-over) to force inotify coalescing:
withoutsrc/ changes → 3/3 fail under ASAN (use-after-poison in TestingBatch.append via wyhash)
Full runs of hot, dev-and-prod, bundle, css, html, esm, stress, ssg-pages-router, incremental-graph-edge-deletion, plugins, sourcemap, server-sourcemap, vfile, framework-router, deinitialization → all green (esm-11 is a pre-existing skip: ["ci"])
d484fd Fix null SSL* dereference in TLSSocket.getServername after close (#30145)
d4cd11 Fix type confusion in JSBundlerPlugin sync-exception fallback (#30153)
What
The C++ synchronous-exception fallback in JSBundlerPlugin__matchOnLoad
/ JSBundlerPlugin__matchOnResolve passed the wrong which
discriminator to JSBundlerPlugin__addError, causing Zig to reinterpret
a *Load as a *Resolve (and vice versa).
The same source fix was independently identified in #29486 — this PR
additionally adds a deterministic regression test for the fallback path.
Root cause
Zig's JSBundlerPlugin__addError casts its ctx pointer based on the which value:
The JS builtin (BundlerPlugin.ts) passes these correctly: runOnResolvePlugins → addError(..., 0), runOnLoadPlugins → addError(..., 1).
When the builtin itself throws synchronously (e.g. a stack overflow or
termination exception that escapes the async IIFE, or a tampered Promise.prototype.then), the C++ DECLARE_TOP_EXCEPTION_SCOPE
fallback kicks in. That fallback had the values swapped:
matchOnLoad (ctx is *Load) passed jsNumber(0) → Zig cast to *Resolve
matchOnResolve (ctx is *Resolve) passed jsNumber(1) → Zig cast
to *Load
matchOnLoad additionally passed plugin->plugin.config (the BundleV2
completion task) as the second argument, where Zig expects the JSBundlerPlugin* so it can call plugin.globalObject(). Both the JS
host function jsBundlerPluginFunction_addError and the matchOnResolve fallback already pass the JSBundlerPlugin* there.
Fix
src/bun.js/bindings/JSBundlerPlugin.cpp:
matchOnLoad fallback: jsNumber(0) → jsNumber(1), second arg plugin->plugin.config → plugin
matchOnResolve fallback: jsNumber(1) → jsNumber(0), route
through plugin->plugin.addError for consistency with the other
callsite
Verification
Added a test in test/bundler/bun-build-api.test.ts that
deterministically reaches the C++ fallback: the plugin callback arms a
one-shot throwing Promise.prototype.then and returns a pending
promise, so the builtin's post-IIFE public .then call throws
synchronously and surfaces to DECLARE_TOP_EXCEPTION_SCOPE.
Before the fix (bun bd, src/ stashed):
onLoad: UBSan member call on null pointer of type 'JSC::JSGlobalObject' (release bun: SIGSEGV at 0x38)
onResolve: build hangs — the resolve counter is never decremented
because Zig dispatched to onLoadAsync instead
After the fix: both report the error via result.logs and the build
completes with success: false.
bundler_plugin.test.ts and plugin-error-nested-throw.test.ts
continue to pass.
0235b6 sql(postgres): validate server-provided len in binary int4[]/float4[] parsing (#30164)
What
Bounds-check the server-provided len field when parsing binary-format int4[] / float4[] DataRow columns.
Why
DataCell.fromBytes only checked bytes.len < 12 before calling PostgresBinarySingleDimensionArray.init(bytes).slice(). slice() then
iterated this.len (a server-controlled signed i32) times, reading and writinghead[i] with no check that 20 + len * 8 <= bytes.len. A malicious or buggy server sending a 20-byte column with len = 65536 causes reads/writes past the connection's receive buffer.
In ReleaseFast this is a heap write primitive; under ASAN it's a
heap-buffer-overflow.
How
Before calling init()/slice():
require bytes.len >= 20 (the full 1-D header: ndim + flags +
elemtype + len + lbound)
require len >= 0
require len <= (bytes.len - 20) / (2 * @​sizeOf(T)) (each element is
a 4-byte length prefix + a 4-byte value)
Malformed input now returns ERR_POSTGRES_INVALID_BINARY_DATA. Also
changed slice()'s early-return from len == 0 to len <= 0 as
defense-in-depth.
Verification
test/js/sql/postgres-binary-array-bounds.test.ts spins up a mock
Postgres server that sends a binary int4[] column with len far
exceeding the column bytes.
Without the fix (git stash push -- src/ && bun bd test ...):
==5186==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7b03f0921840
READ of size 4 at 0x7b03f0921840 thread T0
#0 ... sql.postgres.types.Tag.Tag.PostgresBinarySingleDimensionArray(i32).slice ... Tag.zig:218:40
0x7b03f0921840 is located 0 bytes after 524352-byte region
With the fix: all 7 tests pass (6 malformed-input cases + 1 well-formed
round-trip).
b258d3 sys: bound readlink/readlinkat NUL write to buffer length (#30160)
What
bun.sys.readlink and bun.sys.readlinkat call readlink(2) with buf.len as the buffer size, then write buf[@​intCast(rc)] = 0 to
NUL-terminate the result.
POSIX readlink() does not NUL-terminate and, when the target is longer
than the supplied buffer, truncates and returns the buffer size. In that
case rc == buf.len and buf[rc] = 0 writes one byte past the end of
the stack-allocated bun.PathBuffer.
All current callers pass a full bun.PathBuffer (PATH_MAX = 4096 on
Linux, 1024 on macOS), and symlink(2) on local filesystems refuses to
create targets that long — but FUSE and some network filesystems can
hold symlink targets ≥ PATH_MAX, so this is reachable from fs.readlinkSync on those systems.
Fix
Return ENAMETOOLONG when rc >= buf.len. There is no room for the
sentinel at that point anyway, and the result would have been truncated,
so erroring matches what Node does via libuv for the same condition.
Since standard filesystems cap symlink targets below PATH_MAX, the
overflow itself cannot be reproduced in CI. The added test creates the
longest symlink target the local filesystem will accept (4095 on Linux,
1023 on macOS) and verifies readlinkSync returns it exactly — this
guards against the bounds check being too aggressive and rejecting the
valid PATH_MAX - 1 case.
When readDirectory() fails with a non-ENOENT error (EACCES, EMFILE,
…), readDirectoryError stores .err in rfs.entries (fs.zig:1002).
If the directory later becomes openable and dirInfoCachedMaybeLog
processes it as queue slot [0] (the target path itself, which is never
pre-checked against rfs.entries during queue construction), it
reached:
if (rfs.entries.atIndex(cached_dir_entry_result.index)) |cached_entry| {
if (cached_entry.entries.generation>=r.generation) { // no tag check
Reading .entries while .err is active reinterprets the two anyerror values as a *DirEntry pointer and dereferences it. In debug
this is a safety panic; in release it's a segfault at e.g. 0x1D401D401F8.
Repro
chmodSync(bad,0o000);try{Bun.resolveSync('./bad/index.js',root);}catch{}// caches .err for 'bad'chmodSync(bad,0o755);Bun.resolveSync('./bad',root);// dirInfoCached(bad): open OK, cache has .err -> crash
Before:
panic(main thread): access of union field 'entries' while field 'err
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
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.
Updated Packages