libp2p/circuit-relay-v2: a static relay reservation (`CircuitListen`) is never re-established after the relay connection closes
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 2.6k
- Forks
- 546
- Avg merge
- 8h 18m
- Merged PRs (30d)
- 16
Description
-
Version:
Verified in@libp2p/circuit-relay-v2@4.2.11(withlibp2p@3.3.8), and the
relevant code is unchanged onmainat the time of filing
(packages/transport-circuit-relay-v2/src/transport/index.tsand
transport/listener.ts). -
Platform:
Linux 7.0.0-29-generic #29-Ubuntu SMP PREEMPT_DYNAMIC Fri Jul 17 20:52:35 UTC 2026 x86_64 GNU/Linux -
Subsystem:
circuit-relay-v2
Severity:
High
Description:
If you give libp2p a static relay in your listen addresses (/dns4/relay/.../p2p/Qm.../p2p-circuit), the reservation only ever gets made once. When the relay restarts (or the connection drops for any reason), the client reconnects fine but never re-reserves, so it's unreachable through the relay until you restart the whole process. There's no error either, it just silently stays broken. The internal re-add path assumes every relay was found via discovery and throws HadEnoughRelaysError for static ones, then swallows it. Bonus problem: if the relay happens to be down when your node boots, start() just throws. Repro script attached, ~100 lines, fails on 4.2.11 and current main.
Steps to reproduce the error:
Scenario A — the headline bug:
- Relay up; client listens on
<relayAddr>/p2p/<relayId>/p2p-circuit - Reservation established — client announces
.../p2p-circuit(sanity check) - Relay stops; reservation removed
- Relay restarts with the same key and port; client dials it again, identify completes
- Expected: the
/p2p-circuitaddress comes back
Actual (bug): it never does
Scenario B — the boot-time sharp edge:
With the relay down, a node configured with the same listen address fails start() outright (no retry exists).
import { createLibp2p } from "libp2p";
import { webSockets } from "@libp2p/websockets";
import { noise } from "@chainsafe/libp2p-noise";
import { yamux } from "@chainsafe/libp2p-yamux";
import { identify } from "@libp2p/identify";
import { circuitRelayServer, circuitRelayTransport } from "@libp2p/circuit-relay-v2";
import { generateKeyPair } from "@libp2p/crypto/keys";
import { peerIdFromPrivateKey } from "@libp2p/peer-id";
import { multiaddr } from "@multiformats/multiaddr";
const RELAY_PORT = 40881;
function makeRelay(privateKey) {
return createLibp2p({
privateKey,
addresses: { listen: [`/ip4/127.0.0.1/tcp/${RELAY_PORT}/ws`] },
transports: [webSockets()],
connectionEncrypters: [noise()],
streamMuxers: [yamux()],
services: {
identify: identify(),
relay: circuitRelayServer(),
},
});
}
function makeClient(relayListenAddr) {
return createLibp2p({
addresses: { listen: [relayListenAddr] },
transports: [webSockets(), circuitRelayTransport()],
connectionEncrypters: [noise()],
streamMuxers: [yamux()],
services: {
identify: identify(),
},
});
}
const hasCircuitAddr = (node) =>
node.getMultiaddrs().some((ma) => ma.toString().includes("/p2p-circuit"));
async function pollFor(label, predicate, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
return false;
}
const results = [];
const report = (name, ok, detail) => {
results.push({ name, ok });
console.log(`${ok ? " PASS" : " FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
};
// ---------------------------------------------------------------- scenario A
console.log("scenario A: reservation is never re-established after relay restart");
const relayKey = await generateKeyPair("Ed25519");
const relayId = peerIdFromPrivateKey(relayKey);
const relayWsAddr = `/ip4/127.0.0.1/tcp/${RELAY_PORT}/ws/p2p/${relayId}`;
const relayListenAddr = `${relayWsAddr}/p2p-circuit`;
let relay = await makeRelay(relayKey);
const client = await makeClient(relayListenAddr);
report(
"initial reservation established",
await pollFor("reserve", () => hasCircuitAddr(client), 10_000),
client.getMultiaddrs().map(String).join(", ") || "no addresses announced",
);
await relay.stop();
report(
"reservation withdrawn after relay stops",
await pollFor("withdraw", () => !hasCircuitAddr(client), 10_000),
);
relay = await makeRelay(relayKey);
await client.dial(multiaddr(relayWsAddr));
const reconnected = await pollFor(
"reconnect",
() => client.getConnections(relayId).length > 0,
10_000,
);
report("client reconnected to the restarted relay", reconnected);
// Give the topology/identify/relay:discover path every opportunity to heal it.
const healed = await pollFor("re-reserve", () => hasCircuitAddr(client), 30_000);
if (healed) {
report("reservation re-established after reconnect", true, "bug NOT reproduced on this version");
} else {
report(
"BUG REPRODUCED: connected to the relay for 30s, reservation never re-established",
true,
`announced: [${client.getMultiaddrs().map(String).join(", ") || "nothing"}]`,
);
}
await client.stop();
await relay.stop();
// ---------------------------------------------------------------- scenario B
console.log("scenario B: node start() fails outright when the configured relay is down");
try {
const orphan = await makeClient(relayListenAddr); // relay is stopped now
await orphan.stop();
report("BUG NOT REPRODUCED: start() succeeded with the relay down", true);
} catch (error) {
report(
`BUG REPRODUCED: start() threw with the relay down`,
true,
`${error.name}: ${String(error.message).slice(0, 120)}`,
);
}
console.log(`\n${results.every((r) => r.ok) ? "all checks completed" : "CHECKS FAILED"}`);
process.exit(results.every((r) => r.ok) ? 0 : 1);
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with packages/transport-circuit-relay-v2/src/transport/index.ts and transport/listener.ts, then run the attached reproduction script for scenarios A and B. Trace static relay reservation setup, reconnect handling, and startup when the relay is unavailable. Done means reservations return after reconnect and startup does not fail permanently when the relay is down.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100