Skip to content

fix(masquerade): Close the window where an address belongs to neither list - #1743

Closed
daniel-noland wants to merge 2 commits into
githedgehog:mainfrom
daniel-noland:fix/masquerade-address-handback-window-main
Closed

fix(masquerade): Close the window where an address belongs to neither list#1743
daniel-noland wants to merge 2 commits into
githedgehog:mainfrom
daniel-noland:fix/masquerade-address-handback-window-main

Conversation

@daniel-noland

Copy link
Copy Markdown
Collaborator

Issue

NatPool answers "is this public address available?" from two structures — a bitmap of free addresses and a list of weak references to the addresses currently in use — and an address is meant to be in exactly one of them. Releasing one passes through a state where it is in neither: Weak::upgrade stops resolving the instant the last AllocatedPort drops the strong count to zero, but the address only returns to the bitmap later, inside AllocatedIp::drop, which must first acquire the pool's write lock. An allocation arriving in that gap finds nothing on either path and is told the pool is empty, so it fails with NoFreeIp while the pool is in fact holding an address that belongs to no one. This is not a rare interleaving — Arc guarantees the ordering, so every release passes through the gap; only its duration varies, bounded by how long the releasing thread waits for a write lock that every allocation also takes, on every call, via cleanup_used_ips. The same reasoning covers a second latent hazard the fix closes: reserve_from_pool could create a duplicate AllocatedIp for an address whose owner had not yet finished releasing it, after which the first object's Drop would free an address the second still held.

Impact

The packet that lands in the gap is dropped with DoneReason::NatOutOfResources and its flow is refused, logged as masquerade: Ip/port allocation failed ... no free IP available. Two properties of real deployments turn a narrow race into a recurring one. Where an expose is configured with a single public address per peering — the shape observed in the field — the unavailable address is the entire pool, so there is nothing to fall back on. And DNS drives the window open constantly: a reply from source port 53 moves the flow to Closed and invalidates the pair immediately as a port-conservation measure, so a pool carrying mostly DNS destroys and rebuilds its address roughly once per query, opening one window per query. The user-visible effect is mild and easily misread — resolvers retry, so lookups still succeed and the failures surface only as intermittent warnings and a raised drop count, at a rate that does not correlate with load in any obvious way.

Fix

The root problem is that liveness was inferred from Arc refcounts, which change on whatever thread happens to drop the last reference, while availability is a property of the pool, which changes only under its lock — so the two could disagree. The fix gives every lease of an address a Tenancy, recorded with the address's bitmap offset alongside the weak reference, and keeps a map of current tenancies that is updated in the same critical section as the bitmap itself. That makes the pool's own state the source of truth and lets the hand-back be completed from either side: an allocation that holds the lock and finds a dead weak reference finishes the hand-back itself rather than waiting for the releasing thread to win the lock, and use_new_ip reclaims ended tenancies before it reports an empty bitmap — in the same critical section as the retry, so the answer cannot go stale between the two. A late AllocatedIp::drop whose tenancy has since been retired does nothing, which is what makes that takeover safe and, incidentally, what closes the duplicate-AllocatedIp hazard in reserve_from_pool.

Two smaller corrections ride along, both about being able to see what happened: the reuse scan no longer abandons the search at the first address that has run out of port blocks, stranding every address behind it in the list, and NoFreeIp no longer buries the more specific reason — which, on a single-address pool, it always did.

Testing

  • New property test in the existing shuttle harness, an_allocation_racing_the_last_release_is_still_served: a one-address pool with one thread releasing the only allocation while another allocates. Against the unfixed allocator it reproduces the field error verbatim (NoFreeIp); against this change it passes.
  • cargo test -p dataplane-nat --lib — 165 pass.
  • cargo test -p dataplane-nat --features shuttle --lib shuttle — 8 pass.
  • cargo clippy -p dataplane-nat --all-targets — clean.
  • Confirmed on a live gateway by deliberately widening the window: the unpatched allocator refused 10 of 20 queries with the drop counter and the log agreeing exactly, while this change served all 20 under the identical forcing function.

Notes

  • Not addressed here: ports are handed out in 256-blocks owned by a single thread and freed only when the last port in the block dies. Measured density on the affected fabric was ~3.35 ports/block for UDP, so a pool walls well below its nominal capacity. That is a separate redesign.
  • A backport of this change onto v0.25.2 is prepared on backport/v0.25.2/masquerade-address-handback; it is semantically identical (the two differ only by one clarifying comment).

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f62298e0-1897-4b07-91a1-df81909514a5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.71930% with 14 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
nat/src/masquerade/apalloc/alloc.rs 86.59% 11 Missing and 2 partials ⚠️
nat/src/masquerade/apalloc/concurrent_fuzz.rs 94.11% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

daniel-noland and others added 2 commits August 20, 2026 20:51
Loom 0.7 ships no `Weak`, so this crate supplies one, and it was missing the
count. A caller that needs to ask "has the last strong reference gone?"
while holding a lock that the value's `Drop` also takes cannot answer it with
`upgrade`: the temporary `Arc` that mints may be the last one, and dropping
it runs `Drop` re-entrantly into that same lock. Reading the count answers
the question without taking a reference at all.

Under this shim the answer is always "no", because the `Weak` holds a strong
clone -- the same divergence the module header already records for `upgrade`
and `Arc::strong_count`. That makes such a path inert under loom rather than
wrong, and the note says so at both the method and the module level so nobody
concludes from a passing loom run that the path was exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
… list

