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
102 changes: 102 additions & 0 deletions __tests__/dev-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,3 +575,105 @@ describe('httpStatus — the probe that replaced `curl -o /dev/null`', () => {
expect(Date.now() - started).toBeLessThan(5000);
});
});

describe('planTermination — the only gate between a stored number and a signal', () => {
const { planTermination } = require('../src/lib/dev-process');
const self = { pid: 50_000, ppid: 49_999 };

it('refuses pid 1 on POSIX: `-1` is the kill(2) broadcast, not a group', () => {
// 2026-09-23: exactly this pid, sent through the real signal path by a test,
// SIGTERMed every process of the user and rebooted the Mac.
expect(planTermination(1, 'darwin', self).kind).toBe('refuse');
expect(planTermination(1, 'linux', self).kind).toBe('refuse');
});

it('refuses the Windows system pids 0 and 4, and allows the next one', () => {
expect(planTermination(4, 'win32', self).kind).toBe('refuse');
expect(planTermination(8, 'win32', self)).toEqual({ kind: 'taskkill', target: 8 });
});

it('refuses this CLI and its parent', () => {
expect(planTermination(self.pid, 'linux', self).kind).toBe('refuse');
expect(planTermination(self.ppid, 'linux', self).kind).toBe('refuse');
});

it('refuses what a corrupted state.json can hold', () => {
for (const pid of [0, -5, 1.5, Number.NaN, '4242', null, undefined]) {
expect(planTermination(pid, 'linux', self).kind).toBe('refuse');
}
});

it('plans a group signal for an ordinary pid', () => {
expect(planTermination(2, 'linux', self)).toEqual({ kind: 'group', target: 2 });
});
});

describe('killProcessGroup / terminateProcessGroup — every signal injected, none real', () => {
const { killProcessGroup, terminateProcessGroup: terminate } = require('../src/lib/dev-process');

/** Records instead of acting. Nothing here may reach `process.kill` or `taskkill`. */
const fake = (aliveChecks: boolean[] = []) => {
const signals: [number, string][] = [];
const runs: { args: string[]; command: string }[] = [];
let checks = 0;
return {
options: (platform: NodeJS.Platform) => ({
isAlive: () => aliveChecks[Math.min(checks++, aliveChecks.length - 1)] ?? false,
platform,
run: (command: string, args: string[]) => {
runs.push({ args, command });
return { status: 0 };
},
signal: (pid: number, sig: string) => {
signals.push([pid, sig]);
},
}),
runs,
signals,
};
};

it('sends nothing for pid 1 on POSIX', () => {
const f = fake();
expect(killProcessGroup(1, f.options('linux'))).toBe(false);
expect(f.signals).toEqual([]);
});

it('sends SIGTERM to the group of an ordinary pid on POSIX', () => {
const f = fake();
expect(killProcessGroup(4242, f.options('linux'))).toBe(true);
expect(f.signals).toEqual([[-4242, 'SIGTERM']]);
});

it('uses `taskkill /T /F` on Windows — `/F` is not optional', () => {
// Measured: `taskkill /PID <pid> /T` WITHOUT `/F` fails on the children and
// leaves the port bound — a refusal, not a graceful stop.
const f = fake();
killProcessGroup(4242, f.options('win32'));
expect(f.runs).toEqual([{ args: ['/PID', '4242', '/T', '/F'], command: 'taskkill' }]);
expect(f.signals).toEqual([]);
});

it('on Windows, one `/T /F` ends it — there is no gentler first step to wait out', async () => {
// alive before, gone after the first taskkill
const f = fake([true, false]);
await expect(terminate(4242, 1000, f.options('win32'))).resolves.toBe(true);
expect(f.runs).toHaveLength(1);
});

it('on Windows, a survivor gets a second `/T /F` and an honest false', async () => {
const f = fake([true]);
await expect(terminate(4242, 200, f.options('win32'))).resolves.toBe(false);
expect(f.runs).toHaveLength(2);
expect(f.signals).toEqual([]);
});

it('on POSIX, a survivor is escalated from SIGTERM to SIGKILL on the group', async () => {
const f = fake([true]);
await expect(terminate(4242, 200, f.options('linux'))).resolves.toBe(false);
expect(f.signals).toEqual([
[-4242, 'SIGTERM'],
[-4242, 'SIGKILL'],
]);
});
});
65 changes: 65 additions & 0 deletions __tests__/signal-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';

import { signalVerdict, takeViolations } from './support/signal-guard';

