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
- Close and await the previous channel session (session close/terminal confirmation) before opening a new one.
- Serialize channel opens per worker with a queue/mutex so only one route is live at a time.
- If the previous session is unrecoverable, wait for its lifetime timer to expire and terminalize the route, then retry the open.
- 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
- Maintain exactly one duplex session per plugin worker; track it in the caller.
- Always await session close before reconnecting after failures.
- Serialize opens per worker (mutex/queue) in multi-caller code.
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
- DUPLEX_CHANNEL_OPEN_FAILED
- Plugin worker " " is not running for the duplex channel.
- ACPX provider ownership admission is closed
- ACPX runtime host already has an active turn
- Bridge envelope changed while reading.
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)