From a858041d6ad750ba8680e03ba4e0c215610ebd04 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 16 Jun 2026 20:58:33 -0700 Subject: [PATCH] test(examples/chat): reap e2e server process groups + pre-free ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Playwright global-setup spawns `nx serve` (:4200) and `langgraph dev` (:2024); both fork child processes that hold the actual sockets. Teardown only `SIGTERM`-ed the parents, so the real port-holders survived — and a run killed mid-flight skipped teardown entirely. The next run's `waitForPort` then bound to the STALE server and silently tested the OLD bundle. - global-setup: free :4200/:2024 before starting (recover from a prior orphan) and spawn both servers `detached: true` so they lead their own process groups. - global-teardown: kill the process GROUPS (SIGTERM then SIGKILL backstop), then free the ports as a final backstop. - process-utils: freePort (best-effort lsof) + killTree helpers. Verified: full chat e2e suite runs clean and both ports are FREE afterward (previously orphaned). Co-Authored-By: Claude Fable 5 --- examples/chat/angular/e2e/global-setup.ts | 25 ++++++-- examples/chat/angular/e2e/global-teardown.ts | 14 ++++- examples/chat/angular/e2e/process-utils.ts | 63 ++++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 examples/chat/angular/e2e/process-utils.ts diff --git a/examples/chat/angular/e2e/global-setup.ts b/examples/chat/angular/e2e/global-setup.ts index eb9e42531..2f601f080 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 d41bf2db3..beb3c8c89 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 000000000..28a21e259 --- /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 + } + } +}