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

  1. Stop the consensus protocol (timers/loops) before calling transport.close()
  2. After close, never reuse the instance — create a new LocalTransport (and re-register it) instead
  3. Guard sends with an isClosed check or route all sends through one owner that owns the lifecycle
  4. 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

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


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