From 63f93e047c3cc9d75529ee6f085d731bbe3cb244 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:41:27 +0000 Subject: [PATCH 1/4] Fix quadratic backlog drain in bun test --parallel IPC Channel::flush on POSIX drained the outgoing backlog by memmoving the entire remainder to the front of the buffer after every partial write. The kernel accepts one send-buffer chunk per writable event (about 8KB for macOS socketpairs), so a single 64MB TestDone frame moved roughly 512GB of memory before it finished: about 90 seconds on the macOS x64 CI machines, which made the >64MB result line test time out regularly. Linux socket buffers are large enough that the same drain stayed around a second, so it only showed on darwin lanes. Track a cursor of already-written bytes instead, and compact only once the sent prefix is at least as large as the unsent tail, which keeps total compaction cost linear in bytes sent. With socketpair buffers forced to 8KB on Linux to mimic macOS, the 68MB-frame repro goes from 9.2s to 1.4s in a debug build; on a darwin x64 CI host the same repro took 89s with a current canary build. --- src/runtime/cli/test/parallel/Channel.rs | 32 ++++++++++++++++--- test/cli/test/parallel.test.ts | 39 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/runtime/cli/test/parallel/Channel.rs b/src/runtime/cli/test/parallel/Channel.rs index de5a3c04aaa7..2c03d2b61ae9 100644 --- a/src/runtime/cli/test/parallel/Channel.rs +++ b/src/runtime/cli/test/parallel/Channel.rs @@ -108,6 +108,13 @@ pub type Socket = uws::NewSocketHandler; #[cfg(not(windows))] pub struct PosixBackend { pub(crate) socket: Cell, + /// Bytes at the front of `out` already accepted by the kernel. Tracking a + /// cursor instead of draining `out` after every partial write keeps the + /// backlog drain linear: the kernel accepts one send-buffer chunk per + /// writable event (8KB for macOS socketpairs), and a front-drain each time + /// would memmove the whole remaining backlog per chunk — quadratic, ~90s + /// of pure memmove for one 64MB frame on macOS x64. + out_head: Cell, } #[cfg(not(windows))] @@ -115,6 +122,7 @@ impl Default for PosixBackend { fn default() -> Self { Self { socket: Cell::new(Socket::DETACHED), + out_head: Cell::new(0), } } } @@ -448,13 +456,29 @@ impl Channel { { while !self.done.get() { let mut pending = self.out.replace(Vec::new()); - if pending.is_empty() { + let mut head = self.backend.out_head.get(); + debug_assert!(head <= pending.len()); + if pending.len() <= head { + self.backend.out_head.set(0); self.out.set(pending); return; } - let wrote = self.backend.socket.get().write(pending.as_slice()); - let w = usize::try_from(wrote).unwrap_or(0).min(pending.len()); - pending.drain_front(w); + let wrote = self.backend.socket.get().write(&pending[head..]); + let w = usize::try_from(wrote) + .unwrap_or(0) + .min(pending.len() - head); + head += w; + if head == pending.len() { + pending.clear(); + head = 0; + } else if head >= pending.len() - head { + // Compact only once the sent prefix is at least as large + // as the unsent tail; each compaction is then charged to + // the bytes it discards, keeping the total cost linear. + pending.drain_front(head); + head = 0; + } + self.backend.out_head.set(head); self.out.with_mut(|cur| { pending.extend_from_slice(cur); *cur = pending; diff --git a/test/cli/test/parallel.test.ts b/test/cli/test/parallel.test.ts index 8e5505a73338..5e3b5a73e0c5 100644 --- a/test/cli/test/parallel.test.ts +++ b/test/cli/test/parallel.test.ts @@ -876,6 +876,45 @@ test("--parallel: a test producing a >64MB result line is truncated, not treated expect(exitCode).toBe(1); }, 90_000); +test("--parallel: back-to-back huge result lines drain in linear time without corrupting the channel", async () => { + // Two tests whose status lines each come in just under the 64MB IPC frame + // cap, from the same worker. The second frame is queued while the first + // backlog is still draining, so this exercises the write cursor, the + // amortized compaction, and appends to a partially-sent backlog. The 60s + // kill is the regression signal: draining the backlog by memmoving the + // whole remainder after every partial write is quadratic in frame size, + // and on macOS (~8KB socketpair buffers) one 64MB frame alone took ~90s + // of memmove. + using dir = tempDir("parallel-huge-backlog", { + "huge.test.js": `import {test,expect} from "bun:test"; + test("A".repeat(60_000_000), () => expect(1).toBe(2)); + test("B".repeat(60_000_000), () => expect(1).toBe(2));`, + "ok.test.js": `import {test,expect} from "bun:test"; test("ok",()=>expect(1).toBe(1));`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--parallel=2"], + env: { ...bunEnv, BUN_TEST_PARALLEL_SCALE_MS: "0" }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + timeout: 60_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("PARALLEL"); + // Both huge tests failed normally: the channel survived both frames (no + // worker "crashed"), both 60MB status lines arrived intact (each stays + // just under the 64MB frame cap, so no truncation), and ok.test.js's pass + // survived on the other worker. + expect(stderr).not.toContain("crashed"); + expect(stderr).toContain("A".repeat(10_000)); + expect(stderr).toContain("B".repeat(10_000)); + expect(stderr).toContain("1 pass"); + expect(stderr).toContain("2 fail"); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(1); +}, 90_000); + test("--parallel: a test writing garbage to fd 3 does not hang the coordinator", async () => { using dir = tempDir("parallel-hostile-fd3", { "ok.test.js": `import {test,expect} from "bun:test"; test("ok",()=>expect(1).toBe(1));`, From a1bde4558e7d8b03126c0982cf13c731a6a76b0e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:46:06 +0000 Subject: [PATCH 2/4] test: assert full 60MB titles arrive as exact-length runs --- test/cli/test/parallel.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/cli/test/parallel.test.ts b/test/cli/test/parallel.test.ts index 5e3b5a73e0c5..146ea713d80f 100644 --- a/test/cli/test/parallel.test.ts +++ b/test/cli/test/parallel.test.ts @@ -885,10 +885,11 @@ test("--parallel: back-to-back huge result lines drain in linear time without co // whole remainder after every partial write is quadratic in frame size, // and on macOS (~8KB socketpair buffers) one 64MB frame alone took ~90s // of memmove. + const TITLE_LENGTH = 60_000_000; using dir = tempDir("parallel-huge-backlog", { "huge.test.js": `import {test,expect} from "bun:test"; - test("A".repeat(60_000_000), () => expect(1).toBe(2)); - test("B".repeat(60_000_000), () => expect(1).toBe(2));`, + test(Buffer.alloc(${TITLE_LENGTH}, "A").toString(), () => expect(1).toBe(2)); + test(Buffer.alloc(${TITLE_LENGTH}, "B").toString(), () => expect(1).toBe(2));`, "ok.test.js": `import {test,expect} from "bun:test"; test("ok",()=>expect(1).toBe(1));`, }); await using proc = Bun.spawn({ @@ -907,8 +908,17 @@ test("--parallel: back-to-back huge result lines drain in linear time without co // just under the 64MB frame cap, so no truncation), and ok.test.js's pass // survived on the other worker. expect(stderr).not.toContain("crashed"); - expect(stderr).toContain("A".repeat(10_000)); - expect(stderr).toContain("B".repeat(10_000)); + // Each title must arrive as one contiguous run of exactly TITLE_LENGTH + // characters: a shorter run (dropped bytes) leaves indexOf at -1, and a + // longer run (duplicated bytes) puts the title's letter right after the + // first match. Checked via index math so a failure doesn't dump 60MB + // strings into the log. + for (const letter of ["A", "B"]) { + const title = Buffer.alloc(TITLE_LENGTH, letter).toString(); + const start = stderr.indexOf(title); + expect(start).toBeGreaterThanOrEqual(0); + expect(stderr[start + TITLE_LENGTH]).not.toBe(letter); + } expect(stderr).toContain("1 pass"); expect(stderr).toContain("2 fail"); expect(proc.signalCode).toBeNull(); From 734d8d7bd29be3cfa16b80e92649459a1286cad3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:47:12 +0000 Subject: [PATCH 3/4] Tighten out_head comments --- src/runtime/cli/test/parallel/Channel.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/runtime/cli/test/parallel/Channel.rs b/src/runtime/cli/test/parallel/Channel.rs index 2c03d2b61ae9..bf146c2aa601 100644 --- a/src/runtime/cli/test/parallel/Channel.rs +++ b/src/runtime/cli/test/parallel/Channel.rs @@ -108,12 +108,9 @@ pub type Socket = uws::NewSocketHandler; #[cfg(not(windows))] pub struct PosixBackend { pub(crate) socket: Cell, - /// Bytes at the front of `out` already accepted by the kernel. Tracking a - /// cursor instead of draining `out` after every partial write keeps the - /// backlog drain linear: the kernel accepts one send-buffer chunk per - /// writable event (8KB for macOS socketpairs), and a front-drain each time - /// would memmove the whole remaining backlog per chunk — quadratic, ~90s - /// of pure memmove for one 64MB frame on macOS x64. + /// Bytes at the front of `out` already written to the kernel. A cursor + /// rather than a per-write front-drain, which was quadratic in backlog + /// size (~90s for one 64MB frame over macOS's ~8KB socketpair buffers). out_head: Cell, } @@ -472,9 +469,8 @@ impl Channel { pending.clear(); head = 0; } else if head >= pending.len() - head { - // Compact only once the sent prefix is at least as large - // as the unsent tail; each compaction is then charged to - // the bytes it discards, keeping the total cost linear. + // Sent prefix >= unsent tail: compacting now keeps the + // total compaction cost linear in bytes sent. pending.drain_front(head); head = 0; } From 9909acef1a87ed99dbd77c2eb7aa192e41952c84 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:47:50 +0000 Subject: [PATCH 4/4] Shorten Channel comments further --- src/runtime/cli/test/parallel/Channel.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/runtime/cli/test/parallel/Channel.rs b/src/runtime/cli/test/parallel/Channel.rs index bf146c2aa601..6cc3adb15546 100644 --- a/src/runtime/cli/test/parallel/Channel.rs +++ b/src/runtime/cli/test/parallel/Channel.rs @@ -108,9 +108,8 @@ pub type Socket = uws::NewSocketHandler; #[cfg(not(windows))] pub struct PosixBackend { pub(crate) socket: Cell, - /// Bytes at the front of `out` already written to the kernel. A cursor - /// rather than a per-write front-drain, which was quadratic in backlog - /// size (~90s for one 64MB frame over macOS's ~8KB socketpair buffers). + /// Bytes at the front of `out` already written to the kernel; + /// front-draining per partial write instead is quadratic in backlog size. out_head: Cell, } @@ -469,8 +468,7 @@ impl Channel { pending.clear(); head = 0; } else if head >= pending.len() - head { - // Sent prefix >= unsent tail: compacting now keeps the - // total compaction cost linear in bytes sent. + // Sent prefix caught up to the tail: compact (amortized linear). pending.drain_front(head); head = 0; }