paperclipai/paperclip · error

DUPLEX_CHANNEL_OPEN_FAILED

DUPLEX_CHANNEL_OPEN_FAILED

Error message

DUPLEX_CHANNEL_OPEN_FAILED

What it means

Thrown by the plugin worker manager when a duplex channel open cannot produce a usable session: after the open RPC completes, the reply must carry a bindable worker session id and the route must still be in the opening state (readBindableWorkerSessionId at server/src/services/plugin-worker-manager.ts:1848). A malformed reply, a duplicate or late open reply, or a route that already left opening fails closed with DUPLEX_CHANNEL_OPEN_FAILED; the same constant is also used to wrap non-Error throwables from the send/open path. Every failure path terminalizes the route exactly once.

Source

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

          environmentId: input.environmentId,
          providerLeaseId: input.providerLeaseId,
          command: input.command,
        },
        duplexChannelOpenTimeoutMs,
      );
    } catch (err) {
      // A send failure, an RPC rejection, or an open timeout. Terminalize the
      // route exactly once and fail closed.
      await terminalizeDuplexChannelRoute(route);
      throw err instanceof Error ? err : new Error(DUPLEX_CHANNEL_OPEN_FAILED);
    }

    const workerSessionId = readBindableWorkerSessionId(route, openResult);
    if (!workerSessionId) {
      // A malformed reply, or a route that already left `opening`. A late or a
      // duplicate reply never binds, revives, or reopens a route.
      await terminalizeDuplexChannelRoute(route);
      throw new Error(DUPLEX_CHANNEL_OPEN_FAILED);
    }
    // Bind the worker session identifier one time and move the route to `open`.
    route.workerSessionId = workerSessionId;
    route.state = "open";

    // Replay any data or exit frame that arrived in the open-reply read batch,
    // before the route bound. The route is `open` now, so each replayed frame
    // passes through the normal per-frame bounds and the session match.
    drainPreOpenDuplexChannelNotifications(route);

    // Start the route lifetime timer now the route is open. The route ends when
    // the timer expires. Every terminal path and the worker-exit path clears the
    // timer. Unreference the timer so it never blocks the host process shutdown.
    // A replayed frame can end the route during the drain above, so start the
    // timer only while the route is still open.
    if (route.state === "open") {
      route.lifetimeTimer = setTimeout(() => {
        void terminalizeDuplexChannelRoute(route);

View on GitHub (pinned to a7e689b3c3)

Solutions

  1. Retry the open — the failed route is terminalized, so a fresh open starts from a clean state (close any prior session first to avoid ROUTE_BUSY).
  2. Check plugin worker logs for the open handler: confirm it replies exactly once with the session id in the expected field.
  3. Align plugin and server versions so the duplex open reply schema matches readBindableWorkerSessionId's expectations.
  4. If the worker is unresponsive, restart the plugin worker before retrying.

Example fix

// before
const session = await worker.openDuplexChannel(input); // DUPLEX_CHANNEL_OPEN_FAILED

// after (retry once after the failed route is terminalized)
let session;
try {
  session = await worker.openDuplexChannel(input);
} catch (err) {
  if (err instanceof Error && err.message === "DUPLEX_CHANNEL_OPEN_FAILED") {
    session = await worker.openDuplexChannel(input); // route was terminalized; safe to retry
  } else throw err;
}
Defensive patterns

Strategy: retry

Try / catch

try { return await worker.openDuplexChannel(input); } catch (err) { if ((err as Error).message === "DUPLEX_CHANNEL_OPEN_FAILED") { await backoff(250); return await worker.openDuplexChannel(input); /* failed route was terminalized; retry is safe */ } throw err; }

Prevention

When it happens

Trigger: openDuplexChannel where the worker's open reply lacks/misses the session id field, replies twice (late duplicate never binds), the route timed out or was terminalized between send and reply, or the underlying RPC threw a non-Error value. Also fires when the open send itself fails with a non-Error throwable (Errors propagate unchanged).

Common situations: Plugin protocol version mismatch (worker writes a different reply shape); worker under load replying after the route lifetime expired; bugs in a custom plugin's duplex open handler; serialization losing fields across the worker boundary.

Related errors


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