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.

Source

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

    route.buffered = [];
    route.bufferedChars = 0;
    route.preOpen = [];
    route.preOpenExit = null;
    clearDuplexChannelLifetimeTimer(route);
    settleRouteWait(route, { exitCode: null });
  }

  // Open one live generic duplex channel route. Reserve the route before the open
  // call, bind the worker session identifier one time on the first successful
  // open reply, and return a session a caller drives. Terminalize the route on
  // every open failure path.
  async function openDuplexChannel(
    input: DuplexChannelOpenInput,
  ): Promise<DuplexChannelHostSession> {
    if (duplexChannelRoute) {
      // A route for this worker is not yet closed and confirmed. Reject the
      // second open with one fixed non-secret error before it reaches the worker.
      throw new Error(DUPLEX_CHANNEL_ROUTE_BUSY);
    }
    const hostRouteId = randomUUID();
    let settleWait: (value: { exitCode: number | null }) => void = () => {};
    const waitPromise = new Promise<{ exitCode: number | null }>((resolve) => {
      settleWait = resolve;
    });
    const route: DuplexChannelRoute = {
      hostRouteId,
      state: "reserved",
      workerSessionId: null,
      listener: null,
      buffered: [],
      bufferedChars: 0,
      preOpen: [],
      preOpenExit: null,
      pendingRequests: 0,
      protocolErrors: 0,
      totalDataBytes: 0,

View on GitHub (pinned to a7e689b3c3)

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@a7e689b3c3 (2026-08-21). Data as JSON: /api/errors/7baef7eafb8d334b. Report an issue: GitHub.