Skip to content

Commit bebb7dc

Browse files
committed
fix(local-process): handle EPIPE error when writing to closed stdin
1 parent 4852c78 commit bebb7dc

3 files changed

Lines changed: 52 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai-sandbox-local-process': patch
3+
---
4+
5+
fix: stop an uncaught EPIPE when a write goes to a child that closed its stdin

‎packages/ai-sandbox-local-process/src/handle.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,11 @@ export class LocalProcessHandle implements SandboxHandle {
891891
detached: spawnDetached,
892892
})
893893
this.track(child)
894+
// Node reports a failed write twice: to the `write` callback below, and
895+
// as an `error` event on this socket. An unhandled `error` event throws
896+
// and stops the host process. `stdin.write` still rejects with the same
897+
// error.
898+
child.stdin.on('error', () => {})
894899
if (opts?.signal) {
895900
opts.signal.addEventListener(
896901
'abort',

‎packages/ai-sandbox-local-process/tests/local-process.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,48 @@ describe('local-process killTree — POSIX child.kill(signal) branch', () => {
228228
)
229229
})
230230

231+
describe('local-process stdin errors', () => {
232+
posixOnly(
233+
'a write to a child that closed its stdin rejects without an uncaught error (skipped on Windows: named pipes do not report EPIPE the same way)',
234+
async () => {
235+
const sbx = await fresh()
236+
// `exec 0<&-` makes the child close its own stdin while it keeps running:
237+
// the write end stays open, and no process holds the read end.
238+
const proc = await sbx.process.spawn('exec 0<&- ; echo closed ; sleep 30')
239+
// The shell echoes after it closes fd 0. An earlier write only fills the
240+
// pipe buffer and resolves, which would prove nothing.
241+
let closed = false
242+
for await (const chunk of proc.stdout) {
243+
if (chunk.includes('closed')) {
244+
closed = true
245+
break
246+
}
247+
}
248+
expect(closed).toBe(true)
249+
250+
const uncaught: Array<Error> = []
251+
const onUncaught = (error: Error): void => {
252+
uncaught.push(error)
253+
}
254+
process.on('uncaughtException', onUncaught)
255+
try {
256+
await expect(proc.stdin.write('probe\n')).rejects.toThrow(/EPIPE/)
257+
// Node emits the socket's `error` event on a `nextTick` after the
258+
// callback, and that queue always drains before a `setImmediate`.
259+
await new Promise((resolve) => setImmediate(resolve))
260+
} finally {
261+
process.off('uncaughtException', onUncaught)
262+
}
263+
264+
expect(uncaught).toEqual([])
265+
266+
await proc.kill()
267+
await sbx.destroy()
268+
},
269+
30_000,
270+
)
271+
})
272+
231273
describe('local-process + spawnNdjson (real agent-CLI streaming)', () => {
232274
it('streams NDJSON events emitted by a spawned process', async () => {
233275
const sbx = await fresh()

0 commit comments

Comments
 (0)