Skip to content

Stop a closed terminal from crashing the desktop app through its own logging - #649

Merged
0x-copilot-dev merged 1 commit into
devfrom
claude/desktop-app-ui-ux-9af65c
Aug 29, 2026
Merged

0x-copilot-dev merged 1 commit into
devfrom
claude/desktop-app-ui-ux-9af65c

Conversation

@0x-copilot-dev

Copy link
Copy Markdown
Owner

Surfaced by a crash dialog during testing:

Uncaught Exception:
Error: write EPIPE
    at afterWriteDispatched (node:internal/stream_base_commons:159:15)
    ...
    at console.error (node:internal/console/constructor:444:26)
    at replyWithError (node:electron/js2c/browser_init)

Read it bottom-up, because the order is the whole point: the main process was reporting an IPC error, and the reporting is what killed it. A failure in the error path converted a handled problem into a dead app.

Why a closed terminal reaches the app

tools/cli/bin/copilot.mjs:121 spawns the app with stdio: "inherit", so the GUI writes straight to the terminal that ran copilot — and the window outlives that terminal. Close it (or end the ssh session, or quit the shell) and stdout is a pipe with no reader. The next console.* raises EPIPE, and with no listener on the stream Node promotes it to an uncaught exception.

This is the documented install path — CLI-first, no DMG — so it is the normal way to run the app, not an edge case. There was no EPIPE guard and no uncaughtException handler anywhere in main/.

The fix

There are ~30 console.* sites in main/, so guarding call sites is not the fix; attaching a stream listener is.

EPIPE is swallowed because there is genuinely nothing to do — the reader is gone, and there is nowhere to log the fact that there is nowhere to log. Everything else is re-thrown. A disk-full stderr is a real failure, and swallowing every stream error to fix one of them is how the next one goes unnoticed.

Installed before applyBrandIdentity, because the write it protects can be the first one.

On the tests

They assert the stream does or does not throw, rather than that a listener was registered — "a listener exists" passes just as well over a listener that re-throws everything. The disposal case is the control: with the guard removed the same emit throws again, which proves the passing case is the guard working and not PassThrough being tolerant on its own.

Verification

npm run test --workspace @0x-copilot/desktop → 126 files, 1545 passed, 1 todo, 0 failed. Typecheck clean.

Worth recording, because it cost a detour: bare vitest is not that command. The script is build:workspace-commit-helper && build:workspace-fs && test:native && vitest run, and without the build step 15 helper tests fail on ENOENT spawning a binary this repo never produced. Those failures are an artifact of running the tests wrong, not a defect.

🤖 Generated with Claude Code

…n logging

Closing the terminal that launched `copilot` killed the running app:

    Uncaught Exception:
    Error: write EPIPE
        at afterWriteDispatched (node:internal/stream_base_commons:159:15)
        ...
        at console.error (node:internal/console/constructor:444:26)
        at replyWithError (node:electron/js2c/browser_init)

Read that bottom-up, because the order is the point: the main process was
REPORTING an IPC error, and the reporting is what killed it. A failure in the
error path converts a handled problem into a crash.

Why a closed terminal reaches the app at all: `tools/cli/bin/copilot.mjs:121`
spawns it with `stdio: "inherit"`, so the GUI writes straight to the launching
terminal — and the window outlives that terminal. Close it (or end the ssh
session, or quit the shell) and stdout is a pipe with no reader, so the next
`console.*` raises EPIPE. With no listener on the stream Node promotes that to
an uncaught exception. This is the documented install path — CLI-first, no DMG —
so it is the normal way to run the app, not an edge case.

There are ~30 `console.*` sites in `main/`, so guarding call sites is not the
fix; attaching a stream listener is. EPIPE is swallowed because there is
genuinely nothing to do — the reader is gone and there is nowhere to log the
fact that there is nowhere to log. Everything else is RE-THROWN: a disk-full
stderr is a real failure, and swallowing every stream error to fix one of them
is how the next one goes unnoticed.

Installed before `applyBrandIdentity`, because the write it protects can be the
first one.

The tests assert the stream does or does not THROW rather than that a listener
was registered — "a listener exists" passes just as well over a listener that
re-throws everything. The disposal case is the control: with the guard removed
the same emit throws again, which proves the passing case is the guard working
and not PassThrough being tolerant on its own.

Tests: 1545 passed (126 files) via `npm run test --workspace @0x-copilot/desktop`.
Note that bare `vitest` is NOT that command — the script builds the
workspace-commit-helper binary first, and without it 15 helper tests fail on
ENOENT spawning a binary this repo never produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@0x-copilot-dev
0x-copilot-dev merged commit d2e9feb into dev Aug 29, 2026
13 checks passed
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.

1 participant