Skip to content

Commit 4444e3f

Browse files
committed
test(e2e): narrow address-in-use retries
1 parent 2599672 commit 4444e3f

2 files changed

Lines changed: 44 additions & 3 deletions

File tree

__tests__/e2e/helpers/spawn-server.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ interface HttpCliAttempt {
8181
close: () => Promise<void>;
8282
}
8383

84+
class HttpCliStartupError extends Error {
85+
readonly code?: string;
86+
87+
constructor(message: string, readonly startupStderr: string, code?: string) {
88+
super(message);
89+
this.name = 'HttpCliStartupError';
90+
this.code = code;
91+
}
92+
}
93+
8494
const MAX_HTTP_CLI_START_ATTEMPTS = 2;
8595
const MAX_CAPTURED_OUTPUT_CHARS = 64 * 1024;
8696
const MAX_HEALTH_RESPONSE_CHARS = 16 * 1024;
@@ -251,21 +261,32 @@ async function captureCleanupFailure(close: () => Promise<void>): Promise<string
251261
}
252262
}
253263

264+
function getErrorCode(error: Error): string | undefined {
265+
const code = (error as Error & { code?: unknown }).code;
266+
return typeof code === 'string' ? code : undefined;
267+
}
268+
254269
async function startHttpCliAttempt(port: number, config: HttpCliConfig): Promise<SpawnedHttpCli> {
255270
const attempt = createHttpCliAttempt(port, config);
256271
try {
257272
const health = await waitForHealth(attempt.child, port, attempt.output, config.readyTimeoutMs);
258273
return { url: attempt.url, health, close: attempt.close };
259274
} catch (error) {
260275
const message = error instanceof Error ? error.message : String(error);
276+
const code = error instanceof Error ? getErrorCode(error) : undefined;
261277
const cleanupFailure = await captureCleanupFailure(attempt.close);
262278
const cleanupError = cleanupFailure ? `; cleanupFailure=${JSON.stringify(cleanupFailure)}` : '';
263-
throw new Error(`${message}; cleanup=${diagnostics(attempt.child, attempt.output)}${cleanupError}`);
279+
throw new HttpCliStartupError(
280+
`${message}; cleanup=${diagnostics(attempt.child, attempt.output)}${cleanupError}`,
281+
attempt.output.stderr,
282+
code,
283+
);
264284
}
265285
}
266286

267287
function isAddressInUse(error: Error): boolean {
268-
return error.message.includes('EADDRINUSE');
288+
return getErrorCode(error) === 'EADDRINUSE'
289+
|| error instanceof HttpCliStartupError && error.startupStderr.includes('EADDRINUSE');
269290
}
270291

271292
/** Starts the built CLI on an isolated loopback port and waits for /health. */

__tests__/e2e/http-transport.e2e.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ async function runTests() {
8686
server = await spawnHttpCli({
8787
reservePort: async () => {
8888
reserveCalls++;
89-
if (reserveCalls === 1) throw new Error('EADDRINUSE deterministic test collision');
89+
if (reserveCalls === 1) throw Object.assign(new Error('deterministic test collision'), { code: 'EADDRINUSE' });
9090
return await reserveTestPort();
9191
},
9292
});
@@ -96,6 +96,26 @@ async function runTests() {
9696
}
9797
}, results);
9898

99+
await testFunction('HTTP CLI does not retry when only stdout mentions EADDRINUSE', async () => {
100+
let reserveCalls = 0;
101+
await assert.rejects(
102+
() => spawnHttpCli({
103+
args: ['-e', "process.stdout.write('EADDRINUSE in child stdout only'); process.exit(7);"],
104+
reservePort: async () => {
105+
reserveCalls++;
106+
return await reserveTestPort();
107+
},
108+
}),
109+
(error: unknown) => {
110+
const message = error instanceof Error ? error.message : String(error);
111+
assert.match(message, /HTTP CLI exited before health became ready/);
112+
assert.match(message, /EADDRINUSE in child stdout only/);
113+
return true;
114+
},
115+
);
116+
assert.equal(reserveCalls, 1);
117+
}, results);
118+
99119
await testFunction('HTTP CLI readiness timeout includes diagnostics and reaps the child', async () => {
100120
await assert.rejects(
101121
() => spawnHttpCli({

0 commit comments

Comments
 (0)