diff --git a/packages/conversation/tests/fake-pi.mjs b/packages/conversation/tests/fake-pi.mjs index b3014f86..a78dfebd 100644 --- a/packages/conversation/tests/fake-pi.mjs +++ b/packages/conversation/tests/fake-pi.mjs @@ -615,14 +615,34 @@ const children = []; // K2); `forkLoop` forks every 5 ms (K12); `ignoreTerm` survives SIGTERM, so // only the kill phase ends it (K3, K10, K11). With `pidLog`, the fork loop // appends each child's pid and a `term` line when it gets SIGTERM (K12). +// +// It resolves only once the child writes its ready byte, which it does after +// installing its TERM handler. Node takes 15 to 80 ms to get there, and a +// force stop can send TERM sooner (12 ms when the claim store is on a fast +// disk). A TERM before the handler kills a child that is meant to ignore it +// (#1528). function spawnChild({ setsid = false, forkLoop = false, ignoreTerm = false, pidLog = null } = {}) { const note = pidLog ? `const note=(s)=>require('node:fs').appendFileSync(${JSON.stringify(pidLog)},s+'\\n');` : "const note=()=>{};"; - const code = note + (ignoreTerm ? "process.on('SIGTERM',()=>note('term'));" : "") + (forkLoop + const code = note + (ignoreTerm ? "process.on('SIGTERM',()=>note('term'));" : "") + "process.stdout.write('r');" + (forkLoop ? "const {spawn}=require('node:child_process');setInterval(()=>{try{const c=spawn('sleep',['1000'],{stdio:'ignore'});if(c.pid)note(String(c.pid))}catch{}},5);setInterval(()=>{},1e9)" : "setInterval(()=>{},1e9)"); - const child = spawn(process.execPath, ["-e", code], { stdio: "ignore", detached: setsid }); + const child = spawn(process.execPath, ["-e", code], { stdio: ["ignore", "pipe", "ignore"], detached: setsid }); children.push(child.pid); - return child.pid; + return new Promise((resolve, reject) => { + const fail = (why) => { + clearTimeout(timer); + reject(new Error(`tool child ${child.pid} ${why} before it was ready`)); + }; + const timer = setTimeout(() => fail("took 10 s"), 10000); + child.once("error", (err) => fail(err.code ?? err.message)); + child.once("exit", (code, signal) => fail(`exited (${signal ?? code})`)); + child.stdout.once("data", () => { + clearTimeout(timer); + child.removeAllListeners("exit"); + child.stdout.destroy(); + resolve(child.pid); + }); + }); } // K13: a member writes its own pid to another cgroup's cgroup.procs. @@ -671,7 +691,7 @@ async function main() { drop: () => fake.dropResponse(req.type, req.n ?? 1), extension: () => void fake.extensionPrompt(req.args ?? {}), state: () => ({ streaming: fake.streaming, runs: fake.runs.length, commands: fake.commands, pid: process.pid, children, appends: fake.appends }), - child: () => ({ pid: spawnChild(req.args ?? {}) }), + child: () => spawnChild(req.args ?? {}).then((pid) => ({ pid })), escape: () => escape(req.target), cgroup: () => readFileSync(`/proc/${req.pid ?? process.pid}/cgroup`, "utf8"), waitPaused: () => fake.waitPaused(req.point), @@ -679,12 +699,13 @@ async function main() { stall: () => void process.stdin.pause(), }; if (!ops[req.op]) return sock.write(encodeLine({ id: req.id, ok: false, error: `unknown op ${req.op}` })); + const failed = (err) => sock.write(encodeLine({ id: req.id, ok: false, error: String(err.message) })); try { const out = ops[req.op](); - if (out && typeof out.then === "function") out.then(reply); + if (out && typeof out.then === "function") out.then(reply, failed); else reply(out ?? null); } catch (err) { - sock.write(encodeLine({ id: req.id, ok: false, error: String(err.message) })); + failed(err); } }); sock.on("data", (c) => splitter.push(c));