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

  1. Ensure consensus protocol instances are stopped before any transport is closed (stop election/heartbeat timers first, then close transports)
  2. Sequence teardown so followers/close happens after in-flight sends resolve, or route sends through a supervisor that drains pending messages first
  3. Wrap send()/broadcast() in try-catch and treat 'peer ... is closed' as a shutdown signal to stop retrying
  4. 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

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


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/ada554bebb512281. Report an issue: GitHub.