{"record":{"id":"30cc10288c7abe03","repo":"tinyhumansai/openhuman","slug":"tunnel-no-session-cipher-handshake-incomplete","errorCode":null,"errorMessage":"[tunnel] no session cipher — handshake incomplete","messagePattern":"\\[tunnel\\] no session cipher — handshake incomplete","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"app/src/services/transport/TunnelTransport.ts","lineNumber":322,"sourceCode":"        this.streams.delete(requestId);\n      }\n      return;\n    }\n\n    if (kind === 'response') {\n      const pending = this.pending.get(requestId);\n      if (!pending) return;\n      clearTimeout(pending.timeoutId);\n      this.pending.delete(requestId);\n      pending.resolve(envelope.payload);\n      return;\n    }\n  }\n\n  // -- send ------------------------------------------------------------------\n\n  private async sendEnvelope(envelope: Envelope): Promise<void> {\n    if (!this.cipher) throw new Error('[tunnel] no session cipher — handshake incomplete');\n\n    await this.rateLimiter.consume();\n\n    const chunks = chunk(envelope);\n    for (const raw of chunks) {\n      const encrypted = this.cipher.seal(raw);\n      const frameB64 = base64urlEncode(encrypted);\n      this.socket!.emit('tunnel:frame', { channelId: this.channelId, payload: frameB64 });\n    }\n\n    log(\n      '[tunnel] sent %s requestId=%s chunks=%d',\n      envelope.kind,\n      envelope.requestId,\n      chunks.length\n    );\n  }\n","sourceCodeStart":304,"sourceCodeEnd":340,"githubUrl":"https://github.com/tinyhumansai/openhuman/blob/749120085864ce16e0f273c7b86fac7740b39c5b/app/src/services/transport/TunnelTransport.ts#L304-L340","documentation":"sendEnvelope() requires this.cipher, the symmetric XChaCha20-Poly1305 TunnelCipher that exists only after the sealed handshake is acked by the core. The socket 'disconnect' handler nulls cipher (along with staticDhKey and clientEphemeralKeypair), so the error means: a call was attempted before the first handshake completed, or after a disconnect before the automatic reconnection re-ran the handshake.","triggerScenarios":"Calling transport.call() without awaiting the connect/handshake promise first; a socket.io disconnect (network blip, laptop sleep) landing between calls — the next sendEnvelope hits a nulled cipher until reconnect + tunnel:connected + handshake ack complete again.","commonSituations":"Race at startup where UI issues RPC before the tunnel is ready; mobile clients backgrounding the app (socket drops) and resuming instantly; flaky Wi-Fi causing rapid disconnect/reconnect cycles.","solutions":["Always go through the transport's connect path (or the TransportManager race) and await readiness before the first call","On this error, await the transport's connect/handshake again (ensureConnected re-runs it because disconnect nulled _connectPromise) then retry the call","Call manager.reset() to rebuild both transports if the tunnel keeps flapping"],"exampleFix":"// before\nthis.socket.emit(...); // raw send, cipher may be null after a disconnect\n// after\nawait this.ensureConnected(); // re-runs handshake, re-derives cipher\nawait this.sendEnvelope(envelope);","handlingStrategy":"validation","validationCode":"// Handshake-readiness gate: never emit frames directly — go through call(),\n// which awaits the connect/handshake promise before sending:\nconst result = await tunnel.call('openhuman.ping', {}); // connect + handshake + RPC\n// If you drive the socket yourself, mirror the internal state:\nif (!tunnel.isConnected()) await tunnel.connect();","typeGuard":null,"tryCatchPattern":"try {\n  await tunnel.call(method, params);\n} catch (err) {\n  if (err instanceof Error && err.message.includes('no session cipher')) {\n    await sleep(500);              // allow socket.io reconnect + re-handshake\n    return await tunnel.call(method, params);\n  }\n  throw err;\n}","preventionTips":["Always await the connect/handshake path before the first RPC on a fresh tunnel","After laptop-sleep/network-flap, expect one reconnect cycle before calls succeed — back off briefly","Use manager.reset() when the tunnel flaps repeatedly rather than retrying forever"],"tags":["tunnel","crypto","handshake","encryption","socket-io"],"backgroundTag":"handshake-incomplete","analyzedSha":"749120085864ce16e0f273c7b86fac7740b39c5b","analyzedAt":"2026-08-17T21:21:45.363Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}