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
- 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).
- Check plugin worker logs for the open handler: confirm it replies exactly once with the session id in the expected field.
- Align plugin and server versions so the duplex open reply schema matches readBindableWorkerSessionId's expectations.
- 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
- Keep plugin and server protocol versions aligned so open replies always carry the session id.
- Reply exactly once from custom duplex open handlers — late or duplicate replies never bind.
- If opens fail repeatedly, restart the plugin worker before retrying.
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
- Plugin worker "${pluginId}" is not running for the duplex ch
- DUPLEX_CHANNEL_ROUTE_BUSY
- dropping batch ${batch.batchId} after ${batch.attempt} attem
- The duplex channel launch command needs at least one argumen
- Daytona duplex channel: no cached sandbox resolves the provi
AI-assisted analysis of paperclipai/paperclip@a7e689b3c3 (2026-08-21).
Data as JSON: /api/errors/bd355c13c6a2a3b1.
Report an issue: GitHub.