{"record":{"id":"bd355c13c6a2a3b1","repo":"paperclipai/paperclip","slug":"duplex-channel-open-failed","errorCode":"DUPLEX_CHANNEL_OPEN_FAILED","errorMessage":"DUPLEX_CHANNEL_OPEN_FAILED","messagePattern":"DUPLEX_CHANNEL_OPEN_FAILED","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"server/src/services/plugin-worker-manager.ts","lineNumber":1982,"sourceCode":"\n  // The process-scoped aggregate route-slot controller. The manager injects it, so\n  // the ceiling counts every duplex route across every worker in the process, not\n  // one agent's setting. When it is absent, the worker admits every open, so the\n  // handle runs unbounded in isolation (a unit test constructs it this way).\n  const duplexRouteSlots = options.duplexRouteSlots ?? null;\n  // The routes that currently hold one aggregate slot. The host releases a slot one\n  // time per route, so a double terminalize never releases two slots.\n  const duplexRouteSlotHolders = new Set<DuplexChannelRoute>();\n\n  // The terminalized routes that still hold buffered bytes for a late listener\n  // drain. A terminalized route leaves the opening and live maps, so this registry\n  // keeps the worker-exit sweep able to clear its still-buffered representations.\n  const terminalDuplexRoutes = new Set<DuplexChannelRoute>();\n\n  // Try to reserve one aggregate route slot for a route. Return true when the\n  // route holds a slot after the call. When no controller is present, the route\n  // always holds a slot.\n  function acquireDuplexRouteSlot(route: DuplexChannelRoute): boolean {\n    if (!duplexRouteSlots) return true;\n    if (!duplexRouteSlots.tryAcquire()) return false;\n    duplexRouteSlotHolders.add(route);\n    return true;\n  }\n\n  // Release the aggregate route slot a route holds, one time. A route that never\n  // held a slot releases nothing.\n  function releaseDuplexRouteSlot(route: DuplexChannelRoute): void {\n    if (!duplexRouteSlotHolders.delete(route)) return;\n    duplexRouteSlots?.release();\n  }\n\n  // Retire the worker at once. The host kills the process, so every live route,\n  // every reserved route, and every tombstone drops together. The host uses this\n  // as the fail-closed response to an ownership violation and to a tombstone-set\n  // overflow. The host never logs the raw frame content on a violation.\n  function retireDuplexWorkerOnViolation(reason: string): void {","sourceCodeStart":1964,"sourceCodeEnd":2000,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/server/src/services/plugin-worker-manager.ts#L1964-L2000","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","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."],"exampleFix":"// before\nconst session = await worker.openDuplexChannel(input); // DUPLEX_CHANNEL_OPEN_FAILED\n\n// after (retry once after the failed route is terminalized)\nlet session;\ntry {\n  session = await worker.openDuplexChannel(input);\n} catch (err) {\n  if (err instanceof Error && err.message === \"DUPLEX_CHANNEL_OPEN_FAILED\") {\n    session = await worker.openDuplexChannel(input); // route was terminalized; safe to retry\n  } else throw err;\n}","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"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; }","preventionTips":["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."],"tags":["duplex-channel","plugin-worker","handshake","fail-closed","retry"],"backgroundTag":"channel-open-failed","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"}