{"record":{"id":"7baef7eafb8d334b","repo":"paperclipai/paperclip","slug":"duplex-channel-route-busy","errorCode":"DUPLEX_CHANNEL_ROUTE_BUSY","errorMessage":"DUPLEX_CHANNEL_ROUTE_BUSY","messagePattern":"DUPLEX_CHANNEL_ROUTE_BUSY","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"server/src/services/plugin-worker-manager.ts","lineNumber":1930,"sourceCode":"    pendingRequests: number;\n    protocolErrors: number;\n    totalDataBytes: number;\n    lifetimeTimer: ReturnType<typeof setTimeout> | null;\n    terminalized: boolean;\n    settleWait: (value: { exitCode: number | null; transportClosed?: boolean }) => void;\n    // The bounded raw data events that arrived before the bind, held in order. The\n    // bind replays them through the exact-pair routing, so an early frame is never\n    // lost and a frame whose pair does not match the bound pair still fails closed.\n    // The hold ceiling is `maxDuplexChannelPreBindHoldFrames`, one frame above the\n    // buffered bound, so the replay's buffered-bound check ends the route, not the\n    // hold. The host never retains the original arbitrary notification graph.\n    preBind: HeldDuplexEvent[];\n    // The cumulative raw bytes `preBind` holds. `DUPLEX_ROUTE_INPUT_MAX_BYTES`\n    // bounds this route-local counter, so a worker cannot flood the pre-bind\n    // hold with bytes the frame-count ceiling alone does not catch (a handful\n    // of large frames, each well under the frame-count limit). This counter\n    // covers only the hold; the post-bind buffered queue counts its own bytes\n    // in `bufferedChars`, against the same named bound.\n    preBindBytes: number;\n    // The single bounded exit event that arrived before the bind. An exit never\n    // consumes a data hold slot, so a worker that batches an exit among enough data\n    // frames to fill the hold cannot crowd out a data frame. The bind replays the\n    // held data events first, then this exit last.\n    preBindExit: HeldDuplexExitEvent | null;\n    // The cumulative raw payload bytes of every host→worker write this route\n    // has sent but the worker has not yet acknowledged. The window this\n    // counter tracks spans both retained representations of a pending write:\n    // the raw payload the pending request object holds, and the serialized\n    // child-stdin frame the host builds from it and writes before the request\n    // settles. `DUPLEX_ROUTE_PENDING_WRITE_MAX_BYTES` bounds this route-local\n    // counter, independent of `pendingRequests`: a worker that never replies\n    // cannot make one route hold an unbounded number of write bytes, even\n    // under a raised pending-request count. `sendBoundedRequest` increments it\n    // before the send and decrements it once the request settles, on every\n    // path: a reply, a rejection, a timeout, a worker exit, or a shutdown.\n    pendingWriteBytes: number;","sourceCodeStart":1912,"sourceCodeEnd":1948,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/server/src/services/plugin-worker-manager.ts#L1912-L1948","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nconst ch2 = await worker.openDuplexChannel(input); // DUPLEX_CHANNEL_ROUTE_BUSY while ch1 open\n\n// after\nawait ch1.close(); // route terminalized and confirmed\nconst ch2 = await worker.openDuplexChannel(input);","handlingStrategy":"retry","validationCode":"if (activeChannelForWorker(pluginId)) { await activeChannelForWorker(pluginId).close(); } // one live route per worker — close before open","typeGuard":null,"tryCatchPattern":"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; }","preventionTips":["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."],"tags":["duplex-channel","plugin-worker","resource-busy","concurrency","single-instance"],"backgroundTag":"resource-busy","analyzedSha":"01ad8584922b5d85292b1723cae71fa0d9b07a19","analyzedAt":"2026-08-21T17:58:32.592Z","contentChangedAt":"2026-08-21T17:58:32.592Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}