From 6c7ccf2ea1320ecf033f7cd2c702f640a7e5378f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:38:59 +0000 Subject: [PATCH 01/10] webcore: pin subprocess stdout FileReader while its pipe poll is live ReadableStream::from_pipe moves an already-registered pipe poll from a subprocess PipeReader into a freshly allocated NewSource and re-points the poll's owner at it. The across-read ref that keeps the NewSource box alive (waiting_for_on_reader_done + increment_count, which upgrades this_jsvalue to Strong) was previously 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 pointed into a box whose only ref was the JS wrapper's own Weak back-reference. If the Subprocess and its cached stdout became unreachable before anyone pulled (fire-and-forget spawn where proc.stdout is touched but never read), GC swept the JSFileInternalReadableStreamSource wrapper and freed the box while the poll was still armed. The next readability or EOF event then dispatched into freed memory: FileReader::on_reader_done FileReader.rs:1008 self.buffered.get().is_empty() heap-use-after-free PosixBufferedReader::done PipeReader.rs:846 PosixBufferedReader::read_socket PipeReader.rs:576 file-poll dispatch <- us_internal_dispatch_ready_polls freed by: JSDestructibleObjectDestroyFunc <- MarkedBlock sweep 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 rooted for as long as the poll can fire. on_start now checks waiting_for_on_reader_done so it does not double-count on the same path. The test asserts the invariant directly via heapStats rather than racing for the crash: with the pipe's write end held open by a detached grandchild past the direct child's exit, every FileInternalReadableStreamSource wrapper survives GC; once the pipe EOFs, on_reader_done releases the ref and they become collectable. --- src/runtime/webcore/FileReader.rs | 6 +- src/runtime/webcore/ReadableStream.rs | 15 ++- .../spawn-stdout-filereader-gc-uaf.test.ts | 119 ++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts diff --git a/src/runtime/webcore/FileReader.rs b/src/runtime/webcore/FileReader.rs index cede360a7e60..d4c2892aef26 100644 --- a/src/runtime/webcore/FileReader.rs +++ b/src/runtime/webcore/FileReader.rs @@ -441,6 +441,7 @@ impl FileReader { { use bun_io::pipe_reader::PosixFlags; if !self.started.get() + && !self.waiting_for_on_reader_done.get() && self.reader().flags.contains(PosixFlags::POLLABLE) && !self.reader().is_done() { @@ -454,7 +455,10 @@ impl FileReader { // Non-lazy fromPipe path (Bun.spawn stdout/stderr): hold a // ref across the pending uv_read_start so the source is not // finalized while IOCP has a read queued on it. - if !self.started.get() && self.reader().source.is_some() && !self.reader().is_done() + if !self.started.get() + && !self.waiting_for_on_reader_done.get() + && self.reader().source.is_some() + && !self.reader().is_done() { self.waiting_for_on_reader_done.set(true); // SAFETY: see `parent()`. diff --git a/src/runtime/webcore/ReadableStream.rs b/src/runtime/webcore/ReadableStream.rs index 1fd6640f3994..9d6a1a5bfa98 100644 --- a/src/runtime/webcore/ReadableStream.rs +++ b/src/runtime/webcore/ReadableStream.rs @@ -433,7 +433,20 @@ impl ReadableStream { .reader() .from(buffered_reader, ctx_ptr.cast::()); - source.to_readable_stream(global_this) + let stream = source.to_readable_stream(global_this)?; + + // The transferred reader already has a live poll registered with the + // event loop whose owner now points into this allocation. Take the + // across-read ref (and root the wrapper) immediately so a GC before + // JS first pulls cannot sweep the wrapper and free this box while the + // poll is still armed. `on_start` checks `waiting_for_on_reader_done` + // and will not take a second ref. + if source.context.reader().has_pending_activity() { + source.context.waiting_for_on_reader_done.set(true); + source.increment_count(); + } + + Ok(stream) } pub fn empty(global_this: &JSGlobalObject) -> JsResult { diff --git a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts new file mode 100644 index 000000000000..4208b2c58e59 --- /dev/null +++ b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { join } from "node:path"; + +// Accessing `proc.stdout` on a piped subprocess moves the already-registered +// pipe poll from the subprocess PipeReader into a freshly allocated +// NewSource. The across-read ref (which upgrades the wrapper's +// JsRef to Strong and pins the native box) used to be taken only in +// FileReader::on_start, 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, so if the +// subprocess and its cached stdout become unreachable before anyone pulls, +// GC can sweep the wrapper and free the box while the poll is still armed. +// The next readability/EOF event then dispatches into freed memory +// (FileReader::on_reader_done reached from PosixBufferedReader::read_socket). +// +// This test asserts the lifetime invariant directly rather than racing for +// the crash: while a subprocess pipe's write end is still held open (by a +// detached grandchild that inherits it), the FileInternalReadableStreamSource +// wrapper must survive GC even though nothing in JS references it. Once the +// grandchild exits and the pipe reaches EOF, on_reader_done releases the ref +// and the wrapper becomes collectable (no leak). +// +// Windows uses libuv for pipe I/O; the from_pipe path under test is POSIX. +test.skipIf(isWindows)( + "subprocess stdout FileReader is pinned while its pipe poll is live, then collectable after EOF", + async () => { + using dir = tempDir("fr-gc-uaf", {}); + const flag = join(String(dir), "go"); + + const script = /* js */ ` + const { heapStats } = require("bun:jsc"); + const { writeFileSync } = require("node:fs"); + const ITERS = 4; + const flag = ${JSON.stringify(flag)}; + + // The direct child spawns a detached shell that inherits stdout and + // waits for a flag file, keeping the pipe's write end open past the + // child's exit so the FileReader's poll is still armed while we GC. + const childScript = + "const { spawn } = require('child_process');" + + "spawn('sh', ['-c', 'while [ ! -e " + JSON.stringify(flag) + " ]; do sleep 0.02; done; echo x']," + + " { stdio: ['ignore', 'inherit', 'ignore'], detached: true }).unref();"; + + const count = () => + heapStats().objectTypeCounts.FileInternalReadableStreamSource ?? 0; + + async function once() { + const proc = Bun.spawn({ + cmd: [process.execPath, "-e", childScript], + env: process.env, + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + }); + // Materialize the ReadableStream so the live poll is re-parented + // into a NewSource, but never pull from it. + void proc.stdout; + await proc.exited; + } + + // Warm up once so per-class lazy structure allocation is absorbed + // into the baseline and not counted against ITERS. + await once(); + for (let i = 0; i < 10; i++) { Bun.gc(true); await Bun.sleep(1); } + const base = count(); + + await Promise.all(Array.from({ length: ITERS }, once)); + + // Direct children have exited; grandchildren still hold the write end. + for (let i = 0; i < 20; i++) { Bun.gc(true); await Bun.sleep(1); } + const duringLivePipe = count() - base; + + // Release the grandchildren (including the warmup one). + writeFileSync(flag, ""); + + let afterEof = -1; + for (let i = 0; i < 100; i++) { + Bun.gc(true); + await Bun.sleep(10); + afterEof = count() - base; + if (afterEof <= 0) break; + } + + console.log(JSON.stringify({ iters: ITERS, duringLivePipe, afterEof })); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--smol", "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + const stderrFiltered = stderr + .split("\n") + .filter(l => l && !l.startsWith("WARNING: ASAN interferes")) + .join("\n"); + expect(stderrFiltered).toBe(""); + + const { iters, duringLivePipe, afterEof } = JSON.parse(stdout.trim()); + // Invariant: every FileReader whose pipe poll is still armed must be + // pinned by its own Strong ref. Before the fix they were swept here + // (duringLivePipe ~ 0), which is exactly the UAF precondition. + expect(duringLivePipe).toBeGreaterThanOrEqual(iters); + // Once the pipe reaches EOF the ref is released and the wrappers become + // collectable. One may survive via a conservatively-rooted final + // Subprocess (same caveat as spawn-ipc-gc.test.ts). + expect(afterEof).toBeLessThanOrEqual(1); + expect(exitCode).toBe(0); + }, + 60_000, +); From c3fadfc05ab513cd36dd6fd72988f1da4cd5feaf Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:41:38 +0000 Subject: [PATCH 02/10] [autofix.ci] apply automated fixes --- test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts index 4208b2c58e59..9c1f2fff41bc 100644 --- a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts +++ b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts @@ -92,11 +92,7 @@ test.skipIf(isWindows)( stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const stderrFiltered = stderr .split("\n") From 171024a8e1caec5d8a90a30bf5b8e69def83a1a4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:54:57 +0000 Subject: [PATCH 03/10] test: bound the detached helper loop and drop strict stderr assertion The detached sh helper now exits after ~30s even if the fixture crashes before writing the flag file, and the outer test always writes the flag in a finally block before tempDir cleanup so nothing outlives the test. stderr is still drained but only surfaced as a diagnostic when the fixture fails to produce JSON; the regression signal is the duringLivePipe / afterEof / exitCode triple. --- .../spawn-stdout-filereader-gc-uaf.test.ts | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts index 9c1f2fff41bc..e044ef0698ac 100644 --- a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts +++ b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { join } from "node:path"; @@ -37,9 +38,10 @@ test.skipIf(isWindows)( // The direct child spawns a detached shell that inherits stdout and // waits for a flag file, keeping the pipe's write end open past the // child's exit so the FileReader's poll is still armed while we GC. + // The loop is bounded so a fixture crash cannot leak the helper. const childScript = "const { spawn } = require('child_process');" + - "spawn('sh', ['-c', 'while [ ! -e " + JSON.stringify(flag) + " ]; do sleep 0.02; done; echo x']," + + "spawn('sh', ['-c', 'n=0; while [ ! -e " + JSON.stringify(flag) + " ] && [ $n -lt 1500 ]; do sleep 0.02; n=$((n+1)); done; echo x']," + " { stdio: ['ignore', 'inherit', 'ignore'], detached: true }).unref();"; const count = () => @@ -85,22 +87,31 @@ test.skipIf(isWindows)( console.log(JSON.stringify({ iters: ITERS, duringLivePipe, afterEof })); `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "--smol", "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); + let stdout = "", + stderr = "", + exitCode: number | null = null; + try { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--smol", "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - const stderrFiltered = stderr - .split("\n") - .filter(l => l && !l.startsWith("WARNING: ASAN interferes")) - .join("\n"); - expect(stderrFiltered).toBe(""); + [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + } finally { + // Always release the detached grandchildren so nothing outlives the test + // even if the fixture crashed before writing the flag itself. + writeFileSync(flag, ""); + } - const { iters, duringLivePipe, afterEof } = JSON.parse(stdout.trim()); + let result: { iters: number; duringLivePipe: number; afterEof: number }; + try { + result = JSON.parse(stdout.trim()); + } catch { + throw new Error(`fixture did not emit JSON (exit ${exitCode})\nstdout: ${stdout}\nstderr: ${stderr}`); + } + const { iters, duringLivePipe, afterEof } = result; // Invariant: every FileReader whose pipe poll is still armed must be // pinned by its own Strong ref. Before the fix they were swept here // (duringLivePipe ~ 0), which is exactly the UAF precondition. From 3bc0f4b951d45ffc448afe304bf742161dbc7528 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:56:51 +0000 Subject: [PATCH 04/10] [autofix.ci] apply automated fixes --- test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts index e044ef0698ac..c3aeaf23285b 100644 --- a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts +++ b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { writeFileSync } from "node:fs"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { writeFileSync } from "node:fs"; import { join } from "node:path"; // Accessing `proc.stdout` on a piped subprocess moves the already-registered From e3358d3e46d6f89e632b35b58410c014bdd0b11d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:39:25 +0000 Subject: [PATCH 05/10] from_pipe: gate the across-read ref on !is_done() instead of has_pending_activity() The x64-asan lane saw duringLivePipe=0 with the reader().has_pending_activity() gate, which resolves to FilePoll::is_active() = HasIncrementedPollCount. Switch to the same predicate FileReader::on_start already uses on this path (!reader().is_done()): after PosixBufferedReader::from() the transferred reader's flags contain none of IS_DONE / RECEIVED_EOF / CLOSED_WITHOUT_REPORTING so the ref is taken unconditionally for every live pipe, and it is released by on_reader_done / on_reader_error exactly as before. Also fold base / afterSpawn / streams into the fixture JSON and surface the whole result object in the failure diff so a repeat CI failure shows whether from_pipe ran and whether the wrappers survived the first GC pass. --- src/runtime/webcore/ReadableStream.rs | 6 ++++-- .../spawn/spawn-stdout-filereader-gc-uaf.test.ts | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/runtime/webcore/ReadableStream.rs b/src/runtime/webcore/ReadableStream.rs index 9d6a1a5bfa98..0c62fd16ddaa 100644 --- a/src/runtime/webcore/ReadableStream.rs +++ b/src/runtime/webcore/ReadableStream.rs @@ -440,8 +440,10 @@ impl ReadableStream { // across-read ref (and root the wrapper) immediately so a GC before // JS first pulls cannot sweep the wrapper and free this box while the // poll is still armed. `on_start` checks `waiting_for_on_reader_done` - // and will not take a second ref. - if source.context.reader().has_pending_activity() { + // and will not take a second ref. Use the same predicate as + // `FileReader::on_start` so the ref is always paired with an eventual + // `on_reader_done`/`on_reader_error` release. + if !source.context.reader().is_done() { source.context.waiting_for_on_reader_done.set(true); source.increment_count(); } diff --git a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts index c3aeaf23285b..ee162af0870e 100644 --- a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts +++ b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts @@ -47,6 +47,7 @@ test.skipIf(isWindows)( const count = () => heapStats().objectTypeCounts.FileInternalReadableStreamSource ?? 0; + let streams = 0; async function once() { const proc = Bun.spawn({ cmd: [process.execPath, "-e", childScript], @@ -57,7 +58,7 @@ test.skipIf(isWindows)( }); // Materialize the ReadableStream so the live poll is re-parented // into a NewSource, but never pull from it. - void proc.stdout; + if (proc.stdout instanceof ReadableStream) streams++; await proc.exited; } @@ -68,6 +69,7 @@ test.skipIf(isWindows)( const base = count(); await Promise.all(Array.from({ length: ITERS }, once)); + const afterSpawn = count() - base; // Direct children have exited; grandchildren still hold the write end. for (let i = 0; i < 20; i++) { Bun.gc(true); await Bun.sleep(1); } @@ -84,7 +86,9 @@ test.skipIf(isWindows)( if (afterEof <= 0) break; } - console.log(JSON.stringify({ iters: ITERS, duringLivePipe, afterEof })); + console.log(JSON.stringify({ + iters: ITERS, duringLivePipe, afterEof, base, afterSpawn, streams, + })); `; let stdout = "", @@ -114,7 +118,13 @@ test.skipIf(isWindows)( const { iters, duringLivePipe, afterEof } = result; // Invariant: every FileReader whose pipe poll is still armed must be // pinned by its own Strong ref. Before the fix they were swept here - // (duringLivePipe ~ 0), which is exactly the UAF precondition. + // (duringLivePipe ~ 0), which is exactly the UAF precondition. The full + // result object carries base/afterSpawn/streams for CI diagnostics. + if (duringLivePipe < iters) { + expect({ duringLivePipe, ...result, stderr }).toEqual({ + duringLivePipe: `>= ${iters}`, + }); + } expect(duringLivePipe).toBeGreaterThanOrEqual(iters); // Once the pipe reaches EOF the ref is released and the wrappers become // collectable. One may survive via a conservatively-rooted final From e1b573c6d8e5c41e663216c5a25084e8a43810d6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:24:30 +0000 Subject: [PATCH 06/10] test: add afterSyncGC and grandchildrenStarted diagnostics afterSyncGC samples the source count immediately after two synchronous Bun.gc(true) calls with no event-loop yield, so it distinguishes 'fix did not engage' (afterSyncGC < iters, wrappers swept synchronously) from 'pipes reached EOF during the sleep loop' (afterSyncGC == iters but duringLivePipe dropped afterwards). grandchildrenStarted counts touch-files written by each detached sh so a failing lane shows whether the helpers actually came up. --- .../spawn-stdout-filereader-gc-uaf.test.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts index ee162af0870e..0f95297667c0 100644 --- a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts +++ b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts @@ -28,20 +28,25 @@ test.skipIf(isWindows)( async () => { using dir = tempDir("fr-gc-uaf", {}); const flag = join(String(dir), "go"); + const started = join(String(dir), "started"); const script = /* js */ ` const { heapStats } = require("bun:jsc"); - const { writeFileSync } = require("node:fs"); + const { writeFileSync, readdirSync } = require("node:fs"); const ITERS = 4; const flag = ${JSON.stringify(flag)}; + const startedDir = ${JSON.stringify(started)}; + require("node:fs").mkdirSync(startedDir, { recursive: true }); // The direct child spawns a detached shell that inherits stdout and // waits for a flag file, keeping the pipe's write end open past the // child's exit so the FileReader's poll is still armed while we GC. - // The loop is bounded so a fixture crash cannot leak the helper. + // The loop is bounded so a fixture crash cannot leak the helper. Each + // grandchild touches a file in startedDir so the fixture can tell how + // many actually came up. const childScript = "const { spawn } = require('child_process');" + - "spawn('sh', ['-c', 'n=0; while [ ! -e " + JSON.stringify(flag) + " ] && [ $n -lt 1500 ]; do sleep 0.02; n=$((n+1)); done; echo x']," + + "spawn('sh', ['-c', ': > " + JSON.stringify(startedDir) + "/$$; n=0; while [ ! -e " + JSON.stringify(flag) + " ] && [ $n -lt 1500 ]; do sleep 0.02; n=$((n+1)); done; echo x']," + " { stdio: ['ignore', 'inherit', 'ignore'], detached: true }).unref();"; const count = () => @@ -71,9 +76,16 @@ test.skipIf(isWindows)( await Promise.all(Array.from({ length: ITERS }, once)); const afterSpawn = count() - base; + // Synchronous full GC with no event-loop yield: if the wrappers survive + // here they are Strong-rooted (fix engaged); if they are swept only + // after the sleep loop below, the pipes reached EOF in between. + Bun.gc(true); Bun.gc(true); + const afterSyncGC = count() - base; + // Direct children have exited; grandchildren still hold the write end. for (let i = 0; i < 20; i++) { Bun.gc(true); await Bun.sleep(1); } const duringLivePipe = count() - base; + const grandchildrenStarted = readdirSync(startedDir).length; // Release the grandchildren (including the warmup one). writeFileSync(flag, ""); @@ -87,7 +99,8 @@ test.skipIf(isWindows)( } console.log(JSON.stringify({ - iters: ITERS, duringLivePipe, afterEof, base, afterSpawn, streams, + iters: ITERS, duringLivePipe, afterEof, + base, afterSpawn, afterSyncGC, streams, grandchildrenStarted, })); `; From 9a9a006b427405f59c43e51e2005da463adb8baf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:02:17 +0000 Subject: [PATCH 07/10] test: use a plain sleep grandchild and kill it by pid Replace the sh flag-polling loop with 'exec sleep 30' so the grandchild cannot exit early if fractional sleep or the flag path behave differently on a CI runner, and have the direct child linger 10ms after spawning it so the grandchild definitely holds fd 1 before the child closes its copy. The fixture reads each grandchild pid from the started/ touch-files and SIGTERMs them to drive EOF; the outer test SIGKILLs any survivors in its finally. Also condense the from_pipe ownership comment to three lines. --- src/runtime/webcore/ReadableStream.rs | 11 ++-- .../spawn-stdout-filereader-gc-uaf.test.ts | 50 ++++++++++++------- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/runtime/webcore/ReadableStream.rs b/src/runtime/webcore/ReadableStream.rs index 0c62fd16ddaa..df6f99aac70f 100644 --- a/src/runtime/webcore/ReadableStream.rs +++ b/src/runtime/webcore/ReadableStream.rs @@ -435,14 +435,9 @@ impl ReadableStream { let stream = source.to_readable_stream(global_this)?; - // The transferred reader already has a live poll registered with the - // event loop whose owner now points into this allocation. Take the - // across-read ref (and root the wrapper) immediately so a GC before - // JS first pulls cannot sweep the wrapper and free this box while the - // poll is still armed. `on_start` checks `waiting_for_on_reader_done` - // and will not take a second ref. Use the same predicate as - // `FileReader::on_start` so the ref is always paired with an eventual - // `on_reader_done`/`on_reader_error` release. + // The transferred poll's owner now points into this box; root the + // wrapper before JS can GC it. `on_start` skips a second ref via the + // same `waiting_for_on_reader_done` flag; `on_reader_done` releases. if !source.context.reader().is_done() { source.context.waiting_for_on_reader_done.set(true); source.increment_count(); diff --git a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts index 0f95297667c0..f3d50a8df448 100644 --- a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts +++ b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; -import { writeFileSync } from "node:fs"; +import { readdirSync } from "node:fs"; import { join } from "node:path"; // Accessing `proc.stdout` on a piped subprocess moves the already-registered @@ -27,27 +27,28 @@ test.skipIf(isWindows)( "subprocess stdout FileReader is pinned while its pipe poll is live, then collectable after EOF", async () => { using dir = tempDir("fr-gc-uaf", {}); - const flag = join(String(dir), "go"); const started = join(String(dir), "started"); const script = /* js */ ` const { heapStats } = require("bun:jsc"); - const { writeFileSync, readdirSync } = require("node:fs"); + const { readdirSync } = require("node:fs"); const ITERS = 4; - const flag = ${JSON.stringify(flag)}; const startedDir = ${JSON.stringify(started)}; require("node:fs").mkdirSync(startedDir, { recursive: true }); - // The direct child spawns a detached shell that inherits stdout and - // waits for a flag file, keeping the pipe's write end open past the - // child's exit so the FileReader's poll is still armed while we GC. - // The loop is bounded so a fixture crash cannot leak the helper. Each - // grandchild touches a file in startedDir so the fixture can tell how - // many actually came up. + // The direct child spawns a detached sleep that inherits stdout, + // keeping the pipe's write end open past the child's exit so the + // FileReader's poll is still armed while we GC. The helper records + // its pid so the fixture can kill it to drive EOF, and the bounded + // sleep means nothing outlives the test even if that kill never runs. const childScript = "const { spawn } = require('child_process');" + - "spawn('sh', ['-c', ': > " + JSON.stringify(startedDir) + "/$$; n=0; while [ ! -e " + JSON.stringify(flag) + " ] && [ $n -lt 1500 ]; do sleep 0.02; n=$((n+1)); done; echo x']," + - " { stdio: ['ignore', 'inherit', 'ignore'], detached: true }).unref();"; + "const c = spawn('/bin/sh', ['-c', 'echo $$ > " + JSON.stringify(startedDir) + "/$$; exec sleep 30']," + + " { stdio: ['ignore', 'inherit', 'ignore'], detached: true });" + + "c.unref();" + + // Wait one tick so posix_spawn has definitely handed fd 1 to the + // grandchild before this process closes its own copy on exit. + "setTimeout(() => {}, 10);"; const count = () => heapStats().objectTypeCounts.FileInternalReadableStreamSource ?? 0; @@ -85,10 +86,13 @@ test.skipIf(isWindows)( // Direct children have exited; grandchildren still hold the write end. for (let i = 0; i < 20; i++) { Bun.gc(true); await Bun.sleep(1); } const duringLivePipe = count() - base; - const grandchildrenStarted = readdirSync(startedDir).length; + const pids = readdirSync(startedDir); + const grandchildrenStarted = pids.length; - // Release the grandchildren (including the warmup one). - writeFileSync(flag, ""); + // Release the grandchildren (including the warmup one) so the pipes EOF. + for (const pid of pids) { + try { process.kill(Number(pid), "SIGTERM"); } catch {} + } let afterEof = -1; for (let i = 0; i < 100; i++) { @@ -117,9 +121,19 @@ test.skipIf(isWindows)( [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); } finally { - // Always release the detached grandchildren so nothing outlives the test - // even if the fixture crashed before writing the flag itself. - writeFileSync(flag, ""); + // Always reap any detached grandchildren so nothing outlives the test + // even if the fixture crashed before killing them itself. + for (const pid of (() => { + try { + return readdirSync(started); + } catch { + return []; + } + })()) { + try { + process.kill(Number(pid), "SIGKILL"); + } catch {} + } } let result: { iters: number; duringLivePipe: number; afterEof: number }; From d7c5b0b3cf555496ad06d811f433a3f677fc990d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:32:12 +0000 Subject: [PATCH 08/10] test: clear BUN_FEATURE_FLAG_NO_ORPHANS on the fixture The CI runner sets BUN_FEATURE_FLAG_NO_ORPHANS=1 on ASAN lanes only, which arms PR_SET_CHILD_SUBREAPER around each spawn and kills subreaper-adopted daemons as soon as their intermediate parent exits. That is exactly the process shape this test depends on: a detached grandchild that inherits the direct child's stdout and holds the write end open past the child's exit. With the flag set, the grandchildren were killed the moment the direct children exited, the pipes EOF'd, and on_reader_done released the across-read ref before the fixture could observe it, which is why only the x64-asan lane saw duringLivePipe=0 while debug/release/release-asan all passed locally. The test already enforces the invariant NO_ORPHANS exists for: the fixture SIGTERMs every grandchild by pid and the outer test SIGKILLs survivors in a finally block, so nothing can outlive it. --- test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts index f3d50a8df448..d6c8ec897061 100644 --- a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts +++ b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts @@ -114,7 +114,13 @@ test.skipIf(isWindows)( try { await using proc = Bun.spawn({ cmd: [bunExe(), "--smol", "-e", script], - env: bunEnv, + // The CI runner sets BUN_FEATURE_FLAG_NO_ORPHANS on ASAN lanes, which + // kills a detached grandchild the moment its intermediate parent exits + // (PR_SET_CHILD_SUBREAPER). This test needs that grandchild to outlive + // its parent and hold the pipe open; orphan cleanup is handled + // explicitly instead (the fixture kills by pid, the finally below + // reaps survivors). + env: { ...bunEnv, BUN_FEATURE_FLAG_NO_ORPHANS: undefined }, stdout: "pipe", stderr: "pipe", }); From 26383abdbf163a04c3a8bdba69d7d628ada17165 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:43:49 +0000 Subject: [PATCH 09/10] test: have the direct child await its grandchild's pid file Replace the 10ms setTimeout with a bounded poll for the grandchild's pid file, so the direct child only exits once the grandchild is provably past posix_spawn (owning fd 1) and its pid is recorded. await proc.exited then implies the fixture's readdirSync sees every grandchild, making the kill loop deterministic instead of racing a timer. --- .../spawn-stdout-filereader-gc-uaf.test.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts index d6c8ec897061..cd863dbb4f6e 100644 --- a/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts +++ b/test/js/bun/spawn/spawn-stdout-filereader-gc-uaf.test.ts @@ -38,17 +38,22 @@ test.skipIf(isWindows)( // The direct child spawns a detached sleep that inherits stdout, // keeping the pipe's write end open past the child's exit so the - // FileReader's poll is still armed while we GC. The helper records - // its pid so the fixture can kill it to drive EOF, and the bounded - // sleep means nothing outlives the test even if that kill never runs. + // FileReader's poll is still armed while we GC. It records its pid so + // the fixture can kill it to drive EOF, and the bounded sleep means + // nothing outlives the test even if that kill never runs. const childScript = "const { spawn } = require('child_process');" + - "const c = spawn('/bin/sh', ['-c', 'echo $$ > " + JSON.stringify(startedDir) + "/$$; exec sleep 30']," + + "const { existsSync } = require('node:fs');" + + "const c = spawn('/bin/sh', ['-c', ': > " + JSON.stringify(startedDir) + "/$$; exec sleep 30']," + " { stdio: ['ignore', 'inherit', 'ignore'], detached: true });" + "c.unref();" + - // Wait one tick so posix_spawn has definitely handed fd 1 to the - // grandchild before this process closes its own copy on exit. - "setTimeout(() => {}, 10);"; + // Exit only once the grandchild has recorded its pid. That proves it + // is past posix_spawn and owns fd 1, and it guarantees the fixture's + // readdirSync below sees every grandchild, so the kill loop is + // deterministic instead of racing a timer. Bounded so a failed spawn + // still lets the child exit and surface as grandchildrenStarted < 5. + "const f = " + JSON.stringify(startedDir) + " + '/' + c.pid;" + + "for (let i = 0; i < 5000 && !existsSync(f); i++) Bun.sleepSync(1);"; const count = () => heapStats().objectTypeCounts.FileInternalReadableStreamSource ?? 0; From 38df0f705e8ec8288df8205982df2cbc8eda74db Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:02:41 +0000 Subject: [PATCH 10/10] ci: retrigger