Route pairing connect kickoff rejections to the failure screen - #3351
Conversation
A pair()/connect() kickoff rejection (Bluetooth powered off between scan and tap, a native-bridge error) was only console.error'd while navigation proceeded to /pairing/loading, which routes onward only on fullyBooted or a pair_failure event — a kickoff throw emits neither, so the user sat on the spinner until manually cancelling. Catch the rejection at the fire-and-forget kickoff sites (scan, scan-controller, btclassic including the paired-context connectDefault branch) and replace the loading screen with /pairing/failure using the new errors:pairingCouldNotStart message. Kickoff promises resolve as soon as the native attempt starts, so the happy path is unchanged.
📋 PR Review Helper📱 Mobile App Build✅ Ready to test! (commit 🕶️ ASG Client Build⏳ Waiting for build... 🔀 Test Locallygh pr checkout 3351 |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2a1c077. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2a1c077f74
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // A kickoff rejection (e.g. Bluetooth powered off) emits no | ||
| // pair_failure event, so the loading screen pushed below would spin | ||
| // forever — replace it with the failure screen. | ||
| replace("/pairing/failure", {error: "errors:pairingCouldNotStart", deviceModel: device.model}) |
There was a problem hiding this comment.
Guard delayed pairing failure after cancellation
When the user backs out of /pairing/loading during the fixed 2s delay, or before the native promise rejects, this timer/catch still runs because it is not cancelled on navigation. With Bluetooth off, the new unconditional replace can replace whatever screen the user returned to with /pairing/failure, so a manual cancel can be undone by a stale async callback; gate this on still being on the matching loading route or clear/ignore the delayed callback on cancellation. The same delayed-catch pattern was added in scan-controller.tsx.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 4685fc9 — took the gate approach: the shared routePairingKickoffFailure() helper replaces only while /pairing/loading is still the top route, so a manual cancel can no longer be undone by the stale callback. Same guard applied in scan-controller.tsx and btclassic.tsx.
There was a problem hiding this comment.
3 issues found across 7 files
You’re at about 95% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="mobile/src/app/pairing/scan.tsx">
<violation number="1" location="mobile/src/app/pairing/scan.tsx:143">
P2: The setTimeout in startPairing is never cleared on unmount, so the replace("/pairing/failure") inside the catch can fire after the user has already left the screen or started a different pairing flow, causing an unexpected route change with stale device params. Consider storing the timeout ID in a ref and clearing it in a useEffect cleanup or at the start of a new pairing attempt.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| // A kickoff rejection (e.g. Bluetooth powered off) emits no | ||
| // pair_failure event, so the loading screen pushed below would spin | ||
| // forever — replace it with the failure screen. | ||
| replace("/pairing/failure", {error: "errors:pairingCouldNotStart", deviceModel: device.model}) |
There was a problem hiding this comment.
P2: The setTimeout in startPairing is never cleared on unmount, so the replace("/pairing/failure") inside the catch can fire after the user has already left the screen or started a different pairing flow, causing an unexpected route change with stale device params. Consider storing the timeout ID in a ref and clearing it in a useEffect cleanup or at the start of a new pairing attempt.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mobile/src/app/pairing/scan.tsx, line 143:
<comment>The setTimeout in startPairing is never cleared on unmount, so the replace("/pairing/failure") inside the catch can fire after the user has already left the screen or started a different pairing flow, causing an unexpected route change with stale device params. Consider storing the timeout ID in a ref and clearing it in a useEffect cleanup or at the start of a new pairing attempt.</comment>
<file context>
@@ -137,6 +137,10 @@ export default function SelectGlassesBluetoothScreen() {
+ // A kickoff rejection (e.g. Bluetooth powered off) emits no
+ // pair_failure event, so the loading screen pushed below would spin
+ // forever — replace it with the failure screen.
+ replace("/pairing/failure", {error: "errors:pairingCouldNotStart", deviceModel: device.model})
})
}, 2000)
</file context>
There was a problem hiding this comment.
Fixed in 4685fc9 — rather than tracking timeout IDs across screens, the catch now routes through routePairingKickoffFailure(), which no-ops unless /pairing/loading is still the top route; a late rejection after leaving the flow only logs.
Deploying mentra-live-ota-site with
|
| Latest commit: |
c65fd1b
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://aa5aad19.mentra-live-ota-site.pages.dev |
| Branch Preview URL: | https://pairing-kickoff-failure-rout.mentra-live-ota-site.pages.dev |
Deploying mentra-store-dev with
|
| Latest commit: |
c65fd1b
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://1784c298.augmentos-appstore-2.pages.dev |
| Branch Preview URL: | https://pairing-kickoff-failure-rout.augmentos-appstore-2.pages.dev |
Deploying dev-augmentos-console with
|
| Latest commit: |
c65fd1b
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://a8e789cd.dev-augmentos-console.pages.dev |
| Branch Preview URL: | https://pairing-kickoff-failure-rout.dev-augmentos-console.pages.dev |
…ttempt Address the bot-review findings on the kickoff-failure routing: - The delayed pair()/connect() rejection could land after the user backed out of /pairing/loading, replacing whatever screen they returned to with the failure screen. Route through a shared routePairingKickoffFailure() helper that fires only while /pairing/loading is still the top route. - Match loading.tsx's handlePairFailure by clearing the failed attempt (abandonAttempt) before surfacing the failure screen. - Read default_wearable at rejection time in btclassic's connectDefault branch instead of capturing a render-time value.
Deploying prod-augmentos-account with
|
| Latest commit: |
c65fd1b
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://b0d173bc.augmentos-e84.pages.dev |
| Branch Preview URL: | https://pairing-kickoff-failure-rout.augmentos-e84.pages.dev |
PR Agent Orchestrator State{
"cycle": 2,
"fixRound": 0,
"totalReviewerRuns": 2,
"consecutiveNoNewReviews": 2,
"openFindings": [],
"resolvedFindings": [],
"nitFindings": [],
"phase": "discovery",
"status": "in_progress",
"lastPair": [],
"stagnationFixRounds": 0,
"lastOpenCount": 0,
"fingerprintReopenCounts": {},
"mutedFingerprints": []
} |
🤖 PR Agent Review — cycle 2✅ No blocking findings · 0 blocking · 0 nits No model reviews ran this cycle. Updated automatically by the PR Agent Orchestrator each review cycle. Nits do not block merge. |
PR #3352 reified the identity lifecycle behind toolkit.pairing.identity(); host readers go through the projection, not raw settings keys. The btclassic connectDefault kickoff-failure copy was reading default_wearable raw, which also skips the projection's legacy-junk handling. Read the identity snapshot at rejection time instead and name its model (paired or pending) in the failure copy.

