diff --git a/examples/chat/angular/e2e/global-setup.ts b/examples/chat/angular/e2e/global-setup.ts index eb9e4253..2f601f08 100644 --- a/examples/chat/angular/e2e/global-setup.ts +++ b/examples/chat/angular/e2e/global-setup.ts @@ -3,6 +3,10 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { setTimeout as delay } from 'node:timers/promises'; import { resolve } from 'node:path'; import { startAimock, type AimockHandle } from './aimock-runner'; +import { freePort } from './process-utils'; + +const LANGGRAPH_PORT = 2024; +const ANGULAR_PORT = 4200; interface SharedState { aimock: AimockHandle; @@ -35,13 +39,20 @@ async function waitForPort(url: string, timeoutMs: number): Promise { } export default async function globalSetup(): Promise { + // Recover from a prior run that left an orphaned server bound to our ports + // (teardown skipped on a hard kill, or a child outliving its parent). Without + // this, `waitForPort` below would bind to the STALE server and silently test + // the old bundle. See process-utils.freePort. + freePort(LANGGRAPH_PORT); + freePort(ANGULAR_PORT); + const aimock = await startAimock({ mode: 'replay', fixturePath: FIXTURE_PATH }); // eslint-disable-next-line no-console console.log(`[aimock-e2e] aimock listening at ${aimock.baseUrl}`); const langgraph = spawn( 'uv', - ['run', 'langgraph', 'dev', '--port', '2024', '--no-browser'], + ['run', 'langgraph', 'dev', '--port', String(LANGGRAPH_PORT), '--no-browser'], { cwd: resolve(REPO_ROOT, 'examples/chat/python'), env: { @@ -50,28 +61,34 @@ export default async function globalSetup(): Promise { OPENAI_API_KEY: 'test-not-used', }, stdio: 'pipe', + // Lead its own process group so teardown can reap uvicorn (the real + // port-holder), not just the `uv`/`langgraph` parent. + detached: true, }, ); langgraph.stdout?.on('data', (b) => process.stdout.write(`[langgraph] ${b}`)); langgraph.stderr?.on('data', (b) => process.stderr.write(`[langgraph] ${b}`)); - await waitForPort('http://localhost:2024/ok', 60_000); + await waitForPort(`http://localhost:${LANGGRAPH_PORT}/ok`, 60_000); // eslint-disable-next-line no-console console.log('[aimock-e2e] langgraph ready on :2024'); const angular = spawn( 'npx', - ['nx', 'serve', 'examples-chat-angular', '--port', '4200'], + ['nx', 'serve', 'examples-chat-angular', '--port', String(ANGULAR_PORT)], { cwd: REPO_ROOT, env: { ...process.env }, stdio: 'pipe', + // Lead its own process group so teardown can reap the underlying Angular + // build server (the real port-holder), not just the `nx`/`npx` parent. + detached: true, }, ); angular.stdout?.on('data', (b) => process.stdout.write(`[angular] ${b}`)); angular.stderr?.on('data', (b) => process.stderr.write(`[angular] ${b}`)); - await waitForPort('http://localhost:4200/', 120_000); + await waitForPort(`http://localhost:${ANGULAR_PORT}/`, 120_000); // eslint-disable-next-line no-console console.log('[aimock-e2e] angular ready on :4200'); diff --git a/examples/chat/angular/e2e/global-teardown.ts b/examples/chat/angular/e2e/global-teardown.ts index d41bf2db..beb3c8c8 100644 --- a/examples/chat/angular/e2e/global-teardown.ts +++ b/examples/chat/angular/e2e/global-teardown.ts @@ -1,9 +1,19 @@ // SPDX-License-Identifier: MIT +import { freePort, killTree } from './process-utils'; + +const LANGGRAPH_PORT = 2024; +const ANGULAR_PORT = 4200; + export default async function globalTeardown(): Promise { const state = globalThis.__AIMOCK_E2E_STATE__; if (!state) return; - state.angular.kill('SIGTERM'); - state.langgraph.kill('SIGTERM'); + // Reap the process GROUPS — `nx serve` / `langgraph dev` spawn child + // processes that hold the actual sockets, so SIGTERM-ing the parent alone + // leaves them orphaned on :4200 / :2024. + await Promise.all([killTree(state.angular), killTree(state.langgraph)]); await state.aimock.stop(); + // Backstop: free the ports in case a child slipped the group reap. + freePort(ANGULAR_PORT); + freePort(LANGGRAPH_PORT); globalThis.__AIMOCK_E2E_STATE__ = undefined; } diff --git a/examples/chat/angular/e2e/process-utils.ts b/examples/chat/angular/e2e/process-utils.ts new file mode 100644 index 00000000..28a21e25 --- /dev/null +++ b/examples/chat/angular/e2e/process-utils.ts @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +import { execFileSync } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; +import type { ChildProcess } from 'node:child_process'; + +/** + * Best-effort: kill whatever process is bound to `port`. + * + * `nx serve` and `langgraph dev` spawn *child* processes that hold the actual + * sockets, so SIGTERM-ing the parent (or a run dying mid-flight before + * teardown) routinely leaves an orphan listening on the port. The next run's + * `waitForPort` then binds to the **stale** server and silently tests the old + * bundle. Freeing the port in global-setup makes each run self-healing. + * + * Uses `lsof` (present on macOS and the Linux CI runners). No-ops if `lsof` + * is unavailable or the port is already free. + */ +export function freePort(port: number): void { + let pids: string[] = []; + try { + pids = execFileSync('lsof', ['-ti', `tcp:${port}`], { encoding: 'utf8' }) + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + } catch { + return; // lsof missing, or nothing listening — nothing to free + } + for (const pid of pids) { + try { + process.kill(Number(pid), 'SIGKILL'); + } catch { + // already gone + } + } +} + +/** + * Kill a spawned server and its descendants. + * + * Children are reaped only when the process was spawned `detached: true` (so it + * leads its own group) — then a negative-PID signal hits the whole group. + * Falls back to signalling the parent alone. SIGTERM first for a clean stop, + * SIGKILL shortly after as a backstop. + */ +export async function killTree(child: ChildProcess | undefined): Promise { + if (!child || child.pid === undefined || child.exitCode !== null) return; + const pid = child.pid; + signalGroupOrSelf(pid, 'SIGTERM'); + await delay(1500); + signalGroupOrSelf(pid, 'SIGKILL'); +} + +function signalGroupOrSelf(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(-pid, signal); // negative pid → process group (detached spawns) + } catch { + try { + process.kill(pid, signal); // not a group leader — signal the parent alone + } catch { + // already gone + } + } +}