Skip to content
Merged
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
67 changes: 63 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,23 @@ 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) {
return makeStdinErrorChild(exitCode, 'EPIPE', 'write EPIPE');
}

/** Mock child whose stdin write triggers an async error with the given code. */
function makeStdinErrorChild(exitCode: number, code: string, message: string) {
const child = makeMockChild(exitCode, 20);
child.stdin.write.mockImplementation(() => {
const err: NodeJS.ErrnoException = new Error(message);
err.code = code;
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 @@ -350,10 +367,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 @@ -363,6 +380,48 @@ 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('logs stdin EPIPE at debug level, not as a warning', async () => {
const { debug, warning } = await import('@actions/core');
mockSpawn.mockReturnValue(makeEpipeChild(1));

await runAgent(baseOpts({ maxRetries: 0 }));

expect(debug).toHaveBeenCalledWith(expect.stringContaining('stdin write failed'));
expect(warning).not.toHaveBeenCalledWith(expect.stringContaining('stdin'));
});

it('surfaces non-EPIPE stdin errors as warnings without crashing', async () => {
const { debug, warning } = await import('@actions/core');
mockSpawn.mockReturnValue(makeStdinErrorChild(1, 'EBADF', 'write EBADF'));

const result = await runAgent(baseOpts({ maxRetries: 0 }));

expect(result.exitCode).toBe(1);
expect(warning).toHaveBeenCalledWith(
expect.stringContaining('stdin unexpected error: write EBADF'),
);
expect(debug).not.toHaveBeenCalledWith(expect.stringContaining('stdin write failed'));
});

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
14 changes: 13 additions & 1 deletion src/main/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,20 @@ 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. EPIPE is expected (agent may
// exit before reading the prompt) and logged at debug level; any other
// stdin error is surfaced as a warning to keep novel OS-level failures
// observable.
if (child.stdin) {
child.stdin.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
core.debug(`docker-agent stdin write failed: ${err.message}`);
} else {
core.warning(`docker-agent stdin unexpected error: ${err.message}`);
}
});
child.stdin.write(opts.stdinData);
child.stdin.end();
}
Expand Down