Repository navigation
fix(smoke): retry a port lost between pick and bind - #1589
Conversation
`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.
|
What failed: Why it is not mine:
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 The change's own proof is unaffected: |
|
Thanks for this, and for checking the red shard rather than assuming it. Agreed on that: 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 2. The failure message cannot tell a port collision from a broker that binds and never speaks. The collision run had three broker exits with One smaller point: the 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 On scope: |
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.
|
All three fixed, and the first one led somewhere I would not have looked. 1 — the give-up path leakedYou are right that the previous shape cleaned up on this failure and mine did not. Everything from A third leak turned up while I was measuring the first. Measured on the give-up path, temp dirs counted before and after: 2 — the message could not tell the two apartCorrect, 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 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 —
|
Panel verdict at
|
Ruling on a split panel at
|
Panel now 2 of 2 REQUEST_CHANGES at
|
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.
|
Both open items are addressed at 1. The signal registrationConfirmed 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.
Measured the same way, 3/3 each arm, against a stand-in broker that never binds so the run stays inside the window: The instrument needed the same two corrections yours did, and I hit both before reading your note as anything but a warning: 2. The mutants — one added, one cannot existThe accuracy defect is real: the message on
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. What I could not run
|
|
CI on The failing suite is What this commit touches: The failure shape is an external process killing fixture brokers: 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 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 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. |
|
Two independent reviews are in at VerdictsTwo 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:
The window is narrowed, not closed, and the diagnostic is what makes that safeReviewer B built a genuine collision rather than simulating one: a wrapper squats the port and then 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 Your re-run request: done, and it passed
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 knowingYou 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.
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 One instrument note, since you read the job log yourself and may hit this. The default Non-blocking: the body understates what shipsBoth 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 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 Not doneNeither 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. |
Fixes #1583
The window
pickFreePortcloses 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 promised45019, 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. SostartOnFreePortnotices 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 —
45019appears 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 aci-suites.dfragment.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.
isReachableconnects and speaks NATS; a squatter occupies the port and says nothing, which is the situation the shard was actually in.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.
presence-watch-stallagainst a realnats-server: 12 passed, 0 failed.tsc --noEmit -p packages/core: clean.