/**
* The guard that stops a test from signalling a process it did not spawn.
* Background in `support/signal-guard.ts`: the 2026-09-23 broadcast SIGTERM.
*
* Nothing in this file may reach a live process if the guard were broken: the
* decision is tested as a pure function, and the wiring test aims at a pid far
* above any pid_max, so a missing guard yields a harmless ESRCH, not a signal.
*/
describe('signal guard', () => {
const spawned = new Set([4242]);

it('refuses the broadcast and own-group targets whatever was spawned', () => {
expect(signalVerdict(-1, 'SIGTERM', spawned)).toMatch(/refused/);
expect(signalVerdict(0, 'SIGTERM', spawned)).toMatch(/refused/);
});

it('refuses a pid or group this worker did not spawn', () => {
expect(signalVerdict(1, 'SIGTERM', spawned)).toMatch(/refused/);
expect(signalVerdict(-4243, 'SIGKILL', spawned)).toMatch(/refused/);
});

it('allows a spawned child and its group, and any probe with signal 0', () => {
expect(signalVerdict(4242, 'SIGTERM', spawned)).toBeNull();
expect(signalVerdict(-4242, 'SIGKILL', spawned)).toBeNull();
expect(signalVerdict(1, 0, spawned)).toBeNull();
});

it('is installed: a real process.kill on a foreign pid is intercepted', () => {
// Without the guard this is ESRCH (no such pid) — harmless by construction.
expect(() => process.kill(-9_999_999, 'SIGTERM')).toThrow(/signal-guard/);
expect(takeViolations()).toHaveLength(1);
});

it('is registered for every suite in package.json', () => {
const jest = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')).jest;
expect(jest.setupFilesAfterEnv).toContain('<rootDir>/support/signal-guard.ts');
});

it('src/ builds a negative pid in exactly one place, and only from a SignalTarget', () => {
const offenders: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== 'templates') walk(path);
} else if (path.endsWith('.ts')) {
readFileSync(path, 'utf-8')
.split('\n')
.forEach((line, i) => {
if (/process\.kill\(\s*-/.test(line) || /\bsend\(\s*-/.test(line)) offenders.push(`${path}:${i + 1}`);
});
}
}
};
walk(join(__dirname, '..', 'src'));
// The one allowed site is `signalGroup`, whose parameter is a `SignalTarget`.
expect(offenders).toHaveLength(1);
expect(offenders[0]).toMatch(/src[\\/]lib[\\/]dev-process\.ts:\d+$/);
});
});
86 changes: 86 additions & 0 deletions __tests__/support/signal-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Jest `setupFilesAfterEnv`: a test may send a real signal only to a process it spawned.
*
* Why this exists: on 2026-09-23 at 09:37 a test in `dev-process.test.ts` called
* `killProcessGroup(1, { platform: 'linux' })`. Only the platform was injected;
* the signal path stayed real, `isValidPid(1)` let it through, and
* `process.kill(-1, 'SIGTERM')` is the kill(2) broadcast — every process of the
* user. It ended every terminal and Claude session on the machine, and the Mac
* rebooted at 09:39. The comment above the call said "a pid that exists but
* cannot be signalled"; that was true of `kill(1)`, not of `kill(-1)`.
*
* Reviews did not catch it and a naming convention would not have either. This
* guard does: `process.kill` with a real signal throws unless its target (or the
* group it names) is a child this worker spawned. Probing with signal 0 stays
* allowed — it delivers nothing.
*
* Scope: signals sent from inside the Jest worker. A CLI subprocess a test
* spawns runs without this guard.
*/
import { ChildProcess } from 'child_process';

const SPAWNED = Symbol.for('lt-cli.signal-guard.spawned');

interface Registry {
[SPAWNED]?: Set<number>;
}

/**
* Why a signal must not be sent, or null when it may. Pure, so the guard's own
* test never has to send anything real to prove the decision.
*/
export function signalVerdict(pid: unknown, signal: unknown, spawned: ReadonlySet<number>): null | string {
if (signal === 0) return null;
if (typeof pid !== 'number' || !Number.isInteger(pid)) return `refused: pid ${String(pid)} is not an integer`;
if (pid === 0 || pid === -1) return `refused: pid ${pid} addresses every process of a group or of the user`;
if (!spawned.has(Math.abs(pid))) {
return `refused: ${pid < 0 ? 'process group' : 'pid'} ${Math.abs(pid)} was not spawned by this test worker`;
}
return null;
}

/** Pids of children spawned in this worker, shared across test files. */
function spawnedRegistry(): Set<number> {
const proto = ChildProcess.prototype as unknown as Registry & { spawn: (...a: unknown[]) => unknown };
if (!proto[SPAWNED]) {
const spawned = new Set<number>();
const original = proto.spawn;
// Every async child_process API (spawn, exec, execFile, fork) and cross-spawn
// ends in ChildProcess.prototype.spawn, so recording here sees all of them.
proto.spawn = function (this: ChildProcess, ...args: unknown[]) {
const result = original.apply(this, args);
if (typeof this.pid === 'number') spawned.add(this.pid);
return result;
};
proto[SPAWNED] = spawned;
}
return proto[SPAWNED];
}

const spawned = spawnedRegistry();
const realKill = process.kill.bind(process);
const violations: string[] = [];

process.kill = ((pid: number, signal?: number | string) => {
const verdict = signalVerdict(pid, signal ?? 'SIGTERM', spawned);
if (verdict) {
const message = `signal-guard: process.kill(${pid}, ${String(signal ?? 'SIGTERM')}) ${verdict}`;
violations.push(message);
throw new Error(message);
}
return realKill(pid, signal);
}) as typeof process.kill;

