1/**
2 * Stray-output hardening: while a run is active, every patchable JS-level
3 * path that could write to the terminal is diverted into the run log so it
4 * can never interleave with reporter output (plain mode) or corrupt the
5 * alternate screen (TUI mode). Identified-and-fixed-at-the-source is still
6 * preferred (per-test capture attributes output to its test); this is the
7 * safety net that makes an unknown writer harmless instead of a corruption
8 * hunt.
9 *
10 * Diverted globally (prefixed in the log; all verified under bun):
11 * 1. `process.stdout.write` / `process.stderr.write` [stray stdout|stderr]
12 * 2. global `console.*` — bun's console does NOT go through
13 * `process.stdout.write`, it writes natively [stray console]
14 * 3. `Bun.spawn` with `stdout/stderr: "inherit"` — downgraded to "pipe"
15 * and pumped into the log [stray child]
16 * 4. best-effort only: `node:child_process.spawn` inherit and
17 * `fs.writeSync/write` to fds 1/2. Under bun, ESM namespace imports
18 * bind straight to the native functions and IGNORE module-object
19 * mutation, so these patches only cover CJS `require` callers. ESM
20 * users of these APIs must be fixed at the source (e.g.
21 * LanguageModel.test.ts's fs.writeSync(2) was).
22 *
23 * NOT interceptable from JS at all: native code writing to the fd directly
24 * (e.g. rolldown's Rust progress spinner — held off via CI=true / logLevel).
25 *
26 * The reporter itself writes through {@link writeDirect}, which always uses
27 * the REAL stream saved before patching.
28 */
29// The sinks run inside synchronous patches — they cannot be effectful, so
30// this module uses node:fs append directly (append mode interleaves safely
31// with the Effect-based FileLog appends).
32import * as childProcess from "node:child_process";
33import * as fs from "node:fs";
34
35type StreamWrite = typeof process.stdout.write;
36
37let realStdoutWrite: StreamWrite | undefined;
38
39/** Write to the real terminal, bypassing any active stray-output capture. */
40export const writeDirect = (text: string): void => {
41 const write = realStdoutWrite ?? process.stdout.write.bind(process.stdout);
42 write(text);
43};
44
45const decoder = new TextDecoder();
46
47// Strip ANSI so diverted output is readable in the log.
48const ANSI_RE =
49 // eslint-disable-next-line no-control-regex
50 /\u001B(?:\[[0-9;?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)|[@-Z\\-_])/g;
51
52const toText = (chunk: unknown): string =>
53 (typeof chunk === "string"
54 ? chunk
55 : chunk instanceof Uint8Array
56 ? decoder.decode(chunk)
57 : String(chunk)
58 ).replace(ANSI_RE, "");
59
60/**
61 * Divert every JS-level terminal writer into `logFile`. Returns a restore
62 * function; safe to call multiple times (only the first install wins).
63 */
64export const captureStrayOutput = (logFile: string): (() => void) => {
65 if (realStdoutWrite !== undefined) return () => {};
66
67 const sink = (prefix: string, chunk: unknown): void => {
68 try {
69 const text = toText(chunk);
70 if (text.trim() === "") return;
71 const prefixed = text
72 .split("\n")
73 .map((line) => (line === "" ? line : `[${prefix}] ${line}`))
74 .join("\n");
75 fs.appendFileSync(
76 logFile,
77 prefixed.endsWith("\n") ? prefixed : `${prefixed}\n`,
78 );
79 } catch {
80 // Never let log diversion break the writer.
81 }
82 };
83
84 const restores: Array<() => void> = [];
85
86 // -------------------------------------------------------------------------
87 // 1. process.stdout / process.stderr stream writes
88 // -------------------------------------------------------------------------
89 realStdoutWrite = process.stdout.write.bind(process.stdout);
90 const realStderrWrite = process.stderr.write.bind(process.stderr);
91 const divertStream =
92 (prefix: string): StreamWrite =>
93 (
94 chunk: string | Uint8Array,
95 encodingOrCb?: BufferEncoding | ((err?: Error | null) => void),
96 cb?: (err?: Error | null) => void,
97 ): boolean => {
98 const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
99 sink(prefix, chunk);
100 callback?.(null);
101 return true;
102 };
103 process.stdout.write = divertStream("stray stdout");
104 process.stderr.write = divertStream("stray stderr");
105 restores.push(() => {
106 process.stdout.write = realStdoutWrite!;
107 process.stderr.write = realStderrWrite;
108 realStdoutWrite = undefined;
109 });
110
111 // -------------------------------------------------------------------------
112 // 2. global console — bun's console writes natively, NOT via stdout.write
113 // -------------------------------------------------------------------------
114 try {
115 const methods = [
116 "log",
117 "info",
118 "warn",
119 "error",
120 "debug",
121 "trace",
122 "dir",
123 "table",
124 "group",
125 "groupCollapsed",
126 "groupEnd",
127 ] as const;
128 const original = new Map<string, unknown>();
129 for (const method of methods) {
130 original.set(method, console[method]);
131 (console as unknown as Record<string, unknown>)[method] = (
132 ...args: Array<unknown>
133 ) => {
134 sink(
135 "stray console",
136 args
137 .map((arg) =>
138 typeof arg === "string" ? arg : Bun.inspect(arg, { depth: 4 }),
139 )
140 .join(" "),
141 );
142 };
143 }
144 restores.push(() => {
145 for (const [method, fn] of original) {
146 (console as unknown as Record<string, unknown>)[method] = fn;
147 }
148 });
149 } catch {
150 // best-effort
151 }
152
153 // -------------------------------------------------------------------------
154 // 3. fs.writeSync / fs.write to fds 1 and 2 (bypasses the stream objects)
155 // -------------------------------------------------------------------------
156 try {
157 const realWriteSync = fs.writeSync;
158 const realWrite = fs.write;
159 const fsModule = fs as unknown as Record<string, unknown>;
160 fsModule.writeSync = ((
161 fd: unknown,
162 data: unknown,
163 ...rest: Array<unknown>
164 ) => {
165 if (fd === 1 || fd === 2) {
166 sink(`stray fd${fd}`, data);
167 return typeof data === "string"
168 ? Buffer.byteLength(data)
169 : ((data as Uint8Array).byteLength ?? 0);
170 }
171 return (realWriteSync as (...a: Array<unknown>) => number)(
172 fd,
173 data,
174 ...rest,
175 );
176 }) as typeof fs.writeSync;
177 fsModule.write = ((fd: unknown, data: unknown, ...rest: Array<unknown>) => {
178 if (fd === 1 || fd === 2) {
179 sink(`stray fd${fd}`, data);
180 const callback = rest.findLast((arg) => typeof arg === "function") as
181 | ((err: Error | null, written: number, data: unknown) => void)
182 | undefined;
183 callback?.(null, 0, data);
184 return;
185 }
186 return (realWrite as (...a: Array<unknown>) => unknown)(
187 fd,
188 data,
189 ...rest,
190 );
191 }) as typeof fs.write;
192 restores.push(() => {
193 fsModule.writeSync = realWriteSync;
194 fsModule.write = realWrite;
195 });
196 } catch {
197 // best-effort — the module namespace may not be writable
198 }
199
200 // -------------------------------------------------------------------------
201 // 4. child processes spawned with stdio: "inherit" (fd-level, otherwise
202 // unintersectable) — downgrade stdout/stderr to "pipe" and pump.
203 // -------------------------------------------------------------------------
204 try {
205 const realSpawn = childProcess.spawn;
206 const cpModule = childProcess as unknown as Record<string, unknown>;
207 cpModule.spawn = ((...args: Array<unknown>) => {
208 const options = args.find(
209 (arg): arg is Record<string, unknown> =>
210 typeof arg === "object" && arg !== null && !Array.isArray(arg),
211 );
212 let pumpStdout = false;
213 let pumpStderr = false;
214 if (options !== undefined) {
215 const stdio = options.stdio;
216 if (stdio === "inherit") {
217 options.stdio = ["inherit", "pipe", "pipe"];
218 pumpStdout = pumpStderr = true;
219 } else if (Array.isArray(stdio)) {
220 if (stdio[1] === "inherit") {
221 stdio[1] = "pipe";
222 pumpStdout = true;
223 }
224 if (stdio[2] === "inherit") {
225 stdio[2] = "pipe";
226 pumpStderr = true;
227 }
228 }
229 }
230 const child = (
231 realSpawn as (...a: Array<unknown>) => childProcess.ChildProcess
232 )(...args);
233 if (pumpStdout) {
234 child.stdout?.on("data", (chunk) => sink("stray child", chunk));
235 }
236 if (pumpStderr) {
237 child.stderr?.on("data", (chunk) => sink("stray child", chunk));
238 }
239 return child;
240 }) as typeof childProcess.spawn;
241 restores.push(() => {
242 cpModule.spawn = realSpawn;
243 });
244 } catch {
245 // best-effort
246 }
247
248 try {
249 const realBunSpawn = Bun.spawn;
250 Bun.spawn = ((...args: Array<unknown>) => {
251 // Both signatures: Bun.spawn(cmds, options?) and Bun.spawn({ cmd, ... }).
252 const options = args.find(
253 (arg): arg is Record<string, unknown> =>
254 typeof arg === "object" && arg !== null && !Array.isArray(arg),
255 );
256 let pumpStdout = false;
257 let pumpStderr = false;
258 if (options !== undefined) {
259 if (options.stdout === "inherit") {
260 options.stdout = "pipe";
261 pumpStdout = true;
262 }
263 if (options.stderr === "inherit") {
264 options.stderr = "pipe";
265 pumpStderr = true;
266 }
267 }
268 const proc = (
269 realBunSpawn as (...a: Array<unknown>) => ReturnType<typeof Bun.spawn>
270 )(...args);
271 const pump = (stream: unknown): void => {
272 if (stream instanceof ReadableStream) {
273 void (async () => {
274 const reader = stream.getReader();
275 for (;;) {
276 const { done, value } = await reader.read();
277 if (done) break;
278 sink("stray child", value);
279 }
280 })().catch(() => {});
281 }
282 };
283 if (pumpStdout) pump(proc.stdout);
284 if (pumpStderr) pump(proc.stderr);
285 return proc;
286 }) as typeof Bun.spawn;
287 restores.push(() => {
288 Bun.spawn = realBunSpawn;
289 });
290 } catch {
291 // best-effort
292 }
293
294 return () => {
295 // Restore in reverse installation order.
296 for (const restore of restores.reverse()) {
297 try {
298 restore();
299 } catch {
300 // ignore
301 }
302 }
303 };
304};