Files
open-design/apps/packaged/tests/logging.test.ts
SidandGitHub 362b92c1a6 fix(packaged): swallow harmless setTypeOfService EINVAL from undici (#895) (#906)
* fix(packaged): swallow harmless setTypeOfService EINVAL from undici (#895)

Opening Settings → Pets → Community in the packaged desktop app
surfaced a native "JavaScript error in main process" dialog with
`Uncaught Exception: Error: setTypeOfService EINVAL`. Root cause:
undici's socket setup tries to set the IP_TOS byte for QoS / DSCP
marking on outbound sockets, and the macOS kernel refuses with
EINVAL on certain configurations (VPNs, IPv6-only sockets, some
firewall postures). The byte is purely advisory — the socket
itself is healthy and serves traffic — so the rejection should
not crash the app.

Two cooperating layers:

1. **`protocol.ts`** registers the `od://` scheme that backs every
   renderer page load and API call in the packaged build by
   forwarding through Node's global `fetch` (which is undici under
   the hood). Pulled the inner request handler out as
   `handleOdRequest()` so a test can drive it with a stub fetch,
   and wrapped the `await fetch()` in a try/catch that returns a
   502 Response on failure. Without this, every undici rejection —
   not just `setTypeOfService` — propagated to Electron's default
   uncaught-exception path. Now the renderer sees a normal error
   response and the main process keeps running.

2. **`logging.ts`** adds a defensive `process.on('uncaughtException')`
   handler with a narrow filter, `isHarmlessSocketOptionError`,
   that only matches the canonical undici shape (message contains
   `setTypeOfService` AND code is `EINVAL` or message contains
   `EINVAL`). For any unrecognised error the handler re-throws
   via `setImmediate` so Node's default crash + Electron's
   crash dialog still fire end-to-end — a future regression that
   broadens the filter to "every EINVAL" is caught by the unit
   tests below.

Tests: 13 new tests across `tests/protocol.test.ts` (5) and
`tests/logging.test.ts` (8) pin both layers — including the
explicit #895 regression case (fetch rejecting with the canonical
EINVAL shape returns a 502 instead of throwing) and the negative
guard against the filter swallowing real bugs (a generic write
EINVAL or a setTypeOfService EACCES is *not* matched).

Verified locally:
- `pnpm --filter @open-design/packaged vitest tests/protocol.test.ts tests/logging.test.ts` → 13/13
- packaged `tsconfig.json` and `tsconfig.tests.json` (the CI killer): both clean
- the one pre-existing failure in `tests/sidecars.test.ts` (`adds custom VP_HOME/bin …`) is independent of this PR — confirmed by stashing the change and re-running

* fix(packaged): break recursive rethrow + tighten EINVAL filter (#906 review)

@mrcfps and @lefarcen both flagged a real P1 in the first iteration:
the non-harmless branch of the new uncaughtException handler
rethrew via setImmediate while the same listener was still
registered, so a real bug would re-enter the handler indefinitely
instead of terminating. mrcfps reproduced the loop with a minimal
Node script. lefarcen also flagged that the filter trusted the
message string over a contradicting structured `code`.

Both fixes:

1. **Recursive rethrow (P1).** Extract the handler as a named
   factory, `createFatalUncaughtExceptionHandler(logger)`, that
   captures itself in closure. On non-harmless errors the handler
   now `process.removeListener('uncaughtException', self)` before
   scheduling the rethrow. With no listener registered, the next
   throw lands in Node's default crash path — which is exactly
   what we wanted ("preserve fail-fast for real bugs").

2. **`code` is authoritative (P2).** When `code` is present on the
   error, only `code === 'EINVAL'` qualifies. A contradicting
   `EACCES`/`EPERM` paired with `setTypeOfService EINVAL` in the
   message now slips through to the crash path instead of being
   swallowed. Message-based detection only fires when `code` is
   genuinely absent (some libuv builds don't populate it on raw
   thrown Errors).

3 new tests pin both fixes:
   - `does NOT match when code contradicts the message` and the
     EPERM variant guard against the P2 regression.
   - `removes itself from uncaughtException listeners before
     scheduling the rethrow` uses `vi.spyOn(process,
     'removeListener')` and a stubbed setImmediate to assert the
     call order: removeListener fires before setImmediate
     schedules the throw.
   - `does NOT re-enter itself when invoked twice` is a
     belt-and-suspenders loop guard — even if a future refactor
     dropped the removeListener call, the test would catch
     runaway scheduling.

Verified locally:
- packaged vitest: 18/18 (was 13, +3 new tests; +2 negative-guard
  tests for the P2 filter; -0 deletions)
- packaged tsc -p tsconfig.json --noEmit: clean
- packaged tsc -p tsconfig.tests.json --noEmit (the CI killer): clean
2026-05-09 01:16:23 +08:00

196 lines
8.3 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,
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);
});
});