From b82fe49de89b797c69b3e24039fc85b4daf5f3f6 Mon Sep 17 00:00:00 2001 From: Michael Gannotti Date: Sun, 6 Sep 2026 07:27:33 -0400 Subject: [PATCH] fix(desktop): swallow EPIPE from structured logger stdout File append was already best-effort. Console write was not, so a closed stdout pipe (parent gone, or a launch that inherited a pipe that later closed) raised uncaught write EPIPE from afterWriteDispatched. Electron then showed "A JavaScript error occurred in the main process". Treat stdio the same as the log file: try/catch around write, and attach error listeners so the async EPIPE event is not an uncaughtException. Signed-off-by: Michael Gannotti --- desktop/src/shared/utils/log.ts | 20 +++++++- .../modular/structured-log-stdout.test.ts | 49 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 desktop/tests/modular/structured-log-stdout.test.ts diff --git a/desktop/src/shared/utils/log.ts b/desktop/src/shared/utils/log.ts index 296fe998..eac326fa 100644 --- a/desktop/src/shared/utils/log.ts +++ b/desktop/src/shared/utils/log.ts @@ -87,11 +87,18 @@ function writeEntry(scope: string, level: string, payload: StructuredLogPayload) } } - // Console output with scope prefix + // Console output with scope prefix. Best-effort: a closed stdout (parent + // process gone, launched from a pipe that later closed) raises write EPIPE + // from afterWriteDispatched. That is an uncaughtException in Electron and + // surfaces as "A JavaScript error occurred in the main process". const prefix = `(${scope})`.padEnd(24) const msg = payload.message ?? '' const dataStr = payload.data ? ` ${JSON.stringify(payload.data)}` : '' - process.stdout.write(`${now.toLocaleTimeString()} ${prefix} > ${msg}${dataStr}\n`) + try { + process.stdout.write(`${now.toLocaleTimeString()} ${prefix} > ${msg}${dataStr}\n`) + } catch { + /* best-effort */ + } } // --------------------------------------------------------------------------- @@ -127,6 +134,15 @@ export function initFileLogger(paths: PathProvider): void { } catch { approxFileSize = 0 } + + // Swallow async write errors on stdio. try/catch around write() only + // covers the synchronous throw; EPIPE from a broken pipe is emitted later. + if (process.stdout.listenerCount('error') === 0) { + process.stdout.on('error', () => {}) + } + if (process.stderr.listenerCount('error') === 0) { + process.stderr.on('error', () => {}) + } } export function createStructuredLogger(scope: string): StructuredLogger { diff --git a/desktop/tests/modular/structured-log-stdout.test.ts b/desktop/tests/modular/structured-log-stdout.test.ts new file mode 100644 index 00000000..0c8c9c98 --- /dev/null +++ b/desktop/tests/modular/structured-log-stdout.test.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from 'fs' +import { join } from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createStructuredLogger, initFileLogger } from '@/shared/utils/log' +import { assertIsolated } from '../fixtures/isolation' +import { createTmpUserData } from '../fixtures/tmpdir' + +/** + * File append is already best-effort. Console write was not, so a closed + * stdout pipe became an uncaughtException in the Electron main process + * ("A JavaScript error occurred in the main process" / write EPIPE). + */ +describe('structured logger stdout', () => { + const tmp = createTmpUserData() + + afterEach(() => { + vi.restoreAllMocks() + }) + + function paths() { + return { + getUserData: () => tmp.dir, + getTemp: () => tmp.dir, + getResourcesPath: () => process.cwd(), + getAppName: () => 'Personal AI Router' + } + } + + it('does not throw when stdout.write fails with EPIPE', () => { + assertIsolated() + initFileLogger(paths()) + vi.spyOn(process.stdout, 'write').mockImplementation(() => { + throw new Error('write EPIPE') + }) + const log = createStructuredLogger('app') + expect(() => log.verbose({ sublevel: 'lifecycle', message: 'ping' })).not.toThrow() + const onDisk = readFileSync(join(tmp.dir, 'logs', 'nvpair.jsonl'), 'utf8') + expect(onDisk).toContain('"message":"ping"') + }) + + it('does not turn a later stdout error event into an uncaughtException', () => { + assertIsolated() + initFileLogger(paths()) + expect(() => process.stdout.emit('error', new Error('write EPIPE'))).not.toThrow() + }) +})