/** Drain recorded refusals — for the guard's own wiring test only. */
export function takeViolations(): string[] {
return violations.splice(0);
}

// Throwing alone is not enough: code under test that wraps `process.kill` in a
// try/catch (as every kill helper in `src/` does) would swallow the refusal and
// the test would pass. So a refused signal also fails the test it happened in.
afterEach(() => {
if (violations.length === 0) return;
const found = takeViolations();
throw new Error(`${found.length} refused signal(s) in this test:\n${found.join('\n')}`);
});
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@
"rootDir": "__tests__",
"testTimeout": 60000,
"workerIdleMemoryLimit": "512MB",
"setupFilesAfterEnv": [
"<rootDir>/support/signal-guard.ts"
],
"testMatch": [
"<rootDir>/*.test.ts"
],
Expand Down
53 changes: 48 additions & 5 deletions src/commands/dev/down.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,26 @@ import { GluegunCommand } from 'gluegun';
import { ExtendedGluegunToolbox } from '../../interfaces/extended-gluegun-toolbox';
import { reloadCaddy, removeProjectBlock } from '../../lib/caddy';
import { clearEnvBridge } from '../../lib/dev-env-bridge';
import { killProcessGroup } from '../../lib/dev-process';
import { killProcessGroup, planTermination } from '../../lib/dev-process';
import { resolveLayout } from '../../lib/dev-project';
import { clearSession, detectSlugConflict, isPidAlive, loadSession } from '../../lib/dev-state';
import { hasTestSession, tearDownTestSession } from '../../lib/dev-test-session';
import { resolveDevIdentity } from '../../lib/dev-ticket';
import { isWindows } from '../../lib/platform';

/**
* Stop the processes started by `lt dev up` and remove the project's
* Caddy block.
*
* - SIGTERM is sent to the detached process GROUP (negative PID) so
* children (Vite, Nest watcher) receive the signal too.
* - POSIX: SIGTERM to the detached process GROUP (negative PID), so children
* (Vite, Nest watcher) receive it too and can shut down gracefully. No
* escalation — `down` is the polite stop.
* - Windows: `taskkill /T /F`, i.e. FORCED, while `up`'s reclaim keeps the
* two-phase `terminateProcessGroup`. Not a choice: Windows has no gentle step
* (`/T` without `/F` was measured to leave the tree and its port alive), so
* shutdown hooks do not run there. Details in `killWindowsTree`.
* - Either way the pid is verified gone afterwards; a survivor is reported,
* never listed as stopped.
* - The Caddy block is removed and `caddy reload` is invoked, so the
* subdomain stops resolving immediately.
*/
Expand Down Expand Up @@ -44,8 +52,26 @@ const DownCommand: GluegunCommand = {
stopped.push(`${name} (pid ${pid}, already dead)`);
continue;
}
if (killProcessGroup(pid)) stopped.push(`${name} (pid ${pid})`);
else warning(`Failed to stop ${name} (pid ${pid})`);
// A pid the plan refuses (1, this CLI, a system pid — i.e. a corrupted
// state.json) is neither signalled nor offered as a copy-paste kill hint:
// `kill -9 -1` is the broadcast that rebooted a Mac on 2026-09-23.
const plan = planTermination(pid);
if (plan.kind === 'refuse') {
warning(`Not stopping ${name}: ${plan.reason} — .lt-dev/state.json looks corrupted.`);
continue;
}
killProcessGroup(pid);
// Verify rather than assume: `killProcessGroup` reports that the signal
// was delivered, not that the process went. A compiled API with shutdown
// hooks can sit on SIGTERM while it waits for Mongo; claiming "stopped"
// then sends the user into the next `lt dev up` with a port collision
// nobody can trace back.
if (await waitForExit(pid, 3000)) {
stopped.push(`${name} (pid ${pid})`);
} else {
warning(`${name} (pid ${pid}) did not stop — it may still hold its port.`);
info(colors.dim(` Check with \`lt dev status\`; force it with ${forceKillHint(pid)}`));
}
}
clearSession(layout.root);
} else {
Expand Down Expand Up @@ -93,3 +119,20 @@ const DownCommand: GluegunCommand = {
};

module.exports = DownCommand;

/** The command that actually ends a process tree on this platform. */
function forceKillHint(pid: number): string {
// `/F` is not optional on Windows: measured, `taskkill /PID <pid> /T` without it
// fails on the children and leaves the port bound.
return isWindows() ? `\`taskkill /PID ${pid} /T /F\`` : `\`kill -9 -${pid}\``;
}

/** Poll until `pid` is gone, or the budget runs out. */
async function waitForExit(pid: number, budgetMs: number): Promise<boolean> {
const deadline = Date.now() + budgetMs;
while (Date.now() < deadline) {
if (!isPidAlive(pid)) return true;
await new Promise((resolve) => setTimeout(resolve, 100));
}
return !isPidAlive(pid);
}
Loading
Loading