Skip to content

Commit c1ae4eb

Browse files
bloveclaude
andauthored
test(examples/chat): reap e2e server process groups + pre-free ports (#678)
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 <noreply@anthropic.com>
1 parent 3c45540 commit c1ae4eb

3 files changed

Lines changed: 96 additions & 6 deletions

File tree

examples/chat/angular/e2e/global-setup.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { spawn, type ChildProcess } from 'node:child_process';
33
import { setTimeout as delay } from 'node:timers/promises';
44
import { resolve } from 'node:path';
55
import { startAimock, type AimockHandle } from './aimock-runner';
6+
import { freePort } from './process-utils';
7+
8+
const LANGGRAPH_PORT = 2024;
9+
const ANGULAR_PORT = 4200;
610

711
interface SharedState {
812
aimock: AimockHandle;
@@ -35,13 +39,20 @@ async function waitForPort(url: string, timeoutMs: number): Promise<void> {
3539
}
3640

3741
export default async function globalSetup(): Promise<void> {
42+
// Recover from a prior run that left an orphaned server bound to our ports
43+
// (teardown skipped on a hard kill, or a child outliving its parent). Without
44+
// this, `waitForPort` below would bind to the STALE server and silently test
45+
// the old bundle. See process-utils.freePort.
46+
freePort(LANGGRAPH_PORT);
47+
freePort(ANGULAR_PORT);
48+
3849
const aimock = await startAimock({ mode: 'replay', fixturePath: FIXTURE_PATH });
3950
// eslint-disable-next-line no-console
4051
console.log(`[aimock-e2e] aimock listening at ${aimock.baseUrl}`);
4152

4253
const langgraph = spawn(
4354
'uv',
44-
['run', 'langgraph', 'dev', '--port', '2024', '--no-browser'],
55+
['run', 'langgraph', 'dev', '--port', String(LANGGRAPH_PORT), '--no-browser'],
4556
{
4657
cwd: resolve(REPO_ROOT, 'examples/chat/python'),
4758
env: {
@@ -50,28 +61,34 @@ export default async function globalSetup(): Promise<void> {
5061
OPENAI_API_KEY: 'test-not-used',
5162
},
5263
stdio: 'pipe',
64+
// Lead its own process group so teardown can reap uvicorn (the real
65+
// port-holder), not just the `uv`/`langgraph` parent.
66+
detached: true,
5367
},
5468
);
5569
langgraph.stdout?.on('data', (b) => process.stdout.write(`[langgraph] ${b}`));
5670
langgraph.stderr?.on('data', (b) => process.stderr.write(`[langgraph] ${b}`));
5771

58-
await waitForPort('http://localhost:2024/ok', 60_000);
72+
await waitForPort(`http://localhost:${LANGGRAPH_PORT}/ok`, 60_000);
5973
// eslint-disable-next-line no-console
6074
console.log('[aimock-e2e] langgraph ready on :2024');
6175

6276
const angular = spawn(
6377
'npx',
64-
['nx', 'serve', 'examples-chat-angular', '--port', '4200'],
78+
['nx', 'serve', 'examples-chat-angular', '--port', String(ANGULAR_PORT)],
6579
{
6680
cwd: REPO_ROOT,
6781
env: { ...process.env },
6882
stdio: 'pipe',
83+
// Lead its own process group so teardown can reap the underlying Angular
84+
// build server (the real port-holder), not just the `nx`/`npx` parent.
85+
detached: true,
6986
},
7087
);
7188
angular.stdout?.on('data', (b) => process.stdout.write(`[angular] ${b}`));
7289
angular.stderr?.on('data', (b) => process.stderr.write(`[angular] ${b}`));
7390

74-
await waitForPort('http://localhost:4200/', 120_000);
91+
await waitForPort(`http://localhost:${ANGULAR_PORT}/`, 120_000);
7592
// eslint-disable-next-line no-console
7693
console.log('[aimock-e2e] angular ready on :4200');
7794

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
// SPDX-License-Identifier: MIT
2+
import { freePort, killTree } from './process-utils';
3+
4+
const LANGGRAPH_PORT = 2024;
5+
const ANGULAR_PORT = 4200;
6+
27
export default async function globalTeardown(): Promise<void> {
38
const state = globalThis.__AIMOCK_E2E_STATE__;
49
if (!state) return;
5-
state.angular.kill('SIGTERM');
6-
state.langgraph.kill('SIGTERM');
10+
// Reap the process GROUPS — `nx serve` / `langgraph dev` spawn child
11+
// processes that hold the actual sockets, so SIGTERM-ing the parent alone
12+
// leaves them orphaned on :4200 / :2024.
13+
await Promise.all([killTree(state.angular), killTree(state.langgraph)]);
714
await state.aimock.stop();
15+
// Backstop: free the ports in case a child slipped the group reap.
16+
freePort(ANGULAR_PORT);
17+
freePort(LANGGRAPH_PORT);
818
globalThis.__AIMOCK_E2E_STATE__ = undefined;
919
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// SPDX-License-Identifier: MIT
2+
import { execFileSync } from 'node:child_process';
3+
import { setTimeout as delay } from 'node:timers/promises';
4+
import type { ChildProcess } from 'node:child_process';
5+
6+
/**
7+
* Best-effort: kill whatever process is bound to `port`.
8+
*
9+
* `nx serve` and `langgraph dev` spawn *child* processes that hold the actual
10+
* sockets, so SIGTERM-ing the parent (or a run dying mid-flight before
11+
* teardown) routinely leaves an orphan listening on the port. The next run's
12+
* `waitForPort` then binds to the **stale** server and silently tests the old
13+
* bundle. Freeing the port in global-setup makes each run self-healing.
14+
*
15+
* Uses `lsof` (present on macOS and the Linux CI runners). No-ops if `lsof`
16+
* is unavailable or the port is already free.
17+
*/
18+
export function freePort(port: number): void {
19+
let pids: string[] = [];
20+
try {
21+
pids = execFileSync('lsof', ['-ti', `tcp:${port}`], { encoding: 'utf8' })
22+
.split('\n')
23+
.map((s) => s.trim())
24+
.filter(Boolean);
25+
} catch {
26+
return; // lsof missing, or nothing listening — nothing to free
27+
}
28+
for (const pid of pids) {
29+
try {
30+
process.kill(Number(pid), 'SIGKILL');
31+
} catch {
32+
// already gone
33+
}
34+
}
35+
}
36+
37+
/**
38+
* Kill a spawned server and its descendants.
39+
*
40+
* Children are reaped only when the process was spawned `detached: true` (so it
41+
* leads its own group) — then a negative-PID signal hits the whole group.
42+
* Falls back to signalling the parent alone. SIGTERM first for a clean stop,
43+
* SIGKILL shortly after as a backstop.
44+
*/
45+
export async function killTree(child: ChildProcess | undefined): Promise<void> {
46+
if (!child || child.pid === undefined || child.exitCode !== null) return;
47+
const pid = child.pid;
48+
signalGroupOrSelf(pid, 'SIGTERM');
49+
await delay(1500);
50+
signalGroupOrSelf(pid, 'SIGKILL');
51+
}
52+
53+
function signalGroupOrSelf(pid: number, signal: NodeJS.Signals): void {
54+
try {
55+
process.kill(-pid, signal); // negative pid → process group (detached spawns)
56+
} catch {
57+
try {
58+
process.kill(pid, signal); // not a group leader — signal the parent alone
59+
} catch {
60+
// already gone
61+
}
62+
}
63+
}

0 commit comments

Comments
 (0)