Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions examples/chat/angular/e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,13 +39,20 @@ async function waitForPort(url: string, timeoutMs: number): Promise<void> {
}

export default async function globalSetup(): Promise<void> {
// 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: {
Expand All @@ -50,28 +61,34 @@ export default async function globalSetup(): Promise<void> {
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');

Expand Down
14 changes: 12 additions & 2 deletions examples/chat/angular/e2e/global-teardown.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
}
63 changes: 63 additions & 0 deletions examples/chat/angular/e2e/process-utils.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
}
}
}
Loading