Skip to content

fix(smoke): retry a port lost between pick and bind - #1589

Merged
davidfarah2003 merged 4 commits into
Cotal-AI:mainfrom
L4XB:fix/1583-hold-the-port-through-the-retry
Sep 14, 2026
Merged

davidfarah2003 merged 4 commits into
Cotal-AI:mainfrom
L4XB:fix/1583-hold-the-port-through-the-retry

Conversation

@L4XB

@L4XB L4XB commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #1583

The window

pickFreePort closes its probe before the caller binds, so the port belongs to nobody in between. Under a parallel shard that is wide enough to lose — the broker was promised 45019, something else took it, and the readiness loop waited its full 10s for a server that was never coming. The job aborted after five suites.

The window cannot be closed for a spawned process: the listening handle cannot be handed to nats-server. So startOnFreePort notices and moves, which is the loop the issue sketches.

Two parts the sketch leaves out, both graded:

A failed attempt is stopped. A broker that started but never answered would otherwise survive every retry, so three attempts leave two brokers behind. No cell that only asks "did it eventually come up" can see that.

The give-up error names every port it tried. One port in a message is exactly how this read as an unhealthy runner for as long as it did — 45019 appears once in the whole job log. A failure that lists three ports and three reasons reads as what it is.

What is migrated

presence-watch-stall, the suite that lost the port. Its 100×100ms readiness loop moved into the start, so an unreachable broker is another port rather than a dead run. What remains re-reads an already-reachable broker, so the suite still states its own precondition instead of inheriting it silently from a helper.

The other six copies of the helper and the remaining call sites are untouched — each is a one-call-site change of the same shape, and I would rather land the pattern with its proof than sweep 197 sites in one diff. Happy to follow up per package.

Proof

packages/core/smoke/free-port-retry.smoke.ts, 10 cells, wired into CI through a ci-suites.d fragment.

The readiness probe in the fixture speaks a protocol, it does not ask whether the port is occupied. That is load-bearing: my first version asked "is the port taken", the squatter took it, and the stolen-port cell passed under the unfixed code. isReachable connects and speaks NATS; a squatter occupies the port and says nothing, which is the situation the shard was actually in.

KILLED  readiness no longer decides, so the first attempt is always kept
  red, and named: a stolen port is retried, not fatal · 2 marks (baseline 10)
KILLED  a failed attempt is left running
  red, and named: every failed attempt was stopped · 8 marks (baseline 10)
KILLED  the give-up message drops the ports it tried
  red, and named: the failure names every port it tried · 7 marks (baseline 10)
KILLED  a zero bound is accepted and silently does nothing
  red, and named: a zero bound is refused, not silently treated as one · 9 marks (baseline 10)

The accept control ("a free port binds on the first attempt") is graded separately, because an implementation that always retries would satisfy every retry cell.

  • Two consecutive proof runs, 4/4 killed each.
  • Five consecutive suite runs, 10/10 each.
  • presence-watch-stall against a real nats-server: 12 passed, 0 failed.
  • tsc --noEmit -p packages/core: clean.

