From caf8b42a7f5bf95d3ab5c242f2c39e0f2be76ab4 Mon Sep 17 00:00:00 2001 From: Salim Habash Date: Wed, 27 May 2026 00:22:56 -0700 Subject: [PATCH 1/4] fix(bun-polyfill): Bun.spawn parity for proc.exited and stream consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bun.spawn polyfill omitted `proc.exited`, so eight call sites in browse/src/ that `await proc.exited` (DPAPI decryption, isBrowserRunning, browser-skill-commands, cli, terminal-agent) silently no-op on Windows — the await resolves to `undefined` immediately, the caller reads stdout before the child has produced anything, and the operation looks like a silent failure. Most visible symptom: cookie picker's DPAPI helper reports `DPAPI decryption failed:` with empty stderr. Adding `proc.exited` exposes the second-order issue: every consumer that awaits exited *before* reading stdout hits `Response body disturbed or locked`, because Node's auto-flowing Readable has already drained by the time the consumer wraps it. `Readable.toWeb()` (Node 18+) hands the consumer a buffered Web ReadableStream that behaves like Bun's native API regardless of read order. Falls back to the raw Node Readable on Node < 18 so the polyfill stays backward-compatible. The test file's existing `require('${polyfillPath}')` interpolation escaped its backslashes on Windows, so the tests had been silently unrunnable there; a `.replace(/\/g, '/')` on the resolved path fixes the interpolation and lets the new regressions run on the windows-free-tests matrix. Test plan - bun test browse/test/bun-polyfill.test.ts — 7 pass (3 new + 4 existing) - bun test browse/test/cookie-import-browser.test.ts — 22 pass - bun test (full suite) — passes; the unrelated batch.test.ts beforeEach-timeout flake is the only fail and is present on main. Related: #764 (Windows cookie import), PR #392 (open WIP for full DPAPI + v20 fallback support — this PR is strictly scoped to the polyfill so it can land independently of that broader effort). Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/bun-polyfill.cjs | 30 ++++++++++++++++-- browse/test/bun-polyfill.test.ts | 53 ++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/browse/src/bun-polyfill.cjs b/browse/src/bun-polyfill.cjs index e0ada11b3..ce95b6dce 100644 --- a/browse/src/bun-polyfill.cjs +++ b/browse/src/bun-polyfill.cjs @@ -12,6 +12,7 @@ const http = require('http'); const { spawnSync, spawn } = require('child_process'); +const { Readable } = require('stream'); globalThis.Bun = { serve(options) { @@ -93,11 +94,36 @@ globalThis.Bun = { cwd: options.cwd, }); + // Bun's spawn exposes `proc.exited` as a Promise resolving to the exit + // code; several call sites — DPAPI decryption, isBrowserRunning, + // browser-skill-commands — `await proc.exited` directly or via + // Promise.race with a timeout. Without this, those awaits resolve to + // `undefined` immediately and the operation looks like a silent failure. + const exited = new Promise((resolveExited) => { + proc.once('exit', (code, signal) => { + // Match Bun: exit code on normal exit; 128 + signal number on signal; + // 0 if neither was reported. + if (code !== null) resolveExited(code); + else if (signal) resolveExited(128 + (require('os').constants.signals[signal] || 0)); + else resolveExited(0); + }); + proc.once('error', () => resolveExited(1)); + }); + + // Bun gives consumers a Web ReadableStream so `new Response(proc.stdout)` + // works regardless of read order. With Node's Readable, the stream auto- + // drains once the child exits, so `await proc.exited` followed by + // `new Response(proc.stdout).text()` throws "body disturbed or locked". + // Readable.toWeb hands the consumer a fresh ReadableStream that buffers + // until it's read. Falls back to the raw Node stream on Node < 18. + const toWeb = (s) => (s && typeof Readable.toWeb === 'function' ? Readable.toWeb(s) : s); + return { pid: proc.pid, - stdout: proc.stdout, - stderr: proc.stderr, + stdout: toWeb(proc.stdout), + stderr: toWeb(proc.stderr), stdin: proc.stdin, + exited, unref() { proc.unref(); }, kill(signal) { proc.kill(signal); }, }; diff --git a/browse/test/bun-polyfill.test.ts b/browse/test/bun-polyfill.test.ts index 7ca25dfab..57b2981cb 100644 --- a/browse/test/bun-polyfill.test.ts +++ b/browse/test/bun-polyfill.test.ts @@ -1,8 +1,11 @@ import { describe, test, expect, afterAll } from 'bun:test'; import * as path from 'path'; -// Load the polyfill into a fresh object (don't clobber globalThis.Bun) -const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs'); +// Load the polyfill into a fresh object (don't clobber globalThis.Bun). +// Forward-slash on Windows so the path interpolates cleanly into the +// `require('${polyfillPath}')` template literals below — raw backslashes +// would be interpreted as JS escape sequences in the spawned Node script. +const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs').replace(/\\/g, '/'); describe('bun-polyfill', () => { // We test the polyfill by requiring it in a subprocess under Node.js @@ -48,6 +51,52 @@ describe('bun-polyfill', () => { expect(lines[2]).toBe('HAS_UNREF'); }); + // Bun.spawn parity: `proc.exited` is a Promise resolving to the exit code. + // The DPAPI helper and isBrowserRunning both `await proc.exited`; without + // it the awaits resolve immediately to `undefined` and the caller reads + // stdout before the child has produced it — surfacing as a silent failure. + test('Bun.spawn exposes proc.exited that resolves to the exit code', async () => { + const result = Bun.spawnSync(['node', '-e', ` + require('${polyfillPath}'); + (async () => { + const p = Bun.spawn(['node', '-e', 'process.exit(0)'], { stdio: ['ignore', 'ignore', 'ignore'] }); + console.log(typeof p.exited === 'object' && typeof p.exited.then === 'function' ? 'IS_PROMISE' : 'NOT_PROMISE'); + console.log('exit:' + await p.exited); + })(); + `], { stdout: 'pipe', stderr: 'pipe' }); + const lines = result.stdout.toString().trim().split('\n'); + expect(lines[0]).toBe('IS_PROMISE'); + expect(lines[1]).toBe('exit:0'); + }); + + test('Bun.spawn proc.exited reflects non-zero exit codes', async () => { + const result = Bun.spawnSync(['node', '-e', ` + require('${polyfillPath}'); + (async () => { + const p = Bun.spawn(['node', '-e', 'process.exit(3)'], { stdio: ['ignore', 'ignore', 'ignore'] }); + console.log('exit:' + await p.exited); + })(); + `], { stdout: 'pipe', stderr: 'pipe' }); + expect(result.stdout.toString().trim()).toBe('exit:3'); + }); + + test('Bun.spawn proc.exited resolves before reading stdout (no race)', async () => { + const result = Bun.spawnSync(['node', '-e', ` + require('${polyfillPath}'); + (async () => { + // Real-world pattern: write to stdout, then exit. Awaiting proc.exited + // before reading must guarantee the bytes are flushed. + const p = Bun.spawn(['node', '-e', 'process.stdout.write("ready"); process.exit(0)'], { + stdio: ['ignore', 'pipe', 'ignore'] + }); + const code = await p.exited; + const out = await new Response(p.stdout).text(); + console.log(out + ':' + code); + })(); + `], { stdout: 'pipe', stderr: 'pipe' }); + expect(result.stdout.toString().trim()).toBe('ready:0'); + }); + test('Bun.serve creates an HTTP server that responds', async () => { const result = Bun.spawnSync(['node', '-e', ` require('${polyfillPath}'); From 1d77c85b076171e1564ee1ff81dbfae9f8227999 Mon Sep 17 00:00:00 2001 From: Salim Habash Date: Wed, 27 May 2026 00:37:28 -0700 Subject: [PATCH 2/4] fix(bun-polyfill): drain spawned pipes eagerly to handle large output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on the prior commit flagged the pull-based Readable.toWeb approach: when the child writes more than the OS pipe buffer (~16-64 KB depending on platform) and the consumer awaits proc.exited before reading stdout, the buffer fills, the child blocks in write(), and exit never fires. browser-skill-commands.ts hits exactly this pattern when a spawned skill produces non-trivial output. Replace toWeb with eager-drain-then-replay: stdout and stderr are read into in-memory chunk arrays via 'data' events the moment the child starts, so pipes never back-pressure. proc.exited now resolves only after both exit and the drain have completed, so consumers reading stdout AFTER awaiting exit always see the full output. Consumers reading BEFORE awaiting exit still get the full output too — the replayed ReadableStream's start() awaits the drain before enqueueing. Adds a regression test that writes 1 MB to stdout — pre-fix this hangs forever, post-fix it returns in <500 ms. Removes the now-unused stream.Readable require. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/bun-polyfill.cjs | 63 +++++++++++++++++++++++++------- browse/test/bun-polyfill.test.ts | 25 +++++++++++++ 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/browse/src/bun-polyfill.cjs b/browse/src/bun-polyfill.cjs index ce95b6dce..275d92a7e 100644 --- a/browse/src/bun-polyfill.cjs +++ b/browse/src/bun-polyfill.cjs @@ -12,7 +12,6 @@ const http = require('http'); const { spawnSync, spawn } = require('child_process'); -const { Readable } = require('stream'); globalThis.Bun = { serve(options) { @@ -94,34 +93,70 @@ globalThis.Bun = { cwd: options.cwd, }); + // Drain stdout/stderr eagerly into in-memory buffers. Bun's spawn buffers + // these for the consumer; Node's Readables are pull-based, so if the caller + // awaits `proc.exited` before reading, anything past the OS pipe buffer + // (~16-64 KB) back-pressures the child until it blocks in write() and + // `exit` never fires. Eager draining keeps the pipes flowing regardless + // of read order; replay below is via fresh Web ReadableStreams. + const drain = (stream) => { + if (!stream) return { done: Promise.resolve(), chunks: [] }; + const chunks = []; + const done = new Promise((resolve) => { + stream.on('data', (chunk) => chunks.push(chunk)); + stream.once('end', resolve); + stream.once('error', resolve); // exit event carries the real status + }); + return { done, chunks }; + }; + const stdoutDrain = drain(proc.stdout); + const stderrDrain = drain(proc.stderr); + // Bun's spawn exposes `proc.exited` as a Promise resolving to the exit // code; several call sites — DPAPI decryption, isBrowserRunning, // browser-skill-commands — `await proc.exited` directly or via // Promise.race with a timeout. Without this, those awaits resolve to // `undefined` immediately and the operation looks like a silent failure. + // Resolve only after both pipes have finished draining so consumers that + // read stdout AFTER awaiting exit see the full output, not a partial buffer. const exited = new Promise((resolveExited) => { + let exitStatus; proc.once('exit', (code, signal) => { // Match Bun: exit code on normal exit; 128 + signal number on signal; // 0 if neither was reported. - if (code !== null) resolveExited(code); - else if (signal) resolveExited(128 + (require('os').constants.signals[signal] || 0)); - else resolveExited(0); + if (code !== null) exitStatus = code; + else if (signal) exitStatus = 128 + (require('os').constants.signals[signal] || 0); + else exitStatus = 0; }); - proc.once('error', () => resolveExited(1)); + proc.once('error', () => { + if (exitStatus === undefined) exitStatus = 1; + }); + Promise.all([ + new Promise((r) => proc.once('exit', r)), + stdoutDrain.done, + stderrDrain.done, + ]).then(() => resolveExited(exitStatus !== undefined ? exitStatus : 0)); }); - // Bun gives consumers a Web ReadableStream so `new Response(proc.stdout)` - // works regardless of read order. With Node's Readable, the stream auto- - // drains once the child exits, so `await proc.exited` followed by - // `new Response(proc.stdout).text()` throws "body disturbed or locked". - // Readable.toWeb hands the consumer a fresh ReadableStream that buffers - // until it's read. Falls back to the raw Node stream on Node < 18. - const toWeb = (s) => (s && typeof Readable.toWeb === 'function' ? Readable.toWeb(s) : s); + // Replay buffered output as a fresh Web ReadableStream. `start()` awaits + // the drain before enqueueing so `new Response(proc.stdout).text()` yields + // the complete output regardless of whether the consumer reads before or + // after awaiting `proc.exited`. Stream is single-shot (locked after one + // read), matching Bun's behavior. + const replay = (d) => new ReadableStream({ + async start(controller) { + await d.done; + for (const chunk of d.chunks) { + controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)); + } + controller.close(); + }, + }); return { pid: proc.pid, - stdout: toWeb(proc.stdout), - stderr: toWeb(proc.stderr), + stdout: replay(stdoutDrain), + stderr: replay(stderrDrain), stdin: proc.stdin, exited, unref() { proc.unref(); }, diff --git a/browse/test/bun-polyfill.test.ts b/browse/test/bun-polyfill.test.ts index 57b2981cb..c9309208b 100644 --- a/browse/test/bun-polyfill.test.ts +++ b/browse/test/bun-polyfill.test.ts @@ -97,6 +97,31 @@ describe('bun-polyfill', () => { expect(result.stdout.toString().trim()).toBe('ready:0'); }); + // Regression for the pipe-blocking case: if the child writes more than the + // OS pipe buffer (~16-64 KB) and the polyfill doesn't drain eagerly, the + // child blocks in write() and `exit` never fires. 1 MB is well past every + // OS pipe buffer size. Pre-fix this test hangs forever; post-fix it returns + // in <500ms. Bun's default per-test timeout is 5s — generous here. + test('Bun.spawn drains large stdout so proc.exited still resolves', async () => { + const result = Bun.spawnSync(['node', '-e', ` + require('${polyfillPath}'); + (async () => { + const ONE_MB = 1024 * 1024; + const p = Bun.spawn( + ['node', '-e', 'process.stdout.write("x".repeat(' + ONE_MB + ')); process.exit(0)'], + { stdio: ['ignore', 'pipe', 'ignore'] } + ); + const code = await Promise.race([ + p.exited, + new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 10000)) + ]).catch(e => 'TIMEOUT'); + const out = await new Response(p.stdout).text(); + console.log(out.length + ':' + code); + })().catch((e) => { console.log('THREW:' + e.message); }); + `], { stdout: 'pipe', stderr: 'pipe' }); + expect(result.stdout.toString().trim()).toBe('1048576:0'); + }, 15000); + test('Bun.serve creates an HTTP server that responds', async () => { const result = Bun.spawnSync(['node', '-e', ` require('${polyfillPath}'); From 899b3be30ffd00c1cb5e14953391e9d857c23e08 Mon Sep 17 00:00:00 2001 From: Salim Habash Date: Wed, 27 May 2026 00:41:57 -0700 Subject: [PATCH 3/4] fix(bun-polyfill): resolve proc.exited on spawn failure too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review pass 2 flagged a [P2]: Node emits 'error' but not 'exit' when the spawned binary is missing, so the lifecycle Promise that only listened to 'exit' hangs forever. `await proc.exited` waits until the caller's own timeout fires, which on a missing `bun`, `powershell`, or similar burns the full 10 s the DPAPI helper allows. Listen to both 'exit' and 'error' on the lifecycle Promise so spawn failures resolve immediately. Also add 'close' as a third resolve trigger on the pipe drains for the same reason — the stdio streams may fire 'close' without 'end' or 'error' when the process never started. Regression test asserts proc.exited resolves (not 'TIMEOUT') when the binary doesn't exist. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/bun-polyfill.cjs | 22 ++++++++++++++++------ browse/test/bun-polyfill.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/browse/src/bun-polyfill.cjs b/browse/src/bun-polyfill.cjs index 275d92a7e..bc7236e52 100644 --- a/browse/src/bun-polyfill.cjs +++ b/browse/src/bun-polyfill.cjs @@ -104,8 +104,12 @@ globalThis.Bun = { const chunks = []; const done = new Promise((resolve) => { stream.on('data', (chunk) => chunks.push(chunk)); + // Any terminal event resolves: 'end' on normal close, 'error' on a + // stream-level error, 'close' as the belt-and-suspenders for spawn + // failures where Node fires 'close' but neither 'end' nor 'error'. stream.once('end', resolve); - stream.once('error', resolve); // exit event carries the real status + stream.once('error', resolve); + stream.once('close', resolve); }); return { done, chunks }; }; @@ -131,11 +135,17 @@ globalThis.Bun = { proc.once('error', () => { if (exitStatus === undefined) exitStatus = 1; }); - Promise.all([ - new Promise((r) => proc.once('exit', r)), - stdoutDrain.done, - stderrDrain.done, - ]).then(() => resolveExited(exitStatus !== undefined ? exitStatus : 0)); + // Wait for either 'exit' (normal child lifecycle) or 'error' (spawn + // failure — Node fires error without exit when the binary is missing). + // Either path resolves the lifecycle promise; without listening to both + // a spawn error hangs `await proc.exited` until the consumer's own + // timeout fires. + const lifecycle = new Promise((r) => { + proc.once('exit', r); + proc.once('error', r); + }); + Promise.all([lifecycle, stdoutDrain.done, stderrDrain.done]) + .then(() => resolveExited(exitStatus !== undefined ? exitStatus : 0)); }); // Replay buffered output as a fresh Web ReadableStream. `start()` awaits diff --git a/browse/test/bun-polyfill.test.ts b/browse/test/bun-polyfill.test.ts index c9309208b..6b7ae78a9 100644 --- a/browse/test/bun-polyfill.test.ts +++ b/browse/test/bun-polyfill.test.ts @@ -97,6 +97,30 @@ describe('bun-polyfill', () => { expect(result.stdout.toString().trim()).toBe('ready:0'); }); + // Spawn-failure case: Node emits 'error' but not 'exit' when the binary + // is missing, so listening only for 'exit' hangs `await proc.exited` + // forever. The lifecycle promise must resolve on either event. + test('Bun.spawn proc.exited resolves on spawn failure (missing binary)', async () => { + const result = Bun.spawnSync(['node', '-e', ` + require('${polyfillPath}'); + (async () => { + const p = Bun.spawn(['this-binary-does-not-exist-zzz-' + Date.now()], { + stdio: ['ignore', 'pipe', 'pipe'] + }); + const code = await Promise.race([ + p.exited, + new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 3000)) + ]).catch(() => 'TIMEOUT'); + console.log('exit:' + code); + })(); + `], { stdout: 'pipe', stderr: 'pipe' }); + // Anything other than 'TIMEOUT' (and ideally a non-zero number) means the + // lifecycle promise resolved on the spawn error. + const out = result.stdout.toString().trim(); + expect(out).not.toBe('exit:TIMEOUT'); + expect(out).toMatch(/^exit:\d+$/); + }); + // Regression for the pipe-blocking case: if the child writes more than the // OS pipe buffer (~16-64 KB) and the polyfill doesn't drain eagerly, the // child blocks in write() and `exit` never fires. 1 MB is well past every From 33143ad9239b78c1b79a2bf235ac202ecfe2e61b Mon Sep 17 00:00:00 2001 From: Salim Habash Date: Wed, 27 May 2026 00:46:17 -0700 Subject: [PATCH 4/4] fix(bun-polyfill): cap spawned-pipe buffer to prevent OOM Codex review pass 3 flagged a [P2]: the unbounded eager-drain buffer defeats the consumer's existing readCapped at the call site. A runaway child that prints hundreds of MB before timing out now balloons server memory regardless of the consumer's documented 1 MB cap. Cap the per-pipe drain at 16 MB by default (GSTACK_SPAWN_MAX_BUFFER overrides), generous for every real use case (DPAPI ~32B, tasklist <1KB, codex review ~100KB) and small enough to bound worst-case memory. Past the cap, the pipe keeps flowing so the child never blocks in write(), but further bytes are discarded. Consumers reading the replay stream see exactly MAX_BUFFER bytes; the existing readCapped at browser-skill- commands then applies its own 1 MB cap on top with no surprises. Regression test sets GSTACK_SPAWN_MAX_BUFFER=1024, has the child write 10 KB, asserts captured stdout is exactly 1024 bytes and exit resolves cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/bun-polyfill.cjs | 29 +++++++++++++++++++++++++---- browse/test/bun-polyfill.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/browse/src/bun-polyfill.cjs b/browse/src/bun-polyfill.cjs index bc7236e52..897ce0915 100644 --- a/browse/src/bun-polyfill.cjs +++ b/browse/src/bun-polyfill.cjs @@ -99,11 +99,32 @@ globalThis.Bun = { // (~16-64 KB) back-pressures the child until it blocks in write() and // `exit` never fires. Eager draining keeps the pipes flowing regardless // of read order; replay below is via fresh Web ReadableStreams. + // + // Cap the buffer so a runaway child can't OOM the server. 16 MB is + // generous: DPAPI outputs are tiny, tasklist is <1 KB, and the + // browser-skill consumer has its own 1 MB readCapped. Once the cap is + // reached we keep draining the pipe (so the child never blocks) but + // discard further bytes. Override via GSTACK_SPAWN_MAX_BUFFER (bytes). + const MAX_BUFFER = Math.max( + 0, + parseInt(process.env.GSTACK_SPAWN_MAX_BUFFER || '', 10) || 16 * 1024 * 1024, + ); const drain = (stream) => { - if (!stream) return { done: Promise.resolve(), chunks: [] }; - const chunks = []; + if (!stream) return { done: Promise.resolve(), chunks: [], truncated: false }; + const state = { chunks: [], bytes: 0, truncated: false }; const done = new Promise((resolve) => { - stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('data', (chunk) => { + if (state.bytes >= MAX_BUFFER) { state.truncated = true; return; } + if (state.bytes + chunk.length <= MAX_BUFFER) { + state.chunks.push(chunk); + state.bytes += chunk.length; + } else { + const remaining = MAX_BUFFER - state.bytes; + state.chunks.push(chunk.subarray(0, remaining)); + state.bytes = MAX_BUFFER; + state.truncated = true; + } + }); // Any terminal event resolves: 'end' on normal close, 'error' on a // stream-level error, 'close' as the belt-and-suspenders for spawn // failures where Node fires 'close' but neither 'end' nor 'error'. @@ -111,7 +132,7 @@ globalThis.Bun = { stream.once('error', resolve); stream.once('close', resolve); }); - return { done, chunks }; + return { done, chunks: state.chunks }; }; const stdoutDrain = drain(proc.stdout); const stderrDrain = drain(proc.stderr); diff --git a/browse/test/bun-polyfill.test.ts b/browse/test/bun-polyfill.test.ts index 6b7ae78a9..efca8f39b 100644 --- a/browse/test/bun-polyfill.test.ts +++ b/browse/test/bun-polyfill.test.ts @@ -121,6 +121,32 @@ describe('bun-polyfill', () => { expect(out).toMatch(/^exit:\d+$/); }); + // GSTACK_SPAWN_MAX_BUFFER caps the drain so a runaway child can't OOM the + // server. Past the cap, the pipe keeps flowing (child doesn't block) but + // further bytes are dropped. Set a small cap, write more than that, assert + // the captured stdout equals the cap and the child exits cleanly. + test('Bun.spawn caps buffered output at GSTACK_SPAWN_MAX_BUFFER', async () => { + const result = Bun.spawnSync(['node', '-e', ` + process.env.GSTACK_SPAWN_MAX_BUFFER = '${1024}'; + require('${polyfillPath}'); + (async () => { + // Child writes 10 KB; cap is 1 KB; drained output should be exactly 1 KB + // and exit should still resolve cleanly (child not back-pressured to death). + const p = Bun.spawn( + ['node', '-e', 'process.stdout.write("y".repeat(10 * 1024)); process.exit(0)'], + { stdio: ['ignore', 'pipe', 'ignore'] } + ); + const code = await Promise.race([ + p.exited, + new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 3000)) + ]).catch(() => 'TIMEOUT'); + const out = await new Response(p.stdout).text(); + console.log(out.length + ':' + code); + })(); + `], { stdout: 'pipe', stderr: 'pipe' }); + expect(result.stdout.toString().trim()).toBe('1024:0'); + }); + // Regression for the pipe-blocking case: if the child writes more than the // OS pipe buffer (~16-64 KB) and the polyfill doesn't drain eagerly, the // child blocks in write() and `exit` never fires. 1 MB is well past every