A `NatPool` decides whether a public address can be handed out by consulting
two structures: the bitmap of free addresses, and the list of weak references
to the addresses in use. An address is meant to be in exactly one of them.

Releasing one passes through a state where it is in neither. `Weak::upgrade`
stops resolving the instant the last `AllocatedPort` takes the strong count
to zero, but the address only returns to the bitmap later, inside
`AllocatedIp::drop`, which must first acquire the pool's write lock -- a lock
every allocation also takes, on every call, via `cleanup_used_ips`. An
allocation arriving in between finds nothing on either path and reports
exhaustion while the pool is holding an address that belongs to no one.

That window is not a rare interleaving. `Arc` fixes the ordering, so every
release passes through it; only its duration varies, bounded by how long the
releasing thread waits for a contended lock. Where an expose gives a peering
a single public address, the unavailable address is the whole pool: the
packet is dropped and the flow refused.

The underlying mistake is inferring availability from `Arc` liveness, which
changes on whatever thread drops the last reference, when availability is a
property of the pool, which changes only under its lock. The two could
therefore disagree.

Give each lease of an address a `Tenancy`, recorded with the address's
bitmap offset beside the weak reference, and keep the current tenancies in a
map updated in the same critical section as the bitmap. The pool's own state
becomes the source of truth, and the hand-back becomes completable from
either side: an allocation holding the lock that finds a dead weak reference
finishes the hand-back itself rather than waiting for the releasing thread to
win the lock, and `use_new_ip` reclaims ended tenancies before reporting an
empty bitmap -- in the same critical section as the retry, so the answer
cannot go stale in between. A late `AllocatedIp::drop` whose tenancy has been
retired does nothing, which is what makes the takeover safe.

That last point also settles a hazard `reserve_from_pool` had documented as
harmless: it creates a second `AllocatedIp` for an address whose owner it
cannot find, and the first one's `Drop` would then free an address the second
still held.

Two smaller corrections ride along, both about being able to see what
happened:

  - The reuse scan ended at the first address that had run out of port
    blocks, stranding every address behind it in the list. Running out of
    blocks is exhaustion like any other; keep scanning.

  - Drawing a fresh address reports `NoFreeIp` whenever the bitmap is empty,
    which for a single-address pool is always, and that answer buried the
    reason the addresses already in use could not serve the request. Report
    whichever of the two is the more specific.

The regression test is a one-address pool with one thread releasing the only
allocation while another allocates, which is the shape DNS traffic takes: a
reply from port 53 moves the flow to `Closed` and invalidates the pair at
once to conserve ports, so such a pool destroys and rebuilds its address
about once per query. Against the unfixed allocator it fails under shuttle
with the error seen in the field, `NoFreeIp`.

The behaviour was also confirmed on a live gateway by deliberately widening
the window: the unpatched allocator refused ten of twenty queries with the
drop counter and the log agreeing exactly, while the patched allocator served
all twenty under the identical conditions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
@daniel-noland
daniel-noland force-pushed the fix/masquerade-address-handback-window-main branch from 45c8b4c to 00963f4 Compare August 21, 2026 02:51
@daniel-noland

Copy link
Copy Markdown
Collaborator Author

Thanks — both blockers confirmed and fixed, plus the three non-blocking notes. Force-pushed as 00963f4f4.

ci::loom build break. Reproduced: cargo check -p dataplane-nat --features loom failed on Weak::strong_count. Worth noting it surfaced as two errors, not one — the E0308 mismatched types at the same line is a cascade of the failed method resolution, not an independent problem.

I took the "extend the shim" option, for the reason given in the review: upgrade() here can genuinely deadlock. reclaim_ended_tenancies runs with the pool write lock held, and the temporary Arc that upgrade mints may be the last one — dropping it re-enters deallocate_ippool.write(). That hazard is why cleanup defers through keep_alive instead of dropping inline, and reading a count avoids taking a reference at all. The shim is now its own commit (14808eeb1), ordered first so each commit builds independently, with the divergence recorded at both the method and module level — including the point that a caller asking "has the last strong reference gone?" is always told "no" under loom, so nobody reads a passing loom run as evidence that path was exercised.

ci::check-fmt. Fixed; the stray blank line came in with the rebase onto main, as diagnosed.

DCO. Both commits now carry Signed-off-by.

Non-blocking, all three taken:

  • The "more specific of the two" comment overclaimed — the residual arm doesn't compare specificity. Reworded to say what the code does, and to note every branch stays inside the exhaustion class so is_exhaustion callers are unaffected.
  • Documented why the broad is_exhaustion() arm in reuse_allocated_ip can't swallow NoFreeIp: allocating a port for an address the caller already holds has no address to run out of, so the port allocator never returns it. That fact isn't local to the function, which is exactly why it needed writing down.
  • Left reclaim_ended_tenancies and cleanup separate rather than factoring out a shared helper — the divergence is the point, so I documented it instead. Sharing them would invite someone to unify on upgrade and reintroduce the deadlock.

Verified on 00963f4f4: --features loom compiles, cargo fmt --all --check clean, 165 lib tests, 8 shuttle tests, clippy clean across dataplane-nat and dataplane-concurrency.

One correction to the record: the review noting "no workspace files were changed" is no longer true — the loom shim touches concurrency/. That's unavoidable given the fix needs a liveness check that is safe to call under the lock.

@daniel-noland

Copy link
Copy Markdown
Collaborator Author

Superseded by #1744, opened from an origin branch so CI will actually run. Same head commit (00963f4f4); the review outcomes from this thread are summarised there.

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