diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 001410a739e..582810c24d0 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -800,6 +800,11 @@ async function executeGatewayRequestWithScopes(params: { }): Promise { const { opts, scopes, url, token, password, tlsFingerprint, timeoutMs, safeTimerTimeoutMs } = params; + // Yield to the event loop before starting the WebSocket connection. + // On Windows with large dist bundles, heavy synchronous module loading + // can starve the event loop, preventing timely processing of the + // connect.challenge frame and causing handshake timeouts (#48736). + await new Promise((r) => setImmediate(r)); return await new Promise((resolve, reject) => { let settled = false; let ignoreClose = false; diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 7a22cae41b6..eeedc4e562a 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -727,12 +727,18 @@ export class GatewayClient { private armConnectChallengeTimeout() { const connectChallengeTimeoutMs = resolveGatewayClientConnectChallengeTimeoutMs(this.opts); + const armedAt = Date.now(); this.clearConnectChallengeTimeout(); this.connectTimer = setTimeout(() => { if (this.connectSent || this.ws?.readyState !== WebSocket.OPEN) { return; } - this.opts.onConnectError?.(new Error("gateway connect challenge timeout")); + const elapsedMs = Date.now() - armedAt; + this.opts.onConnectError?.( + new Error( + `gateway connect challenge timeout (waited ${elapsedMs}ms, limit ${connectChallengeTimeoutMs}ms)`, + ), + ); this.ws?.close(1008, "connect challenge timeout"); }, connectChallengeTimeoutMs); } diff --git a/src/gateway/handshake-timeouts.test.ts b/src/gateway/handshake-timeouts.test.ts index 13fe14ea746..9feccdab8ef 100644 --- a/src/gateway/handshake-timeouts.test.ts +++ b/src/gateway/handshake-timeouts.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { clampConnectChallengeTimeoutMs, DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS, + getConnectChallengeTimeoutMsFromEnv, getPreauthHandshakeTimeoutMsFromEnv, MAX_CONNECT_CHALLENGE_TIMEOUT_MS, MIN_CONNECT_CHALLENGE_TIMEOUT_MS, @@ -34,4 +35,30 @@ describe("gateway handshake timeouts", () => { }), ).toBe(20); }); + + test("getConnectChallengeTimeoutMsFromEnv reads OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS", () => { + expect(getConnectChallengeTimeoutMsFromEnv({})).toBeUndefined(); + expect( + getConnectChallengeTimeoutMsFromEnv({ OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS: "15000" }), + ).toBe(15_000); + expect( + getConnectChallengeTimeoutMsFromEnv({ OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS: "garbage" }), + ).toBeUndefined(); + }); + + test("resolveConnectChallengeTimeoutMs falls back to env override", () => { + const original = process.env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS; + try { + process.env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS = "5000"; + expect(resolveConnectChallengeTimeoutMs()).toBe(5_000); + // Explicit value still takes precedence over env + expect(resolveConnectChallengeTimeoutMs(3_000)).toBe(3_000); + } finally { + if (original === undefined) { + delete process.env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS; + } else { + process.env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS = original; + } + } + }); }); diff --git a/src/gateway/handshake-timeouts.ts b/src/gateway/handshake-timeouts.ts index 1911db22658..545d3e72d65 100644 --- a/src/gateway/handshake-timeouts.ts +++ b/src/gateway/handshake-timeouts.ts @@ -9,10 +9,28 @@ export function clampConnectChallengeTimeoutMs(timeoutMs: number): number { ); } +export function getConnectChallengeTimeoutMsFromEnv( + env: NodeJS.ProcessEnv = process.env, +): number | undefined { + const raw = env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS; + if (raw) { + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return undefined; +} + export function resolveConnectChallengeTimeoutMs(timeoutMs?: number | null): number { - return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) - ? clampConnectChallengeTimeoutMs(timeoutMs) - : DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS; + if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { + return clampConnectChallengeTimeoutMs(timeoutMs); + } + const envOverride = getConnectChallengeTimeoutMsFromEnv(); + if (envOverride !== undefined) { + return clampConnectChallengeTimeoutMs(envOverride); + } + return DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS; } export function getPreauthHandshakeTimeoutMsFromEnv(env: NodeJS.ProcessEnv = process.env): number {