Skip to content

fix: handle stdin EPIPE when agent exits before reading prompt#50

Open
Sayt-0 wants to merge 1 commit into
mainfrom
fix/exec-stdin-epipe
Open

fix: handle stdin EPIPE when agent exits before reading prompt#50
Sayt-0 wants to merge 1 commit into
mainfrom
fix/exec-stdin-epipe

Conversation

@Sayt-0

@Sayt-0 Sayt-0 commented Jul 6, 2026

Copy link
Copy Markdown
Member

Summary

Fixes B5: unhandled EPIPE crash in src/main/exec.ts when docker-agent dies before draining stdin (invalid yaml, API key rejected at boot) while the prompt exceeds the pipe buffer (about 64 KB, which review prompts easily exceed).

Cause

child.stdin is a Writable stream: an EPIPE from write() is emitted asynchronously as an 'error' event on that stream. The existing child.on('error') handler only covers spawn failures of the ChildProcess itself. With no stream listener, Node raises Unhandled 'error' event: write EPIPE and the whole process crashes, skipping the retry loop, secret scan, artifact upload and job summary (the entire finally block in index.ts).

agent crashes at boot
  -> stdin pipe closes while write() is in flight
  -> 'error' (EPIPE) emitted on child.stdin
       before: no listener, unhandled event, process crash,
               retry loop and finally block skipped
       after:  logged at debug level, 'close' reports the real
               exit code, retry loop and cleanup run normally

Changes

File Change
src/main/exec.ts Attach an 'error' listener on child.stdin before write()
src/main/__tests__/exec.test.ts stdin mocks become EventEmitters (the fix calls .on), new makeEpipeChild helper, 2 regression tests
src/main/__tests__/main.integration.test.ts Same stdin mock update

The listener logs at core.debug level rather than core.warning: EPIPE can also occur when the agent exits successfully without draining stdin, and a warning annotation on a green run would be misleading. The real failure path is already surfaced by the retry loop warnings and the exit code flowing through 'close'.

Validation

Check Result
Real repro (agent script exit 1 + 300 KB prompt) on unfixed code crash, raw EPIPE stack, no retry
Same repro on fixed code EPIPE logged at debug, retry loop executed, exit code returned normally
Regression tests with the fix reverted 2 unhandled EPIPE errors reported by vitest
pnpm test (unit) 747 passed
pnpm test:integration 16 passed, 3 skipped
tsc --noEmit, biome ci ., pnpm build pass

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.

@derekmisler derekmisler left a comment

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

Assessment: 🟢 APPROVE

The fix is correct and well-motivated. Attaching an 'error' listener on child.stdin before the write()/end() calls is the right approach — it handles the asynchronous nature of EPIPE on writable streams and ensures the existing retry loop and finally block run normally when the agent exits early. The mock upgrades in both test files are necessary and correct, and the two new regression tests exercise both the survive-EPIPE and retry-after-EPIPE paths.

One minor note (no action required): the listener catches all stdin error codes, not just EPIPE. Any hypothetical unexpected error (e.g. EBADF, EIO) would be swallowed at debug level. Since the child process's close event still surfaces the real exit code, this is unlikely to cause any operational problem — it just slightly reduces observability for novel OS-level conditions. A if (err.code !== 'EPIPE') core.warning(...) guard would preserve that signal if desired, but it's not blocking.

CI: All 8 checks passed. ✅

Comment thread src/main/exec.ts
// 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants