ruvnet/ruflo · error · Error
LocalTransport: peer ${target.nodeId} is closed
Error message
LocalTransport: peer ${target.nodeId} is closed What it means
Thrown by LocalTransport.deliver() when a consensus message is being handed to a peer transport whose close() has already run (its internal closed flag is true). This is the in-process transport layer for raft/byzantine/gossip consensus (ADR-095 G2), so it means the recipient node in the same process was torn down while another node still had a message destined for it. The sender's send()/broadcast() promise rejects with this error.
Source
Thrown at v3/@claude-flow/swarm/src/consensus/transport.ts:237
peers(): readonly string[] {
return this.registry.peerIds(this.nodeId);
}
private stamp(msg: Omit<ConsensusMessage, 'from'>): ConsensusMessage {
const base: Omit<ConsensusMessage, 'signature'> = {
...msg,
from: this.nodeId,
seq: this.keyPair ? ++this.seqCounter : msg.seq,
};
if (this.keyPair) {
return { ...base, signature: signMessage(base, this.keyPair.privateKeyPem) };
}
return base;
}
/** Deliver an inbound message to a target's handler, with optional sig + replay checks. */
private async deliver(target: LocalTransport, msg: ConsensusMessage): Promise<ConsensusReply> {
if (target.closed) throw new Error(`LocalTransport: peer ${target.nodeId} is closed`);
// Verification path — only when the *target* expects signed messages.
if (target.keyPair && target.resolvePeerPublicKey) {
const pub = target.resolvePeerPublicKey(msg.from);
if (!pub || !verifyMessage(msg, pub)) {
throw new Error(`LocalTransport: signature verification failed for message from ${msg.from}`);
}
// Replay defense: seq must be strictly increasing per sender.
if (typeof msg.seq === 'number') {
const last = target.lastSeenSeq.get(msg.from) ?? 0;
if (msg.seq <= last) throw new Error(`LocalTransport: replayed/out-of-order seq from ${msg.from} (${msg.seq} <= ${last})`);
target.lastSeenSeq.set(msg.from, msg.seq);
}
}
if (!target.handler) return null;
const reply = await target.handler(msg);
return (reply ?? null) as ConsensusReply;
}
View on GitHub (pinned to fa13ee4ad6)
Solutions
- Ensure consensus protocol instances are stopped before any transport is closed (stop election/heartbeat timers first, then close transports)
- Sequence teardown so followers/close happens after in-flight sends resolve, or route sends through a supervisor that drains pending messages first
- Wrap send()/broadcast() in try-catch and treat 'peer ... is closed' as a shutdown signal to stop retrying
- In tests, await Promise.allSettled on in-flight consensus operations before calling close() on any node
Example fix
// before
raftNode.stop(); // timers stopped, but an in-flight send is still pending
await transportA.send('nodeB', { type: 'append-entries', payload: {...} });
await transportB.close();
// after
await raftNode.stop();
await drainPendingSends(transportA); // settle in-flight messages first
await transportB.close();
try {
const reply = await transportA.send('nodeB', msg);
} catch (e) {
if (e instanceof Error && e.message.startsWith('LocalTransport: peer') && e.message.endsWith('is closed')) return; // peer gone during shutdown
throw e;
} Defensive patterns
Strategy: try-catch
Validate before calling
// In-process reachability check before sending:
const peers = transport.peers(); // close() unregisters, so closed peers drop out
if (!peers.includes(targetNodeId)) throw new Error(`peer ${targetNodeId} not reachable`); Try / catch
try {
const reply = await transport.send(to, msg);
} catch (e) {
if (e instanceof Error && /peer .+ is closed/.test(e.message)) {
// recipient tore down during shutdown — stop, don't retry
return shutdown();
}
throw e;
} Prevention
- Stop consensus protocol timers before closing any transport
- Treat 'peer is closed' as a terminal shutdown signal, never as a retryable error
- Sequence swarm teardown: drain in-flight sends, then close transports in reverse join order
When it happens
Trigger: Calling transportA.send(nodeB, ...) concurrently with (or interleaved after) transportB.close() — send() resolved the target from the registry before unregister happened, or a broadcast's deliver() races a peer's close(). Also happens when application code holds stale transport references and sends to a node that was already shut down during swarm teardown.
Common situations: Swarm/coordinator shutdown sequences that close transports in arbitrary order while consensus ticks are still in flight; tests that close one node's transport then let a leader election continue; forgetting to stop a consensus protocol instance before closing its transport.
Related errors
- LocalTransport: closed
- LocalTransport: signature verification failed for message fr
- LocalTransport: replayed/out-of-order seq from ${msg.from} (
- LocalTransport: unreachable peer ${to}
- Can only resume paused agent
AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18).
Data as JSON: /api/errors/ada554bebb512281.
Report an issue: GitHub.