Skip to content

EIO on a stdout write escapes to uncaughtException and kills the agent process (stderr already guards it) #3810

Description

@probepark

Symptom

The gjc process dies outright in the middle of a long session. The in-flight turn is lost, and recovery depends entirely on an external supervisor bringing it back with --resume.

It happened twice in my session today (~/.gjc/agent/gjc-crash.log):

2026-08-04T04:33:19.171Z pid=73527 [Uncaught Exception] Error: EIO: i/o error, write
2026-08-04T04:34:02.123Z pid=75845 [Uncaught Exception] Error: EIO: i/o error, write
Error: EIO: i/o error, write
    at write (unknown)
    at writeFast (internal:fs/streams:345:38)
    at N (/$bunfs/root/gjc:101:382)
    at h_9 (/$bunfs/root/gjc:25788:89)
    ...
    at processTicksAndRejections (native:7:39)

Right after the second crash the supervisor restarted the session as gjc-patched --resume 019faef8-bc59-....

It is not OOM

I suspected memory first. It is not.

  • vm.swapusage: used = 0.00M — no swap in use at all
  • zero jetsam (macOS memory-pressure kill) events
  • no crash report in ~/Library/Logs/DiagnosticReports/ for today, which means the process was not killed by a signal — it exited through the normal fatal-error path

So this is an unhandled exception, not resource exhaustion.

Root cause

EIO on a write is a transient, environmental failure: the terminal being written to went away. These processes run under SCREEN -dmS inside login -pflq, reached over SSH. When that pty is torn down, the next stdout write fails with EIO.

The problem is that nothing catches it, so it reaches the fatal handler:

packages/utils/src/postmortem.ts:370

.on("uncaughtException", async error => {
    await handleFatalError("Uncaught Exception", error, Reason.UNCAUGHT_EXCEPTION);
})

handleFatalError terminates the process.

The repo already knows the answer — it is just wired to stderr only

There is a helper in this codebase for exactly this condition:

packages/utils/src/safe-stderr.ts

const CLOSED_STDERR_ERROR_CODES = new Set(["EIO", "EPIPE", "EBADF"]);

export function safeStderrWrite(message: string): void {
	if (!process.stderr.writable) return;
	try {
		fs.writeSync(process.stderr.fd, message);
	} catch (error) {
		if (isClosedStderrWriteError(error)) return;   // ← EIO treated as benign
		throw error;
	}
}

An EIO on a stderr write is correctly read as "the stream is closed" and swallowed. The identical condition on stdout kills the process. The judgement already exists; it is applied on one side only.

Suggested fix

  1. Apply the same guard to the stdout write path — a safeStdoutWrite, or a shared helper that takes the stream.
  2. At minimum, stop handleFatalError from treating closed-stream error codes (EIO / EPIPE / EBADF) as fatal. A terminal going away and the agent being broken are different events, and only the second one should end the process.

Why this is worse than it looks

The trigger is routine. I reach this host over SSH on a Tailscale tailnet, and the link itself is healthy — a direct path (no DERP relay), 13 ms, 0% packet loss over a sampled ping. Sessions still drop occasionally, as SSH sessions do.

A dropped terminal should cost a reconnect. Right now it costs the whole agent process and whatever work was in flight.

Wider picture from the same log

gjc-crash.log has accumulated 81 Uncaught Exception entries. Two of today's are the EIO above, and a number of the others also look like things that should not be fatal:

   2  Error: fixture: closed fd write EPIPE      ← same family
   2  Error: EIO: i/o error, write
   2  Error: Anthropic credential is unavailable
   1  Error: timeout
   1  AgentBusyError: Agent is already processing...
   1  Error: Settings not initialized...

By date they cluster — 22 on 07-25 and 40 on 07-29 — which suggests a condition that repeats rather than a one-off.

The closed fd write EPIPE entries share the same root and should be covered by fix (2) as well.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions