Hands out the /30 a sandbox's netns is built on, and takes it back only
when the sandbox's policy is gone (005 T060a3, contracts/egress.md).
Why this exists as its own module
ExSandbox.Egress.Registry's moduledoc has always stated the invariant in
two halves — "release/1 deletes the entry and only then returns the /30 to
the pool; assign/2 refuses a /30 that still carries one". Only the second
half was enforced. There was no pool: assign/2 takes whatever source_key
its caller supplies, and :pool_exhausted sat in the refusal type from the
first commit without any code path able to produce it.
⚠️ A documented invariant with one half missing is worse than an undocumented one, because it reads as settled. Every reviewer since has seen "returns the /30 to the pool" and had no reason to check whether a pool existed.
The ordering, and why it is a callback rather than a convention
release/3 takes a predicate that answers "is this /30's policy gone?" and
puts the address back only if it answers true. The alternative — document
that callers must release the policy first — is the ordering convention the
Registry moduledoc explicitly refuses to rely on, for the reason given
there: the correct ordering in a destroy callback is exactly what a later
refactor reorders without knowing why it was written that way.
Passing the check in means the allocator cannot be wrong about it. A caller who releases the address while the policy stands does not corrupt the pool; the /30 simply stays out until someone releases it again with the policy actually gone.
Why a free list rather than a counter
A counter that recycles on release makes the reuse race trivially reachable
and passes every "distinct sandboxes get distinct addresses" test — see
ExSandbox.Egress.AllocatorTest, which is written against exactly that
implementation.
Summary
Functions
Takes the next free /30, or refuses.
Returns a specification to start this module under a supervisor.
Returns key to the pool if policy_gone?.(key) says its policy is gone.
Types
@type refusal() :: :pool_exhausted | {:still_registered, ExSandbox.Egress.Policy.source_key()}
Why an acquisition was refused.
Functions
@spec acquire(GenServer.server()) :: {:ok, ExSandbox.Egress.Policy.source_key()} | {:error, refusal()}
Takes the next free /30, or refuses.
{:error, {:still_registered, key}} means the only remaining addresses are
ones whose policy has not been released — see the ordering note above.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec release( ExSandbox.Egress.Policy.source_key(), (ExSandbox.Egress.Policy.source_key() -> boolean()), GenServer.server() ) :: :ok
Returns key to the pool if policy_gone?.(key) says its policy is gone.
Idempotent, and safe for a /30 this allocator never issued (003-FR-013):
destroy is reached twice for the same sandbox and must not fault the second
time.