diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index df9eb0a0..e972e614 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -37,6 +37,7 @@ import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs'; import { attachRendererRecovery } from './renderer-recovery.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; import { fetchUpdateNotes } from '@openchamber/web/server/lib/changelog/update-notes.js'; +import { applyConnectAttemptTimeout } from '@openchamber/web/server/lib/network-defaults.js'; const execFileAsync = promisify(execFile); @@ -104,6 +105,11 @@ if (shouldIgnoreLoopbackConnectionLimit({ })) { app.commandLine.appendSwitch('ignore-connections-limit', '127.0.0.1,localhost'); } +// This process runs quota/provider fetches under Node/undici, whose happy-eyeballs +// default aborts each connect attempt after 250ms — distant provider endpoints +// routinely need longer handshakes, surfacing as "fetch failed" (#3399). No-op on +// runtimes without the setter. +applyConnectAttemptTimeout(); protocol.registerSchemesAsPrivileged([ { diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index dd56e0ad..cdf1f201 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -72,6 +72,13 @@ import { printJson, logStatus, } from './cli-output.js'; +import { applyConnectAttemptTimeout } from '../server/lib/network-defaults.js'; + +// The CLI process performs provider fetches (quota/usage, update notes) under +// Node/undici, whose happy-eyeballs default aborts each connect attempt after +// 250ms — distant provider endpoints routinely need longer handshakes, surfacing +// as "fetch failed" (#3399). No-op on runtimes without the setter. +applyConnectAttemptTimeout(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/packages/web/server/lib/network-defaults.js b/packages/web/server/lib/network-defaults.js new file mode 100644 index 00000000..32b0f82d --- /dev/null +++ b/packages/web/server/lib/network-defaults.js @@ -0,0 +1,23 @@ +import net from 'node:net'; + +// Node caps each happy-eyeballs connect attempt at 250ms by default +// (autoSelectFamilyAttemptTimeout). TCP handshakes to provider endpoints that are +// geographically distant routinely take 300-1500ms, so fetch() from a Node process +// aborts every attempt (ETIMEDOUT) and surfaces "fetch failed" even though the host +// is reachable — e.g. the z.ai quota endpoint from an IPv4-only egress (#3399). +// +// Every Node process entrypoint that performs provider fetches raises the +// per-attempt cap. Family autoselection itself stays enabled, so the IPv6→IPv4 +// fallback and ::1/localhost servers keep working; disabling it instead breaks +// local MCP servers. Runtimes without the setter (Bun's fetch path, older Node) +// are a no-op. +export const CONNECT_ATTEMPT_TIMEOUT_MS = 5_000; + +export const applyConnectAttemptTimeout = (netModule = net) => { + try { + netModule.setDefaultAutoSelectFamilyAttemptTimeout(CONNECT_ATTEMPT_TIMEOUT_MS); + return true; + } catch { + return false; + } +}; diff --git a/packages/web/server/lib/network-defaults.test.js b/packages/web/server/lib/network-defaults.test.js new file mode 100644 index 00000000..31ee963b --- /dev/null +++ b/packages/web/server/lib/network-defaults.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import net from 'node:net'; + +import { applyConnectAttemptTimeout, CONNECT_ATTEMPT_TIMEOUT_MS } from './network-defaults.js'; + +describe('applyConnectAttemptTimeout', () => { + it('raises the per-attempt connect timeout on runtimes that expose the setter', () => { + const previous = net.getDefaultAutoSelectFamilyAttemptTimeout(); + try { + expect(applyConnectAttemptTimeout()).toBe(true); + expect(net.getDefaultAutoSelectFamilyAttemptTimeout()).toBe(CONNECT_ATTEMPT_TIMEOUT_MS); + } finally { + net.setDefaultAutoSelectFamilyAttemptTimeout(previous); + } + }); + + it('is a no-op on runtimes without the setter', () => { + expect(applyConnectAttemptTimeout({})).toBe(false); + }); + + it('survives a throwing setter', () => { + const setDefaultAutoSelectFamilyAttemptTimeout = vi.fn(() => { + throw new Error('not supported'); + }); + expect(applyConnectAttemptTimeout({ setDefaultAutoSelectFamilyAttemptTimeout })).toBe(false); + }); + + it('leaves family autoselection itself untouched', () => { + const setDefaultAutoSelectFamily = vi.fn(); + const setDefaultAutoSelectFamilyAttemptTimeout = vi.fn(); + applyConnectAttemptTimeout({ setDefaultAutoSelectFamily, setDefaultAutoSelectFamilyAttemptTimeout }); + expect(setDefaultAutoSelectFamilyAttemptTimeout).toHaveBeenCalledTimes(1); + expect(setDefaultAutoSelectFamilyAttemptTimeout).toHaveBeenCalledWith(CONNECT_ATTEMPT_TIMEOUT_MS); + expect(setDefaultAutoSelectFamily).not.toHaveBeenCalled(); + }); +});