fix(quota): raise Node's 250ms connect-attempt cap for provider fetches (#3404)
Node's happy-eyeballs default (autoSelectFamilyAttemptTimeout=250ms) aborts every connect attempt to provider endpoints whose TCP handshake exceeds 250ms, so quota fetches from Node processes fail with "fetch failed" while the Bun-based opencode CLI path succeeds (#3399). Raise the per-attempt cap to 5s via a shared applyConnectAttemptTimeout() helper called from the two Node entrypoints that host the quota module: the Electron main process and the openchamber CLI. Family autoselection stays enabled (::1 and IPv6→IPv4 fallback preserved); runtimes without the setter are a no-op.
This commit is contained in:
@@ -37,6 +37,7 @@ import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs';
|
|||||||
import { attachRendererRecovery } from './renderer-recovery.mjs';
|
import { attachRendererRecovery } from './renderer-recovery.mjs';
|
||||||
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
|
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
|
||||||
import { fetchUpdateNotes } from '@openchamber/web/server/lib/changelog/update-notes.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);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -104,6 +105,11 @@ if (shouldIgnoreLoopbackConnectionLimit({
|
|||||||
})) {
|
})) {
|
||||||
app.commandLine.appendSwitch('ignore-connections-limit', '127.0.0.1,localhost');
|
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([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ import {
|
|||||||
printJson,
|
printJson,
|
||||||
logStatus,
|
logStatus,
|
||||||
} from './cli-output.js';
|
} 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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user