diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index a3524107..bc671ad2 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -35,6 +35,7 @@ import { setAppMode, } from './services/appMode' import { installMacOsChromiumKeychainPromptGuard } from './services/keychain' +import { installStdioWriteFailureGuards } from './services/stdioGuards' import { applyWindowsAppUserModelId } from './services/appIdentity' import { installMainWindowNavigationGuards, installPreviewNavigationGuards } from './services/navigationGuards' import { installPreviewCleanupOnRendererNavigation } from './services/previewLifecycle' @@ -90,6 +91,9 @@ const traceWindows = new Map() let isQuitting = false let trayController: TrayController | null = null +// Must run before anything logs: a Finder/Dock launch inherits unreadable +// stdio, and an unguarded write failure there surfaces as a crash dialog. +installStdioWriteFailureGuards() installMacOsChromiumKeychainPromptGuard(app) function appRoot() { diff --git a/desktop/electron/services/stdioGuards.test.ts b/desktop/electron/services/stdioGuards.test.ts new file mode 100644 index 00000000..2907834a --- /dev/null +++ b/desktop/electron/services/stdioGuards.test.ts @@ -0,0 +1,44 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it } from 'vitest' +import { installStdioWriteFailureGuards } from './stdioGuards' + +const makeStream = () => new EventEmitter() + +describe('Electron stdio write failure guards', () => { + it('keeps an EPIPE write failure from escalating to an uncaught exception', () => { + const stdout = makeStream() + installStdioWriteFailureGuards([stdout]) + + const epipe: NodeJS.ErrnoException = Object.assign(new Error('write EPIPE'), { + code: 'EPIPE', + }) + + // Without a listener, EventEmitter rethrows 'error' — exactly how Node + // turns a failed console.log into the Electron crash dialog. + expect(() => stdout.emit('error', epipe)).not.toThrow() + }) + + it('guards stderr as well as stdout', () => { + const stdout = makeStream() + const stderr = makeStream() + + expect(installStdioWriteFailureGuards([stdout, stderr])).toBe(2) + expect(() => stderr.emit('error', new Error('write EPIPE'))).not.toThrow() + }) + + it('does not stack duplicate listeners when installed twice', () => { + const stdout = makeStream() + + expect(installStdioWriteFailureGuards([stdout])).toBe(1) + expect(installStdioWriteFailureGuards([stdout])).toBe(0) + expect(stdout.listenerCount('error')).toBe(1) + }) + + it('defaults to the real process stdio streams', () => { + // Idempotent by design, so calling it here cannot disturb the test runner's + // own streams beyond the single no-op listener the app installs at startup. + expect(installStdioWriteFailureGuards()).toBeGreaterThanOrEqual(0) + expect(process.stdout.listenerCount('error')).toBeGreaterThan(0) + expect(process.stderr.listenerCount('error')).toBeGreaterThan(0) + }) +}) diff --git a/desktop/electron/services/stdioGuards.ts b/desktop/electron/services/stdioGuards.ts new file mode 100644 index 00000000..8e139ad8 --- /dev/null +++ b/desktop/electron/services/stdioGuards.ts @@ -0,0 +1,38 @@ +type ErrorEmittingStream = { + on(event: 'error', listener: (error: NodeJS.ErrnoException) => void): unknown +} + +const guarded = new WeakSet() + +/** + * Keep a failed log write from taking the whole app down. + * + * When the app is launched from Finder or the Dock, the main process inherits + * stdio that has no reader. Writing to it fails asynchronously with EPIPE from + * inside the stream machinery, so a plain `console.log` cannot be defended with + * try/catch at the call site — and with no `error` listener on the stream, Node + * escalates it to an uncaught exception. Electron then shows the user a + * "A JavaScript error occurred in the main process" dialog. + * + * This is reachable from ordinary operation: the sidecar exit handler logs a + * line whenever a sidecar dies (`serverRuntime.ts`), so any crashed or killed + * sidecar could surface that dialog. Guarding the single noisiest call site + * would not help — the main process has ~25 console call sites, and every one + * of them writes to these two streams. + * + * Losing a diagnostic line is an acceptable outcome; killing the user's session + * over one never is. Real diagnostics are persisted separately through + * `appendHostDiagnostic`, which writes to a file rather than to stdio. + */ +export function installStdioWriteFailureGuards( + streams: ErrorEmittingStream[] = [process.stdout, process.stderr], +): number { + let installed = 0 + for (const stream of streams) { + if (guarded.has(stream)) continue + stream.on('error', () => {}) + guarded.add(stream) + installed += 1 + } + return installed +}