Skip to content

Commit 96b7c97

Browse files
committed
workflow: align Bun stdio probe with supervisor
1 parent f979689 commit 96b7c97

2 files changed

Lines changed: 98 additions & 36 deletions

File tree

‎src/workflow/supervised-ipc-capability.ts‎

Lines changed: 40 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { spawn as nodeSpawn } from "node:child_process";
2+
import type { ChildProcessWithoutNullStreams } from "node:child_process";
13
import path from "node:path";
24

35
const cachedProbes = new Map<string, Promise<boolean>>();
@@ -10,6 +12,20 @@ function isBunExecutable(executablePath: string): boolean {
1012
}
1113

1214
type RuntimeVersions = NodeJS.ProcessVersions & { bun?: string };
15+
type SpawnFn = (
16+
command: string,
17+
args: string[],
18+
options: {
19+
stdin: "pipe";
20+
stdout: "pipe";
21+
stderr: "ignore";
22+
env: NodeJS.ProcessEnv;
23+
},
24+
) => ChildProcessWithoutNullStreams;
25+
26+
export function resetChildBunStdinPipeProbeCache(): void {
27+
cachedProbes.clear();
28+
}
1329

1430
function isBunScriptEntryPoint(childEntryPoint: string | undefined): boolean {
1531
if (!childEntryPoint) return false;
@@ -56,6 +72,7 @@ export async function supportsChildBunStdinPipe(
5672
childEntryPoint?: string,
5773
executablePath: string = process.execPath,
5874
versions: RuntimeVersions = process.versions,
75+
spawnFn: SpawnFn = nodeSpawn as unknown as SpawnFn,
5976
): Promise<boolean> {
6077
if (!usesBunChildRuntime(childEntryPoint, executablePath, versions)) {
6178
// The known stdin pipe issue is Bun-runtime specific.
@@ -79,23 +96,20 @@ export async function supportsChildBunStdinPipe(
7996
"process.stdin.resume();" +
8097
"setTimeout(() => process.exit(2), 1200);";
8198

82-
// IMPORTANT: use Bun.spawn here to match the actual supervised runner,
83-
// which spawns the Core process via Bun.spawn (ProcessManager).
84-
// The stdin-pipe issue can differ between node:child_process.spawn and Bun.spawn.
85-
const bunAny = (globalThis as unknown as { Bun?: typeof Bun }).Bun;
86-
if (!bunAny) {
87-
resolve(false);
88-
return;
89-
}
90-
91-
const child = bunAny.spawn({
92-
cmd: [...probeCommand, script],
99+
// Match the actual supervised stdio path in Supervisor, which uses
100+
// node:child_process.spawn for Bun child entrypoints.
101+
const child = spawnFn(probeCommand[0]!, [...probeCommand.slice(1), script], {
93102
stdin: "pipe",
94103
stdout: "pipe",
95104
stderr: "ignore",
96105
env: { ...process.env },
97106
});
98107

108+
if (!child.stdin || !child.stdout) {
109+
resolve(false);
110+
return;
111+
}
112+
99113
let stdout = "";
100114
let settled = false;
101115
const finish = (value: boolean) => {
@@ -109,35 +123,25 @@ export async function supportsChildBunStdinPipe(
109123
resolve(value);
110124
};
111125

112-
// Read stdout until token observed or timeout.
113-
const decoder = new TextDecoder();
114-
const reader = child.stdout.getReader();
115-
void (async () => {
116-
try {
117-
while (true) {
118-
const { done, value } = await reader.read();
119-
if (done) break;
120-
stdout += decoder.decode(value, { stream: true });
121-
if (stdout.includes(token)) {
122-
finish(true);
123-
return;
124-
}
125-
}
126-
} catch {
127-
finish(false);
128-
} finally {
129-
try {
130-
reader.releaseLock();
131-
} catch {
132-
// ignore
133-
}
126+
child.stdout.setEncoding("utf8");
127+
child.stdout.on("data", (chunk: string) => {
128+
stdout += chunk;
129+
if (stdout.includes(token)) {
130+
finish(true);
134131
}
132+
});
133+
child.stdout.on("error", () => {
134+
finish(false);
135+
});
136+
child.once("error", () => {
137+
finish(false);
138+
});
139+
child.once("exit", () => {
135140
finish(stdout.includes(token));
136-
})();
141+
});
137142

138143
try {
139-
child.stdin.write(new TextEncoder().encode(`${token}\n`));
140-
child.stdin.flush();
144+
child.stdin.write(`${token}\n`);
141145
child.stdin.end();
142146
} catch {
143147
finish(false);

‎test/unit/workflow/supervised-ipc-capability.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,35 @@
11
import { describe, test, expect } from "bun:test";
2+
import { EventEmitter } from "node:events";
3+
import type { ChildProcessWithoutNullStreams } from "node:child_process";
4+
import { PassThrough } from "node:stream";
25

36
import {
47
isBunRuntime,
8+
resetChildBunStdinPipeProbeCache,
59
supportsChildBunStdinPipe,
610
usesBunChildRuntime,
711
} from "../../../src/workflow/supervised-ipc-capability.js";
812

13+
function createMockChildProcess(
14+
onWrite: (input: string, stdout: PassThrough) => void,
15+
): ChildProcessWithoutNullStreams {
16+
const proc = new EventEmitter() as ChildProcessWithoutNullStreams;
17+
const stdin = new PassThrough();
18+
const stdout = new PassThrough();
19+
proc.stdin = stdin;
20+
proc.stdout = stdout;
21+
proc.stderr = new PassThrough();
22+
proc.kill = () => true;
23+
stdin.setEncoding("utf8");
24+
stdin.on("data", (chunk: string) => {
25+
onWrite(chunk, stdout);
26+
});
27+
stdin.on("end", () => {
28+
proc.emit("exit", 0, null);
29+
});
30+
return proc;
31+
}
32+
933
describe("isBunRuntime", () => {
1034
test("returns true for the Bun executable path", () => {
1135
expect(isBunRuntime("/home/reoring/.bun/bin/bun", {} as NodeJS.ProcessVersions)).toBe(true);
@@ -67,4 +91,38 @@ describe("supportsChildBunStdinPipe", () => {
6791
),
6892
).resolves.toBe(false);
6993
});
94+
95+
test("returns true when node child_process stdio echoes the probe token", async () => {
96+
resetChildBunStdinPipeProbeCache();
97+
const spawnFn = () =>
98+
createMockChildProcess((input, stdout) => {
99+
stdout.write(input);
100+
});
101+
102+
await expect(
103+
supportsChildBunStdinPipe(
104+
"/tmp/roboppi/src/index.ts",
105+
"/usr/bin/node",
106+
{} as NodeJS.ProcessVersions,
107+
spawnFn,
108+
),
109+
).resolves.toBe(true);
110+
});
111+
112+
test("returns false when node child_process stdio never echoes the probe token", async () => {
113+
resetChildBunStdinPipeProbeCache();
114+
const spawnFn = () =>
115+
createMockChildProcess((_input, _stdout) => {
116+
// Simulate the hung stdio path: process exits without returning the token.
117+
});
118+
119+
await expect(
120+
supportsChildBunStdinPipe(
121+
"/tmp/roboppi/src/index.ts",
122+
"/usr/bin/node",
123+
{} as NodeJS.ProcessVersions,
124+
spawnFn,
125+
),
126+
).resolves.toBe(false);
127+
});
70128
});

0 commit comments

Comments
 (0)