diff --git a/.changeset/fix-local-process-wait.md b/.changeset/fix-local-process-wait.md new file mode 100644 index 0000000000..4c2bfec80c --- /dev/null +++ b/.changeset/fix-local-process-wait.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-local-process': patch +--- + +Fix local-process spawn handles hanging when the child exits before `wait()` is called. diff --git a/packages/ai-sandbox-local-process/src/handle.ts b/packages/ai-sandbox-local-process/src/handle.ts index 10a012c427..6c5b49d81d 100644 --- a/packages/ai-sandbox-local-process/src/handle.ts +++ b/packages/ai-sandbox-local-process/src/handle.ts @@ -875,6 +875,13 @@ export class LocalProcessHandle implements SandboxHandle { }, ) } + const closed = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code) => resolve(code ?? 0)) + }) + // The child can close while stdout is still being drained. Keep the + // rejection handled until the caller asks for the result via wait(). + closed.catch(() => {}) const handle: SpawnHandle = { pid: child.pid ?? -1, stdout: decodeStream(child.stdout), @@ -889,11 +896,7 @@ export class LocalProcessHandle implements SandboxHandle { child.stdin.end(() => resolve()) }), }, - wait: () => - new Promise((resolve, reject) => { - child.on('error', reject) - child.on('close', (code) => resolve(code ?? 0)) - }), + wait: () => closed, kill: (signal) => { killTree(child, signal, this.options.logger) return Promise.resolve() diff --git a/packages/ai-sandbox-local-process/tests/local-process.test.ts b/packages/ai-sandbox-local-process/tests/local-process.test.ts index cf46230cd6..cdec1262c3 100644 --- a/packages/ai-sandbox-local-process/tests/local-process.test.ts +++ b/packages/ai-sandbox-local-process/tests/local-process.test.ts @@ -89,6 +89,30 @@ describe('local-process process', () => { await sbx.destroy() }) + it('resolves wait() when the child closes before stdout is fully consumed', async () => { + const sbx = await fresh() + // Write digits as strings. `console.log(number)` colorizes under FORCE_COLOR + // (CI sets that), so the assertion would see `\u001b[33m0\u001b[39m`. + await sbx.fs.write( + '/workspace/count.mjs', + `for (let i = 0; i < 20; i++) process.stdout.write(String(i) + '\\n')`, + ) + const proc = await sbx.process.spawn('node count.mjs', { + cwd: '/workspace', + }) + let out = '' + for await (const chunk of proc.stdout) { + out += chunk + await new Promise((resolve) => setTimeout(resolve, 10)) + } + await expect(proc.wait()).resolves.toBe(0) + const lines = out.trimEnd().split(/\r?\n/) + expect(lines).toEqual( + Array.from({ length: 20 }, (_, index) => String(index)), + ) + await sbx.destroy() + }) + it('advertises killableProcesses (killTree forcibly kills spawned processes)', async () => { const sbx = await fresh() // NOTE: this only reads a module constant. What makes the constant TRUE is