From 3e3315e68326c14833e7fdacf57ccfa67938bd8a Mon Sep 17 00:00:00 2001 From: Genie Date: Sat, 18 Apr 2026 09:50:27 -0300 Subject: [PATCH] fix(otel): await server.stop(true) to release port before next start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The push-event CI intermittently failed on `otel-receiver > POST /v1/logs handles empty payload` with EADDRINUSE because `stopOtelReceiver()` called `server.stop()` without awaiting the returned Promise. The server state was nulled synchronously, but the underlying TCP port lingered. When the next test's `beforeEach` picked a random port from the 7000-slot window (57000-63999) and `startOtelReceiver()` raced against the unreleased port, start silently returned false (caught as non-fatal EADDRINUSE), leaving `isOtelReceiverRunning()` true but the server unreachable — so downstream fetch() calls returned 0 or hit the wrong route. Fix: - Make `stopOtelReceiver()` async, await `server.stop(true)` (force close). - Update the two callers in `src/genie.ts` (finally blocks) to await. - Update `afterEach` in otel-receiver.test.ts to await stop. - Add `expect(started).toBe(true)` assertions after every `await startOtelReceiver()` call so any future silent-false failure surfaces immediately instead of cascading into misleading HTTP errors. Validated: 5 consecutive local runs of otel-receiver.test.ts pass; full `bun test` (2581 tests) passes; `bun run typecheck` clean. --- src/genie.ts | 4 ++-- src/lib/otel-receiver.test.ts | 24 +++++++++++++++--------- src/lib/otel-receiver.ts | 11 +++++++++-- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/genie.ts b/src/genie.ts index b1ecb7e23..5405ca496 100644 --- a/src/genie.ts +++ b/src/genie.ts @@ -697,7 +697,7 @@ if (sessionIdx !== -1 && sessionIdx + 1 < args.length) { try { await program.parseAsync(process.argv); } finally { - stopOtelReceiver(); + await stopOtelReceiver().catch(() => {}); await shutdownDb().catch(() => {}); } } @@ -708,7 +708,7 @@ if (sessionIdx !== -1 && sessionIdx + 1 < args.length) { if (process.env.GENIE_PROFILE_DB) console.error(`[profile] parseAsync=${Date.now() - _cmdStart}ms`); } finally { const _shutStart = Date.now(); - stopOtelReceiver(); + await stopOtelReceiver().catch(() => {}); await shutdownDb().catch(() => {}); if (process.env.GENIE_PROFILE_DB) console.error(`[profile] shutdown=${Date.now() - _shutStart}ms`); } diff --git a/src/lib/otel-receiver.test.ts b/src/lib/otel-receiver.test.ts index 4519f397d..c8078d70f 100644 --- a/src/lib/otel-receiver.test.ts +++ b/src/lib/otel-receiver.test.ts @@ -11,8 +11,8 @@ describe('otel-receiver', () => { process.env.GENIE_OTEL_PORT = String(57000 + Math.floor(Math.random() * 7000)); }); - afterEach(() => { - stopOtelReceiver(); + afterEach(async () => { + await stopOtelReceiver(); if (origPort !== undefined) process.env.GENIE_OTEL_PORT = origPort; else process.env.GENIE_OTEL_PORT = undefined; }); @@ -41,15 +41,17 @@ describe('otel-receiver', () => { }); test('stopOtelReceiver stops the server', async () => { - await startOtelReceiver(); + const started = await startOtelReceiver(); + expect(started).toBe(true); expect(isOtelReceiverRunning()).toBe(true); - stopOtelReceiver(); + await stopOtelReceiver(); expect(isOtelReceiverRunning()).toBe(false); }); test('POST /v1/logs returns 200', async () => { - await startOtelReceiver(); + const started = await startOtelReceiver(); + expect(started).toBe(true); const port = getOtelPort(); const res = await fetch(`http://127.0.0.1:${port}/v1/logs`, { @@ -83,7 +85,8 @@ describe('otel-receiver', () => { }); test('POST /v1/metrics returns 200', async () => { - await startOtelReceiver(); + const started = await startOtelReceiver(); + expect(started).toBe(true); const port = getOtelPort(); const res = await fetch(`http://127.0.0.1:${port}/v1/metrics`, { @@ -116,7 +119,8 @@ describe('otel-receiver', () => { }); test('POST /v1/logs handles empty payload', async () => { - await startOtelReceiver(); + const started = await startOtelReceiver(); + expect(started).toBe(true); const port = getOtelPort(); const res = await fetch(`http://127.0.0.1:${port}/v1/logs`, { @@ -129,7 +133,8 @@ describe('otel-receiver', () => { }); test('GET /health returns ok', async () => { - await startOtelReceiver(); + const started = await startOtelReceiver(); + expect(started).toBe(true); const port = getOtelPort(); const res = await fetch(`http://127.0.0.1:${port}/health`); @@ -139,7 +144,8 @@ describe('otel-receiver', () => { }); test('unknown route returns 404', async () => { - await startOtelReceiver(); + const started = await startOtelReceiver(); + expect(started).toBe(true); const port = getOtelPort(); const res = await fetch(`http://127.0.0.1:${port}/v1/unknown`, { diff --git a/src/lib/otel-receiver.ts b/src/lib/otel-receiver.ts index 39fbfe86d..db14ec2c2 100644 --- a/src/lib/otel-receiver.ts +++ b/src/lib/otel-receiver.ts @@ -370,10 +370,17 @@ export async function startOtelReceiver(): Promise { /** * Stop the OTel receiver. Used in tests and graceful shutdown. + * + * Awaits `server.stop(true)` so the listening port is fully released before + * the next start attempt. Without the await, back-to-back start/stop cycles + * in tests (afterEach → next beforeEach) could race on the TCP port and + * produce EADDRINUSE when parallel tests randomly collide within the + * 7000-port window (57000-63999), which caused the intermittent push-event + * CI failure on `POST /v1/logs handles empty payload`. */ -export function stopOtelReceiver(): void { +export async function stopOtelReceiver(): Promise { if (server) { - server.stop(); + await server.stop(true); server = null; } }