Skip to content

fix: invalidate relay reservations when the peer's last connection closes - #271

Merged
adust09 merged 1 commit into
mainfrom
fix/255-reservation-lifecycle
Aug 23, 2026
Merged

fix: invalidate relay reservations when the peer's last connection closes#271
adust09 merged 1 commit into
mainfrom
fix/255-reservation-lifecycle

Conversation

@adust09

@adust09 adust09 commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Closes #255.

Problem

RelayState.rsReservations keyed reservations by peer with only an expiration, and the Switch teardown path never notified the relay of a disconnect. A reservation held its slot until expiry, and handleConnect briefly treated it as valid before opening the stop stream failed. That permits reservation-cap exhaustion through reserve-then-disconnect churn.

Spec

specs/relay/circuit-v2:

the reservation remains valid until its expiration, as long as there is an active connection from the peer to the relay. If the peer disconnects, the reservation is no longer valid.

The reservation is tied to an active connection from the peer, not to the connection the RESERVE arrived on.

Which reference semantics

The two reference implementations differ, and both are spec-conformant:

  • go-libp2prsvp map[peer.ID]time.Time, one reservation per peer. Its disconnect notifiee returns early while Connectedness(p) == Connected, so a peer holding a second connection keeps its reservation.
  • rust-libp2pconnections: HashMap<PeerId, HashMap<ConnectionId, Reservation>> with max_reservations_per_peer: 4; each reservation dies with its own connection.

This PR follows go-libp2p, because rsReservations is already Map PeerId ActiveReservation and rcMaxReservations = 128 already matches both implementations' defaults. The rust model would require restructuring the reservation store and adding a per-peer cap.

Issue #255 originally asked for the opposite behaviour ("a second connection from the same PeerId cannot accidentally keep a reservation alive"); the issue body has been corrected to match the spec wording and the reference implementations.

Change

  • Switch gains swDisconnectNotifiers :: TVar [Connection -> IO ()].
  • closeConnection — already the single, idempotent teardown path — fires them synchronously, after the pool-removal STM transaction has committed and before muxClose. Synchronous firing means the reservation is gone by the time teardown returns, closing the window where a concurrent CONNECT would still see it. Each notifier is exception-isolated so a failing one cannot abort teardown.
  • Because the closing connection has already left the pool when the notifiers run, the liveness check is a plain lookupConn with no special-casing.
  • registerNATHandlers subscribes registerReservationCleanup, which deletes the peer's reservation only when no connection to it remains.

ActiveReservation, handleReserve and handleConnect are unchanged — no new fields, no signature changes.

Tests

New LibP2P.NAT.Relay.ReservationLifecycleSpec, driving real switches over loopback TCP and calling closeConnection on the relay's own side so the assertions are deterministic rather than timing-dependent:

  • the reservation drops when the reserving peer's last connection closes
  • the freed slot is reusable by another peer immediately (rcMaxReservations = 1: refused before, granted after)
  • with two connections from the same peer, closing one keeps the reservation and closing the second drops it
  • disconnecting a peer that holds no reservation leaves an unrelated holder's reservation untouched
  • repeated teardown of the same connection does not run the cleanup twice

Full suite: 1150 examples, 0 failures.

Note on verification

The Haskell toolchain was unavailable on the host for this change, so the build and test runs were done in the repo's own haskell:9.10-slim-bookworm image.

…oses

RelayState kept reservations keyed by peer with only an expiration, and
the Switch teardown path never told the relay about a disconnect. A
reservation therefore held its slot until expiry, and handleConnect
briefly treated it as valid before opening the stop stream failed. That
permits reservation-cap exhaustion through reserve-then-disconnect churn.

specs/relay/circuit-v2: "the reservation remains valid until its
expiration, as long as there is an active connection from the peer to the
relay. If the peer disconnects, the reservation is no longer valid."

The reservation is bound to the peer, not to the connection the RESERVE
arrived on, so a peer keeping a second connection keeps its reservation.
This follows go-libp2p, whose relay disconnect notifiee returns early
while Connectedness(p) == Connected. (rust-libp2p instead keys
reservations per connection with a per-peer cap; both readings are
spec-conformant, and the per-peer model is the one this codebase's
rsReservations already implements.)

Add swDisconnectNotifiers to the Switch, fired from closeConnection --
already the single idempotent teardown path -- after the pool removal has
committed and before the muxer is closed. Notifiers run synchronously so
the reservation is gone by the time teardown returns, and each is
isolated so a failing notifier cannot abort it. Because the closing
connection has already left the pool, the liveness check needs no
special-casing.

registerNATHandlers subscribes registerReservationCleanup, which drops
the peer's reservation only when lookupConn finds no connection left.
ActiveReservation, handleReserve and handleConnect are unchanged.

Closes #255.
@adust09
adust09 merged commit 976342d into main Aug 23, 2026
3 checks passed
@adust09
adust09 deleted the fix/255-reservation-lifecycle branch August 23, 2026 05:19
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.

relay: invalidate reservations when their owning connection disconnects

1 participant