fix(desktop): stop a failed log write from crashing the main process

Launching from Finder or the Dock leaves the main process with stdio that has
no reader. Writing there fails asynchronously with EPIPE from inside the stream
machinery, and with no `error` listener Node escalates it to an uncaught
exception — which Electron shows as "A JavaScript error occurred in the main
process".

This is reachable in ordinary use: the sidecar exit handler logs a line every
time a sidecar dies, so any crashed or killed sidecar could raise that dialog.
Guarding that one call site would not help, since the main process has ~25
console call sites and all of them write to the same two streams. A try/catch
at the call site cannot help either, because the throw happens off-stack.

Install the guard on stdout/stderr before anything logs. Losing a diagnostic
line is acceptable; killing the user's session over one is not — real
diagnostics already persist to a file through appendHostDiagnostic.
This commit is contained in:
程序员阿江(Relakkes) 2026-07-28 00:34:37 +08:00
parent eaeab84b16
commit ec5094fb57
3 changed files with 86 additions and 0 deletions

View File

@ -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<string, BrowserWindow>()
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() {

View File

@ -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)
})
})

View File

@ -0,0 +1,38 @@
type ErrorEmittingStream = {
on(event: 'error', listener: (error: NodeJS.ErrnoException) => void): unknown
}
const guarded = new WeakSet<ErrorEmittingStream>()
/**
* 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
}