nat: free a synced NAT reservation only when the last worker releases it (#6211 F2) - #6981
Open
psaab wants to merge 2 commits into
Open
nat: free a synced NAT reservation only when the last worker releases it (#6211 F2)#6981psaab wants to merge 2 commits into
psaab wants to merge 2 commits into
Conversation
added 2 commits
August 12, 2026 21:12
An HA-synced session is pushed to EVERY worker's session table (`afxdp/ha/session_import.rs` fans `UpsertSynced` out to each worker's command queue) while the source-NAT / NAT64 allocator is a single shared `Arc`. So N workers reserve the same `(flow, translated)` against one allocator, and each releases it independently — the GC reap, the replicated `DeleteSynced`, and the alias purge all run per worker. `reserve_flow` returned `true` and did nothing when the flow already held that exact translated tuple, so the N reserves collapsed into ONE record; `release_flow` then removed that record unconditionally once the tuple matched. The FIRST worker to let go therefore freed a `(pool_addr, port)` the other N-1 workers were still forwarding through, and the allocator handed it to the next local flow — a NAT source collision / session-hijack surface. This is not a narrow race window. Pre-failover the active's periodic re-`UpsertSynced` keeps all N replicas alive; post-failover that refresh stops and RSS lands traffic on exactly one worker, so the other N-1 idle out with nothing refreshing them. Whichever expires first in `reap_expired_sessions` frees the live worker's port. That is the expected steady state after any failover carrying a synced SNAT session older than the inactivity timeout. N is 6 on the reference cluster. Add `LiveAllocation.holders`, a `u128` bitmask with one bit per `worker_id`. The bit is OR-ed in at `reserve_flow`'s AND `reserve_address_only`'s idempotent early return — which is both where workers 2..N land and the path an already-holding worker takes on every refresh (each HA session-sync reconnect, each periodic re-upsert). OR is idempotent there where an increment is not: a counter would climb without bound on every re-sync and never drain to zero, so the port would never be freed. `LiveAllocation` stays `Clone, Copy` (both read paths use `.copied()`); no `Vec`/`HashSet` enters the per-flow record. `holders == 0` marks an untracked LOCAL allocation — RSS steers a 5-tuple to exactly one worker, so a local allocation has a single holder by construction — and keeps the previous first-release-frees contract bit-identical. A tracked reservation frees only when the mask empties. An untracked release of a TRACKED reservation keeps it: an under-release leaks a bounded, observable pool port, whereas an over-release is the security bug this closes. `worker_id` is threaded from `WorkerLaunchPlan::worker_id` — the worker's own identity established at spawn, not read off a `BindingWorker` slot — through `apply_worker_commands`, `reap_expired_sessions`, `resolve_flow_session_decision`, `delete_terminal_filtered_session`, `purge_translated_synced_hit`, the DSCP-revalidation purge, and the nine packet-path rollbacks (`poll_descriptor` already received `_worker_id` as an inert parameter; it is now live). Completeness is compile-enforced rather than asserted. The untracked entry points (`release_source_nat_allocation`, `reserve_synced_source_nat_allocation`, `rollback_source_nat_allocation` and their NAT64 twins) are now `#[cfg(test)]`, so a production path that forgot to thread its worker id fails the release build instead of silently performing a single-holder release of a reservation every worker holds. `cargo build --release` is green with those entry points excluded. Bound the mask with `MAX_NAT_HOLDER_WORKERS = 128`, tied to the `u128` width by `const _: () = assert!(u128::BITS == MAX_NAT_HOLDER_WORKERS);` so the two cannot drift apart. Enforce it where worker ids are MINTED: `replan_bindings_from_candidates` refuses the whole plan (fail-closed — no bindings, no forwarding) if any `queue_id % workers` would exceed it. A silent set-no-bit take paired with a clear-no-bit release would reintroduce the original over-release through its own fix. The check deliberately is NOT a cap on `--workers`: `queue_count` is the per-interface RX-queue minimum, computed independently of `workers`, so the ids actually minted span `[0, min(queue_count, workers))` and a raw cap would false-refuse a safe box (`--workers 200` on a 16-queue NIC mints ids 0..15). Validation. Two-worker binder: reserve on workers 0 and 1, retire worker 0, assert the tuple is still reserved; a companion cell in its own body asserts the last retire does free it. A refresh cell re-reserves from one worker eight times and asserts a single retire frees — the cell a bare counter fails. An address-only twin covers the #5338 arm via `address_only_owners`. An over-reach guard asserts a LOCAL allocation still frees on its first release. Three mint-site cells cover refusal above the bound, acceptance at the boundary, and the false-refusal control (`--workers 200` on 16 queues must be accepted). `go build ./...` rc=0; no Go files changed. Advances #6211.
# Conflicts: # _Log.md
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.
Stacked on #6876 (base
fix/6211-synced-snat-rule-identity@ad439e5a0). Advances #6211 — this is the F2 holder-set item; it does not close the issue.The defect
An HA-synced session is pushed to every worker's session table (
afxdp/ha/session_import.rsfansUpsertSyncedout to each worker's command queue), while the source-NAT / NAT64 allocator is a single sharedArc. So N workers reserve the same(flow, translated)against one allocator, and each releases it independently — the GC reap, the replicatedDeleteSynced, and the alias purge all run per worker.nat/allocator.rs—reserve_flowreturnedtrueand did nothing when the flow already held that exact translated tuple. Idempotent, no refcount.nat/allocator.rs—release_flowremoved the record unconditionally once the tuple matched.The N reserves therefore collapsed into one record, and the first worker to let go freed a
(pool_addr, port)the other N-1 were still forwarding through — the allocator then handed it to the next local flow. That is a NAT source collision / session-hijack surface.This is not a race window. Pre-failover the active's periodic re-
UpsertSyncedkeeps all N replicas alive. Post-failover that refresh stops and RSS lands traffic on exactly one worker, so the other N-1 idle out with nothing refreshing them, and whichever expires first inreap_expired_sessionsfrees the live worker's port. It is the expected steady state after any failover carrying a synced SNAT session older than the inactivity timeout. N is 6 on the reference cluster.The fix — a holder set, and why not a counter
LiveAllocation.holders: u128, one bit perworker_id. The bit is OR-ed in atreserve_flow's andreserve_address_only's idempotent early return.That site is load-bearing for two reasons at once: it is where workers 2..N land and the path an already-holding worker takes on every refresh (each HA session-sync reconnect, each periodic re-upsert). OR is idempotent there where increment is not — a counter would climb without bound on every re-sync and never drain to zero, so the port would never be freed at all. That is the failure a bare refcount ships, and it is what the refresh cell below pins.
LiveAllocationstaysClone, Copy(both read paths use.copied()); noVec/HashSetenters the per-flow record.Semantics:
holders == 0— an untracked local allocation. RSS steers a 5-tuple to exactly one worker, so a local allocation has a single holder by construction; first release frees, bit-identical to today.Untrackedrelease of a tracked reservation keeps it. Deliberate direction: an under-release leaks a bounded, observable pool port (AllocatorExhausted), whereas an over-release is exactly the bug being closed.Completeness is compile-enforced, not asserted
The threading is 20 production call sites. Rather than claim it is complete, the untracked entry points (
release_source_nat_allocation,reserve_synced_source_nat_allocation,rollback_source_nat_allocationand their NAT64 twins) are now#[cfg(test)]. A production path that forgot to thread its worker id fails the release build.cargo build --releaseis green with those entry points excluded from the crate, which is the proof.worker_idcomes fromWorkerLaunchPlan::worker_id— the worker's own identity established at spawn, not read off aBindingWorkerslot — threaded throughapply_worker_commands,reap_expired_sessions,resolve_flow_session_decision,delete_terminal_filtered_session,purge_translated_synced_hit, the DSCP-revalidation purge, and the nine packet-path rollbacks (poll_descriptoralready received_worker_idas an inert parameter; it is now live).The width, and where the bound is enforced
MAX_NAT_HOLDER_WORKERS = 128, tied to the mask width byconst _: () = assert!(u128::BITS == MAX_NAT_HOLDER_WORKERS);so the two cannot drift apart.Enforced at the mint site:
replan_bindings_from_candidatesrefuses the whole plan (fail-closed — no bindings, no forwarding) if anyqueue_id % workerswould exceed it. A silent set-no-bit take paired with a clear-no-bit release would reintroduce the original over-release through its own fix.It is deliberately not a cap on
--workers.queue_countis the per-interface RX-queue minimum, computed independently ofworkers, and the id isqueue_id % workerswithqueue_id < queue_count— so minted ids span[0, min(queue_count, workers)). Capping--workerswould false-refuse a safe box:--workers 200on a 16-queue NIC mints ids 0..15. A dedicated cell pins that.A CLI validator mirroring
validate_ring_entries_argwas considered and dropped: any check on the raw--workersvalue can only refuse configurations the mint-site check would allow, which is the false-refusal above.Validation
Fail-on-revert, RED first
Reverting only the three-line load-bearing hunk (the
holders |= holder.bit()inreserve_flow's idempotent early return) via edit —diffagainst the pristine copy confirms that is the sole delta:RED_RC=101, and 0error[lines — an assertion failure, not a build break. Exactly one cell went RED.What stayed GREEN under that revert
synced_reservation_frees_on_last_worker_retire_6211_f2synced_reservation_refresh_by_one_worker_does_not_accumulate_holders_6211_f2local_allocation_still_frees_on_first_release_6211_f2synced_address_only_token_survives_first_worker_retire_6211_f2replan_refuses_worker_ids_beyond_the_nat_holder_mask_6211_f2replan_accepts_the_widest_representable_worker_id_6211_f2replan_accepts_huge_worker_count_on_a_small_queue_nic_6211_f2--workers 200on 16 queues acceptedThe address-only binder staying GREEN is informative rather than weak: it binds a different production site (
reserve_address_only's own early return), which this mutation did not touch. The two binders are independent.The two-worker fixtures use two workers by construction — a single-worker fixture cannot express this property and would pass forever.
Suite
cargo test --release(full, exit code captured unpiped)cargo test --release --no-rungo build ./...The pre-existing
afxdp::ha::testsprocess-global-counter flake documented in #6876 did not fire in this run. 0 Go files differ fromad439e5a0; the repo'sgofmt -lhits are pre-existing and verified byte-identical to the PR head.Docs
docs/session-sync-architecture.md— a "Per-worker holder set (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 F2)" bullet and a "Worker-id bound (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 F2)" bullet in the synced-reservation section.userspace-dp/src/afxdp/session_glue/README.md— records that every release site in the module now takes aworker_idand why the untracked twins are#[cfg(test)]._Log.mdper project convention.For the reviewer — attack these first
reserve_flow. When a synced decision's translated tuple changes for the same flow key, the first worker to process it frees tuple A while workers 2..N still hold A, then re-inserts with a fresh mask. I argue this is not a regression (pre-fix, that path freed A immediately too, and the same fanned-out command updates every worker to B), but it is the least obvious corner.delete_terminal_halftakesworker_idFIRST; the other threaded functions take it LAST. The coordinator asked for first-position throughout. I placed it last on the wide functions so ~40 test call-site updates became mechanical appends rather than positional inserts, which materially cut mis-ordering risk;u32cannot silently swap with the neighbouringc_int/u64/usizeparams. Happy to normalise to first-position on request.make test-failoverbefore merge. Not run here — cluster tooling is the lead's to schedule.