{"record":{"id":"6409f9b95815ab65","repo":"stablyai/orca","slug":"relay-pairing-client-closed-6409f9","errorCode":null,"errorMessage":"relay pairing client closed","messagePattern":"relay pairing client closed","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"info","filePath":"mobile/src/transport/pairing-relay-candidate.ts","lineNumber":85,"sourceCode":"        'info',\n        `Relay: resolving director (attempt ${attempt + 1}/${maxAttempts})`,\n        redactSocketEndpoint(relay.directorUrl)\n      )\n      try {\n        const moved = await args.resolveDirector(relay)\n        // Why: the authenticated newer assignment must be durable before a\n        // target dial so a crash cannot revert to the known-stale cell.\n        await args.persistMove(moved)\n        log(\n          'info',\n          'Relay: cell moved',\n          `${redactSocketEndpoint(relay.cellUrl)} → ${redactSocketEndpoint(moved.cellUrl)}`\n        )\n        client.close()\n        relay = moved\n        await backOff(attempt)\n        if (closed) {\n          throw new Error('relay pairing client closed')\n        }\n        client = args.connect(relay, args.onLog)\n        return await client.sendRequest(method, params)\n      } catch (error) {\n        lastError = error\n        log('warn', `Relay: recovery attempt ${attempt + 1} failed`, pairingRelayErrorDetail(error))\n        if (!isDirectorRecoverable(error) || attempt + 1 >= maxAttempts) {\n          log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`)\n          throw error\n        }\n        await backOff(attempt)\n      }\n    }\n    throw lastError\n  }\n}\n\nfunction pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay {","sourceCodeStart":67,"sourceCodeEnd":103,"githubUrl":"https://github.com/stablyai/orca/blob/1136503c6a231a16dce8f921f6fadb63d181e8db/mobile/src/transport/pairing-relay-candidate.ts#L67-L103","documentation":"Thrown inside recoverThroughDirector after the relay candidate's close() was called externally between persistMove(moved) and the next connect attempt. It is a cooperative-cancellation signal: the wrapper sets closed=true on close() (line 41-42), and after backOff(attempt) the recovery loop checks `if (closed)` and throws this so the in-flight sendRequest rejects cleanly rather than dialing a fresh client on a dead candidate. It is NOT a transport failure — it means the caller abandoned the relay candidate mid-recovery.","triggerScenarios":"Concretely: (1) the pre-profile pairing coordinator's dispose() ran (timer fired or caller cancelled), which iterates clients and calls client.close(); (2) racePairingCandidates declared another candidate the winner, prompting the loser (this relay candidate) to be closed; (3) startPreProfilePairing's .finally() block at line 112-115 closed the client while recovery was awaiting backOff.","commonSituations":"Appears during normal pairing races where the direct path wins while the relay is still resolving a director move, or when the user cancels pairing during a slow relay cell migration. Also surfaces in tests that don't await the recovery loop before tearing down.","solutions":["Treat this as cancellation, not an error: if your caller initiated close()/dispose(), swallow it; if not, audit who is calling close() on the relay candidate concurrently.","Ensure racePairingCandidates closes losing candidates only after the winner is fully selected, not speculatively.","In tests, await any in-flight sendRequest promise before invoking close() on the candidate.","If surfacing to users, map this message to a 'pairing cancelled' UI state rather than a relay failure."],"exampleFix":"// before\ntry { await client.sendRequest(method, params) } catch (e) { throw e }\n\n// after — distinguish cancellation from real relay failure\ntry { await client.sendRequest(method, params) }\ncatch (error) {\n  if (error instanceof Error && /relay pairing client closed/.test(error.message)) return // expected on dispose\n  throw error\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"function isRelayClientClosedError(error: unknown): boolean {\n  return error instanceof Error && error.message === 'relay pairing client closed'\n}","tryCatchPattern":"try {\n  await candidate.sendRequest(method, params)\n} catch (error) {\n  if (isRelayClientClosedError(error)) {\n    // cancellation — caller invoked close() during recovery; not a relay fault\n    return\n  }\n  throw error\n}","preventionTips":["Await in-flight sendRequest promises before calling close() on a relay candidate.","Treat 'relay pairing client closed' as a cancellation signal in error-reporting/UI layers, not a transport failure.","In racePairingCandidates, close losing candidates only after the winner is committed."],"tags":["relay","pairing","cancellation","concurrency"],"backgroundTag":null,"analyzedSha":"1136503c6a231a16dce8f921f6fadb63d181e8db","analyzedAt":"2026-08-12T23:15:58.167Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}