Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions src/main/__tests__/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>; end: ReturnType<typeof vi.fn> };
stdin: EventEmitter & { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> };
kill: ReturnType<typeof vi.fn>;
};
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(() => {
Expand All @@ -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<Parameters<typeof runAgent>[0]> = {}) {
return {
Expand Down Expand Up @@ -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<typeof vi.fn>; end: ReturnType<typeof vi.fn> };
stdin: EventEmitter & { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> };
kill: ReturnType<typeof vi.fn>;
};
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);
Expand All @@ -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));

Expand Down
4 changes: 2 additions & 2 deletions src/main/__tests__/main.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>; end: ReturnType<typeof vi.fn> };
stdin: EventEmitter & { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> };
kill: ReturnType<typeof vi.fn>;
};
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;
Expand Down
7 changes: 6 additions & 1 deletion src/main/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated PR Reviewthis comment was posted by the pr-reviewer bot, not by a human or the implementer agent

[LOW] Catch-all stdin error listener silences non-EPIPE errors at debug level

The listener swallows every error emitted by child.stdin, not only EPIPE. An unexpected error code such as EBADF or EIO would be logged at core.debug and then silently discarded. The child close event still delivers the exit code, so the retry loop and caller behavior remain correct — but diagnosing a novel OS-level stdin failure in a future incident would be harder than necessary.

If you'd like to preserve observability without breaking the fix's intent, consider:

child.stdin.on('error', (err: Error) => {
  if ((err as NodeJS.ErrnoException).code === 'EPIPE') {
    core.debug(`docker-agent stdin write failed: ${err.message}`);
  } else {
    core.warning(`docker-agent stdin unexpected error: ${err.message}`);
  }
});

Not blocking — the existing close-based exit-code path is the authoritative failure signal.

core.debug(`docker-agent stdin write failed: ${err.message}`);
});
child.stdin.write(opts.stdinData);
child.stdin.end();
}
Expand Down