From bfe3d5c9ce30d8cbdb46949b30b0377b770d8efa Mon Sep 17 00:00:00 2001 From: Rinse Date: Thu, 21 May 2026 05:32:11 +0000 Subject: [PATCH 1/4] fix(daemon): fail fast when PKC RPC port is already in use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the daemon silently switched into client mode when it detected another process on the RPC port (default ws://localhost:9138), then idled: it skipped kubo startup, never loaded challenge packages, never started its own daemon server, and the keepalive tick was gated off — so the second process did nothing useful while pretending to. Now the daemon checks the port up front and exits non-zero with a message that names the port and points the user at --pkcRpcUrl on the other commands (which already exists on BaseCommand and is the right escape hatch for talking to a running daemon). Removes the usingDifferentProcessRpc state and the connect-as-client branch in createOrConnectRpc; simplifies runKeepKuboUpTick deps; drops the two tests that asserted the old silent-takeover and adds a new "fails when PKC RPC port is already in use" integration test. Closes #46 --- src/cli/commands/daemon.ts | 44 +++++++--------- test/cli/daemon.test.ts | 85 ++++++------------------------ test/cli/keep-kubo-up-tick.test.ts | 4 -- 3 files changed, 33 insertions(+), 100 deletions(-) diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 0cfa717..d813546 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -51,7 +51,6 @@ export interface KeepKuboUpTickDeps { pkcRpcUrl: URL; tcpPortUsedCheck: (port: number, host: string) => Promise; pkcOptionsFromFlag: { kuboRpcClientsOptions?: unknown } | undefined; - usingDifferentProcessRpc: boolean; hasKuboProcess: boolean; hasPendingKuboStart: boolean; keepKuboUp: () => Promise; @@ -71,10 +70,10 @@ export async function runKeepKuboUpTick(deps: KeepKuboUpTickDeps): Promise let isRpcPortTaken = false; try { isRpcPortTaken = await deps.tcpPortUsedCheck(Number(deps.pkcRpcUrl.port), deps.pkcRpcUrl.hostname); - if (!deps.pkcOptionsFromFlag?.kuboRpcClientsOptions && !isRpcPortTaken && !deps.usingDifferentProcessRpc) await deps.keepKuboUp(); - else if (deps.pkcOptionsFromFlag?.kuboRpcClientsOptions && !deps.usingDifferentProcessRpc) await deps.keepKuboUp(); + if (!deps.pkcOptionsFromFlag?.kuboRpcClientsOptions && !isRpcPortTaken) await deps.keepKuboUp(); + else if (deps.pkcOptionsFromFlag?.kuboRpcClientsOptions) await deps.keepKuboUp(); // Retry if kubo died and onKuboExit's restart attempt failed (e.g. transient port conflict) - else if (!deps.hasKuboProcess && !deps.hasPendingKuboStart && !deps.usingDifferentProcessRpc) await deps.keepKuboUp(); + else if (!deps.hasKuboProcess && !deps.hasPendingKuboStart) await deps.keepKuboUp(); } catch (error) { deps.onError(`keepKuboUp tick error (will retry): ${error instanceof Error ? error.message : String(error)}`); } @@ -284,6 +283,16 @@ export default class Daemon extends Command { if (pkcOptionsFromFlag?.ipfsGatewayUrls && pkcOptionsFromFlag.ipfsGatewayUrls.length !== 1) this.error("Can't provide pkcOptions.ipfsGatewayUrls as an array with more than 1 element, or as a non array"); + const isRpcPortAlreadyTaken = await tcpPortUsed.check(Number(pkcRpcUrl.port), pkcRpcUrl.hostname); + if (isRpcPortAlreadyTaken) { + this.error( + `PKC RPC port is already in use at ${pkcRpcUrl} (another bitsocial daemon is likely running). ` + + `To talk to the running daemon, use other bitsocial commands with --pkcRpcUrl ${pkcRpcUrl} ` + + `(e.g. 'bitsocial community list --pkcRpcUrl ${pkcRpcUrl}'). ` + + `To run a second daemon, restart with a different port, e.g. --pkcRpcUrl ws://${pkcRpcUrl.hostname}:${Number(pkcRpcUrl.port) + 1}.` + ); + } + const ipfsConfig = await loadKuboConfigFile(pkcOptionsFromFlag?.dataPath || defaultPkcOptions.dataPath!); const kuboRpcEndpoint = pkcOptionsFromFlag?.kuboRpcClientsOptions ? new URL(pkcOptionsFromFlag.kuboRpcClientsOptions[0]!.toString()) @@ -329,7 +338,7 @@ export default class Daemon extends Command { const keepKuboUp = async () => { if (mainProcessExited) return; const kuboApiPort = Number(kuboRpcEndpoint.port); - if (kuboProcess || pendingKuboStart || usingDifferentProcessRpc) return; // already started, no need to intervene + if (kuboProcess || pendingKuboStart) return; // already started, no need to intervene const connectHostname = toConnectableHostname(kuboRpcEndpoint.hostname); const isKuboApiPortTaken = await tcpPortUsed.check(kuboApiPort, connectHostname); if (isKuboApiPortTaken) { @@ -418,27 +427,13 @@ export default class Daemon extends Command { }; let startedOwnRpc = false; - let usingDifferentProcessRpc = false; let daemonServer: Awaited> | undefined; const createOrConnectRpc = async () => { if (mainProcessExited) return; if (startedOwnRpc) return; + // Tick may call this after our own server is up — port being taken means our server is still healthy. const isRpcPortTaken = await tcpPortUsed.check(Number(pkcRpcUrl.port), pkcRpcUrl.hostname); - if (isRpcPortTaken && usingDifferentProcessRpc) return; - if (isRpcPortTaken) { - log( - `PKC RPC is already running (${pkcRpcUrl}) by another program. bitsocial-cli will use the running RPC server, and if shuts down, bitsocial-cli will start a new RPC instance` - ); - console.log("Using the already started RPC server at:", pkcRpcUrl); - console.log("bitsocial-cli daemon will monitor the PKC RPC and kubo ipfs API to make sure they're always up"); - const PKC = await import("@pkcprotocol/pkc-js"); - const pkc = await PKC.default({ pkcRpcClientsOptions: [pkcRpcUrl.toString()] }); - await new Promise((resolve) => pkc.once("communitieschange", resolve)); - pkc.on("error", (error) => console.error("Error from pkc instance", error)); - console.log(`Communities in data path: `, pkc.communities); - usingDifferentProcessRpc = true; - return; - } + if (isRpcPortTaken) return; // Load installed challenge packages before starting the RPC server const loadedChallenges = await loadChallengesIntoPKC(mergedPkcOptions.dataPath); @@ -446,7 +441,6 @@ export default class Daemon extends Command { daemonServer = await startDaemonServer(pkcRpcUrl, ipfsGatewayEndpoint, mergedPkcOptions); - usingDifferentProcessRpc = false; startedOwnRpc = true; console.log(`pkc rpc: listening on ${pkcRpcUrl} (local connections only)`); console.log(`pkc rpc: listening on ${pkcRpcUrl}${daemonServer.rpcAuthKey} (secret auth key for remote connections)`); @@ -470,9 +464,8 @@ export default class Daemon extends Command { } }; - const isRpcPortTaken = await tcpPortUsed.check(Number(pkcRpcUrl.port), pkcRpcUrl.hostname); - - if (!pkcOptionsFromFlag?.kuboRpcClientsOptions && !isRpcPortTaken && !usingDifferentProcessRpc) await keepKuboUp(); + // RPC port was already verified free above (fail-fast); only the kuboRpcClientsOptions branch skips local kubo. + if (!pkcOptionsFromFlag?.kuboRpcClientsOptions) await keepKuboUp(); await createOrConnectRpc(); let keepKuboUpInterval: NodeJS.Timeout | undefined; @@ -576,7 +569,6 @@ export default class Daemon extends Command { pkcRpcUrl, tcpPortUsedCheck: (port, host) => tcpPortUsed.check(port, host), pkcOptionsFromFlag, - usingDifferentProcessRpc, hasKuboProcess: !!kuboProcess, hasPendingKuboStart: !!pendingKuboStart, keepKuboUp, diff --git a/test/cli/daemon.test.ts b/test/cli/daemon.test.ts index 1386f97..ef7d61a 100644 --- a/test/cli/daemon.test.ts +++ b/test/cli/daemon.test.ts @@ -362,6 +362,21 @@ describe("bitsocial daemon port availability validation", () => { () => true ); }); + + it("fails when PKC RPC port is already in use", { timeout: 60000 }, async () => { + const server = await occupyPort(validationRpcPort, "localhost"); + occupiedServers.push(server); + + const result = await runPkcDaemonExpectFailure( + ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", validationRpcUrl], + { KUBO_RPC_URL: validationKuboUrl, IPFS_GATEWAY_URL: validationGatewayUrl } + ); + expect(result.exitCode).not.toBe(0); + const combinedOutput = `${result.stdout}\n${result.stderr}`; + expect(combinedOutput).toContain("PKC RPC port is already in use"); + expect(combinedOutput).toContain(String(validationRpcPort)); + expect(combinedOutput).toContain("--pkcRpcUrl"); + }); }); describe("bitsocial daemon kubo restart cleanup", async () => { @@ -633,76 +648,6 @@ describe("bitsocial daemon survives transient port occupation after its own kubo }); }); -describe(`bitsocial daemon (relying on PKC RPC started by another process)`, async () => { - let rpcProcess: ManagedChildProcess; - const rpcRpcUrl = `ws://localhost:9368`; - const rpcKuboUrl = `http://0.0.0.0:50149/api/v0`; - const rpcGatewayUrl = `http://0.0.0.0:6603`; - - beforeAll(async () => { - await ensureKuboNodeStopped(`http://localhost:50149/api/v0`); - rpcProcess = await startPkcDaemon( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", rpcRpcUrl], - { KUBO_RPC_URL: rpcKuboUrl, IPFS_GATEWAY_URL: rpcGatewayUrl } - ); - await testConnectionToPkcRpc(9368); - }); - - afterAll(async () => { - await stopPkcDaemon(rpcProcess); - await waitForPortFree(9368, "localhost", 10000); - }); - - it(`bitsocial daemon detects and uses another process' PKC RPC`, async () => { - let anotherRpcProcess: ManagedChildProcess | undefined; - try { - anotherRpcProcess = await startPkcDaemon( - ["--pkcRpcUrl", rpcRpcUrl], - { KUBO_RPC_URL: rpcKuboUrl, IPFS_GATEWAY_URL: rpcGatewayUrl } - ); - await testConnectionToPkcRpc(9368); - } finally { - await stopPkcDaemon(anotherRpcProcess); // should not affect rpcProcess - } - await testConnectionToPkcRpc(9368); - }); - it(`bitsocial daemon is monitoring another process' PKC RPC and make sure it's always up`, async () => { - let anotherRpcProcess: ManagedChildProcess | undefined; - try { - anotherRpcProcess = await startPkcDaemon( - ["--pkcOptions.dataPath", randomDirectory(), "--pkcRpcUrl", rpcRpcUrl], - { KUBO_RPC_URL: rpcKuboUrl, IPFS_GATEWAY_URL: rpcGatewayUrl } - ); - await stopPkcDaemon(rpcProcess); - - // Wait for anotherRpcProcess to restart the RPC - const rpcRestarted = await waitForCondition(async () => { - try { - const ws = new WebSocket(rpcRpcUrl); - const opened = await new Promise((resolve) => { - const timer = setTimeout(() => resolve(false), 2000); - ws.once("open", () => { - clearTimeout(timer); - resolve(true); - }); - ws.once("error", () => { - clearTimeout(timer); - resolve(false); - }); - }); - ws.close(); - return opened; - } catch { - return false; - } - }, 30000, 1000); - expect(rpcRestarted).toBe(true); - } finally { - await stopPkcDaemon(anotherRpcProcess); - } - }); -}); - describe(`bitsocial daemon --pkcRpcUrl`, async () => { it(`A bitsocial daemon should be change where to listen URL`, async () => { const rpcUrl = new URL("ws://localhost:11138"); diff --git a/test/cli/keep-kubo-up-tick.test.ts b/test/cli/keep-kubo-up-tick.test.ts index 174c4c4..81f5625 100644 --- a/test/cli/keep-kubo-up-tick.test.ts +++ b/test/cli/keep-kubo-up-tick.test.ts @@ -33,7 +33,6 @@ describe("runKeepKuboUpTick", () => { return Promise.reject(err); }, pkcOptionsFromFlag: undefined, - usingDifferentProcessRpc: false, hasKuboProcess: false, hasPendingKuboStart: false, keepKuboUp: async () => {}, @@ -54,7 +53,6 @@ describe("runKeepKuboUpTick", () => { pkcRpcUrl: new URL("ws://localhost:9138"), tcpPortUsedCheck: async () => false, pkcOptionsFromFlag: undefined, - usingDifferentProcessRpc: false, hasKuboProcess: false, hasPendingKuboStart: false, keepKuboUp: async () => { @@ -76,7 +74,6 @@ describe("runKeepKuboUpTick", () => { pkcRpcUrl: new URL("ws://localhost:9138"), tcpPortUsedCheck: async () => true, pkcOptionsFromFlag: undefined, - usingDifferentProcessRpc: true, hasKuboProcess: true, hasPendingKuboStart: false, keepKuboUp: async () => {}, @@ -98,7 +95,6 @@ describe("runKeepKuboUpTick", () => { pkcRpcUrl: new URL("ws://localhost:9138"), tcpPortUsedCheck: async () => false, pkcOptionsFromFlag: undefined, - usingDifferentProcessRpc: false, hasKuboProcess: false, hasPendingKuboStart: false, keepKuboUp: async () => { From 06693ba4106a057fbeac39222452f4ef751a2551 Mon Sep 17 00:00:00 2001 From: Rinse Date: Thu, 21 May 2026 06:19:55 +0000 Subject: [PATCH 2/4] fix(daemon): normalize wildcard hostname before RPC port check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same darwin EINVAL gotcha that toConnectableHostname() already guards on the kubo path: a user passing --pkcRpcUrl ws://0.0.0.0:9138 would feed 0.0.0.0 straight to tcpPortUsed.check, which rejects it on macOS. Wrap the hostname in toConnectableHostname at all three RPC check sites — the fail-fast pre-check, createOrConnectRpc, and runKeepKuboUpTick. --- src/cli/commands/daemon.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index d813546..c0e30ae 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -69,7 +69,7 @@ export interface KeepKuboUpTickDeps { export async function runKeepKuboUpTick(deps: KeepKuboUpTickDeps): Promise { let isRpcPortTaken = false; try { - isRpcPortTaken = await deps.tcpPortUsedCheck(Number(deps.pkcRpcUrl.port), deps.pkcRpcUrl.hostname); + isRpcPortTaken = await deps.tcpPortUsedCheck(Number(deps.pkcRpcUrl.port), toConnectableHostname(deps.pkcRpcUrl.hostname)); if (!deps.pkcOptionsFromFlag?.kuboRpcClientsOptions && !isRpcPortTaken) await deps.keepKuboUp(); else if (deps.pkcOptionsFromFlag?.kuboRpcClientsOptions) await deps.keepKuboUp(); // Retry if kubo died and onKuboExit's restart attempt failed (e.g. transient port conflict) @@ -283,7 +283,8 @@ export default class Daemon extends Command { if (pkcOptionsFromFlag?.ipfsGatewayUrls && pkcOptionsFromFlag.ipfsGatewayUrls.length !== 1) this.error("Can't provide pkcOptions.ipfsGatewayUrls as an array with more than 1 element, or as a non array"); - const isRpcPortAlreadyTaken = await tcpPortUsed.check(Number(pkcRpcUrl.port), pkcRpcUrl.hostname); + const rpcConnectHostname = toConnectableHostname(pkcRpcUrl.hostname); + const isRpcPortAlreadyTaken = await tcpPortUsed.check(Number(pkcRpcUrl.port), rpcConnectHostname); if (isRpcPortAlreadyTaken) { this.error( `PKC RPC port is already in use at ${pkcRpcUrl} (another bitsocial daemon is likely running). ` + @@ -432,7 +433,7 @@ export default class Daemon extends Command { if (mainProcessExited) return; if (startedOwnRpc) return; // Tick may call this after our own server is up — port being taken means our server is still healthy. - const isRpcPortTaken = await tcpPortUsed.check(Number(pkcRpcUrl.port), pkcRpcUrl.hostname); + const isRpcPortTaken = await tcpPortUsed.check(Number(pkcRpcUrl.port), rpcConnectHostname); if (isRpcPortTaken) return; // Load installed challenge packages before starting the RPC server From 5094658c032ecaf434b348a96e14f8f61aed0241 Mon Sep 17 00:00:00 2001 From: Rinse Date: Thu, 21 May 2026 07:46:46 +0000 Subject: [PATCH 3/4] fix(daemon): throw instead of silent return when RPC port races createOrConnectRpc had an early `if (isRpcPortTaken) return` meant to be defensive. But startedOwnRpc already short-circuits every tick call once our server is up, so that branch is only reachable during initial startup in a TOCTOU race where the port becomes occupied between the pre-flight fail-fast check and the actual bind. Today the daemon silently continues with kubo running but no RPC server and no web UI; throw instead so startup fails fast with the port in the message. --- src/cli/commands/daemon.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index c0e30ae..47aafe5 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -432,9 +432,15 @@ export default class Daemon extends Command { const createOrConnectRpc = async () => { if (mainProcessExited) return; if (startedOwnRpc) return; - // Tick may call this after our own server is up — port being taken means our server is still healthy. + // Re-check the port: the early fail-fast at startup is a few ms before this runs, + // so a TOCTOU race could let another process grab the port in between. If that + // happens we must fail rather than silently leaving the daemon without an RPC. const isRpcPortTaken = await tcpPortUsed.check(Number(pkcRpcUrl.port), rpcConnectHostname); - if (isRpcPortTaken) return; + if (isRpcPortTaken) { + throw new Error( + `PKC RPC port ${pkcRpcUrl.hostname}:${pkcRpcUrl.port} (${pkcRpcUrl}) became occupied before the daemon could bind it.` + ); + } // Load installed challenge packages before starting the RPC server const loadedChallenges = await loadChallengesIntoPKC(mergedPkcOptions.dataPath); From f7c7d1de5f8d0eb5c08a2f4f8af6cbf6622958d5 Mon Sep 17 00:00:00 2001 From: Rinse Date: Thu, 21 May 2026 09:26:17 +0000 Subject: [PATCH 4/4] fix(ipfs): listen on 'close' so stderr drains before _spawnAsync rejects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulled the Promise wiring out of _spawnAsync into an exported _gatherChildOutput so the race is unit-testable. Listen on 'close' (not 'exit'): Node guarantees 'close' fires after all stdio streams have closed, so every stderr 'data' event has been delivered by the time we build the rejection Error. Why this matters: a fast-exiting child can deliver its exit signal before its stderr drains. With the old 'exit' listener, _spawnAsync rejected with errorMessage="". In startKuboNode the empty message defeated the `error.message.includes("ipfs configuration file already exists!")` suppression, turned a benign already-initialised repo into "Failed to call ipfs init" — and because startKuboNode wraps an async executor in `new Promise(...)`, that throw became an unhandledRejection instead of propagating, hanging keepKuboUp() and letting the daemon exit silently with code 0. That's the macos-latest CI failure on PR #47. The regression test in test/kubo/gather-child-output.test.ts feeds a fake EventEmitter the macOS event ordering (exit → data → close) and asserts the rejection captures the stderr text. Cross-platform deterministic, fails on the old 'exit' listener, passes on 'close'. --- src/ipfs/startIpfs.ts | 34 +++++++++++++--------- test/kubo/gather-child-output.test.ts | 41 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 14 deletions(-) create mode 100644 test/kubo/gather-child-output.test.ts diff --git a/src/ipfs/startIpfs.ts b/src/ipfs/startIpfs.ts index da9cb89..d50ee35 100644 --- a/src/ipfs/startIpfs.ts +++ b/src/ipfs/startIpfs.ts @@ -75,32 +75,38 @@ export async function mergeCliDefaultsIntoIpfsConfig(log: any, ipfsConfigPath: s // use this custom function instead of spawnSync for better logging // also spawnSync might have been causing crash on start on windows -function _spawnAsync(log: any, ...args: any[]) { +// Listens on 'close' (not 'exit') so all stderr 'data' events have been delivered +// before we read errorMessage — otherwise on macOS a fast-exiting child can deliver +// its exit signal before its stderr drains, producing a rejection with an empty +// message and breaking the "configuration file already exists" suppression upstream. +export function _gatherChildOutput(log: any, child: ChildProcessWithoutNullStreams): Promise { return new Promise((resolve, reject) => { - //@ts-ignore - const spawedProcess: ChildProcessWithoutNullStreams = spawn(...args); let errorMessage = ""; - spawedProcess.on("exit", (exitCode, signal) => { - if (exitCode === 0) resolve(null); - else { - const error = new Error(errorMessage); - Object.assign(error, { exitCode, pid: spawedProcess.pid, signal }); - reject(error); - } + child.on("close", (exitCode, signal) => { + if (exitCode === 0) return resolve(null); + const error = new Error(errorMessage); + Object.assign(error, { exitCode, pid: child.pid, signal }); + reject(error); }); - spawedProcess.stderr.on("data", (data) => { + child.stderr.on("data", (data) => { log.trace(data.toString()); errorMessage += data.toString(); }); - spawedProcess.stdin.on("data", (data) => log.trace(data.toString())); - spawedProcess.stdout.on("data", (data) => log.trace(data.toString())); - spawedProcess.on("error", (data) => { + child.stdin.on("data", (data) => log.trace(data.toString())); + child.stdout.on("data", (data) => log.trace(data.toString())); + child.on("error", (data) => { errorMessage += data.toString(); log.error(data.toString()); }); }); } +function _spawnAsync(log: any, ...args: any[]) { + //@ts-ignore + const child: ChildProcessWithoutNullStreams = spawn(...args); + return _gatherChildOutput(log, child); +} + type MultiaddrComponent = { name: string; value?: string }; type MultiaddrModule = { multiaddr: (multiAddr: string) => { diff --git a/test/kubo/gather-child-output.test.ts b/test/kubo/gather-child-output.test.ts new file mode 100644 index 0000000..7e88613 --- /dev/null +++ b/test/kubo/gather-child-output.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { EventEmitter } from "node:events"; +import { _gatherChildOutput } from "../../dist/ipfs/startIpfs.js"; + +const noopLog = Object.assign(() => {}, { trace: () => {}, error: () => {} }); + +const makeFakeChild = () => + Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + stdin: new EventEmitter(), + pid: 12345 + }) as any; + +describe("_gatherChildOutput", () => { + it("captures stderr even when the child's 'exit' event fires before stderr 'data' (macOS race)", async () => { + const child = makeFakeChild(); + const promise = _gatherChildOutput(noopLog, child); + + // Simulate the macOS-style ordering: process exits first, stderr drains + // afterwards, 'close' fires last. The previous 'exit'-based listener would + // settle the promise with an empty errorMessage at the first emit and miss + // the stderr payload entirely. + queueMicrotask(() => { + child.emit("exit", 1, null); + setImmediate(() => { + child.stderr.emit("data", Buffer.from("Error: ipfs configuration file already exists!\n")); + child.emit("close", 1, null); + }); + }); + + await expect(promise).rejects.toThrow(/ipfs configuration file already exists!/); + }); + + it("resolves cleanly when the child exits with code 0", async () => { + const child = makeFakeChild(); + const promise = _gatherChildOutput(noopLog, child); + queueMicrotask(() => child.emit("close", 0, null)); + await expect(promise).resolves.toBeNull(); + }); +});