From 3fcb830ca6b0110702d1afcf7cb317a9cce85c2d Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Mon, 6 Jul 2026 18:31:56 +0200 Subject: [PATCH] fix: handle stdin EPIPE when agent exits before reading prompt An unhandled 'error' event on child.stdin crashed the whole process when docker-agent died before draining the stdin pipe (prompt larger than the pipe buffer), skipping the retry loop, secret scan, artifact upload and job summary. Attach an 'error' listener before writing so the failure is reported through the normal 'close' exit code path. --- src/main/__tests__/exec.test.ts | 39 ++++++++++++++++++--- src/main/__tests__/main.integration.test.ts | 4 +-- src/main/exec.ts | 7 +++- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/main/__tests__/exec.test.ts b/src/main/__tests__/exec.test.ts index e8c2845..1070ffc 100644 --- a/src/main/__tests__/exec.test.ts +++ b/src/main/__tests__/exec.test.ts @@ -39,10 +39,10 @@ let verboseLogFile: string; /** Create a mock child process that closes with the given exit code. */ function makeMockChild(exitCode: number, delayMs = 0) { const emitter = new EventEmitter() as EventEmitter & { - stdin: { write: ReturnType; end: ReturnType }; + stdin: EventEmitter & { write: ReturnType; end: ReturnType }; kill: ReturnType; }; - emitter.stdin = { write: vi.fn(), end: vi.fn() }; + emitter.stdin = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn() }); // When killed (SIGTERM/SIGKILL), emit close shortly after — simulates real process dying. emitter.kill = vi.fn().mockImplementation(() => { @@ -55,6 +55,18 @@ function makeMockChild(exitCode: number, delayMs = 0) { return emitter; } +/** Mock child whose stdin write triggers an async EPIPE — agent died before reading the prompt. */ +function makeEpipeChild(exitCode: number) { + const child = makeMockChild(exitCode, 20); + child.stdin.write.mockImplementation(() => { + const err: NodeJS.ErrnoException = new Error('write EPIPE'); + err.code = 'EPIPE'; + setImmediate(() => child.stdin.emit('error', err)); + return false; + }); + return child; +} + /** Minimal valid RunAgentOptions. */ function baseOpts(overrides: Partial[0]> = {}) { return { @@ -348,10 +360,10 @@ describe('runAgent', () => { it('resolves with exit code 1 when spawn emits error', async () => { const emitter = new EventEmitter() as EventEmitter & { - stdin: { write: ReturnType; end: ReturnType }; + stdin: EventEmitter & { write: ReturnType; end: ReturnType }; kill: ReturnType; }; - emitter.stdin = { write: vi.fn(), end: vi.fn() }; + emitter.stdin = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn() }); emitter.kill = vi.fn(); mockSpawn.mockReturnValue(emitter); @@ -361,6 +373,25 @@ describe('runAgent', () => { expect(result.exitCode).toBe(1); }); + it('survives stdin EPIPE when agent dies before reading the prompt', async () => { + mockSpawn.mockReturnValue(makeEpipeChild(1)); + + const result = await runAgent(baseOpts({ maxRetries: 0 })); + + expect(result.exitCode).toBe(1); + }); + + it('keeps retrying after a stdin EPIPE failure', async () => { + mockSpawn + .mockImplementationOnce(() => makeEpipeChild(1)) + .mockImplementation(() => makeMockChild(0)); + + const result = await runAgent(baseOpts({ maxRetries: 1, retryDelay: 0 })); + + expect(result.exitCode).toBe(0); + expect(mockSpawn).toHaveBeenCalledTimes(2); + }); + it('injects all API keys into env (never args)', async () => { mockSpawn.mockReturnValue(makeMockChild(0)); diff --git a/src/main/__tests__/main.integration.test.ts b/src/main/__tests__/main.integration.test.ts index 54da199..bf27112 100644 --- a/src/main/__tests__/main.integration.test.ts +++ b/src/main/__tests__/main.integration.test.ts @@ -180,10 +180,10 @@ let eventPayloadPath: string; /** Create a mock child process that closes with the given exit code. */ function makeMockChild(exitCode: number) { const emitter = new EventEmitter() as EventEmitter & { - stdin: { write: ReturnType; end: ReturnType }; + stdin: EventEmitter & { write: ReturnType; end: ReturnType }; kill: ReturnType; }; - emitter.stdin = { write: vi.fn(), end: vi.fn() }; + emitter.stdin = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn() }); emitter.kill = vi.fn(); setImmediate(() => emitter.emit('close', exitCode)); return emitter; diff --git a/src/main/exec.ts b/src/main/exec.ts index 61e1d19..8424875 100644 --- a/src/main/exec.ts +++ b/src/main/exec.ts @@ -151,8 +151,13 @@ function spawnAgent(opts: { stdio: ['pipe', opts.verboseLogFd, opts.verboseLogFd], }); - // Feed stdin + // Feed stdin. Without an 'error' listener, an EPIPE from the agent dying + // before draining the pipe would crash the whole process; the 'close' + // event still reports the real exit code. if (child.stdin) { + child.stdin.on('error', (err: Error) => { + core.debug(`docker-agent stdin write failed: ${err.message}`); + }); child.stdin.write(opts.stdinData); child.stdin.end(); }