diff --git a/src/server/ws/liveServer.ts b/src/server/ws/liveServer.ts index e48b4a91b8..59ebb669af 100644 --- a/src/server/ws/liveServer.ts +++ b/src/server/ws/liveServer.ts @@ -108,6 +108,24 @@ function toWebHeaders(headers: import("http").IncomingMessage["headers"]): Heade return webHeaders; } +// Auth-module warmer. The SSE auth graph is large (hundreds of transitive +// modules); a cold dynamic import takes several seconds and runs synchronously +// enough to stall the single-threaded event loop. Loading it lazily inside the +// connection handler meant the FIRST API-key WebSocket connection blocked the +// loop long enough that any connection arriving in that window (e.g. a +// same-origin cookie client) could not complete its handshake and timed out. +// Memoize the import and warm it once during startup (before listen) so +// connection handling never pays that cost. Kept as a dynamic import (not a +// top-level static one) to preserve the sidecar's decoupling from the SSE auth +// graph at module-load time. +let authModulePromise: Promise | null = null; +function loadAuthModule(): Promise { + if (!authModulePromise) { + authModulePromise = import("../../sse/services/auth.ts"); + } + return authModulePromise; +} + async function authorizeConnection(request: import("http").IncomingMessage): Promise { const sessionId = randomUUID().slice(0, 8); @@ -128,8 +146,8 @@ async function authorizeConnection(request: import("http").IncomingMessage): Pro } try { - // Validate API key via the existing auth system. - const { extractApiKey, isValidApiKey } = await import("../../sse/services/auth.ts"); + // Validate API key via the existing auth system (warmed at startup). + const { extractApiKey, isValidApiKey } = await loadAuthModule(); const apiKey = extractApiKey({ headers: { authorization: `Bearer ${token}` } } as any, { allowUrl: false, }); @@ -448,6 +466,12 @@ export async function startLiveDashboardServer( const unsubscribe = subscribeToEventBus(); await seedLatestCompressionRunFromDb(); + // Warm the auth module before accepting clients so the first API-key connection + // does not block the event loop on a cold import — which would starve concurrent + // WebSocket handshakes (see loadAuthModule). A failed warm is non-fatal: the + // handler retries the import lazily. + await loadAuthModule().catch(() => {}); + wss.on("connection", async (ws, request) => { const pendingMessages: string[] = []; let activeClientId: string | null = null; diff --git a/tests/unit/cli/live-ws-startup.test.ts b/tests/integration/live-ws-startup.test.ts similarity index 85% rename from tests/unit/cli/live-ws-startup.test.ts rename to tests/integration/live-ws-startup.test.ts index 4a4e799dcb..a097e26433 100644 --- a/tests/unit/cli/live-ws-startup.test.ts +++ b/tests/integration/live-ws-startup.test.ts @@ -1,3 +1,10 @@ +// Integration test (relocated from tests/unit/cli): it spawns the real +// start-ws-server.mjs subprocess, which boots a full WebSocket server + SQLite +// and eagerly warms the SSE auth module (~7s under tsx). Running it in the unit +// suite under --test-concurrency=20 made it flaky/red because the heavy subprocess +// boot contended for CPU; it belongs in the serial (--test-concurrency=1) +// integration runner. It still guards #4004's same-origin cookie-parse fix on +// every PR via the integration CI job. import assert from "node:assert/strict"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { SignJWT } from "jose"; @@ -34,9 +41,12 @@ function waitForStartup( getOutput: () => string ): Promise { return new Promise((resolve, reject) => { + // Startup eagerly warms the SSE auth module (see liveServer.ts), which takes + // several seconds under tsx, so "listening" can appear ~7s after spawn. 30s + // leaves headroom for a loaded CI runner. const timeout = setTimeout(() => { reject(new Error(`LiveWS startup timed out. Output:\n${getOutput()}`)); - }, 8_000); + }, 30_000); const onData = () => { const output = getOutput(); @@ -69,7 +79,7 @@ function waitForStartup( test( "LiveWS startup script boots on current Node and accepts API-key WebSocket clients", - { timeout: 15_000 }, + { timeout: 45_000 }, async () => { const port = await getFreePort(); const apiKey = "test-live-ws-key";