Stacked on #3346 (
pairing-two-phase-identity) — builds on its pairing screens; merges into that branch.Problem
A pairing connect KICKOFF rejection — e.g. Bluetooth powered off between scan and tap, or a native-bridge error — was only
console.error'd while navigation proceeded to/pairing/loading. The loading screen routes onward only when the glasses reachfullyBootedor apair_failureevent arrives; a kickoff throw emits neither (thewaitForReadyresult is discarded, only the 35s diagnostic report fires), so the user sat on the spinner until manually cancelling.Fix
Catch the kickoff rejection at each fire-and-forget call site and
replace()the top route (loading by then) with/pairing/failure— the same navigation shape asloading.tsx'shandlePairFailure:pairing/scan.tsx—startPairing()direct path (toolkit.pairing.pairin the 2ssetTimeout)pairing/scan-controller.tsx— same shape as scan.tsx (found while scoping; identical two-line fix)pairing/btclassic.tsx—handleSuccess(), both branches:toolkit.glasses.connect(device, {saveAsDefault: false})and the paired-contextconnectDefault()(failuredeviceModelcomes from thedefault_wearablesetting there)New
errors:pairingCouldNotStartkey ("Couldn't start connecting to your {{glassesModel}}…") matchesfailure.tsx'stranslate(error, {glassesModel})rendering; other locales spread...enso onlyen.tschanges. Existingconsole.errorlogs are kept. Kickoff promises resolve as soon as the native attempt starts (BT-off throws synchronously viarequirePoweredOn), so there is no happy-path latency cost.This also resolves the cubic P2 on #3346 (unawaited
connectinbtclassic.tsxhandleSuccessleaving the user to the 35s timeout with no error).Tests
src/__tests__/app/pairing/btclassic.test.tsx: happy-path kickoff does not route to failure; device-branch rejection and paired-contextconnectDefaultrejection both route to/pairing/failurewith the rightdeviceModel.scan.test.tsx: new case — pair kickoff rejection after the 2s delay replaces loading with the failure route.jest.setup.js: added the missingotherBtConnected/onOtherBtConnectedmembers to the island pairing mock (btclassic.tsx subscribes to them; no btclassic suite existed before).loading.test.tsxunchanged (loading.tsx untouched).Evidence:
cd mobile && bun run compileclean;bun run test412/412 passing, 53 suites (the "worker process has failed to exit gracefully" teardown warning reproduces on the base branch too). Prettier-clean on touched files.Note
Medium Risk
Touches core pairing navigation and delayed async callbacks; guards limit wrong-screen redirects, but regressions could still affect scan, BT Classic, and loading flows.
Overview
When
toolkit.pairing.pairortoolkit.glasses.connect/connectDefaultreject at kickoff (e.g. Bluetooth off), the app no longer only logs the error while/pairing/loadingspins indefinitely. A sharedroutePairingKickoffFailurehelper **replace**s to/pairing/failurewitherrors:pairingCouldNotStart, callsabandonAttemptlikeloading.tsx, and no-ops if the user already left loading.Wired from
scan.tsx,scan-controller.tsx, andbtclassic.tsx(includingconnectDefaultfailures using the pairing identity model). English copy adds the new error string; Jest gainsotherBtConnectedpairing mocks,btclassictests, andscancases for failure routing and suppressed stale rejections.Reviewed by Cursor Bugbot for commit 752278e. Bugbot is set up for automated code reviews on this repo. Configure here.