paperclipai/paperclip · warning

DUPLEX_CHANNEL_ROUTE_BUSY

DUPLEX_CHANNEL_ROUTE_BUSY

Error message

DUPLEX_CHANNEL_ROUTE_BUSY

What it means

Thrown by the plugin worker manager's openDuplexChannel when a duplex channel route already exists for the worker: each worker supports exactly one live generic duplex channel route, reserved before the open call and released only after close is confirmed (server/src/services/plugin-worker-manager.ts:1796, DUPLEX_CHANNEL_ROUTE_BUSY). A second open before the previous route reached its terminal state is rejected with this fixed, non-secret error before anything is sent to the worker.

Solutions

  1. Close and await the previous channel session (session close/terminal confirmation) before opening a new one.
  2. Serialize channel opens per worker with a queue/mutex so only one route is live at a time.
  3. If the previous session is unrecoverable, wait for its lifetime timer to expire and terminalize the route, then retry the open.
  4. Design reconnect paths to reuse the existing session instead of opening a second route.

Example fix

// before
const ch2 = await worker.openDuplexChannel(input); // DUPLEX_CHANNEL_ROUTE_BUSY while ch1 open

// after
await ch1.close(); // route terminalized and confirmed
const ch2 = await worker.openDuplexChannel(input);
Defensive patterns

Strategy: retry

Validate before calling

if (activeChannelForWorker(pluginId)) { await activeChannelForWorker(pluginId).close(); } // one live route per worker — close before open

Try / catch

try { return await worker.openDuplexChannel(input); } catch (err) { if ((err as Error).message === "DUPLEX_CHANNEL_ROUTE_BUSY") { await existingSession.close(); return await worker.openDuplexChannel(input); } throw err; }

Prevention

When it happens

Trigger: Calling openDuplexChannel twice on the same plugin worker without closing the first session — e.g. a client reconnects (dropped connection, retry loop) while the old route is still open/opening, or two callers stream into the same plugin concurrently.

Common situations: Reconnect logic that opens a new channel on transient failure without terminating the previous one; a leaked session whose close was never awaited; concurrent agents both granted duplex on the same plugin worker.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-08-21). Data as JSON: /api/errors/7baef7eafb8d334b. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/plugin-worker-manager.ts:1930

    pendingRequests: number;
    protocolErrors: number;
    totalDataBytes: number;
    lifetimeTimer: ReturnType<typeof setTimeout> | null;
    terminalized: boolean;
    settleWait: (value: { exitCode: number | null; transportClosed?: boolean }) => void;
    // The bounded raw data events that arrived before the bind, held in order. The
    // bind replays them through the exact-pair routing, so an early frame is never
    // lost and a frame whose pair does not match the bound pair still fails closed.
    // The hold ceiling is `maxDuplexChannelPreBindHoldFrames`, one frame above the
    // buffered bound, so the replay's buffered-bound check ends the route, not the
    // hold. The host never retains the original arbitrary notification graph.
    preBind: HeldDuplexEvent[];
    // The cumulative raw bytes `preBind` holds. `DUPLEX_ROUTE_INPUT_MAX_BYTES`
    // bounds this route-local counter, so a worker cannot flood the pre-bind
    // hold with bytes the frame-count ceiling alone does not catch (a handful
    // of large frames, each well under the frame-count limit). This counter
    // covers only the hold; the post-bind buffered queue counts its own bytes
    // in `bufferedChars`, against the same named bound.
    preBindBytes: number;
    // The single bounded exit event that arrived before the bind. An exit never
    // consumes a data hold slot, so a worker that batches an exit among enough data
    // frames to fill the hold cannot crowd out a data frame. The bind replays the
    // held data events first, then this exit last.
    preBindExit: HeldDuplexExitEvent | null;
    // The cumulative raw payload bytes of every host→worker write this route
    // has sent but the worker has not yet acknowledged. The window this
    // counter tracks spans both retained representations of a pending write:
    // the raw payload the pending request object holds, and the serialized
    // child-stdin frame the host builds from it and writes before the request
    // settles. `DUPLEX_ROUTE_PENDING_WRITE_MAX_BYTES` bounds this route-local
    // counter, independent of `pendingRequests`: a worker that never replies
    // cannot make one route hold an unbounded number of write bytes, even
    // under a raised pending-request count. `sendBoundedRequest` increments it
    // before the send and decrements it once the request settles, on every
    // path: a reply, a rejection, a timeout, a worker exit, or a shutdown.
    pendingWriteBytes: number;

View on GitHub (pinned to 01ad858492)