fix(nat): reserve a synced SNAT session on the rule the active matched (#6211) - #6876
fix(nat): reserve a synced SNAT session on the rule the active matched (#6211)#6876psaab wants to merge 9 commits into
Conversation
The HA standby picked the source-NAT rule for a peer-synced reservation by "first rule whose pool CONTAINS the translated address"; the active node picked its rule by zone/policy match. Two source-NAT rules can carry the SAME public pool address in SEPARATE allocators -- the allocator is shared per `SourceNatRule::allocator_key` (pool name + addresses + port range), so distinct `pool_name`s with a common member address give one address two independent `PortAllocator`s. Under that config the standby's reservation landed in a different allocator than the active used for the same session, so after a failover a new local flow matching the OTHER rule missed the collision guard: the reverse-identity token sat in the wrong allocator, reintroducing the reverse-path ambiguity the token exists to prevent. Pre-existing and LOW severity -- byte-identical to the shipped port-bearing arm (#4388/#5336); #6210 mirrored it for the address-only case (#5338) without introducing it. Single-rule and non-overlapping-pool configs were never affected. Advances #6211. The fix is LOCAL; no wire change is needed. Every input the active's rule match consumes is already synced: the zone pair rides as `ingress_zone_id`/`egress_zone_id` (Go `buildSessionSyncRequestV4`/`V6` -> `SessionSyncRequest` -> `SessionMetadata::ingress_zone`/`egress_zone`, with the legacy name strings as the old-peer fallback), and the 5-tuple IS the session key. Rather than introduce a second rule-identity scheme -- the `PolicyCounterStore` stable-`rule_id` precedent applies only when the identity must ride the wire, which it need not here -- `reserve_synced_source_nat_allocation` re-runs the active's own predicate. `SourceNatRule::matches` is split into shared `zone_matches` / `l4_matches` / `address_matches` axes plus a new `matches_ignoring_scope`, so the standby and the packet path cannot drift on what "matches" means. The flow key the reservation already built is byte-identical to the active's SNAT-match tuple (original source, POST-DNAT destination, original ports -- `nat_match_flow.forward_key` in `poll_descriptor`), and, like `match_source_nat_result_for_tuple`, the narrowed pass takes the FIRST matching rule in snapshot order (that order IS the #4161 Junos specificity precedence). The #3096 interface / routing-instance scope is the one axis the standby cannot confirm: `NatScopeCtx` derives from LOCAL `ifindex_to_config_name` / `ifindex_to_routing_instance` maps keyed on the ACTIVE node's ifindices, which a synced entry does not carry. It is therefore treated as UNCONSTRAINED rather than as a mismatch -- rejecting an interface-scoped rule the standby cannot refute would push the selection PAST the rule the active actually used and onto a later one, strictly worse than the first-pool-match it replaces. Both passes share one `reserve_synced_on_first_pool_owner` body, so the pool-mode gate, address-index math, address-only vs port-bearing arms and per-rule fall-through cannot diverge between them. The pre-existing first-pool-match remains an unconditional pass-2 fallback -- unresolvable zone pair (old peer / config drift), no confirmable match owning the address, or every candidate refusing the reservation -- so no configuration ends up with FEWER reservations than before: the narrowing can only move a reservation to a better-justified allocator, never remove one. Rolling upgrades are therefore safe in both directions. Validation. A RED probe on the parent (`ad9591177`) proved the divergence before any fix: with a `dmz->wan` rule ordered ahead of a `lan->wan` rule over a shared pool address, a `lan->wan` synced session reserved in the `dmz->wan` allocator (cargo exit 101). Nine new tests cover it -- two fail-on-revert (port-bearing and address-only arms), four negative controls (no zone pair, unmatched zone pair, single rule, non-overlapping pools; the last two asserted invariant across `None` and `Some(..)`), and three axis guards (interface scope ignored, L4 narrowing, post-DNAT destination). A six-case mutation matrix -- disable pass 1, swap in the scope-checked `matches`, delete the fallback, drop the L4 axis, feed the pre-DNAT destination, drop the zone axis -- confirms each guard fires. `cargo test --release` exits 101 on 4241 passed / 1 failed. The one failure is `afxdp::ha::tests::current_generation_install_and_delete_still_apply_on_ poisoned_shared_mutex`, a pre-existing flake unrelated to this change: it passes in isolation (exit 0); the `afxdp::ha::tests` module alone fails 4-of-6 consecutive runs with a VARYING failure set (sometimes also `stale_generation_install_refused_on_poisoned_shared_mutex`); its assertion is a before/after delta on the process-global `session_delete_stale_ignored_total`, which a sibling test in the same module deliberately increments; and the #6819 fix for exactly this process-global-counter flake is not in this base. The two modules this change touches are clean: `nat::` 275 passed exit 0, `session_glue` 98 passed exit 0. `go test ./pkg/refactoraudit/` exits 0 (source.rs grows 1765 -> 1895 lines but stays in the same [WATCH] tier). Docs: docs/session-sync-architecture.md gains a "Rule selection (#6211)" bullet under the synced-reservation section, and session_glue/README.md records that the release path is unaffected (it scans every pool-mode rule and stops at the first allocator reporting the flow released, so it locates the reservation wherever the reserve put it). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
The #6211 two-pass selection introduced a permanent standby pool-port leak, and this closes it. Advances #6211. Before #6211 the reserve was a pure function of `rules`, so a re-upsert of the same session always re-entered the SAME allocator and short-circuited on `reserve_flow`'s idempotence: at most one allocator could hold a given flow, and `release_source_nat_allocation`'s first-hit `break` was sufficient. The two-pass selection breaks that invariant. Pass 1 and pass 2 can choose DIFFERENT rules for the same session at different times -- a zone delete/renumber flips `synced_zones` to `None`, and a rule-set `from zone` / `match` edit moves pass 1's candidate set -- and the two rules' allocators are independent, so `reserve_flow` does not short-circuit. A re-upsert then reserves the flow a SECOND time elsewhere. Every live session re-upserts on HA session-sync reconnect and on a post-delete-journal-overflow resync (`upsert_synced_with_origin` removes and re-inserts, so `handle_upsert_synced` re-runs the reserve). With the first-hit `break`, teardown freed one reservation and stranded the other forever. Nothing reaps it: `live_by_flow` is removed only by `release_flow` / `rollback_flow` / the stale-tuple replace inside `reserve_flow`, and `gc_expired_chunked` sweeps persistent LEASES rather than live flows. A config change does not rebuild the allocator either -- carryover is keyed on `allocator_key()` (pool name + addresses + port range), so the very edit that flips pass 1's outcome preserves the leak. The orphan also counts against `max_tracked_flows`, so sustained leakage ends in `AllocatorExhausted` on the standby. Release now frees from EVERY pool-mode rule. That cannot over-free: `release_flow` / `rollback_flow` return false unless `live_by_flow[flow].translated` equals this `translated` tuple, so an allocator holding a different flow -- or the same flow under a different translation -- is untouched. For every single-reservation case the outcome is bit-identical; only the early exit is gone, on a cold path. The session_glue README asserted the opposite ("it locates the reservation wherever the reserve put it"). That was true for ONE reservation, and #6211 is what made two possible; corrected. Also binds the production call site, which was entirely uncovered: all nine tests called `reserve_synced_source_nat_allocation` directly with a literal `Some(("lan","wan"))`, so passing `None` at the call site (which disables the feature) or inverting ingress/egress inside `synced_source_nat_zone_pair` both left the suite green. A new `handle_upsert_synced` test drives the real entry point. Validation. Three mutations, each RED on exactly one new test and on nothing else: restore the release `break` -> the double-upsert leak test only; call site passes `None` -> the call-site test only; invert ingress/egress in the helper -> the call-site test only. Under the call-site mutations all nine pre-existing #6211 tests stay GREEN, which is precisely the gap this fold closes. A companion control proves the release sweep does not free an unrelated flow's reservation in another rule's allocator. `nat::` 277 passed exit 0, `session_glue` 99 passed exit 0, `nat64` 201 passed exit 0. Scope, now stated in the code and the architecture doc: #5144 hard-rejects the motivating duplicate-pool config at strict commit (`TestNAT5144ExactDuplicateSourcePools`), so the live surface is only the paths that bypass the strict compiler -- a pre-#5144 persisted config and the tolerant load / peer-sync path. Also corrects the heatmap line count to 1896 and uses the unit-qualified `ge-0/0/1.0` the Go snapshot builder actually ships. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
A call-site deletion matrix over all four `release_source_nat_allocation` call sites found two unbound. `promote.rs` and `session_glue/mod.rs` are covered by the pre-existing #5295 / #5622 tests. `handle_delete_synced` was NOT: deleting its release call left the package green apart from the known-flaky `shared_cos_lease` and `wg::engine` families, which the zero-failure full suite at the same head rules out as the cause. The GC reap in `worker/loop_body/mod.rs` was also unbound (exit 0, no failures) -- pre-existing and unrelated to #6211, filed as #6901 rather than folded here. Advances #6211. The r1 leak test calls `release_source_nat_allocation` directly, so it binds the function's internals and leaves the wiring free to be deleted. This adds `delete_synced_frees_both_allocators_end_to_end_6211`, which drives the whole story through the REAL entry points: `handle_upsert_synced` twice, then `handle_delete_synced`. The second upsert runs against a snapshot that SHARES the allocators (the `PortAllocator` clone is `Arc`-backed) but has lost `zone_id_to_name` -- a zone delete or renumber, which is what flips the selection outcome between upserts. Its `(1, 1)` precondition assertion is load-bearing: it proves the two-allocator state is reachable through the real import path, not merely constructible by calling the reserve function twice by hand. Both reservations are created by the IMPORT (`reserve_flow` on a pre-computed wire tuple); the fixture never calls `allocate_translation`, so this is the genuinely synced path rather than a local allocation relabelled. Validation, each mutation restored clean: - delete the release call from `handle_delete_synced` (the previously GREEN cell) -> the new test REDs, and only it. The wiring is bound. - restore the first-hit `break` in `release_source_nat_allocation` -> the new test AND the r1 direct leak test both RED, so the end-to-end test also catches the leak itself rather than only the call. `cargo test --release --bin xpf-userspace-dp -- 6211 --test-threads=1` exits 0 with 13 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
# Conflicts: # _Log.md
|
Hostile gate at Correcting my own scoping first: I measured this PR with a Go-scoped filter and read "zero non-comment production-Go lines". True but vacuous — the change is 100% Rust ( Identity is not an index or ordinal. The standby receives the inputs the active's matcher consumed and re-runs the matcher: the zone pair ( The drift worry does not hold: pass 1's candidate set is a strict subset of pass 2's and both take first-match, so pass 1 can only select at the same or a later index — never earlier, never none. And a reservation on a wrong allocator is weakly better than none, which leaves the port reusable in every allocator. Monotone: strictly stronger than what shipped, in every config. Unresolvable zone pair or no matching rule both fall through to the pre-#6211 path at The fixture makes X and Y genuinely differ — Folding:
Noted, not blocking: NAT64 synced sessions never reach pass 1 (cross-family key, identical to master) and the #6211 fixtures are IPv4-only. |
Resolve the sole conflict, _Log.md, by union: every entry from both sides is retained and none is rewritten. The file is no longer append-ordered, so line counts and prefix checks say nothing useful about the result; the resolution was verified structurally instead, by confirming that each pre-merge side diffs into the merged file with add-hunks only and no changed-or-deleted hunk on either side. Every other path merged without conflict. Because a clean textual auto-merge can still break compilation when a signature moves on one side, that was confirmed by building rather than by inspection: go build ./... clean on the merged tree, and cargo check --all-targets clean for the Rust crate. Advances #6876.
The #6211 review found two statements in the source-NAT release/rollback sweep comment that the code does not support. Both are comment-only; no behaviour changes. The first called the swept body "a cold teardown path" to wave off the cost of losing the first-hit early exit. It is not cold. rollback_source_nat_allocation has five non-test call sites, all of them on the packet path in afxdp/poll_descriptor/mod.rs (:2313, :2374, :2472, :2634, :4902), and :2374 is the admission-refusal arm, which is exactly the flood regime. Per refused SNAT'ed flow the sweep now takes K allocator locks where K is the pool-mode rule count, instead of (owning index + 1). The comment states that as a mechanism and says plainly that no throughput measurement was taken and none is claimed, then gives the argument that justifies paying it: a leaked (pool_addr, port) is permanent and counts against max_tracked_flows until the allocator reports exhaustion, while the extra locks are bounded and per-teardown. The second said that before #6211 "at most one allocator could ever hold a given flow", stated unconditionally. That invariant held only against an UNCHANGED rules set. parse_source_nat_rules_with_previous carries allocators over keyed on allocator_key() alone, so an edit that reshuffled which rule a session matched could already strand a flow in a carried-over allocator. #6211 does not create that hazard; it makes it reachable with no config edit at all, because pass 1 and pass 2 can disagree on an unchanged rule set. The comment now scopes the invariant rather than asserting it universally, so a maintainer who reads it does not conclude the pre-#6211 code was leak-free by construction. Validation: cargo build --release rc 0.
# Conflicts: # _Log.md
Gate state at
|
| mutation | RED | GREEN |
|---|---|---|
| M1 disable pass 1 entirely (revert to pre-#6211 first-pool-match) | 4 | 5 |
M2 use the scope-checked matches with an empty scope ctx |
1 | 8 |
| M3 remove the fallback | 4 | 5 |
| M4 drop the L4 axis | 1 | 8 |
| M5 key the destination pre-DNAT instead of post | 1 | 8 |
| M6 drop the zone axis | 2 | 7 |
The value is in which cells fire, not the counts: M4 kills only narrows_on_l4_match_6211; M5 kills only narrows_on_post_dnat_destination_6211; M2 kills only ignores_unconfirmable_interface_scope_6211; M6 kills exactly the two zone-match tests; M3 kills exactly the four invariant/fallback tests. Every mutation is distinguished by the test whose name describes that axis, and no mutation reddens the whole suite — which is what separates a real matrix from an over-strong mutation that reddens everything and proves nothing.
The matrix was measured at 6d881ebf8. It still holds at this head: git diff 6d881ebf8 567aaedd5 -- userspace-dp/src/nat/source.rs shows comment-only changes, so the production logic under mutation is unchanged.
Independent AGY leg at 567aaedd5 — MERGE-READY
Given a narrow brief over named files (no tree search), it answered four questions with quoted code:
- Release completeness. Every reservation
reserve_synced_source_nat_allocationcan take is releasable, becauserelease_source_nat_allocation_with_modesweeps all pool-mode rules (for rule in rules,source.rs:906-915) rather than stopping at the first match. It constructs the identicalflowkey andtranslatedtuple as the reserve path. - Double-take. A re-upsert cannot strand a reservation:
reserve_flowshort-circuits for the same flow key, and if selection moved, the all-rules sweep still frees both. - Wrong-allocator selection. Consequence is a possible double-allocated port, not a leak — the sweep frees the wrongly-chosen allocator at teardown.
- Claim audit,
source.rs:870-899. All five universal claims (every,only,cannot,all) check out against the code.
On the one place the two legs read differently
The earlier hostile leg called a wrong-allocator reservation "weakly better than none"; AGY describes the same state as "a double-allocated port". Both are right about the mechanism and they are not in conflict — AGY is describing the residual, not a regression. Before #6211 no reservation was taken, so the port was free in every allocator; after it, the port is held in at least one. The set of allocators that can collide shrinks; it does not grow. The change is monotone in every config, which is why neither leg treats it as blocking.
Folded this round
Both MINORs from the hostile leg are closed at 9ba61f27c, and the fix put the caveat in the shipping artifact rather than in a review comment: the release-sweep rationale now states that this body also backs rollback_source_nat_allocation from five packet-path call sites including the admission-refusal arm (the flood regime), that the sweep costs K allocator locks instead of (owning index + 1), and — explicitly — "this is a mechanism statement: no throughput measurement was taken and none is claimed."
Codex leg still running; merge is gated on it.
Independent Codex leg at
|
Scoping decision: F1 is PRE-EXISTING on master and does not block here — split to #6979Correcting the disposition I posted with the Codex verdict. That leg framed F1 as "an unclosed instance of the original #6211 failure", which reads as this PR failed to close what it set out to close. I checked it against master rather than against the PR's own diff, and that charge does not hold.
F1, F7b, F3, F4, F5 and F6 are therefore tracked in #6979 with that verification recorded, so the attribution does not drift back later. What stays here — this PR's own debtF2 — the missing multi-worker refcount. Confirmed, and the characterisation is sharper than the original finding: this is not a collision window, it is the expected steady state after any failover carrying a synced SNAT session that outlives the inactivity timeout. Pre-failover the active's periodic re- It is a bounded fix, not a design fork: both production release sites are already per-worker ( Implementing as a holder set (worker-id bitmask) on F7a — the NAT64 release's first-hit The invariant at Two stale citations: |
The NAT64 release swept `nat64.prefixes` but stopped at the first allocator that freed the flow. That is the same first-hit `break` this branch already removed from the source-NAT release, left in place in the parallel path — while the source-NAT comment explains at length why the break is wrong and calls the resulting retention permanent. A change that fixes one of two parallel paths and leaves the other contradicting its own stated reasoning is an inconsistency worth closing here. Two prefixes come to hold one flow with no config edit at all, because the reserve is occupancy-dependent: `reserve_synced_nat64_allocation` takes the first prefix whose allocator accepts. A prefix whose port is transiently held by an unrelated local flow is skipped and the reservation lands on a later prefix; when the earlier one frees and the synced session refreshes — every HA session-sync reconnect and every periodic re-upsert re-runs the reserve — the same flow is held in both. One release then freed one and stranded the other forever, since nothing else removes a `live_by_flow` entry and the lease GC sweeps persistent leases rather than live flows. Sweeping cannot over-free: release/rollback return false unless the stored translated tuple equals this one, so a prefix holding a different flow is untouched. Also correct the sweep's own justification in the source-NAT release. It read "Before #6211 the reserve was a pure function of `rules`", scoping the at-most-one-allocator invariant to an unchanged rule set. That premise is false on master and master documents it: the pre-existing loop has the same per-rule fall-through and its comment says a collision "leaves the rule untouched and tries the next". Selection has always depended on allocator occupancy, so one flow could already be held in two allocators with the rule set untouched. The trailing "every pre-#6211 config" parenthetical that the rewrite falsified is corrected too, and two stale line citations become :2644 / :4912. Validation: the new guard reaches the two-prefix state through the production path and issues one release. Against the unfixed code it fails as an assertion — prefix B still owns the port — while prefix A's assertion passes in the same run, so it discriminates rather than merely failing. With the break removed the guard passes and the full cargo suite is green, 4288 passed / 0 failed in the main suite across seven suites, exit codes captured unpiped. No Go changed; `go build ./...` still exits 0.
# Conflicts: # _Log.md
Closes #6211
The defect
The HA standby picked the source-NAT rule for a peer-synced reservation by "first rule whose pool CONTAINS the translated address". The active node picked its rule by zone/policy match.
Two source-NAT rules can carry the SAME public pool address in SEPARATE allocators — the allocator is shared per
SourceNatRule::allocator_key(pool name + addresses + port range), so distinctpool_names with a common member address give one address two independentPortAllocators. Under that config the standby's reservation landed in a different allocator than the active used for the same session, so after a failover a new local flow matching the OTHER rule missed the collision guard: the reverse-identity token sat in the wrong allocator, reintroducing the reverse-path ambiguity the token exists to prevent.Pre-existing and LOW severity — byte-identical to the shipped port-bearing arm (#4388/#5336); #6210 mirrored it for the address-only case (#5338) without introducing it. Single-rule and non-overlapping-pool configs were never affected.
The design fork: LOCAL, not a wire change
The issue asked whether the rule identity is already available on the standby or needs adding to the sync payload. It is already available. Every input the active's rule match consumes is synced today:
from zone/to zoneingress_zone_id/egress_zone_idon the wirematch destination-port,match application)Both-sides grep confirms the zone carriage in production, not just in tests:
pkg/dataplane/userspace/manager_ha.go, bothbuildSessionSyncRequestV4(~L1585) andbuildSessionSyncRequestV6(~L1669):req.IngressZoneID = val.IngressZone/req.EgressZoneID = val.EgressZone.pkg/dataplane/userspace/protocol_ha.goSessionSyncRequest.IngressZoneID/EgressZoneID⇄userspace-dp/src/protocol/control.rsSessionSyncRequest.ingress_zone_id/egress_zone_id. Tags agree (ingress_zone_id/egress_zone_id).server/helpers/session_sync.rsbuild_synced_session_entryprefers the IDs and falls back to the legacy name strings viazone_name_to_id, landing them inSessionMetadata::ingress_zone/egress_zone.No field was added, so
protocol_wire_v1.jsonneeds no regeneration (verified: no schema change).Mixed-version peers. An old peer that carries neither a usable zone id nor a resolvable zone name resolves to
Noneand takes the pre-#6211 first-pool-match — the safe direction the issue called for, never a failed reservation.Reusing the existing precedent rather than inventing a scheme
The
PolicyCounterStorestable-rule_idmechanism (session/entry.rs,session/README.md) solves "a positional id frozen onto a session goes stale after renumbering". That is the right shape if the identity must ride the wire. Here it need not: the standby is not carrying a stale id, it is re-deriving from scratch, and the selecting inputs are already synced. So instead of a second identity scheme,reserve_synced_source_nat_allocationre-runs the active's own predicate.SourceNatRule::matchesis split into sharedzone_matches/l4_matches/address_matchesaxes plus a newmatches_ignoring_scope, so the standby and the packet path cannot drift on what "matches" means. The flow key the reservation already built is byte-identical to the active's SNAT-match tuple (original source, POST-DNAT destination, original ports —nat_match_flow.forward_keyinpoll_descriptor), and, likematch_source_nat_result_for_tuple, the narrowed pass takes the FIRST matching rule in snapshot order (that order IS the #4161 Junos specificity precedence).Why the scope axis is ignored rather than rejected
NatScopeCtxderives from the LOCALifindex_to_config_name/ifindex_to_routing_instancemaps keyed on the active node's ingress/egress ifindices, which a synced entry does not carry. Rejecting an interface-scoped rule the standby cannot refute would push the selection PAST the rule the active actually used and onto a later one — strictly worse than the first-pool-match it replaces. Ignoring the axis only declines to narrow on it; every other axis still narrows, and the pre-#6211 selection narrowed on none.Why the fallback is unconditional
Both passes share one
reserve_synced_on_first_pool_ownerbody, so the pool-mode gate, address-index math, address-only vs port-bearing arms and per-rule fall-through cannot diverge. Pass 2 (the pre-#6211 behaviour) runs whenever pass 1 reserves nothing — unresolvable zone pair, no confirmable match owning the address (NAT config drift), or every candidate refusing. No configuration ends up with FEWER reservations than before: the narrowing can only move a reservation to a better-justified allocator, never remove one.Validation
RED on the parent, before the fix
A probe on
ad9591177with the current (pre-fix) signature — admz->wanrule ordered ahead of alan->wanrule over a shared pool address, and alan->wansynced session:cargo test --releaseexit 101. The reservation landed in thedmz->wanallocator.GREEN after, with every guard watched to fail
9 tests: 2 fail-on-revert (port-bearing + address-only arms), 4 negative controls, 3 axis guards. A 6-case mutation matrix distinguishes them:
follows_active_zone_match,address_only_token_follows_active_zone_match,narrows_on_l4_match,narrows_on_post_dnat_destinationmatcheswith an emptyNatScopeCtxignores_unconfirmable_interface_scopewithout_zone_pair_falls_back_to_first_pool_match,unmatched_zone_pair_still_reserves,single_rule_is_zone_pair_invariant,non_overlapping_pools_is_zone_pair_invariantmatches_ignoring_scopenarrows_on_l4_matchnarrows_on_post_dnat_destinationmatches_ignoring_scopefollows_active_zone_match,address_only_token_follows_active_zone_matchThe two invariance controls assert across BOTH
NoneandSome(("lan","wan")), so single-rule and non-overlapping-pool configs are pinned identical either way — a fix that changed them would be over-reaching, and M3 proves those assertions are load-bearing rather than decorative.Suite exit codes
cargo test --release(full)cargo test --release --bin xpf-userspace-dp nat::cargo test --release --bin xpf-userspace-dp session_gluego test ./pkg/refactoraudit/The one full-suite failure is
afxdp::ha::tests::current_generation_install_and_delete_still_apply_on_poisoned_shared_mutex— a pre-existing flake, not this change:afxdp::ha::testsmodule alone fails 4 of 6 consecutive runs, with a varying failure set — sometimes alsostale_generation_install_refused_on_poisoned_shared_mutex, which this PR does not touch.session_delete_stale_ignored_total, which a sibling test in the same module deliberately increments — so the delta is contaminated whenever they interleave.afxdp/ha/.ad9591177.Fold r1 (
54854039e) — a SECOND production change, stated here because the title does not cover itThe rev6876 gate found that the two-pass selection introduced a permanent standby pool-port leak, and fixing it required a change to the RELEASE path that the PR title ("reserve ... on the rule the active matched") does not describe. Stating it explicitly rather than leaving the diff wider than the title:
Selection is no longer a pure function of
rules, so a session re-upserted after the selection outcome changes (zone delete/renumber →synced_zonesbecomesNone; a rule-setfrom zone/matchedit moves pass 1's candidate set) reserves a SECOND time in a different, independent allocator —reserve_flow's idempotence is per-allocator. Every live session re-upserts on HA session-sync reconnect and on a post-delete-journal-overflow resync.release_source_nat_allocationstopped at the first allocator reporting released, stranding the other forever; nothing reaps it and it counts againstmax_tracked_flowsto eventualAllocatorExhausted.release_source_nat_allocationnow frees from EVERY pool-mode rule instead of breaking at the first hit. It cannot over-free —release_flow/rollback_flowreturn false unless the stored translated tuple matches — and single-reservation cases are bit-identical.Fold also binds the previously-uncovered production call site (
handle_upsert_synced), corrects thesession_glue/README.mdsentence that asserted the opposite of the leak, and bounds the scope in code + docs: #5144 hard-rejects the motivating duplicate-pool config at strict commit, so the live surface is only pre-#5144 persisted configs and the tolerant load / peer-sync path.Fold mutations, each RED on exactly one test: restore the release
break→ the leak test only; call site passesNone→ the call-site test only; invert ingress/egress in the helper → the call-site test only. Under both call-site mutations all nine original tests stay GREEN.Docs
docs/session-sync-architecture.md— a "Rule selection (nat/HA: synced source-NAT rule-selection picks first-pool-match, can diverge from the active node's zone/policy match under overlapping pool addresses #6211)" bullet in the synced-reservation section.userspace-dp/src/afxdp/session_glue/README.md— records that the release path is unaffected:release_source_nat_allocationscans every pool-mode rule and stops at the first allocator reporting the flow released, so it locates the reservation wherever the reserve put it (it does not assume the same rule-selection rule).Notes for the reviewer
make test-failoverbefore merge. Not run here — cluster tooling is the lead's to schedule.userspace-dp/src/nat/source.rsgrows 1765 → 1895 lines. Tier is unchanged ([WATCH], 1500–1999), but it is now ~105 lines from the[REFACTOR]threshold.