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(); }