Files
open-design/apps/packaged/tests/logging.test.ts
4d0ea247a7 fix(desktop): swallow setTypeOfService EINVAL crashes in dev main (#647) (#1298)
* fix(desktop): swallow harmless setTypeOfService EINVAL crashes in dev main

The packaged Electron entry (apps/packaged/src/logging.ts) already
filters the undici "setTypeOfService EINVAL" crash that issue #895
introduced for the prod build, but the dev / source-built desktop
entry was missing the parallel guard. Result: switching settings
tabs in a from-source desktop run could fire a fresh fetch, undici
would try to set IP_TOS on the outbound socket, the kernel would
refuse on certain macOS / VPN configurations, and the rejection
bubbled to Electron's default handler as the "JavaScript error in
the main process" dialog reported in issue #647.

Add the same defensive filter to apps/desktop:

  - isHarmlessSocketOptionError matches only the canonical undici
    shape (syscall name AND EINVAL code). A contradicting code
    (EACCES, EPERM, etc) explicitly fails the match so real bugs
    don't get hidden.
  - The uncaughtException handler logs harmless cases at warn and
    returns silently. For anything else it removes itself from the
    listener list and re-throws via setImmediate, restoring Node's
    default crash path so Electron's native dialog renders exactly
    as it would without this filter.
  - unhandledRejection mirrors the same harmless / fall-through
    split.

The filter is installed BEFORE app.whenReady so it is armed by the
time the renderer fires its first fetch.

The helper is duplicated rather than imported from apps/packaged
because AGENTS.md forbids cross-app private-source imports. The file
header calls out the parallel and notes that the two copies should
stay in sync until the helper is promoted to a shared workspace
package (follow-up); the contract is identical so a regression in
one will surface in the other's test suite.

Tests in apps/desktop/tests/main/uncaught-exception.test.ts mirror
apps/packaged/tests/logging.test.ts: 8 cases pinning the matcher
shape, 2 cases pinning the handler's harmless-log-warn vs
fall-through-rethrow split.

Validated: pnpm guard, pnpm --filter @open-design/desktop typecheck,
pnpm --filter @open-design/desktop build, and pnpm --filter
@open-design/desktop test (14 passed, 10 new).

* fix(desktop,packaged): fail-fast on non-harmless unhandled rejections

The previous unhandledRejection listeners logged non-harmless reasons
and returned, which kept the main process alive after any rejected
promise. A real bug, a failed IPC registration, or any unexpected
async exception was reduced to a console line instead of surfacing
through Node/Electron's default crash path the filter was meant to
preserve.

Both copies now route non-harmless rejections through a parallel
factory (createDesktopUnhandledRejectionHandler /
createFatalUnhandledRejectionHandler) that mirrors the
uncaughtException policy: harmless setTypeOfService EINVAL shapes log
at warn and return, anything else logs at error, removes the
listener, and re-throws via setImmediate. Listener removal happens
before the scheduled throw, so the rethrown reason lands in the
uncaughtException path with no recursion.

Tests cover the harmless branch, the detach + ordered rethrow, and
non-Error / primitive rejection reasons (Promise.reject(42)) which
must fall through. Desktop suite: 13/13, packaged suite: 16/16.
Flagged on PR #1298 by Siri-Ray and the codex P2 review thread; the
two file copies stay in lockstep per the AGENTS.md sync invariant.

---------

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-12 16:50:57 +08:00

274 lines
12 KiB
TypeScript

/**
* Regression coverage for the harmless-socket-option filter the
* packaged Electron entry uses to swallow `setTypeOfService EINVAL`
* undici crashes (issue #895). Without the filter, those errors
* surface as native "JavaScript error in main process" dialogs the
* moment a renderer fetch hits the affected socket option setter on
* macOS / VPN configurations that don't allow IP_TOS marking.
*
* Match strategy is intentionally narrow — name the syscall AND
* verify the EINVAL code — so a future regression that broadens the
* filter to "every EINVAL" (which would silently swallow real bugs)
* trips a test.
*
* @see https://github.com/nexu-io/open-design/issues/895
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createFatalUncaughtExceptionHandler,
createFatalUnhandledRejectionHandler,
isHarmlessSocketOptionError,
type PackagedDesktopLogger,
} from '../src/logging.js';
function stubLogger(): PackagedDesktopLogger {
return {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe('isHarmlessSocketOptionError (issue #895)', () => {
it('matches the canonical undici setTypeOfService EINVAL shape', () => {
const error = new Error('setTypeOfService EINVAL') as NodeJS.ErrnoException;
error.code = 'EINVAL';
error.syscall = 'setTypeOfService';
expect(isHarmlessSocketOptionError(error)).toBe(true);
});
it('matches when only the message has both tokens (no code property set)', () => {
// Some libuv builds surface the error without populating the
// `code` property — the message string itself is the sole signal.
const error = new Error('setTypeOfService EINVAL');
expect(isHarmlessSocketOptionError(error)).toBe(true);
});
it('matches when code is EINVAL and message references setTypeOfService', () => {
const error = new Error('Error: setTypeOfService failed') as NodeJS.ErrnoException;
error.code = 'EINVAL';
expect(isHarmlessSocketOptionError(error)).toBe(true);
});
it('does NOT match a generic EINVAL — that code is also raised by real bugs', () => {
// Guard against a future refactor that broadens the filter to
// "every EINVAL" and silently swallows configuration errors.
const error = new Error('write EINVAL') as NodeJS.ErrnoException;
error.code = 'EINVAL';
expect(isHarmlessSocketOptionError(error)).toBe(false);
});
it('does NOT match a setTypeOfService error with a different errno (e.g. EACCES)', () => {
const error = new Error('setTypeOfService EACCES') as NodeJS.ErrnoException;
error.code = 'EACCES';
expect(isHarmlessSocketOptionError(error)).toBe(false);
});
// #906 review (lefarcen P2): the structured `code` property must be
// authoritative when present. Without this guard a contradicting
// EACCES code paired with a message containing both `setTypeOfService`
// and `EINVAL` (a stale token, copy-pasted shape, or future libuv
// formatting change) would slip through and silently swallow a real
// permissions failure.
it('does NOT match when code contradicts the message — code is authoritative (#906 review)', () => {
const error = new Error('setTypeOfService EINVAL') as NodeJS.ErrnoException;
error.code = 'EACCES';
expect(isHarmlessSocketOptionError(error)).toBe(false);
});
it('does NOT match when code is a different errno but message has both tokens (defence-in-depth)', () => {
const error = new Error('setTypeOfService EINVAL — extra context') as NodeJS.ErrnoException;
error.code = 'EPERM';
expect(isHarmlessSocketOptionError(error)).toBe(false);
});
it('does NOT match unrelated errors with similar shape', () => {
const error = new Error('something happened');
expect(isHarmlessSocketOptionError(error)).toBe(false);
});
it('does NOT match non-Error rejection values (preserves fail-fast for primitives)', () => {
expect(isHarmlessSocketOptionError('setTypeOfService EINVAL')).toBe(false);
expect(isHarmlessSocketOptionError(null)).toBe(false);
expect(isHarmlessSocketOptionError(undefined)).toBe(false);
expect(isHarmlessSocketOptionError({ message: 'setTypeOfService EINVAL' })).toBe(false);
});
it('does NOT match Error with empty message even if code is EINVAL', () => {
const error = new Error('') as NodeJS.ErrnoException;
error.code = 'EINVAL';
expect(isHarmlessSocketOptionError(error)).toBe(false);
});
});
// Regression coverage for the #906 review (mrcfps + lefarcen):
// the non-harmless branch must NOT re-enter itself when it re-throws.
// Without the explicit `process.removeListener` call, the rethrown
// error landed back in this same listener, scheduled another
// `setImmediate`, and the packaged main process span forever instead
// of terminating. The factory exposes the named handler so we can
// assert the listener-removal step in isolation.
describe('createFatalUncaughtExceptionHandler (issue #906)', () => {
it('logs harmless socket option errors at warn level and returns silently', () => {
const logger = stubLogger();
const handler = createFatalUncaughtExceptionHandler(logger);
const harmless = new Error('setTypeOfService EINVAL') as NodeJS.ErrnoException;
harmless.code = 'EINVAL';
const removeListenerSpy = vi.spyOn(process, 'removeListener');
const setImmediateSpy = vi.spyOn(globalThis, 'setImmediate');
handler(harmless);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.error).not.toHaveBeenCalled();
// Critical: the harmless branch must not deregister or schedule
// anything — the process must keep running normally.
expect(removeListenerSpy).not.toHaveBeenCalled();
expect(setImmediateSpy).not.toHaveBeenCalled();
});
it('removes itself from uncaughtException listeners before scheduling the rethrow (#906 P1)', () => {
const logger = stubLogger();
const handler = createFatalUncaughtExceptionHandler(logger);
const removeListenerSpy = vi.spyOn(process, 'removeListener');
// Stub setImmediate to capture the scheduled callback without
// actually firing it (which would dump the rethrown error onto
// vitest's own uncaughtException path).
const scheduled: Array<() => void> = [];
const setImmediateSpy = vi
.spyOn(globalThis, 'setImmediate')
.mockImplementation(((fn: () => void) => {
scheduled.push(fn);
return 0 as unknown as NodeJS.Immediate;
}) as typeof setImmediate);
const realBug = new Error('something genuinely broke');
handler(realBug);
expect(logger.error).toHaveBeenCalledTimes(1);
// The actual #906 P1 fix: handler unregisters itself BEFORE the
// rethrow is scheduled. Mirrors mrcfps's reproduction script.
expect(removeListenerSpy).toHaveBeenCalledWith('uncaughtException', handler);
const removeListenerOrder = removeListenerSpy.mock.invocationCallOrder[0]!;
const setImmediateOrder = setImmediateSpy.mock.invocationCallOrder[0]!;
expect(removeListenerOrder).toBeLessThan(setImmediateOrder);
// The scheduled callback rethrows the original error so Node's
// default uncaughtException path picks it up after the listener
// is gone.
expect(scheduled).toHaveLength(1);
expect(() => scheduled[0]!()).toThrow(realBug);
});
it('does NOT re-enter itself when invoked twice with non-harmless errors (loop guard)', () => {
// Belt-and-suspenders: even if a future refactor accidentally
// forgot the removeListener call, this test would catch the
// recursion the original review reproduced.
const logger = stubLogger();
const handler = createFatalUncaughtExceptionHandler(logger);
const setImmediateCallCount: number[] = [];
vi.spyOn(globalThis, 'setImmediate').mockImplementation(((fn: () => void) => {
setImmediateCallCount.push(setImmediateCallCount.length);
// Crucially do NOT actually call fn() — that would test the
// recursion path through Node's process.emit, which is what
// we're trying to break out of.
void fn;
return 0 as unknown as NodeJS.Immediate;
}) as typeof setImmediate);
handler(new Error('first'));
handler(new Error('second'));
// Each invocation schedules exactly one rethrow. If the handler
// re-entered itself we'd see runaway scheduling.
expect(setImmediateCallCount).toHaveLength(2);
expect(logger.error).toHaveBeenCalledTimes(2);
});
});
// The parallel `unhandledRejection` listener mirrors the
// uncaughtException policy: harmless EINVAL rejections log at warn
// and return, anything else logs at error, detaches the listener, and
// schedules a re-throw via setImmediate so Node/Electron's default
// fail-fast path takes over. Before this factory landed, the inline
// listener logged non-harmless rejections and returned, which silently
// kept the main process alive after any rejected promise. Siri-Ray
// and the codex P2 thread on PR #1298 flagged the same gap on the
// parallel apps/desktop filter, so the two copies stay in lockstep.
describe('createFatalUnhandledRejectionHandler (issue #647 review follow-up)', () => {
it('logs harmless socket option rejections at warn level and returns silently', () => {
const logger = stubLogger();
const handler = createFatalUnhandledRejectionHandler(logger);
const harmless = new Error('setTypeOfService EINVAL') as NodeJS.ErrnoException;
harmless.code = 'EINVAL';
const removeListenerSpy = vi.spyOn(process, 'removeListener');
const setImmediateSpy = vi.spyOn(globalThis, 'setImmediate');
handler(harmless);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.error).not.toHaveBeenCalled();
expect(removeListenerSpy).not.toHaveBeenCalled();
expect(setImmediateSpy).not.toHaveBeenCalled();
});
it('removes itself from unhandledRejection listeners before scheduling the rethrow', () => {
const logger = stubLogger();
const handler = createFatalUnhandledRejectionHandler(logger);
const removeListenerSpy = vi.spyOn(process, 'removeListener');
const scheduled: Array<() => void> = [];
const setImmediateSpy = vi
.spyOn(globalThis, 'setImmediate')
.mockImplementation(((fn: () => void) => {
scheduled.push(fn);
return 0 as unknown as NodeJS.Immediate;
}) as typeof setImmediate);
const realBug = new Error('failed ipc registration');
handler(realBug);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(removeListenerSpy).toHaveBeenCalledWith('unhandledRejection', handler);
const removeOrder = removeListenerSpy.mock.invocationCallOrder[0]!;
const setImmediateOrder = setImmediateSpy.mock.invocationCallOrder[0]!;
expect(removeOrder).toBeLessThan(setImmediateOrder);
expect(scheduled).toHaveLength(1);
expect(() => scheduled[0]!()).toThrow(realBug);
});
it('falls through for primitive rejection reasons (Promise.reject(42))', () => {
// A primitive reason is never the undici socket shape, so the
// handler must reach the fail-fast path. Without this guard a
// `Promise.reject('boom')` would silently log and disappear.
const logger = stubLogger();
const handler = createFatalUnhandledRejectionHandler(logger);
const removeListenerSpy = vi.spyOn(process, 'removeListener');
const scheduled: Array<() => void> = [];
vi
.spyOn(globalThis, 'setImmediate')
.mockImplementation(((fn: () => void) => {
scheduled.push(fn);
return 0 as unknown as NodeJS.Immediate;
}) as typeof setImmediate);
handler(42);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(removeListenerSpy).toHaveBeenCalledWith('unhandledRejection', handler);
expect(scheduled).toHaveLength(1);
});
});