Summary
libp2p-relay's server behaviour picks an arbitrary connection to the destination peer and
asserts that it holds the reservation:
// protocols/relay/src/behaviour.rs:714-719
} else if let Some((dst_conn, status)) = self
.connections
.get(&inbound_circuit_req.dst())
.and_then(|cs| cs.iter().next()) // any connection
{
assert_eq!(*status, Reservation::Active);
connections is a HashMap<PeerId, HashMap<ConnectionId, Reservation>>. Every connection is
inserted as Reservation::None on ConnectionEstablished and only flipped to Active once a
reservation is accepted. So as soon as the destination has any connection to the relay that
does not carry the reservation, the randomly ordered iterator can yield it and the assertion
takes down the whole relay task.
This is reachable remotely: the source peer only has to send a circuit request while the
destination has an extra connection open. That happens on its own during a reconnect (the old
connection lingers) or when a peer dials the relay again for any other reason — no malice needed,
though it is trivially reproducible on purpose.
Note that Reservation::is_active() already exists but is not used at this call site, which
suggests the check was simply missed here.
Expected behavior
The relay picks the connection that actually holds the reservation. If none does, it denies the
circuit request with NO_RESERVATION — the branch right below already does exactly this.
Actual behavior
The relay process panics and dies.
There is a second, quieter consequence: even when the assertion happens to pass, the circuit is
routed over whichever connection the iterator returned, which need not be the reserved one.
Relevant log output
From a production relay (libp2p pinned at 989cb61, a fork of master with two unrelated WebRTC
patches; this code path is untouched):
2026-07-28T07:02:29.634519414Z thread 'tokio-rt-worker' (8) panicked at protocols/relay/src/behaviour.rs:719:21:
2026-07-28T07:02:29.634522794Z assertion `left == right` failed
2026-07-28T07:02:29.634525201Z left: None
2026-07-28T07:02:29.634534572Z right: Active
The node had been up for ~44 minutes serving browser clients over WebRTC-Direct; the log ends
there because the process exited.
Possible Solution
Look for the reserved connection instead of asserting on an arbitrary one:
.and_then(|cs| cs.iter().find(|(_, status)| status.is_active()))
and drop the assert_eq!. Requests with no reserved connection then fall through to the existing
NO_RESERVATION branch.
I have a patch with a regression test ready and will open a PR.
Version
master (reproduced at 38b8a2c0). The code is unchanged since at least libp2p-relay 0.21.
Would you like to work on fixing this bug?
Yes
Disclosure: this report was written with AI assistance (Claude Code). The crash is from my own
production relay; I read the code path and reproduced the panic in a test before filing.
Summary
libp2p-relay's server behaviour picks an arbitrary connection to the destination peer andasserts that it holds the reservation:
connectionsis aHashMap<PeerId, HashMap<ConnectionId, Reservation>>. Every connection isinserted as
Reservation::NoneonConnectionEstablishedand only flipped toActiveonce areservation is accepted. So as soon as the destination has any connection to the relay that
does not carry the reservation, the randomly ordered iterator can yield it and the assertion
takes down the whole relay task.
This is reachable remotely: the source peer only has to send a circuit request while the
destination has an extra connection open. That happens on its own during a reconnect (the old
connection lingers) or when a peer dials the relay again for any other reason — no malice needed,
though it is trivially reproducible on purpose.
Note that
Reservation::is_active()already exists but is not used at this call site, whichsuggests the check was simply missed here.
Expected behavior
The relay picks the connection that actually holds the reservation. If none does, it denies the
circuit request with
NO_RESERVATION— the branch right below already does exactly this.Actual behavior
The relay process panics and dies.
There is a second, quieter consequence: even when the assertion happens to pass, the circuit is
routed over whichever connection the iterator returned, which need not be the reserved one.
Relevant log output
From a production relay (libp2p pinned at
989cb61, a fork of master with two unrelated WebRTCpatches; this code path is untouched):
The node had been up for ~44 minutes serving browser clients over WebRTC-Direct; the log ends
there because the process exited.
Possible Solution
Look for the reserved connection instead of asserting on an arbitrary one:
and drop the
assert_eq!. Requests with no reserved connection then fall through to the existingNO_RESERVATIONbranch.I have a patch with a regression test ready and will open a PR.
Version
master(reproduced at38b8a2c0). The code is unchanged since at leastlibp2p-relay0.21.Would you like to work on fixing this bug?
Yes
Disclosure: this report was written with AI assistance (Claude Code). The crash is from my own
production relay; I read the code path and reproduced the panic in a test before filing.