mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-07 14:38:42 +08:00
* 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
133 lines
5.0 KiB
TypeScript
133 lines
5.0 KiB
TypeScript
/**
|
|
* Regression coverage for the `od://` protocol proxy in
|
|
* apps/packaged/src/protocol.ts.
|
|
*
|
|
* The packaged Electron entry registers `od://` as the loader for the
|
|
* web runtime and forwards every renderer request to the local web
|
|
* sidecar through Node's global `fetch` (which is undici under the
|
|
* hood). Without a try/catch in the handler, undici throwing
|
|
* `setTypeOfService EINVAL` from socket internals on certain macOS /
|
|
* VPN configurations bubbled up to Electron's default uncaught
|
|
* exception handler — surfacing as a native "JavaScript error in
|
|
* main process" dialog the moment the user did anything that
|
|
* triggered a fetch (e.g. Settings → Pets → Community).
|
|
*
|
|
* @see https://github.com/nexu-io/open-design/issues/895
|
|
*/
|
|
|
|
// `protocol.handle` from the `electron` module is invoked at import
|
|
// time inside `apps/packaged/src/protocol.ts`. Stub the module before
|
|
// importing so the test environment doesn't need a real Electron
|
|
// runtime.
|
|
import { vi } from 'vitest';
|
|
|
|
vi.mock('electron', () => ({
|
|
protocol: {
|
|
registerSchemesAsPrivileged: vi.fn(),
|
|
handle: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { handleOdRequest } from '../src/protocol.js';
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('od:// protocol proxy', () => {
|
|
it('proxies the request through fetchImpl with the rewritten target URL', async () => {
|
|
const captured: Request[] = [];
|
|
const fetchImpl: typeof fetch = async (input) => {
|
|
captured.push(input as Request);
|
|
return new Response('ok', { status: 200 });
|
|
};
|
|
|
|
const request = new Request('od://app/api/codex-pets/sync', { method: 'POST' });
|
|
const response = await handleOdRequest(request, 'http://127.0.0.1:17579/', fetchImpl);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(captured).toHaveLength(1);
|
|
expect(captured[0]!.url).toBe('http://127.0.0.1:17579/api/codex-pets/sync');
|
|
expect(captured[0]!.method).toBe('POST');
|
|
});
|
|
|
|
it('preserves the request path, search, and hash when rewriting to the web sidecar', async () => {
|
|
const captured: Request[] = [];
|
|
const fetchImpl: typeof fetch = async (input) => {
|
|
captured.push(input as Request);
|
|
return new Response('', { status: 204 });
|
|
};
|
|
|
|
const request = new Request('od://app/api/projects?limit=5#section', { method: 'GET' });
|
|
await handleOdRequest(request, 'http://127.0.0.1:42424/', fetchImpl);
|
|
|
|
const target = new URL(captured[0]!.url);
|
|
expect(target.host).toBe('127.0.0.1:42424');
|
|
expect(target.pathname).toBe('/api/projects');
|
|
expect(target.search).toBe('?limit=5');
|
|
// `Request` strips the hash fragment per the Fetch spec, but the
|
|
// pathname + search above are the values the proxy is responsible
|
|
// for getting right. Pin those.
|
|
});
|
|
|
|
// The flagship #895 regression: undici can throw `setTypeOfService
|
|
// EINVAL` mid-fetch from socket internals. Without the try/catch
|
|
// wrapper around the handler's fetch call, that rejection propagates
|
|
// up to Electron's default uncaught exception handler and surfaces
|
|
// as a native "JavaScript error in main process" dialog. The
|
|
// handler must instead return a 502 Response so the renderer sees
|
|
// a normal failure and the process keeps running.
|
|
it('returns a 502 Response when the underlying fetch rejects (issue #895)', async () => {
|
|
const fetchImpl: typeof fetch = async () => {
|
|
const error = new Error('setTypeOfService EINVAL') as NodeJS.ErrnoException;
|
|
error.code = 'EINVAL';
|
|
error.syscall = 'setTypeOfService';
|
|
throw error;
|
|
};
|
|
|
|
const request = new Request('od://app/api/codex-pets/sync', { method: 'POST' });
|
|
const response = await handleOdRequest(request, 'http://127.0.0.1:17579/', fetchImpl);
|
|
|
|
expect(response.status).toBe(502);
|
|
const body = (await response.json()) as {
|
|
error: string;
|
|
message: string;
|
|
code?: string;
|
|
target: string;
|
|
};
|
|
expect(body.error).toBe('OD_PROTOCOL_PROXY_FAILED');
|
|
expect(body.message).toContain('setTypeOfService');
|
|
expect(body.code).toBe('EINVAL');
|
|
expect(body.target).toBe('http://127.0.0.1:17579/api/codex-pets/sync');
|
|
});
|
|
|
|
it('does not throw when fetch rejects (the actual #895 root-cause guard)', async () => {
|
|
const fetchImpl: typeof fetch = async () => {
|
|
throw new Error('socket hang up');
|
|
};
|
|
|
|
// The promise must resolve with a Response, never reject.
|
|
await expect(
|
|
handleOdRequest(new Request('od://app/'), 'http://127.0.0.1:1/', fetchImpl),
|
|
).resolves.toBeInstanceOf(Response);
|
|
});
|
|
|
|
it('handles non-Error rejection values without throwing', async () => {
|
|
const fetchImpl: typeof fetch = async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
|
throw 'sync timeout';
|
|
};
|
|
|
|
const response = await handleOdRequest(
|
|
new Request('od://app/api/probe'),
|
|
'http://127.0.0.1:1/',
|
|
fetchImpl,
|
|
);
|
|
expect(response.status).toBe(502);
|
|
const body = (await response.json()) as { message: string };
|
|
expect(body.message).toBe('sync timeout');
|
|
});
|
|
});
|