fix(cli): keep a live preview's ownership record and stop past a bad one (#3308)

* fix(cli): keep a live preview's ownership record and stop past a bad one

A missed liveness probe is not proof the preview is gone — a server blocked on
a Puppeteer capture answers nothing for a second or two — but any miss retired
the session record, and the record carries the only PID-reuse guard `--stop`
has. Reproduced by SIGSTOPping a managed preview and running `--status`: the
record was deleted and never came back, leaving every later stop to fall
through to an unauthenticated port scan with no ownership proof at all. Only a
wrapper process that is provably gone now retires a record.

That record gains a process-birth token so a recycled PID reads as a different
process, and it is written through a temp file and renamed — every reader
deletes it when it fails to parse, so a torn read would otherwise destroy a
live server's proof of ownership.

Two failure-propagation bugs in the stop path: `--kill-all` collected the
first unprovable record's exception and abandoned every server after it, so
they were left running AND unreported; and a replacement refused to launch
when the server it was replacing had already exited on its own, which is the
goal state rather than a failure. `--list` now shows managed sessions ahead of
whatever else answers the scan.

* fix(cli): keep a record whose identity lookup gave no answer, not a different one

Review blocker. The keep-alive path this PR adds could still retire a LIVE
record — through a different door than the one it closed.

`processIdentity` catches every failure into `null`, and on two of three
platforms that failure is a subprocess timeout on a live process: the win32
`Win32_Process` CIM query and the POSIX `ps -o lstart=` both run on a 2 s
budget, under exactly the load that made the HTTP probe miss in the first
place. A `null` compared unequal to the saved token, so the record was deleted
and `wrapperIdentity` — the only PID-reuse guard `--stop` has — was gone for
good. Only Linux, reading /proc directly, was reliable.

No answer is now distinguished from a different answer: the PID is checked with
`kill(pid, 0)` first, which asks the kernel without signalling and treats EPERM
as alive. A PID nothing can signal is gone and retires the record with no
subprocess at all; a signalable PID whose token cannot be read keeps it. Only a
token that comes back and differs retires it.

That ordering also answers the `--list` note: the identity subprocess no longer
runs for the stale records that made it slow, so the N x 2 s worst case is gone
along with the timeouts that fed the bug.

Verified by mutation: restoring the old "no answer means gone" behaviour reds
the new case. Also clean up the temp file when a rename fails, rather than
orphaning it in the session directory.

* test(cli): assert only what the birth-token lookup actually guarantees

`captures a stable birth token for the current process` made two assertions
that a lookup allowed to fail cannot support. `processIdentity` returns null
whenever the lookup cannot be completed — not only when the process is absent —
and on Windows and macOS it shells out to PowerShell or `ps` on a 2 s budget
that a cold CI runner routinely outruns.

Both failed on windows-latest, in sequence: first `.toMatch()` received null,
and once that was guarded, `expect(second).toBe(first)` compared a null from the
cold first spawn against a token from the warm second one.

Two lookups can disagree for exactly one reason — one of them failed — so
stability is only assertable across two successful ones. The token itself
cannot change between calls; it is a birth timestamp and the process did not
restart. `processIdentity(-1)` stays unconditional: the guard rejects it before
any subprocess runs.

The strict shape assertion moves to a Linux-only case, where /proc is read
directly with no subprocess and null is genuinely not allowed — keeping the
guarantee on the one platform that can honour it rather than dropping it
everywhere. Callers already depend on this contract: `wrapperProcessIsAlive`
treats null as "no answer" rather than "gone" precisely because it is reachable.
This commit is contained in:
Miguel Ángel
2026-08-18 19:50:02 -04:00
committed by GitHub
parent b31dde35b1
commit 74149e249a
5 changed files with 949 additions and 78 deletions
+33 -2
View File
@@ -18,12 +18,43 @@ describe("Windows process-tree cleanup", () => {
describe("process-tree ownership", () => {
it("captures a stable birth token for the current process", () => {
// `processIdentity` is documented to return null when the lookup cannot be
// completed, not only when the process is absent — and on Windows and macOS
// it shells out to PowerShell / `ps` on a 2 s budget, which a cold CI runner
// routinely outruns. Asserting an unconditional token therefore tested the
// runner's spawn latency rather than the function: it failed on
// windows-latest with `.toMatch()` receiving null.
//
// What is actually promised: a well-formed token OR null, the same answer
// twice in a row, and null for a pid that cannot exist. Callers are built
// on exactly that contract — `wrapperProcessIsAlive` treats null as "no
// answer" rather than "gone" precisely because it is reachable here.
const first = processIdentity(process.pid);
expect(first).toMatch(/^(?:linux|posix|windows):/);
expect(processIdentity(process.pid)).toBe(first);
const second = processIdentity(process.pid);
// Stability is only assertable across two SUCCESSFUL lookups. Two calls can
// disagree here for one reason — one of them failed — and that is exactly
// what happens on a cold Windows runner: the first PowerShell spawn outruns
// the 2 s budget and returns null, the second is warm and returns a token.
// The token itself cannot change between them; it is a birth timestamp and
// the process did not restart.
if (first !== null && second !== null) {
expect(first).toMatch(/^(?:linux|posix|windows):/);
expect(second).toBe(first);
}
// This one holds everywhere: the guard rejects it before any subprocess.
expect(processIdentity(-1)).toBeNull();
});
it("reads a well-formed token where the lookup cannot fail", () => {
// Linux reads /proc directly with no subprocess, so there the token is not
// allowed to be null — this keeps the strict assertion on the one platform
// that can honour it, rather than dropping it everywhere.
if (process.platform !== "linux") return;
expect(processIdentity(process.pid)).toMatch(/^linux:\d+$/);
});
it("proves ancestry through every intermediate wrapper", () => {
const parents = new Map([
[400, 300],