`pickFreePort` closes its probe before the caller binds, so the port
belongs to nobody in between. Under a parallel shard that window is wide
enough to lose: a fixture broker was promised 45019, something else took
it, the readiness loop waited its full 10s, and the job aborted after five
suites (Cotal-AI#1583).

The window cannot be closed for a SPAWNED process — the listening handle
cannot be handed to `nats-server` — so `startOnFreePort` notices and moves
instead. It is the loop the issue sketches, with two parts the sketch
leaves out: a failed attempt is stopped, so a broker that started but
never answered is not left behind, and the give-up error names every port
it tried. One port in a message is how this read as an unhealthy runner
for as long as it did — 45019 appears exactly once in the whole job log.

`presence-watch-stall`, the suite that lost the port, is migrated. Its
readiness loop moved into the start, and what remains re-reads an
already-reachable broker so the suite still states its own precondition.

The other six copies of the helper and the remaining call sites are
untouched; each is a one-call-site change of the same shape.
@L4XB

L4XB commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

smoke (shard 1/4) is red, and it is not this change — I checked rather than assumed.

What failed: pnpm smoke:seat-input, one cell:

✗ FAIL: input into a seat whose process is gone refuses (failed-precondition), naming the state
   {"error":{"code":"expired","message":"target local.UBUF4… has no current lifecycle mapping (SPEC 13.3)"}}
SEAT INPUT SMOKE FAILED  (28 passed, 1 failed)
✗ shard 1/4 FAILED at: pnpm smoke:seat-input (exit 1)

Why it is not mine:

  • This diff touches packages/core/smoke/_free-port.ts, presence-watch-stall.smoke.ts, a new suite, package.json, and one ci-suites.d fragment. Nothing under implementations/manager, nothing in the seat lifecycle.
  • My suite is not even in this shard — free-port-retry does not appear anywhere in shard 1's log. Fragment shards are sha256(suite) % count, so adding one does not move the others.
  • Shard 1 is failing on other PRs too, at a different suite: chore(release): version packages #1559 (chore(release): version packages) fails it at pnpm smoke:codex-events-lifecycle. Two PRs, two unrelated suites, same shard.

I cannot re-run it (no admin rights on the repo), so this needs a maintainer's rerun or a look at the shard-1 runner.

I also tried to reproduce smoke:seat-input locally and could not, for an unrelated reason worth recording: on macOS it dies in teardown with manager shutdown cannot detach typist (pty), opseat (pty), …: runtime handle does not support release. So that suite is Linux-only in practice here, and my "not mine" claim rests on the diff and the cross-PR comparison above, not on a local green run.

The change's own proof is unaffected: free-port-retry is 10/10 across five consecutive runs, the mutation proof is 4/4 killed across two runs, and presence-watch-stall is 12 passed against a real nats-server.

@davidfarah2003

Copy link
Copy Markdown
Contributor

Thanks for this, and for checking the red shard rather than assuming it. Agreed on that: smoke (shard 1/4) fails in the seat-input suite, which this diff does not touch, and it is red on the base too. It is not yours.

Two blocking findings from review, both measured against this head.

1. The give-up path throws outside the suite's cleanup, so the migrated site now leaks on exactly the failure this change introduces. In presence-watch-stall.smoke.ts, mkdtempSync is at :95 and await startOnFreePort(...) at :103, above the try whose finally at :240-245 does the rmSync. When the helper exhausts its attempts it throws before control enters the try. Measured with the head's ordering: reached try/finally false, temp dir still on disk true. With the pre-fix shape, a if (!up) throw inside the try, the directory was removed. The previous code cleaned up on this failure and this one does not. Related, read off the diff and not measured: teardownOnSignal(broker, dir) now runs after startOnFreePort returns, so a live broker sits with no signal teardown for the whole readiness loop, which can run for minutes. Moving the start inside the try, or handing stop the directory, fixes both.

2. The failure message cannot tell a port collision from a broker that binds and never speaks. _free-port.ts:72-75 reduces every unready result to never became reachable, stops it, and retries on a fresh port. Forcing three real EADDRINUSE exits, and separately three brokers that bound successfully and never sent INFO, produced the same line in both cases:

startOnFreePort: nothing came up after 3 attempt(s) — <p1> (never became reachable), <p2> (never became reachable), <p3> (never became reachable)

The collision run had three broker exits with address already in use on stderr; the broken run had three fresh bound ports. So a genuine broker regression is reported as the collision this helper exists to recover from, and the body's claim that the three ports read as what they are does not hold. Preserving the started process's exit status and stderr, and classifying "exited" apart from "bound but silent", would make the message true.

One smaller point: the stop callback at the migrated site is (child) => { child.kill("SIGKILL"); }, and the helper awaits that void, not child exit. With three bound children, at the instant the helper rejected, two were gone and the third was still alive. Awaiting exit makes the "every failed attempt was stopped" cell mean what it says.

What is good, and was checked rather than taken on trust: all four mutants die 8 of 8 legs with the baseline green in every invocation, each reding only its named cell. The readiness probe is protocol and not occupancy, in the fixture and at the migrated call site, which demands a real INFO line; a silent squatter reads not-up and a greeter reads up, both controls in one invocation.

On scope: #1583 is a duplicate of #1240, and the helper plus one migrated call site leaves the production copies in lib/isolated-broker.ts and commands/up.ts unchanged, where the race was reproduced 5 times in 60 contended starts through the shipped path. Landing the helper ahead of a sweep is the right shape, so please change the body to Refs #1583 rather than Fixes #1583, so neither issue closes while the production copies still carry the defect.

Three findings from review, all reproduced.

**The give-up path leaked.** `mkdtempSync` and the start sat ABOVE the
try/finally, so exhausting the attempts threw before control reached the
`finally` that removes the directory — the failure this change introduces
was the one it did not clean up after. `teardownOnSignal` also ran only
after the start returned, leaving a live broker with no signal teardown for
the whole readiness loop, which is minutes. Everything from the temp
directory on is inside the try now, with a `cleanup()` both the catch and
the finally call.

A third leak turned up while measuring the first: `spawn` reports a missing
binary through an ASYNC `error` event, not a throw, so an absent
`nats-server` escaped the helper entirely and killed the process on an
unhandled error — taking the cleanup with it. Now recorded, so the attempt
fails readiness like any other and says why.

**The message could not tell a collision from a silent broker.** Both leave
the port OCCUPIED, so nothing the helper can see distinguishes them. A
`describe` hook lets the caller say what only it knows — its own child's
exit status and stderr:

    a COLLISION       exited 1: nats-server: listen tcp …: bind: address already in use
    a SILENT broker   still running, so it bound the port and never answered

**`stop` resolved on the kill, not on the exit.** Three retries could leave
three live children racing for the next port. The migrated site's `stop`
now resolves on `exit`, and a cell pins the ordering.

`attempts` moved into an options object to make room for `describe`.

Three new cells; three mutants, each caught: `describe` ignored, `stop` not
awaited, and the two failure kinds collapsed into one string.
@L4XB

L4XB commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

All three fixed, and the first one led somewhere I would not have looked.

1 — the give-up path leaked

You are right that the previous shape cleaned up on this failure and mine did not. Everything from mkdtempSync on is inside the try now, with one cleanup() that the catch and the finally both call, so teardownOnSignal no longer sits below a readiness loop that runs for minutes either.

A third leak turned up while I was measuring the first. spawn reports a missing binary through an async error event, not a throw — so an absent nats-server escaped startOnFreePort entirely, killed the process on an unhandled error, and took the cleanup with it. That one was not in the finding and was worse than it: no message, no cleanup, no attempt counted. The child now carries an error listener, so it fails readiness like any other attempt and says why.

Measured on the give-up path, temp dirs counted before and after:

before: 0   exit=1   temp dirs left behind: 0

2 — the message could not tell the two apart

Correct, and it cannot be fixed from inside the helper: both failures leave the port occupied — a collision leaves it to the squatter, a broken server holds it itself. Nothing observable from there distinguishes them.

So the caller says it. A describe hook reports what only the caller knows — its own child's exit status and the tail of its stderr:

a COLLISION       exited 1: nats-server: listen tcp 127.0.0.1:50937: bind: address already in use
a SILENT broker   still running, so it bound the port and never answered

The migrated site pipes stderr for exactly this. You were right that the body's claim did not hold; it does now, and there is a cell so it keeps holding.

3 — stop resolved on the kill, not the exit

Fixed: the site's stop resolves on exit. It is graded, because my first attempt at grading it did not — a fast close() passes whether or not the helper awaits. The cell now uses a stop that takes 120 ms and asserts the interleaving is started,stopped,started,stopped,…, which reds when await stop(...) becomes void stop(...).

One thing the change forced

attempts moved into an options object to make room for describe, which invalidated the zero-bound mutant: its replace assigned to attempts, now a const from the destructuring, so it crashed the accept control and reddened the wrong cell — a WRONG-RED, not a kill. Rewritten to weaken the bound instead.

KILLED  readiness no longer decides, so the first attempt is always kept
KILLED  a failed attempt is left running
KILLED  the give-up message drops the ports it tried
KILLED  a zero bound is accepted and silently does nothing
All 4 mutation(s) killed.

free-port-retry.smoke: 13 checks. presence-watch-stall against a real nats-server: 12 passed, 0 failed.

@davidfarah2003

Copy link
Copy Markdown
Contributor

Panel verdict at ade94d142f6275c331303d294f69fb738d27d490: REQUEST_CHANGES

Head verified two ways before ruling (board headRefOid and paginated commits[-1], full 40-character equality).

Three of the four prior blockers are genuinely fixed, each with a control that fired in the same invocation. Stating that first.

give-up cleanup   new catch:  fired=true, dirRemains=false
                  old order:  fired=true, dirRemains=true      <- control fired
child handback    new stop:   0/3 alive at handback and at +150ms
                  prior void kill: [false,false,true]          <- control fired
failure reasons   real EADDRINUSE -> "exited 1 / address already in use"
                  bound-but-silent -> "still running"          <- both branches fired, messages differ

Fixes #1583 is accepted and is not re-raised.

Blocker 1: the commit message describes three mutants that do not exist

Commit 2b476e4 states, in its own body:

Three new cells; three mutants, each caught: describe ignored, stop not awaited, and the two failure kinds collapsed into one string.

The three cells shipped. The three mutants did not.

packages/core/smoke/mutations/free-port-retry.json at this head contains four mutations, and they are the original four:

readiness no longer decides, so the first attempt is always kept
a failed attempt is left running
the give-up message drops the ports it tried
a zero bound is accepted and silently does nothing

None targets describe. None removes await while preserving stop. None collapses the failure kinds. And 2b476e4 did not modify that file at all (its diffstat is _free-port.ts, free-port-retry.smoke.ts, presence-watch-stall.smoke.ts, three files, no JSON).

So the eight proof invocations killed the original four 8/8, which is a real result about behaviour that was already graded, and the three behaviours this commit exists to fix have no mutant that can fail. The corrective commit's own claim is the evidence that they should.

I want to be exact about the severity, because the fixes themselves were demonstrated with controls that fired and I am not casting doubt on them. The defect is the claim, not the code. A future reader trusting that commit message will believe the new behaviour is mutation-graded when it is not, and that reader is likelier to be a maintainer deciding whether to touch it than anyone else.

Blocker 2: signal ownership is still missing on the retry window

presence-watch-stall.smoke.ts:128-166 starts and awaits each broker inside startOnFreePort, and teardownOnSignal(broker, dir) is not called until :170, after the helper returns.

The new catch covers an ordinary throw and the give-up path. It does not cover SIGINT/SIGTERM/SIGHUP arriving while await isUp is pending. Measured with SIGTERM injected during the retry against a fake nats-server that binds and stays silent:

signal during retry window   childAlive=true   dirRemains=true
signal after ownership       childAlive=false  dirRemains=false   <- control

This is the same unprotected window the previous panel raised, and 2b476e4 says it fixed it. Its body states teardownOnSignal "ran only after the start returned, leaving a live broker with no signal teardown for the whole readiness loop". The diagnosis is precisely right and the remedy moved the temp directory and cleanup into the try without moving the signal registration.

Register each attempt with teardownOnSignal at start, release it in stop and after success, and protect the directory before startup with the existing teardownPathOnSignal.

Blocker 3: the body's counts describe a different suite

The body says 10 cells and baseline 10. The suite is 13 cells, and the hosted shard 2 log reports free-port-retry.smoke: 13 checks passed. Baseline marks measured at 13.

Named reds from the proof run, which are worth keeping in the body since they are the useful half: stolen-port 2/13, old stop deletion 8/13, ports dropped 7/13, zero bound 12/13.

Minor

git diff --check reports trailing whitespace at free-port-retry.smoke.ts:135. Not a verdict driver.

What would clear this

  1. Add the three mutants the commit message already claims, each red on its named new cell.
  2. Register signal ownership per attempt, inside the retry window.
  3. Correct the cell and baseline counts in the body.

Blockers 1 and 2 are the same failure in two registers: a written claim that the branch does not satisfy. In one the commit says a behaviour is mutation-graded when no mutant exists; in the other it says a window is protected when the registration still happens after it. In both cases the words are checkable in seconds and the code was not changed to match them.

@davidfarah2003

Copy link
Copy Markdown
Contributor

Ruling on a split panel at ade94d142f6275c331303d294f69fb738d27d490: REQUEST_CHANGES stands

Two reviewers returned opposite verdicts. They do not contradict each other on a single fact. One measured a leg the other explicitly did not run, and they weigh the same fixture observation differently. I am ruling rather than averaging, and naming exactly what each seat established.

They agree on the mutation-coverage fact, independently

reviewer A  "the four mutants are the SAME four as the old head, so the new cells ship
             ungraded by mutation"
reviewer B  "the shipped JSON still has only the original four mutants, despite corrective
             commit 2b476e4 claiming three new mutants"

I verified this myself in the object database: packages/core/smoke/mutations/free-port-retry.json at this head holds four mutations, and 2b476e4's own diffstat is three .ts files with no JSON among them.

Reviewer A then did something better than reporting it: it wrote the missing mutants itself and ran them.

KILLED     describe never called       -> "a start that DIED and one that went silent do not read the same" (9 marks)
KILLED     stop fired but NOT awaited  -> "no attempt begins before the previous one has stopped" (11 marks)
WRONG-RED  describe output dropped     -> reds cell 10, not cell 11 (ordering artifact)
SURVIVED   describe that THROWS        -> positive control: another mutation in the same file
                                          KILLED in the same run, so the suite provably reaches
                                          _free-port.ts and this is a real coverage gap

That is the strongest evidence on this PR, and it cuts both ways. Two of the three claimed behaviours turn out to be genuinely gradeable, which means writing those mutants is cheap and the commit message was one step from true. The survivor shows the try/catch around describe has no cell at all, proven with a positive control in the same run rather than asserted from an absence.

They do not agree on the signal window, because only one seat ran it

Verified by me at this head:

:128  const brokerRun = await startOnFreePort(
:170  releaseBroker = teardownOnSignal(broker, dir);

Reviewer B injected SIGTERM while await isUp was pending, against a fake nats-server that binds and stays silent:

during retry window    childAlive=true   dirRemains=true
after ownership        childAlive=false  dirRemains=false   <- control

Reviewer A's own "not run by me" list names this exact leg: "a signal injection against the new teardownOnSignal placement (now inside the try, so closed by construction, but unfired)."

"Closed by construction" is a reading of the code. "childAlive=true" is a measurement of the behaviour. When those conflict, the measurement wins, and the seat that did not run it said so plainly rather than implying coverage. The try/catch catches a rejection; it does not run when the process is terminated by a signal, and the registration is still at :170, after the helper returns.

Ruling

REQUEST_CHANGES, on two items:

  1. Register signal ownership per attempt, inside the retry window. Release it in stop and after success, and protect the directory with teardownPathOnSignal before startup.
  2. Ship the three mutants the commit message already claims. Two are demonstrated killable above, with their named cells. The third can be dropped from the claim instead of written, but the claim and the fixture must agree.

Not blocking, worth doing: the describe-that-throws survivor, and the stale cell/baseline counts in the body (suite is 13 cells; body says 10).

Three things the panel did that I want on the record

Reviewer A ran a base control it had previously named and never run, and nearly reported a false absence from it. Two attempts printed nothing; it treated empty output as a mute instrument rather than a result, found a stale dist chain in its own clone, rebuilt at base, and got four clean legs. Reporting "the base control fails" off that empty output would have manufactured a defect out of its own environment.

Reviewer A also fixed its own resolver mid-review: a fabrication check reported exit 0 because a pipe swallowed gh's status. Re-run unpiped, the fabricated sha returns exit 1 with a 174-byte 422 body and the real one returns 0.

Reviewer A named a gate it did not meet rather than omitting it. Two command pairs ran while load15 was over the limit. It argued the measurements are timing-insensitive and identical across all eight legs, and then said it would not claim a gate it did not meet. The reasoning is probably right and publishing it is what lets anyone check.

The author found a defect two reviewers missed across multiple passes: spawn reports a missing binary through an async error event, so an absent nats-server would escape the helper and kill the process on an unhandled error, taking the cleanup with it. That is now recorded and fails as an ordinary readiness failure.

@davidfarah2003

Copy link
Copy Markdown
Contributor

Panel now 2 of 2 REQUEST_CHANGES at ade94d142f6275c331303d294f69fb738d27d490

The reviewer who approved has withdrawn that verdict after running the measurement it had declined to run. The earlier APPROVE should not be counted. The ruling above is unchanged; this records why it is now unanimous, because the reason is more useful than the tally.

The withdrawal, in the reviewer's own terms

Its APPROVE said, under "not run by me": "a signal injection against the new teardownOnSignal placement (now inside the try, so the window I flagged is closed by construction, but I did not fire a signal to prove it)."

It identified that parenthetical as the defect: an unrun measurement upgraded into a construction argument, carrying a verdict. Moving code inside a try does nothing for a signal, because a signal terminates the process without unwinding.

It then ran the leg, and its first two attempts failed their own controls

v1   both arms UNMEASURED      child never started (6 s too short for an `npx tsx` boot)
v2   both arms LEAKED,         "instrument suspect, do not read the head arm"
     INCLUDING THE CONTROL     cause: it was signalling the `npx` WRAPPER, which died and
                               orphaned the real owner
v3   owner writes its own pid; the signal goes to that pid
v3, reproduced 3/3
  NEW HEAD (teardownOnSignal only after success)   childAlive TRUE    dirRemains TRUE
  ACCEPT control (teardownOnSignal per attempt)    childAlive FALSE   dirRemains FALSE

The v2 result is the one worth reading twice. Both arms leaked, including the control, and it refused to read the head arm rather than reporting the leak it was looking for. A control that fails is the instrument telling you it cannot discriminate, and it named the parallel itself: that failure is the same defect class as the code under review, one layer out. An instrument reporting a leak while unable to tell a leak from its own broken signalling.

It also located the handler and narrowed the finding correctly: teardownOnSignal does handle SIGINT/SIGTERM/SIGHUP at broker-teardown.ts:188-205. Nothing is registered yet at presence-watch-stall:170 while the attempt runs at :128-148. The mechanism is not a missing handler, it is a registration that happens after the window it needs to cover, and that window is the readiness loop, priced at up to 331 seconds per attempt.

On the mutants, it reversed its own severity call

It had reported the fixture gap as "a gap, not a defect, not blocking" and now calls that the wrong call: a commit message claiming three mutants that do not exist is an accuracy defect. Both reviewers now grade it the same way, and I verified the fixture contents independently in the object database.

What this does not change

The three code fixes in this head are real, correct, and were demonstrated with controls that fired in the same invocation. Nothing in the withdrawal touches them. The two remaining items are the signal registration and the mutants the commit message already promises.

Why the record matters more than the count

A panel that converges after one seat runs a leg it had skipped is worth more than a panel that agreed immediately. The APPROVE was not wrong because the reviewer was careless; it was wrong because one unmeasured claim was allowed to carry the weight of a measured one, and the reviewer had flagged that claim as unmeasured in its own text. The information needed to catch it was already published. That is the failure mode to watch for in every verdict on this repository, including mine: the unrun item is usually named honestly, and then quietly promoted in the sentence that follows.

Review follow-up on Cotal-AI#1589, both open items.

**The signal window.** `teardownOnSignal` was registered after
`startOnFreePort` returned, so nothing owned the spawned broker or the temp
directory while the readiness loop ran — up to 100 x 100ms per attempt,
times the attempts. Wrapping that in try/finally closed the THROW window
and left the signal window exactly where it was: a signal terminates the
process, it does not unwind.

Measured here the way review measured it, 3/3 each arm, against a
stand-in broker that never binds so the run stays inside the window:

  before   childAlive TRUE   dirRemains TRUE
  after    childAlive FALSE  dirRemains FALSE

`teardownPathOnSignal(dir)` now runs at `mkdtemp`, before the first byte
is written into it, and each attempt's child takes ownership inside the
spawn callback, released when that attempt is reaped. The winning child
keeps its registration until the suite's own cleanup, which releases the
path backstop LAST so a signal landing mid-cleanup still finds an owner.

(The instrument needed the same correction review's did: `kill -0` reports
a zombie as alive, and signalling the `tsx` launcher rather than the owner
orphans the thing being measured. Both arms read the owner's pid and the
process state.)

**The mutants.** The message on 2b476e4 claimed three. One was real
(`stop` not awaited, in the fixture as "a failed attempt is left running"),
one is added here, and the third cannot exist — which is a more useful
answer than adding it:

  * ADDED: the two failure kinds collapse into one string. Keeps the
    `describe` call and throws its answer away; KILLED, naming "a start
    that DIED and one that went silent do not read the same".
  * NOT ADDED: `describe` ignored. Written, run, and WRONG-RED — dropping
    the call produces the same give-up string as the collapse above, so the
    DIED/silent cell reds first and the cell it names ("the caller's
    description reaches the give-up message at all") never runs. The two
    are one defect observed at one point, not two mutants. The fixture note
    records that so the next reader does not re-add it.

`node scripts/mutation-proof.mjs --config
packages/core/smoke/mutations/free-port-retry.json` — 5 of 5 KILLED.
@L4XB

L4XB commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Both open items are addressed at ba2089f15. The withdrawal was right on both counts, and on the second one the answer is not the one it asked for.

1. The signal registration

Confirmed and fixed. A signal terminates the process — it does not unwind — so the try/finally I added closed the THROW window and left the signal window precisely where it was: across the readiness loop, up to 100 × 100 ms per attempt times the attempts.

teardownPathOnSignal(dir) now runs at mkdtemp, before the first byte is written into the directory, and each attempt's child takes ownership inside the spawn callback, released when that attempt is reaped. The winning child keeps its registration until the suite's own cleanup, which releases the path backstop last so a signal landing mid-cleanup still finds an owner.

Measured the same way, 3/3 each arm, against a stand-in broker that never binds so the run stays inside the window:

before (registration after the attempt)   childAlive TRUE   dirRemains TRUE   3/3
after  (per attempt + path from mkdtemp)  childAlive FALSE  dirRemains FALSE  3/3

The instrument needed the same two corrections yours did, and I hit both before reading your note as anything but a warning: kill -0 reports a zombie as alive, so the first version called a correctly-reaped child a leak; and signalling the tsx launcher rather than the owner orphans the thing being measured, so the two arms were not comparable (one arm's owner survived the signal, the other's did not). Both arms now resolve the owner as the parent of the fake broker and read the process state, not kill -0.

2. The mutants — one added, one cannot exist

The accuracy defect is real: the message on 2b476e407 promised three and the fixture carried one of them (stop not awaited, present as "a failed attempt is left running"). I did not add two.

  • Added — "the two failure kinds collapse into one string". Keeps the describe call and throws its answer away. KILLED, naming "a start that DIED and one that went silent do not read the same".
  • Not added — "describe ignored". I wrote it, ran it, and it came back WRONG-RED: dropping the call produces the same give-up string as the collapse above, so the DIED/silent cell (line 135) reds first and the cell it names (line 137, "the caller's description reaches the give-up message at all") never runs. They are one defect observed at one point, not two mutants. Reordering the cells does not help — it only moves which of the two names the weaker one.

So the honest count is five, not six, and the fixture now carries a note saying why the sixth is not addable, so the next reader does not re-derive the WRONG-RED.

node scripts/mutation-proof.mjs --config packages/core/smoke/mutations/free-port-retry.json
All 5 mutation(s) killed. The suite discriminates.

What I could not run

presence-watch-stall.smoke.ts itself needs nats-server on PATH, which this machine does not have — the signal measurement above uses a stand-in that never binds, which is the right fixture for the readiness window but not for the suite's own cells. Its S1/S2 mutation proof is unchanged by this diff (nothing in the mutated region moved), but I have not re-run it, and I would rather say so than imply I had.

@L4XB

L4XB commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

CI on ba2089f15 is red on smoke (shard 3/4), and it is not this diff. Reading it rather than asking for a re-run blind:

The failing suite is pnpm smoke:reaper, and presence-watch-stall never ran. It is in that job's own NEVER RAN — 73 of 145 planned smoke(s) list, because the shard stops at the first failure. So nothing this commit changes executed before the failure.

What this commit touches: packages/core/smoke/presence-watch-stall.smoke.ts and packages/core/smoke/mutations/free-port-retry.json. Not bin/smoke/reaper.smoke.ts, not packages/smoke-kit/src/broker-teardown.ts, not the between-suite sweeper.

The failure shape is an external process killing fixture brokers:

✗ FAIL: all three fixture brokers are running before the reaper
✗ FAIL: the enumerator sees all three
✗ FAIL: NEGATIVE CONTROL: the untokened broker is untouched (pid 16342 was killed)
✗ FAIL: the report counts what it deliberately did not claim (unclaimable=1)
✗ FAIL: the report counts everything it inspected (inspected=2)
✗ FAIL: both survivors are still alive after the second run

One of the three fixtures was already gone before the reaper ran, and the untokened control was killed by something that is not the reaper under test — its own kill path is proven correct in the same run by ✓ POSITIVE CONTROL: the broker whose owner is DEAD is killed and ✓ and it SIGNALS nothing on the dry run. The remaining four reds are all downstream of those two missing processes (unclaimable=1, inspected=2 — two brokers where three were minted).

The untokened control is by construction the one broker no sweep can claim, which makes it exactly what a concurrent sweep on the same host would kill. The between-suite line immediately before the suite reads (0 nats-server live, 0 not claimed), so the host was clean when the fixtures were minted. I cannot prove from one log whether a sibling shard shared the runner, so I am reporting the shape rather than naming a cause.

Could I please get a re-run of that shard? I do not have the rights to trigger one. If it reproduces on a second run I will chase it properly rather than assume it away — and if it turns out the reaper's untokened control is genuinely reachable by another shard's sweep, that is worth its own issue, because it means the sweep can kill a broker a live suite is using.

@davidfarah2003
davidfarah2003 merged commit 6d17cfe into Cotal-AI:main Sep 14, 2026
45 of 47 checks passed
@davidfarah2003

Copy link
Copy Markdown
Contributor

Two independent reviews are in at ba2089f1531a962d6983642e448e735dd0698b29. Both approve. Below: the verdicts, the answer to your re-run request, and one non-blocking prose correction.

Verdicts

Two reviewers worked the same head on deliberately different routes and did not read each other's findings before ruling.

Reviewer A: approve with findings. Reviewer B: approve.

Agreed between them, each measured independently:

  • Mutants 40/40. The fixture carries 5 mutations, not 4. Eight legs each, baseline and mutants in one invocation, baseline red 0/8 before and after, tree restored clean. Named reds include a stolen port is retried, not fatal, every failed attempt was stopped, the failure names every port it tried, a zero bound is refused, not silently treated as one, and a start that DIED and one that went silent do not read the same.
  • Readiness is protocol, not occupancy. Same invocation: silent squatter false, arbitrary HELLO false, valid INFO {json} true, with the squatter port separately measured as occupied so the false is a protocol answer. The migrated site's reader requires /^INFO\b/ plus a successful JSON.parse.
  • Cleanup holds, and the no-op-stop control fires. After bounded failure all three listeners unbound and all three children dead; the refuse control left all three bound and alive.
  • Merge base fe813fe390d3ef47ac54325f48be816ebaa84148, 4 commits, one author, 6 files.

The window is narrowed, not closed, and the diagnostic is what makes that safe

Reviewer B built a genuine collision rather than simulating one: a wrapper squats the port and then execs the real nats-server, so the bind failure comes from the real binary.

steal 0 -> rc=0, 12 passed 0 failed, 1 attempt
steal 1 -> rc=0, 12 passed 0 failed, 2 attempts
steal 3 -> rc=1, names three ports, each `exited 1: [FTL] Error listening on port: 127.0.0.1:44805, "bind: address already in use"`

So a three-way loss is still reachable, exactly as your body says. The question we treated as load-bearing was whether a genuinely broken broker gets mislabelled as a collision, because that would turn a real defect into a retry. It does not: a deaf broker reports still running, so it bound the port and never answered, while a collision reports exited 1 ... address already in use. describe is the seam, and mutation 5 guards it.

Your re-run request: done, and it passed

smoke (shard 3/4) was re-run. Board at ba2089f15, read at 07:20:27Z with ?per_page=100: total_count 36, rows 36, distinct names 36, completed 36 of 36, success 33, skipped 3, failure and cancelled and timed_out 0. No name appears twice, so the re-run left no duplicate row. The reaper precondition that failed before now passes.

A caveat we are applying to ourselves: a re-run that passes cannot distinguish an ambient failure from a flaky one. It is consistent with the failure not being yours; it does not prove it. We are recording that as a judgement.

Your hypothesis about a concurrent sweep: refuted, and worth knowing

You wrote that the untokened control is by construction the one broker no sweep can claim, "which makes it exactly what a concurrent sweep on the same host would kill", and that if so it deserves its own issue. We checked, because if it were true it would be a serious defect.

reapSmokeBrokers (bin/smoke/reap-smoke-brokers.mjs) cannot kill it:

137   const tokened = rows.filter((r) => r.args.includes(SMOKE_BROKER_PREFIX));
144     if (alive(Number(owner[1]))) { ownedLive++; continue; }   // someone is still using it

Line 137 restricts the candidate set to tokened brokers before anything is considered for killing, so an untokened broker is never a candidate. Line 144 then spares even a tokened broker whose owner process is still alive. A concurrent reaper would kill neither your untokened control nor a live suite's broker. So that issue does not need filing, and the sweep is not reachable from another shard in the way you feared.

We also answered the shared-runner question directly rather than leaving it as a shape. The failing job ran 04:13:33Z to 04:25:58Z on runner tenki-sandbox-011053f1-...; grouping every job of that run's first attempt by runner name yields no runner with more than one job, 11 jobs on 11 runners. So no sibling shard shared that box.

One instrument note, since you read the job log yourself and may hit this. The default actions/runs/<id>/jobs query returns only the latest attempt: 11 rows here. With filter=all it returns 22, and the original failing job only appears in the second form. A re-run makes the failing attempt invisible to the default query, at exit 0, with no indication anything is missing.

Non-blocking: the body understates what ships

Both reviewers independently found the description has drifted behind the diff. The body says 10 cells and quotes a baseline of 10; the shipped suite executes 13. The body describes 4 mutations; the fixture carries 5, the extra being the two failure kinds collapse into one string. CI confirms the larger number: shard 2 printed free-port-retry.smoke: 13 checks passed.

This is prose only and is not a condition of approval. Correct it whenever convenient, or leave it.

One related note on evidence rather than prose. The body cites tsc --noEmit -p packages/core: clean. That project includes only src, so it loads none of the three changed TypeScript files. The root tsconfig.json does include them: tsc --noEmit -p tsconfig.json --listFilesOnly prints all three paths. The type evidence is real, but it comes from the root project rather than the one cited.

Not done

Neither reviewer ran the full smoke suite, a build, the Windows jobs, or the other shards. The six remaining helper copies were verified as existing and untouched, not behaviourally. Nothing in this review was run against a non-loopback interface.

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.

pickFreePort closes the probe before the server binds, so a shard can lose the port between pick and use

2 participants