fix(ws): warm SSE auth import on LiveWS startup; relocate boot test to integration (#4063)

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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-17 09:32:21 -03:00
committed by GitHub
parent e6675cccaf
commit 13c4316c16
2 changed files with 38 additions and 4 deletions

View File

@@ -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<typeof import("../../sse/services/auth.ts")> | null = null;
function loadAuthModule(): Promise<typeof import("../../sse/services/auth.ts")> {
if (!authModulePromise) {
authModulePromise = import("../../sse/services/auth.ts");
}
return authModulePromise;
}
async function authorizeConnection(request: import("http").IncomingMessage): Promise<WsAuthResult> {
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;

View File

@@ -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<void> {
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";