fix(dev): stop Windows stacks with taskkill /T /F, and never turn a stored pid into a broadcast (AP-5) - #118
Merged
Merged
Conversation
…tored pid into a broadcast (AP-5)
Windows: `killProcessGroup` ends the tree with `taskkill /PID <pid> /T /F`.
Measured on a Windows laptop: `/T` without `/F` fails on the children and
leaves the port bound, so there is no gentle step. `lt dev down` is therefore
forced there, while `up`'s reclaim keeps the two-phase ladder on POSIX. The
docblock says what that costs: no shutdown hooks, and `/T` walks a snapshot
of the tree, so a re-parented grandchild escapes it.
Broadcast: on 2026-09-23 at 09:37 an uncommitted test called
`killProcessGroup(1, { platform: 'linux' })`. Only the platform was
injected, so the real `process.kill(-1, 'SIGTERM')` ran. That is the kill(2)
broadcast to every process of the user, and the Mac rebooted at 09:39. The
same call is reachable in the product: `isValidPid` accepts 1, so a corrupted
`.lt-dev/state.json` (through `lt dev down`) or an lsof owner of 1 (through
`up`'s reclaim) would do it too.
- `planTermination` is the only gate between a number and a signal. It
refuses pid <= 1 (<= 4 on Windows), this CLI and its parent. The one
negative-pid send takes a `SignalTarget` that only the plan produces.
- `lt dev down` verifies the pid is gone before reporting "stopped". A
refused pid is neither signalled nor offered as a `kill -9 -1` hint.
- `__tests__/support/signal-guard.ts` (setupFilesAfterEnv): a test may
signal only children its worker spawned. A refusal that code under test
swallows still fails the test.
- Termination tests inject the signal, taskkill and liveness. None of them
sends a real signal.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Incident, 2026-09-23. An uncommitted AP-5 test called
killProcessGroup(1, { platform: 'linux', run })in__tests__/dev-process.test.ts. It injected only the platform, so the POSIX signal path ran for real.isValidPid(1)is true, so the call becameprocess.kill(-1, 'SIGTERM'). That is not a process group. It is the kill(2) broadcast, sent to every process of the user. The last command in that session's transcript wasnpx jest --runInBand dev-processat 09:37:03 local time.kern.boottimeshows the Mac rebooted at 09:39:26. The test was never committed. This PR deletes it.The same call can happen in the product:
lt dev down: a corrupted.lt-dev/state.jsonthat holds1.lt dev up'sreclaimPort:lsofreports pid 1 as the owner of a port (launchd socket activation).Nothing checked a pid for anything beyond
> 0.Windows (AP-5).
downonly ever sent SIGTERM to a negative pid. On Windows that throwsEINVAL, so nothing stopped and the port stayed bound. Measured on the Windows laptop:taskkill /PID <pid> /Twithout/Ffails on the children ("must be forcefully terminated") and the port stays bound. With/Fthe tree is gone and the port is free.What changed
Signal gate (
src/lib/dev-process.ts)planTermination(pid, platform, self)is pure and is the only path from a number to a signal. It refuses:-1is the broadcast, and1is launchd/init0is Idle,4is SystemsignalGroupis the one place insrc/that negates a pid. It only accepts a brandedSignalTarget, which only the plan returns.TerminateOptionscan injectsignal,run(taskkill) andisAlive, so both platform paths can be tested without a real signal.Windows termination
killProcessGroup→taskkill /PID <pid> /T /F.terminateProcessGrouphas only one real step on Windows. If the process survives, it gets a second/T /Fand the function honestly returnsfalse.killWindowsTreecovers what this costs:/Tfollows a snapshot of the parent/child tree, not a process group, so a re-parented grandchild escapes itlt dev down(src/commands/dev/down.ts)downsends SIGTERM with no escalation. On Windows it forces the kill.up's reclaim keeps the two-phase ladder on POSIX.downprints no copy-paste hint for it. Without that check it would have printedkill -9 -1.Test guard (
__tests__/support/signal-guard.ts, registered assetupFilesAfterEnv)ChildProcess.prototype.spawn, which every async child_process API and cross-spawn go through. Signal 0 (a probe) stays allowed.src/does), the guard still records it and fails the test inafterEach.signal-guard.test.tschecks that the guard is registered and thatsrc/negates a pid in exactly one place.Mutation check (each one was reverted from a backup)
/Fremovedtaskkillon WindowssetupFilesAfterEnventry removedkillProcessGroup(4_000_000)without a spawned child (temporary file, removed)Two probes first went green or red for the wrong reason, and I redid them:
afterEachprobe used a pid aboveisValidPid's ceiling, so the plan refused it before any signal was sent.Every probe target sits above any macOS pid ceiling, so if the guard were missing the call would end in a harmless
ESRCH.npm test: 79 suites, 1229 tests, 0 skipped, no refused signals.npm run lintandnpm run buildare clean.Not measured yet: the real
lt devstack on WindowsThe
taskkillmeasurement used a synthetic node parent with two children. The claim that the whole stack hangs below cross-spawn'scmd.exehas not been measured yet. Run in PowerShell, inside a project and with anltbuilt from this branch:Expected result:
down exit=0before … bound=Trueon every portafter … bound=Falseon every portrecorded pids still alive:stays emptyIf a port is still bound afterwards,
owner=names the survivor. WithGet-Process -Id <owner>and itsParentProcessIdyou can tell whether it is a re-parented grandchild that/Tmissed. That is exactly the gap the docblock describes. Ifports=comes out empty, the registry path did not match. Please send the rawprojects.jsonentry in that case. Note: Claude Code itself also runs asnode.exe, which is why the list is filtered by project path.🤖 Generated with Claude Code