Skip to content

fix(dev): stop Windows stacks with taskkill /T /F, and never turn a stored pid into a broadcast (AP-5) - #118

Merged
DKoenig9 merged 1 commit into
mainfrom
feat/windows-dev-terminate
Sep 25, 2026
Merged

DKoenig9 merged 1 commit into
mainfrom
feat/windows-dev-terminate

Conversation

@DKoenig9

@DKoenig9 DKoenig9 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

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 became process.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 was npx jest --runInBand dev-process at 09:37:03 local time. kern.boottime shows 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.json that holds 1.
  • lt dev up's reclaimPort: lsof reports pid 1 as the owner of a port (launchd socket activation).

Nothing checked a pid for anything beyond > 0.

Windows (AP-5). down only ever sent SIGTERM to a negative pid. On Windows that throws EINVAL, so nothing stopped and the port stayed bound. Measured on the Windows laptop: taskkill /PID <pid> /T without /F fails on the children ("must be forcefully terminated") and the port stays bound. With /F the 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:
    • an invalid pid
    • pid ≤ 1 on POSIX: -1 is the broadcast, and 1 is launchd/init
    • pid ≤ 4 on Windows: 0 is Idle, 4 is System
    • this CLI and its parent
  • signalGroup is the one place in src/ that negates a pid. It only accepts a branded SignalTarget, which only the plan returns.
  • TerminateOptions can inject signal, run (taskkill) and isAlive, so both platform paths can be tested without a real signal.

Windows termination

  • killProcessGroup → taskkill /PID <pid> /T /F.
  • terminateProcessGroup has only one real step on Windows. If the process survives, it gets a second /T /F and the function honestly returns false.
  • The docblock on killWindowsTree covers what this costs:
    • there is no gentle step, so shutdown hooks do not run
    • /T follows a snapshot of the parent/child tree, not a process group, so a re-parented grandchild escapes it
    • it states what has been measured so far and what has not (see below)

lt dev down (src/commands/dev/down.ts)

  • The header documents the platform difference: on POSIX, down sends SIGTERM with no escalation. On Windows it forces the kill. up's reclaim keeps the two-phase ladder on POSIX.
  • After signalling, it waits up to 3 s until the pid is actually gone. A survivor is reported with a force hint and never listed as "stopped". This applies on every platform.
  • A pid the plan refuses is not signalled, and down prints no copy-paste hint for it. Without that check it would have printed kill -9 -1.

Test guard (__tests__/support/signal-guard.ts, registered as setupFilesAfterEnv)

  • Tests may send a real signal only to children their own worker spawned. It hooks ChildProcess.prototype.spawn, which every async child_process API and cross-spawn go through. Signal 0 (a probe) stays allowed.
  • If the code under test catches the refusal (every kill helper in src/ does), the guard still records it and fails the test in afterEach.
  • signal-guard.test.ts checks that the guard is registered and that src/ negates a pid in exactly one place.
  • Limit: a CLI subprocess that a test spawns runs without the guard.

Mutation check (each one was reverted from a backup)

Mutation Result
Plan allows pid 1 3 red
Plan allows self/parent 1 red
/F removed 1 red
No second taskkill on Windows 1 red
No SIGKILL escalation on POSIX 2 red
setupFilesAfterEnv entry removed 1 red
Guard allows pids it did not spawn 2 red
Probe test: killProcessGroup(4_000_000) without a spawned child (temporary file, removed) red: "2 refused signal(s) in this test"

Two probes first went green or red for the wrong reason, and I redid them:

  • The first afterEach probe used a pid above isValidPid's ceiling, so the plan refused it before any signal was sent.
  • The first registration mutation pointed at a missing file. Jest then failed on its config, not on the guard test.

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 lint and npm run build are clean.

Not measured yet: the real lt dev stack on Windows

The taskkill measurement used a synthetic node parent with two children. The claim that the whole stack hangs below cross-spawn's cmd.exe has not been measured yet. Run in PowerShell, inside a project and with an lt built from this branch:

lt dev up; "up exit=$LASTEXITCODE"
$s = Get-Content .lt-dev\state.json -Raw | ConvertFrom-Json
$e = (Get-Content "$HOME\.lenneTech\projects.json" -Raw | ConvertFrom-Json).projects.PSObject.Properties |
  Where-Object { $_.Value.path -eq (Get-Location).Path } | Select-Object -First 1
$ports = @($e.Value.internalPorts.api, $e.Value.internalPorts.app) | Where-Object { $_ }
$pids  = @($s.pids.api, $s.pids.app) | Where-Object { $_ }
"pids=$pids  ports=$ports"
function Bound($p) { [bool](Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue) }
# `lt dev up` can return while the API is still booting: wait up to 120 s for both ports
$deadline = (Get-Date).AddSeconds(120)
while ((Get-Date) -lt $deadline -and @($ports | Where-Object { -not (Bound $_) }).Count) { Start-Sleep 2 }
# BEFORE: all must be True, otherwise the "after" check below proves nothing
foreach ($p in $ports) { "before $p bound=$(Bound $p)" }
lt dev down; "down exit=$LASTEXITCODE"
Start-Sleep 2
foreach ($p in $ports) {
  $c = Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue
  "after  $p bound=$([bool]$c) owner=$($c.OwningProcess)"
}
"recorded pids still alive: $(@(Get-Process -Id $pids -ErrorAction SilentlyContinue).Id)"
Get-CimInstance Win32_Process -Filter "Name='node.exe'" |
  Where-Object { $_.CommandLine -like "*$((Get-Location).Path)*" } | Select-Object ProcessId, ParentProcessId, CommandLine

Expected result:

  • down exit=0
  • before … bound=True on every port
  • after … bound=False on every port
  • recorded pids still alive: stays empty
  • an empty process list at the end

If a port is still bound afterwards, owner= names the survivor. With Get-Process -Id <owner> and its ParentProcessId you can tell whether it is a re-parented grandchild that /T missed. That is exactly the gap the docblock describes. If ports= comes out empty, the registry path did not match. Please send the raw projects.json entry in that case. Note: Claude Code itself also runs as node.exe, which is why the list is filtered by project path.

🤖 Generated with Claude Code

…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>
@DKoenig9
DKoenig9 marked this pull request as ready for review September 25, 2026 10:02
@DKoenig9
DKoenig9 merged commit bc16747 into main Sep 25, 2026
2 checks passed
@DKoenig9
DKoenig9 deleted the feat/windows-dev-terminate branch September 25, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant