From 3319e03d0ecffe4574c1c77cb13c46c4a224d1cd Mon Sep 17 00:00:00 2001 From: "yu.xia" Date: Fri, 30 Oct 2026 08:57:14 +0800 Subject: [PATCH] fix(host): guard the bundled undici write path against setTypeOfService EINVAL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 24's bundled undici (7.29.x) calls socket.setTypeOfService on every HTTP/1.1 request write with no try/catch. A connection reset in the window between connect and the first write makes macOS setsockopt(IP_TOS) answer EINVAL, the synchronous throw escapes as an uncaught exception, and the Harness host dies — entering the plugin market fires enough requests to hit it (#821). Upstream fixed by ignoring type-of-service errors (undici#5547, 7.x backport undici#6076), but no Node 24 release ships the backport yet. The harness node entry now wraps Socket.prototype.setTypeOfService before the DSH entry loads — same reach as the windowsHide enforcement in that file: every fetch in the process tree is covered without an upstream fix in each caller. EINVAL is swallowed with a one-time diagnostic; other errors still surface. The guard self-disables once the bundled runtime reports undici >= 7.30.1, so it disappears with the runtime bump. --- build/harness-node-entry.mjs | 13 +++++++ build/type-of-service-guard.d.mts | 12 +++++++ build/type-of-service-guard.mjs | 55 ++++++++++++++++++++++++++++++ test/harness-node-entry.test.ts | 56 +++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 build/type-of-service-guard.d.mts create mode 100644 build/type-of-service-guard.mjs diff --git a/build/harness-node-entry.mjs b/build/harness-node-entry.mjs index ba104d29d..86c28674f 100644 --- a/build/harness-node-entry.mjs +++ b/build/harness-node-entry.mjs @@ -1,4 +1,5 @@ import childProcess from 'node:child_process' +import net from 'node:net' import { syncBuiltinESMExports } from 'node:module' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -10,6 +11,7 @@ import { buildPackagedPnpmInvocation } from './packaged-pnpm-invocation.mjs' import { enforceWindowsChildProcessHide } from './windows-child-process-hide.mjs' +import { installTypeOfServiceGuard } from './type-of-service-guard.mjs' // On macOS Harness runs inside an Electron utility process (TCC responsibility // isolation), so `process.execPath` and `argv0` point at the Electron helper @@ -85,6 +87,17 @@ if (process.platform === 'win32') { process.stdout.write('[harness-node] windowsHide enforcement enabled for child processes\n') } +// Same reach as the windowsHide enforcement above: the guard patches the +// socket prototype before dshEntryPath loads, so the bundled undici's +// HTTP/1.1 write path — and anything else in this process tree — stops +// dying on a platform EINVAL (issue #821). Self-disables once the bundled +// runtime reports undici >= 7.30.1. +if (installTypeOfServiceGuard(net, process.versions.undici, (message) => process.stderr.write(message))) { + process.stderr.write( + `[harness-node] type-of-service guard enabled (bundled undici ${process.versions.undici})\n` + ) +} + if (!dshEntryPath) { report('startup error', 'missing DSH entry path') process.exitCode = 1 diff --git a/build/type-of-service-guard.d.mts b/build/type-of-service-guard.d.mts new file mode 100644 index 000000000..5dc39c277 --- /dev/null +++ b/build/type-of-service-guard.d.mts @@ -0,0 +1,12 @@ +/** + * Wrap `Socket.prototype.setTypeOfService` so an EINVAL from the platform + * no longer escapes the undici write path. Returns true when the guard was + * installed (the runtime exposes the API and the bundled undici predates + * undici#6076). `netModule` is the node:net module; typed as unknown because + * the repository's own @types/node may predate `setTypeOfService`. + */ +export function installTypeOfServiceGuard( + netModule: unknown, + undiciVersion: string | undefined, + warn: (message: string) => void +): boolean diff --git a/build/type-of-service-guard.mjs b/build/type-of-service-guard.mjs new file mode 100644 index 000000000..99f3fbccf --- /dev/null +++ b/build/type-of-service-guard.mjs @@ -0,0 +1,55 @@ +// Node 24's bundled undici (7.29.x) calls socket.setTypeOfService on every +// HTTP/1.1 request write without a try/catch. When a connection is reset in +// the window between connect and the first write, macOS setsockopt(IP_TOS) +// answers EINVAL, the synchronous throw escapes as an uncaught exception, +// and the Harness host dies — entering the plugin market fires enough +// requests to hit it (issue #821; upstream undici#5544, fixed by the 7.x +// backport undici#6076 which no Node 24 release ships yet). The upstream fix +// ignores type-of-service errors entirely, so this guard does the same for +// EINVAL only and installs itself only while the bundled undici predates the +// fix; it self-disables once the runtime reports >= 7.30.1. + +const FIXED_UNDICI = [7, 30, 1] + +function undiciPredatesFix(undiciVersion) { + const parts = String(undiciVersion ?? '').split('.').map((n) => Number.parseInt(n, 10)) + if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) { + return false + } + for (let index = 0; index < FIXED_UNDICI.length; index += 1) { + if (parts[index] < FIXED_UNDICI[index]) return true + if (parts[index] > FIXED_UNDICI[index]) return false + } + return false +} + +/** + * Wrap `Socket.prototype.setTypeOfService` so an EINVAL from the platform + * no longer escapes the undici write path. Returns true when the guard was + * installed (bundled undici predates the fix and the API exists). + */ +export function installTypeOfServiceGuard(netModule, undiciVersion, warn) { + const proto = netModule?.Socket?.prototype + if (!proto || typeof proto.setTypeOfService !== 'function' || !undiciPredatesFix(undiciVersion)) { + return false + } + const original = proto.setTypeOfService + let warned = false + proto.setTypeOfService = function setTypeOfServiceGuarded(value) { + try { + return original.call(this, value) + } catch (error) { + if (error && error.code === 'EINVAL') { + if (!warned) { + warned = true + warn( + `[harness-node] ignoring setTypeOfService EINVAL (bundled undici ${undiciVersion} < 7.30.1, undici#5544, issue #821)\n` + ) + } + return undefined + } + throw error + } + } + return true +} diff --git a/test/harness-node-entry.test.ts b/test/harness-node-entry.test.ts index d87b5bd5c..5b4e110c1 100644 --- a/test/harness-node-entry.test.ts +++ b/test/harness-node-entry.test.ts @@ -680,3 +680,59 @@ describe('DSH entry dispatch', () => { expect(bin).toMatch(/export \{[^}]*\brunCli\b/) }) }) + +describe('type-of-service guard (issue #821)', () => { + it('swallows EINVAL from setTypeOfService and warns once', async () => { + const { installTypeOfServiceGuard } = await import('../build/type-of-service-guard.mjs') + let calls = 0 + const proto = { + setTypeOfService(_value: number) { + calls += 1 + throw Object.assign(new Error('setTypeOfService EINVAL'), { code: 'EINVAL' }) + } + } + const warnings: string[] = [] + const installed = installTypeOfServiceGuard( + { Socket: { prototype: proto } }, + '7.29.1', + (m: string) => warnings.push(m) + ) + expect(installed).toBe(true) + expect(proto.setTypeOfService(0)).toBeUndefined() + expect(proto.setTypeOfService(0)).toBeUndefined() + expect(calls).toBe(2) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('undici#5544') + }) + + it('rethrows errors other than EINVAL', async () => { + const { installTypeOfServiceGuard } = await import('../build/type-of-service-guard.mjs') + const proto = { + setTypeOfService(_value: number) { + throw Object.assign(new Error('setTypeOfService EPERM'), { code: 'EPERM' }) + } + } + installTypeOfServiceGuard({ Socket: { prototype: proto } }, '7.29.1', () => {}) + expect(() => proto.setTypeOfService(0)).toThrowError('EPERM') + }) + + it('does not install when the bundled undici already has the fix', async () => { + const { installTypeOfServiceGuard } = await import('../build/type-of-service-guard.mjs') + const original = () => 1 + const proto = { setTypeOfService: original } + expect(installTypeOfServiceGuard({ Socket: { prototype: proto } }, '7.30.1', () => {})).toBe(false) + expect(installTypeOfServiceGuard({ Socket: { prototype: proto } }, '8.1.0', () => {})).toBe(false) + expect(proto.setTypeOfService).toBe(original) + }) + + it('does not install when the runtime has no setTypeOfService (Node 22 and older)', async () => { + const { installTypeOfServiceGuard } = await import('../build/type-of-service-guard.mjs') + expect(installTypeOfServiceGuard({ Socket: { prototype: {} } }, '7.29.1', () => {})).toBe(false) + // The real net module on this test runner: the guard must be a no-op there too. + const net = (await import('node:net')).default + const before = (net.Socket.prototype as { setTypeOfService?: unknown }).setTypeOfService + const installed = installTypeOfServiceGuard(net, process.versions.undici, () => {}) + expect(installed).toBe(false) + expect((net.Socket.prototype as { setTypeOfService?: unknown }).setTypeOfService).toBe(before) + }) +})