mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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>
156 lines
6.7 KiB
TypeScript
156 lines
6.7 KiB
TypeScript
/**
|
|
* Defensive uncaught-exception filter for the dev / source-built desktop
|
|
* Electron main process.
|
|
*
|
|
* The packaged Electron entry (`apps/packaged/src/logging.ts`) already
|
|
* carries the same filter, where it was added for issue #895 (the
|
|
* `setTypeOfService EINVAL` crash from undici socket internals on certain
|
|
* macOS / VPN configurations). The same crash reproduces on the dev
|
|
* entry when users switch settings tabs because the renderer fires a
|
|
* fresh `fetch` and undici tries to set the IP_TOS byte on the outbound
|
|
* socket; on a kernel that refuses, the rejection bubbles to Electron's
|
|
* default handler and surfaces as the "JavaScript error in main process"
|
|
* dialog reported in issue #647.
|
|
*
|
|
* Why this file isn't a direct import from `apps/packaged`: AGENTS.md
|
|
* forbids one app package from importing another app's private `src/`.
|
|
* The honest fix is to promote the helper to a shared workspace package
|
|
* (e.g. `@open-design/platform`); doing that as part of this bug-fix
|
|
* sprint would balloon the PR. Until that promotion lands, this module
|
|
* is the source of truth for the desktop entry's filter and the two
|
|
* copies should stay in sync. Any change to the matching rules here
|
|
* should land in `apps/packaged/src/logging.ts` in the same PR.
|
|
*
|
|
* Behaviour contract (mirrors the packaged version):
|
|
*
|
|
* 1. `isHarmlessSocketOptionError(value)` recognises the canonical
|
|
* undici `setTypeOfService EINVAL` shape and nothing else. A
|
|
* contradicting `code` (e.g. `EACCES`) explicitly fails the match
|
|
* so real bugs don't get hidden.
|
|
*
|
|
* 2. The installed `uncaughtException` handler logs harmless errors
|
|
* 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 error dialog
|
|
* renders exactly as it would without this filter.
|
|
*
|
|
* 3. The installed `unhandledRejection` handler mirrors that policy:
|
|
* harmless `setTypeOfService EINVAL` rejections log at warn and
|
|
* return; every other rejection logs at error, removes the
|
|
* listener, and schedules a re-throw via `setImmediate`. Without
|
|
* the detach + rethrow a non-harmless rejection would land in
|
|
* this log line and silently keep the process alive, which would
|
|
* hide real main-process bugs (issue #647 review by Siri-Ray and
|
|
* the codex P2 thread on PR #1298).
|
|
*/
|
|
|
|
/**
|
|
* Recognise the known-harmless `setTypeOfService EINVAL` shape thrown by
|
|
* undici socket internals when the kernel refuses to set the IP_TOS byte
|
|
* on an outbound socket. Exported so a unit test can pin the exact
|
|
* surface this filter matches.
|
|
*/
|
|
export function isHarmlessSocketOptionError(value: unknown): boolean {
|
|
if (!(value instanceof Error)) return false;
|
|
const message = typeof value.message === 'string' ? value.message : '';
|
|
if (!message) return false;
|
|
if (!message.includes('setTypeOfService')) return false;
|
|
const code = (value as NodeJS.ErrnoException).code;
|
|
if (typeof code === 'string' && code.length > 0) {
|
|
// Structured code is authoritative when present. Only EINVAL
|
|
// qualifies; anything else (EACCES, EPERM, ECONNRESET, …) is a
|
|
// contradicting signal and the filter rejects the match so a real
|
|
// bug carrying a stale `setTypeOfService` substring in its message
|
|
// doesn't slip through.
|
|
return code === 'EINVAL';
|
|
}
|
|
// No structured code: fall back to message-based detection. libuv's
|
|
// error string is `<syscall> <errcode>` so the EINVAL token sits
|
|
// alongside the syscall name.
|
|
return message.includes('EINVAL');
|
|
}
|
|
|
|
/** Minimal logger surface the desktop entry already has by way of
|
|
* `console.*`. Exposed as an injectable parameter so tests can capture
|
|
* log calls without mocking the console. */
|
|
export interface DesktopErrorFilterLogger {
|
|
warn: (message: string, meta?: Record<string, unknown>) => void;
|
|
error: (message: string, meta?: Record<string, unknown>) => void;
|
|
}
|
|
|
|
/** Build the named handler so a unit test can drive it without
|
|
* installing it on the live `process` object. */
|
|
export function createDesktopUncaughtExceptionHandler(
|
|
logger: DesktopErrorFilterLogger,
|
|
): (error: unknown) => void {
|
|
const handler = (error: unknown): void => {
|
|
if (isHarmlessSocketOptionError(error)) {
|
|
logger.warn('desktop main swallowed harmless socket option error', { error });
|
|
return;
|
|
}
|
|
logger.error('desktop main fatal uncaught exception', { error });
|
|
process.removeListener('uncaughtException', handler);
|
|
setImmediate(() => {
|
|
throw error;
|
|
});
|
|
};
|
|
return handler;
|
|
}
|
|
|
|
/**
|
|
* Parallel factory for the `unhandledRejection` listener. Harmless
|
|
* undici `setTypeOfService EINVAL` rejections log at warn and return;
|
|
* anything else logs at error, removes the listener, and schedules a
|
|
* re-throw via `setImmediate`. The detached re-throw lands in the
|
|
* `uncaughtException` listener installed above, which then walks its
|
|
* own non-harmless path (log + detach + rethrow), so Node's default
|
|
* crash semantics take over and Electron's native error dialog
|
|
* renders as it would without this filter. Pinned by a unit test so a
|
|
* future refactor that drops the detach can't silently regress.
|
|
*/
|
|
export function createDesktopUnhandledRejectionHandler(
|
|
logger: DesktopErrorFilterLogger,
|
|
): (reason: unknown) => void {
|
|
const handler = (reason: unknown): void => {
|
|
if (isHarmlessSocketOptionError(reason)) {
|
|
logger.warn('desktop main swallowed harmless socket option rejection', { reason });
|
|
return;
|
|
}
|
|
logger.error('desktop main unhandled rejection', { reason });
|
|
process.removeListener('unhandledRejection', handler);
|
|
setImmediate(() => {
|
|
throw reason;
|
|
});
|
|
};
|
|
return handler;
|
|
}
|
|
|
|
/** Install the filter on `process`. Idempotent across the dev entry's
|
|
* hot-reload paths because Node's listener registry deduplicates
|
|
* identical function references; we capture the once-built handler
|
|
* inside this module so repeated calls don't pile up. */
|
|
let installedHandler: ((error: unknown) => void) | null = null;
|
|
|
|
export function attachDesktopProcessErrorFilter(
|
|
logger: DesktopErrorFilterLogger = consoleLogger(),
|
|
): void {
|
|
if (installedHandler !== null) return;
|
|
const handler = createDesktopUncaughtExceptionHandler(logger);
|
|
installedHandler = handler;
|
|
process.on('uncaughtException', handler);
|
|
process.on('unhandledRejection', createDesktopUnhandledRejectionHandler(logger));
|
|
}
|
|
|
|
function consoleLogger(): DesktopErrorFilterLogger {
|
|
return {
|
|
warn: (message, meta) => {
|
|
if (meta === undefined) console.warn(message);
|
|
else console.warn(message, meta);
|
|
},
|
|
error: (message, meta) => {
|
|
if (meta === undefined) console.error(message);
|
|
else console.error(message, meta);
|
|
},
|
|
};
|
|
}
|