Skip to content

fix(machine): the hot attach takes turns too, and a transient port-group conflict stops disarming the firewall capability - #578

Merged
stephrobert merged 2 commits into
mainfrom
fix/attach-and-ensurefirewall-races
Aug 28, 2026
Merged

fix(machine): the hot attach takes turns too, and a transient port-group conflict stops disarming the firewall capability#578
stephrobert merged 2 commits into
mainfrom
fix/attach-and-ensurefirewall-races

Conversation

@stephrobert

Copy link
Copy Markdown
Owner

Follows #577, which closed the same shape on three doors of Start. Two
neighbouring 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-* rule
set, a running container, and the detach (network unset then acl delete, the
order IsolateNetwork uses) fired during the operation:

branch result
config device add — the hot attach 11 killed of 14, detach fired 25–200 ms into a ~400 ms add
device set ipv4.address — the address move 6 killed of 8
the same, detach at 0 ms or after 250 ms clean

Word for word the same failure as #577: Cannot find security ACL ID for "iso-…".

The second branch was not in #577's report. ipv4.address is not a key an
OVN 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 Attach now holds its network's lock around its one
runtime call, the exact shape attachExtra already used. Lock order is one-way
(attach-lock then network-lock, never the reverse), so peerLock's multi-lock
rule 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.
TestTwoMachinesAttachWithoutQueueing now drives two networks and still refuses
its named defect (#348).

One detail that is not cosmetic: the new locals are named unlock so that
start-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
:

write firewall osc-7b5ef90b888: Failed creating port group "incus_acl4448_net4613"
… constraint violation: Transaction causes multiple rows in "Port_Group" table

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.
TestANonTransientFailureIsNotRetried is unchanged.

The worst consequence was never the failed write. A transient conflict went
through firewallRefused, which withdrew capabilities.firewall for the rest
of 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 isTransientConflict already 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

prepush 0 (after the rebase onto main) · FEINT_VM=incus-ovn conformance:leg -- runtime 0, 626 s, clean teardown · functional.sh outscale
green three times in a row
— the suite that revealed this family judges the
fix.

Falsification: attach-vs-isolation-detach.json 2 mutations, and
ensurefirewall-conflict.json 3 (back to d.run, tolerance neutralised, the
condition falsified) — each compiles, each bites. The 16 neighbouring specs
testplan named replayed 16/16; falsify:lint 867 mutations over 141 specs, so
#577's fragments still match exactly once.

What was not obtained

  • The port-group collision was never reproduced on demand, despite ~2000
    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.
  • No full LinkNic + AcceptNetPeering chain through the emulator for site 1.
    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.

stephrobert and others added 2 commits August 28, 2026 10:18
…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
stephrobert merged commit 5f1357d into main Aug 28, 2026
30 checks passed
@stephrobert
stephrobert deleted the fix/attach-and-ensurefirewall-races branch August 28, 2026 09:14
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)
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