ruvnet/ruflo · error · Error
LocalTransport: closed
Error message
LocalTransport: closed
What it means
send() checks its own closed flag first and rejects if close() was already called on this transport. close() also unregisters the node from the registry, so the transport is permanently unusable afterwards — this is a use-after-close lifecycle error, not a transient failure.
Source
Thrown at v3/@claude-flow/swarm/src/consensus/transport.ts:257
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;
}
async send(to: string, msg: Omit<ConsensusMessage, 'from'>, timeoutMs?: number): Promise<ConsensusReply> {
if (this.closed) throw new Error('LocalTransport: closed');
const target = this.registry.get(to);
if (!target) throw new Error(`LocalTransport: unreachable peer ${to}`);
const stamped = this.stamp(msg);
const t = timeoutMs ?? this.defaultTimeoutMs;
return Promise.race([
this.deliver(target, stamped),
new Promise<ConsensusReply>((_, rej) => setTimeout(() => rej(new Error(`LocalTransport: send to ${to} timed out (${t}ms)`)), t)),
]);
}
async broadcast(msg: Omit<ConsensusMessage, 'from'>): Promise<void> {
if (this.closed) throw new Error('LocalTransport: closed');
const stamped = this.stamp(msg);
await Promise.allSettled(
this.registry.peerIds(this.nodeId).map(id => {
const target = this.registry.get(id);
return target ? this.deliver(target, stamped).catch(() => {}) : Promise.resolve();
}),View on GitHub (pinned to fa13ee4ad6)
Solutions
- Stop the consensus protocol (timers/loops) before calling transport.close()
- After close, never reuse the instance — create a new LocalTransport (and re-register it) instead
- Guard sends with an isClosed check or route all sends through one owner that owns the lifecycle
- In tests, fully await shutdown in afterEach before the next case starts
Example fix
// before await consensus.stop(); someTimer = setInterval(() => transport.send(leader, heartbeatMsg), 50); // still firing await transport.close(); // interval's next send throws 'LocalTransport: closed' // after await consensus.stop(); clearInterval(someTimer); // stop all senders first await transport.close();
Defensive patterns
Strategy: type-guard
Validate before calling
// Owner-of-lifecycle pattern: only send while not torn down
let closed = false;
async function safeSend(t, to, msg) {
if (closed) return null;
return t.send(to, msg);
}
// close path:
closed = true; await drain(); await t.close(); Type guard
// Wrap the transport in a guard object (closed flag is private on LocalTransport):
function isUsable(t, registry) { return registry.get(t.nodeId) === t; } // close() unregisters
if (!isUsable(tx, registry)) throw new Error('transport already closed'); Try / catch
try { await transport.send(to, msg); }
catch (e) {
if (e instanceof Error && e.message === 'LocalTransport: closed') return; // expected during teardown
throw e;
} Prevention
- Cancel every interval/loop that can call send() BEFORE close()
- Own the transport in exactly one component; close it there once, after senders stop
- Never reuse a closed LocalTransport — construct a new one
When it happens
Trigger: Any await transport.send(...) that runs after await transport.close() — typically a consensus timer (election timeout, heartbeat) or an application promise that was not stopped/drained before teardown A race where close() wins against a send() scheduled in the same tick (close sets closed synchronously; send checks it before delivering) Tests that close in afterEach but let pending protocol loops continue into the next test
Common situations: Forgetting to stop the raft/byzantine/gossip protocol instance before closing its transport Shared/singleton transports closed by one code path while another still sends Promise chains surviving component unmount in long-running services
Related errors
- LocalTransport: peer ${target.nodeId} is 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/923280f3889ff241.
Report an issue: GitHub.