From 13c4316c16de2a42ed4cffa53c217008fd3eb468 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:32:21 -0300 Subject: [PATCH] fix(ws): warm SSE auth import on LiveWS startup; relocate boot test to integration (#4063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live dashboard WebSocket sidecar lazily import()-ed the SSE auth module inside the connection handler, only on the API-key path. That cold import pulls in hundreds of transitive modules and takes ~7s under tsx, blocking the single-threaded event loop. The first API-key WebSocket connection therefore stalled 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. This was deterministic, not an "env flake": the boot test fires an API-key connection immediately followed by a cookie connection, so the cookie connection always raced the cold import and timed out (reproduced 3/3 locally and red on every CI run; proven via instrumented probes — reversing the order or warming the module first makes both connections open in ~20ms). Fix: - Memoize the auth-module import and warm it once at startup (before listen), so connection handling never pays the cold-import cost. Real improvement: the first API-key client no longer stalls the event loop for concurrent clients. - Relocate the boot test from tests/unit/cli to tests/integration. It spawns a real subprocess + WS server + SQLite (~9-11s); under the unit suite's --test-concurrency=20 it contended for CPU and destabilized the shard. The serial integration runner is its correct home; it still guards #4004's cookie-parse fix on every PR via the integration CI job. - Bump the test's startup/overall timeouts to absorb the eager auth warm. Makes `npm run test:unit` deterministically green (the only remaining unit red). Validated: relocated test 3/3 green via the integration runner (was 3/3 red); typecheck:core + eslint clean; confirmed it no longer matches the test:unit glob and does match tests/integration/*.test.ts. --- src/server/ws/liveServer.ts | 28 +++++++++++++++++-- .../live-ws-startup.test.ts | 14 ++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) rename tests/{unit/cli => integration}/live-ws-startup.test.ts (85%) 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";