test(web): remove two timing races that hung CI for a minute each
Both failures showed up as "Test timed out in 30000ms" followed by "Hook timed out in 30000ms" on loaded PR runners, in files nobody had touched. relay host-client: the scripted client dialed its WebSocket before awaiting its WebCrypto setup and only then attached the open/message listeners. On a loaded runner the loopback socket opened while key generation was still queued on the threadpool, the open event found no listener, no hello was sent, and the client waited forever. The socket is now dialed after the key material is ready, in the same tick as the listeners. The fake relay also terminates leftover sockets on stop so a failure is reported once, not twice. walkthrough routes: each test slept 20 ms and assumed the request had reached the route by then. The tests now wait until the service has been asked to generate one more time than before the request, and afterEach closes idle keep-alive connections so server.close() cannot hang on a response a failed test never received. Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
This commit is contained in:
@@ -98,6 +98,9 @@ const startFakeRelay = () => {
|
||||
wsUrl: `ws://127.0.0.1:${port}`,
|
||||
state,
|
||||
stop: () => new Promise((r) => {
|
||||
// A socket a failed test left open would hold server.close() until
|
||||
// the hook timeout; drop them so a failure is reported once.
|
||||
for (const client of wss.clients) client.terminate();
|
||||
wss.close();
|
||||
server.close(() => r());
|
||||
}),
|
||||
@@ -162,7 +165,6 @@ const runScriptedClient = async ({ relayUrl, serverId, hostEncPubJwk }) => {
|
||||
url.searchParams.set('role', 'client');
|
||||
url.searchParams.set('serverId', serverId);
|
||||
url.searchParams.set('connectionId', connectionId);
|
||||
const ws = new WebSocket(url.toString());
|
||||
|
||||
const hostPub = await globalThis.crypto.subtle.importKey(
|
||||
'jwk',
|
||||
@@ -182,6 +184,12 @@ const runScriptedClient = async ({ relayUrl, serverId, hostEncPubJwk }) => {
|
||||
resolveDone = resolve;
|
||||
});
|
||||
|
||||
// Dialed only now, with the key material ready and the listeners attached
|
||||
// in the same tick. Dialing before the WebCrypto awaits above let a loopback
|
||||
// socket open while the key generation was still queued on the threadpool,
|
||||
// and an `open` event with no listener means no hello, no ready, and a
|
||||
// client that waits forever. Loaded CI runners hit exactly that.
|
||||
const ws = new WebSocket(url.toString());
|
||||
ws.on('open', async () => {
|
||||
ws.send(JSON.stringify({
|
||||
t: 'hello',
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('walkthrough routes', () => {
|
||||
let job;
|
||||
|
||||
let lastArgs;
|
||||
let generateCalls = 0;
|
||||
|
||||
const service = {
|
||||
async getWalkthrough(args) {
|
||||
@@ -23,6 +24,7 @@ describe('walkthrough routes', () => {
|
||||
},
|
||||
async generateWalkthrough(args) {
|
||||
lastArgs = args;
|
||||
generateCalls += 1;
|
||||
if (job) return job;
|
||||
job = new Promise((resolve) => {
|
||||
releaseJob = () => resolve({ walkthrough: { title: 'DONE' }, hunks: [], hunkCount: 1 });
|
||||
@@ -54,12 +56,27 @@ describe('walkthrough routes', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// A response a failed test never received keeps its keep-alive socket
|
||||
// open, and server.close() would wait on it until the hook timeout.
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
// Resolves once the route has asked the service to generate one more time
|
||||
// than `seen`. A fixed sleep assumed the request had arrived by then; on a
|
||||
// loaded runner it had not, and the step that followed acted on a request
|
||||
// the server had not seen yet.
|
||||
const untilGenerateCalled = async (seen) => {
|
||||
for (let attempt = 0; attempt < 300 && generateCalls <= seen; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
if (generateCalls <= seen) throw new Error('the route never asked the service to generate');
|
||||
};
|
||||
|
||||
it('answers a generation request that nobody interrupted', async () => {
|
||||
const seen = generateCalls;
|
||||
const pending = generate();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await untilGenerateCalled(seen);
|
||||
releaseJob();
|
||||
|
||||
const body = await (await pending).json();
|
||||
@@ -69,8 +86,9 @@ describe('walkthrough routes', () => {
|
||||
|
||||
it('delivers the result to a client that reconnected after a refresh', async () => {
|
||||
const controller = new AbortController();
|
||||
const seen = generateCalls;
|
||||
generate(controller.signal).catch(() => {});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await untilGenerateCalled(seen);
|
||||
controller.abort();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
@@ -80,8 +98,9 @@ describe('walkthrough routes', () => {
|
||||
)).json();
|
||||
expect(read.generating).toBe(true);
|
||||
|
||||
const seenBeforeReattach = generateCalls;
|
||||
const reattached = generate();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await untilGenerateCalled(seenBeforeReattach);
|
||||
releaseJob();
|
||||
|
||||
const body = await (await reattached).json();
|
||||
@@ -108,12 +127,13 @@ describe('walkthrough routes', () => {
|
||||
);
|
||||
expect(lastArgs.language).toBe('uk');
|
||||
|
||||
const seen = generateCalls;
|
||||
const pending = fetch(`${base}/api/walkthrough/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ directory: '/repo', source: SOURCE, language: 'ja' }),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await untilGenerateCalled(seen);
|
||||
releaseJob();
|
||||
await pending;
|
||||
|
||||
@@ -129,8 +149,9 @@ describe('walkthrough routes', () => {
|
||||
});
|
||||
|
||||
it('cancels through its own endpoint rather than a dropped connection', async () => {
|
||||
const seen = generateCalls;
|
||||
generate().catch(() => {});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await untilGenerateCalled(seen);
|
||||
|
||||
const response = await fetch(`${base}/api/walkthrough/cancel`, {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user