fix(machine): the hot attach takes turns too, and a transient port-group conflict stops disarming the firewall capability - #578
Merged
Conversation
…o a peering acceptance no longer kills the NIC a pack is attaching The fourth and fifth doors of the family #577 closed on three. Attach — the hot half of a membership, the call the packs' Join drives — adds a NIC to a machine that is already running, so the add plugs an OVN port and the daemon resolves the network's ACL references inside it, with no lock shared with its own ACL paths. An isolation detach landing inside (a peering acceptance empties the foreign list mid-apply, IsolateNetwork unsets and deletes the iso-fnt-* set) kills the add. The per-machine attach lock Attach already holds orders nothing here: the detach never takes it. Measured raw on the station before anything was written (2026-08-28, Incus 7.2, OVN), because the same shape had never been observed failing and two audit findings on this repository have already been reversed by measurement: - `config device add <c> eth1 nic network=<net>` on a running container, with the detach (unset + delete, IsolateNetwork's own order) fired 25-200 ms into the ~400 ms add: 11 adds of 14 killed with exactly `Cannot find security ACL ID for "iso-…"`; at 0 ms and at 250 ms or later, clean; - the move branch, `config device set … ipv4.address=…`: ipv4.address is not a key an OVN NIC updates in place, so the daemon removes and re-adds the device (~10 s on this station) and re-plugs its port — 6 moves of 8 killed the same way with the detach fired 50 ms or later in. So the window exists on both mutating branches, and the fix is the family's: each branch holds the network's lock around its one runtime call, the detach already holds the same lock, and the two take turns. Lock order stays one-way — attach-lock then network-lock, nowhere the reverse — so the multi-lock rule of peerLock is untouched. One consequence is accepted and written down: two adds on one network now take turns, the same bounded plug-length wait #577 accepted for two starts on one network. TestTwoMachinesAttachWithoutQueueing now drives two networks, and still refuses the defect it was measured against (#348, one lock every machine pays): across networks nothing queues. Falsified: tools/falsify/specs/attach-vs-isolation-detach.json, two mutations, each compiling, each caught (TestAHotAttachAndAnIsolationDetachTakeTurns, TestAnAddressMoveAndAnIsolationDetachTakeTurns). Attach's locals are named unlock rather than release so the fragments of start-vs-isolation-detach.json keep matching exactly once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d a timing conflict stops disarming the capability EnsureFirewall's PUT is the write-side sibling of the device edit #522 taught to retry, and it had no retry: replacing a rule set that is in use makes the daemon re-ensure it in OVN, and that ensure loses the same OVSDB port-group creation. Observed in the wild during #577's full-chain reproduction (serve-after.log, 2026-08-28 08:58): `write firewall osc-7b5ef90b888: Failed creating port group "incus_acl4448_net4613" … constraint violation: Transaction causes multiple rows in "Port_Group" table`, twice in one run. Retry against ordering, decided by the measurement and written here because "#577 chose ordering" is the objection this change must answer: - the two wild failures came nine seconds apart, and both inserts collided with the same row (UUID 170e92bb) that predated them both — the daemon's own existence check can read stale for seconds under load, so no ordering of this process's calls could have prevented the second failure; a lock provably does not close this window; - direct staging on an idle host does not open it at all: some two thousand PUTs overlapping device sets and starts in every arming measured (fresh ACL, worn-by-stopped, worn-elsewhere-active), zero collisions — the conflict needs a loaded OVSDB, and its frequency in functional runs is of the order of once per session; - #577's refusal to retry (`Cannot find security ACL ID`, TestANonTransientFailureIsNotRetried) names a different cause: a *deleted* rule set that no retry can resurrect, where ordering is the only remedy. Here the missing thing is *created* by whoever wins, and asking again is how the loser sees it. The two decisions are one rule read from both sides: retry what resolves itself, order what does not. The non-transient test stands unchanged. Three parts, each with the measurement that demanded it: - the create and the PUT go through runUntilFree, whose isTransientConflict already names this exact wording; - a lost create race is a success: two EnsureFirewall of one absent name both pass the show, and the daemon answers the loser "The network ACL already exists" (measured raw on this station, 2026-08-28) — the object the loser wanted is there, so the rules are still written; - a transient conflict no longer reaches firewallRefused. The wild occurrence's worst consequence was not the failed write: it was `capabilities.firewall is now false` for the rest of the process, after which every suite keyed on the capability skips its enforcement assertions — the instrument muted by the very noise it exists to see through. A conflict outliving the retry budget is still an error, returned and logged; a real refusal by the host still withdraws the capability (TestAFirewallWriteTheHostRefusesWithdrawsTheCapability, unchanged). Falsified: tools/falsify/specs/ensurefirewall-conflict.json, three mutations, each compiling, each caught (TestEnsureFirewallRidesOutADuplicatePortGroupRace, TestALostCreateRaceIsNotARefusal, TestASpentConflictBudgetDoesNotWithdrawTheCapability). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
stephrobert
added a commit
that referenced
this pull request
Aug 28, 2026
…d the gate that does it names its runtime and its budget (#504) (#582) * docs(limits): the Exoscale stack's two null outputs are measured, and no change to this pack would lift them (#520) #520 records a real observation and a deduced cause, and the deduction was wrong. The filing says so itself: "Deduced, not instrumented: ... that points at the pack's by-id GET racing its own async create-completion, but nobody has captured the raw exchange." Instrumented on 2026-08-28, on the surface this repository has decided to drive. Terraform is refused for this pack since #525, so the reads were driven against the pack's own HTTP API, with --vm off because the question is the document and not the runtime: GET /v2/load-balancer/{id} back to back, one balancer 100 reads, 0 empty create a balancer, read it by id with no delay 100 pairs, 0 empty create an elastic IP, read it by id with no delay 1 pair, 0 empty create an instance pool, read its size by id 50 pairs, 0 empty There is no window to race. createLoadBalancer puts the resource with `name` and `ip` in its attributes before it writes the operation, and that operation already answers `state: success`: the async shape is in the envelope, never in the document. The `exo` CLI reads the same balancer back with its name and ip_address filled. Two consequences, and the second is why this stays a limit rather than becoming a fix. The stack has three by-id data sources and only two came back null; this emulator serves all three from one store and answers all three completely, so whatever produced the nulls tells them apart and this pack does not. What tells them apart is on the client side, and it is the client #525 refuses: the provider builds two clients and one does not honour EXOSCALE_API_ENDPOINT (upstream #573), and `setEndpointFromContext` in egoscale/v2 does not rewrite a literal address. That last sentence is a reading of the provider rather than a measurement of it, and docs/limits.md says so: the fork is not checked out here. The disproof is written where the next reader meets it, per the rule that a finding is verified before it is fixed and a disproof is recorded so nobody "fixes" it again. The changelog line for this lands with the rest of the lot. Assisted-by: Claude Code (claude-opus-5) * feat(conformance,ci): the example stacks are applied every night, and the gate that does it names its runtime and its budget (#504, #574) `conformance:functional` is the only gate here that boots real machines and applies `examples/stacks/`, and nothing in CI played it. No leg of runtime-proof.yml applies a stack; `mise run conformance` skips the stack suites without a runtime. It is what surfaced the isolation-detach race — an ACL deleted while the daemon resolved it at `incus start`, machines dying behind a green apply — a defect that cost three pull requests (#577, #578) and was older than the whole range bisected. WHERE IT RUNS, AND WHY THERE A `stacks` job of its own on runtime-proof.yml, parallel to the two mode legs, incus-ovn only. Three reasons, and the first two are measurements rather than preferences: - a step on the incus-ovn leg queues behind six suites that were red on four of the last seven scheduled nights, and a step that is never reached is a gate that never runs, which is this issue's own complaint; - it would inherit that leg's host. Reproduced locally on 2026-08-28: the three ssh suites each exit 0 and each leave something standing — `scw-…` and `exo-…` rule sets no client call can delete, `fnt-default` and `feint-uplink` no resource owns — and `feint stop` sweeps none of them. That is why the witness gate failed at its own doorstep on the one night it has been reached since it landed. A job of its own starts clean; - three passes is half an hour, which would push a 45-minute job onto its own timeout, and a job that times out is a verdict nobody wrote. No job renamed, so the required status checks in .github/rulesets/main.json are untouched: this workflow is schedule and dispatch only, and none of its jobs is a required check. `streak` and `report` now wait for `stacks`, so a night whose stack gate went red is not summarised, or closed, before anybody looked at it. The setup steps repeat the incus-ovn leg's deliberately — extracting them while that leg is red would make any new red ambiguous between the gate and the refactor — and TestBothRuntimeJobsPinTheSameHost holds the two copies to the same pins. THE FOUR THINGS THE DAY IMPOSED 1. The doorstep at both ends, and this one was a comment. The gate closed with guard_leftovers_for "$RUNTIME" "the end of the run" under a paragraph saying the doorstep question was asked again on the way out. `guard_leftovers_for` arms `--doorstep` on the literal `doorstep` alone, so the closing call asked about DHCP orphans and trapped objects and never once asked what machines and networks were left standing. A green run that leaked a network exited 0 and the next run met the refusal: the state #521 removed from `mise run conformance`, described here and never done. TestTheStackGateEndsOnItsOwnDoorstep reads the scope the file passes and then executes the guard with it, so it measures the effect, not the spelling. 2. The mode announced with its provenance. The gate opened with `RUNTIME="${FEINT_FUNCTIONAL_RUNTIME:-incus-ovn}"` — #574's line one directory over — so an exported FEINT_VM was ignored without a word. Worse here than there: the one assertion the two modes disagree about, isolation between two VPCs, is this gate's own subject. The resolution moved to tools/runtime-mode.sh and both callers pass what differs (subject, knob, default, what `off` would cost). tools/evidence/mode_test.go did not move, deliberately: those tests were written against the behaviour, so they are what proves the extraction changed none of it. 3. A red that names the stack and the machine, unchanged and proved rather than assumed. Planted #475's defect (ApplyFirewall hands nothing to the runtime), the gate went red in 69 s on "scaleway: a rule of platform-app-worker-a's group opens 8080 and platform-web-0 does not reach 10.30.2.5:8080". 4. The intermittency budget: three passes, sequential on one host. The class struck 9 times in 13 runs, so one pass calls it absent 31% of the time; three passes make that 3%, against 9.6% for two. Five would buy another factor of ten for the same time again and put the CI job on its timeout. At 295 s a pass, three are ~15 min locally. Sequential rather than parallel because pass k+1's doorstep is pass k's closing verdict. The count is announced with its provenance like the mode, and lowering it says what it gives up. MEASURED mise run prepush green mise run docs:check green falsify the-stack-gate-runs-somewhere.json 8/8 mutations bit falsify evidence-leg-mode.json (retargeted) 7/7 mutations bit the gate bites #475 planted in ApplyFirewall, red in 69 s: "scaleway: a rule of platform-app-worker-a's group opens 8080 and platform-web-0 does not reach 10.30.2.5:8080" FEINT_VM=incus-ovn mise run conformance:functional run 1: passes 1 and 2 green, pass 3 RED, 665 s; run 2, on an idle station: green, 884 s THE ONE RED, WRITTEN DOWN RATHER THAN RE-RUN AWAY Pass 3 of the first three-pass run went red on the Scaleway firewall pair, with the same verdict shape the planted defect produced: FAIL: scaleway: a rule of platform-app-worker-a's group opens 8080 and platform-web-0 does not reach 10.30.2.4:8080 — the group describes a port the host does not open Passes 1 and 2 were green on that same assertion, same binary, same host. This lot changes no emulator code — the diff is a workflow, a shell gate, tests, a resolver and a testplan rule — so whatever it is, it is on main. Measured after it: 6 green and 0 red out of 6 further single-pass runs of the Scaleway stack, back to back on an otherwise idle station, plus the second three-pass run above. The failing pass ran while this station was also compiling and running `go test`; that perturbation is a candidate this measurement does not separate from the emulator, and it is written down rather than dismissed. So it is seen once and not reproduced, and it is not attributed here. What it does establish is the point of the budget rather than a defect: a one-pass gate would have reported green on the very run that contained it. Assisted-by: Claude Code (claude-opus-5)
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.
Follows #577, which closed the same shape on three doors of
Start. Twoneighbouring sites were measured in passing there and left alone. One turned
out to be worse than reported, and the other's remedy is the opposite of #577's
— with the measurement that says why.
Site 1 — the hot attach: the window is real, and there are two of them
Measured on a bare runtime (Incus 7.2, OVN), a network carrying an
iso-*ruleset, a running container, and the detach (
network unsetthenacl delete, theorder
IsolateNetworkuses) fired during the operation:config device add— the hot attachdevice set ipv4.address— the address moveWord for word the same failure as #577:
Cannot find security ACL ID for "iso-…".The second branch was not in #577's report.
ipv4.addressis not a key anOVN NIC updates in place: the daemon detaches and re-attaches the device — about
ten seconds on this station — and re-plugs the port. A far wider window than the
attach's, and nobody had looked at it.
Every mutating branch of
Attachnow holds its network's lock around its oneruntime call, the exact shape
attachExtraalready used. Lock order is one-way(attach-lock then network-lock, never the reverse), so
peerLock's multi-lockrule is intact.
Consequence, taken deliberately and written at the site: two adds on one network
take turns — the same arbitration #577 made for two starts.
TestTwoMachinesAttachWithoutQueueingnow drives two networks and still refusesits named defect (#348).
One detail that is not cosmetic: the new locals are named
unlockso thatstart-vs-isolation-detach.json's fragments keep matching exactly once.Without it, #577's own falsification spec would have become ambiguous silently.
Site 2 — the firewall write: the defect was not the conflict, it was the consequence
The original observation, recovered from the run that produced it: two PUT
failures at 08:58:35 and 08:58:44 on 2026-08-28, nine seconds apart, both
inserts colliding with the same row that predated them both:
The second failure had no concurrent writer. The daemon's own existence check
can read stale for seconds under load, so no ordering of this process's calls
could have prevented it.
That is what decides retry versus ordering, and it decides it the opposite way
from #577 without contradicting it: there the missing object had been
deleted — no retry resurrects it, and ordering is the only remedy; here it is
created by the winner, and asking again is how the loser sees it.
TestANonTransientFailureIsNotRetriedis unchanged.The worst consequence was never the failed write. A transient conflict went
through
firewallRefused, which withdrewcapabilities.firewallfor the restof the process's life — and every suite keyed on that capability then skips its
enforcement assertions. One timing collision silently disarmed the firewall proof
for a whole run: the instrument muzzled by the noise it was meant to ride out.
A genuine refusal from the host still withdraws the capability.
Three parts, each measured: the create and the PUT go through
runUntilFree(whose
isTransientConflictalready names this wording, #522); a lost create —"The network ACL already exists", the wording Incus 7.2 answers the loser with,measured on this station — is a success, not a refusal, because the object
the caller wanted is there; and a transient conflict no longer reaches
firewallRefused.Gates
prepush0 (after the rebase ontomain) ·FEINT_VM=incus-ovn conformance:leg -- runtime0, 626 s, clean teardown ·functional.sh outscalegreen three times in a row — the suite that revealed this family judges the
fix.
Falsification:
attach-vs-isolation-detach.json2 mutations, andensurefirewall-conflict.json3 (back tod.run, tolerance neutralised, thecondition falsified) — each compiles, each bites. The 16 neighbouring specs
testplannamed replayed 16/16;falsify:lint867 mutations over 141 specs, so#577's fragments still match exactly once.
What was not obtained
instrumented PUTs overlapping device sets and starts on a quiet host: zero
collisions. It needs a loaded OVSDB, and shows up roughly once per session of
functional runs. The measurement therefore rests on the two wild occurrences —
whose UUID reading is the decisive part — and on a staged replay of the exact
wording. If it reappears, the journal should now show it absorbed, with no pack
ERROR and no capability withdrawal.
The measurement asked for was the bare runtime and it is done; fix(machine): a start takes turns with the isolation detach, so a peering acceptance no longer kills the machines booting beside it #577's full
chain covers the family through the starts.