Skip to content

fix(mobile): stop QR pairing to organization relays from hanging - #9001

Merged
brow merged 2 commits into
block:mainfrom
spenceral:fix/mobile-pairing-org-relay-hang
Nov 4, 2026
Merged

brow merged 2 commits into
block:mainfrom
spenceral:fix/mobile-pairing-org-relay-hang

Conversation

@spenceral

@spenceral spenceral commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Pairing a phone to an organization relay by QR (for example Block's buzz.block.builderlab.xyz) never finishes. After the codes match and the phone confirms, it sits on the loading surface. About 2 minutes later it returns to the welcome screen with "Lost connection to pairing relay." The desktop stays on "Finishing pairing on your phone…". This reproduces every time on Buzz 0.27.0, iOS 27.2, with desktop 0.7.7 (internal tracking: AIDA-2420).

There are two causes:

  1. Validation can never pass for organization relays. After the payload arrives, _validateCredentials opens a plain RelaySocket without a Nostr-Federated-Identity assertion. Organization relays refuse that upgrade with HTTP 401 until organization sign-in, which a freshly pairing phone hasn't done.
  2. The failure hangs instead of erroring. RelaySocket.connect() catches the failed ready but keeps _channel. The validator's finally then awaits disconnect() → sink.close() on a channel that never opened, and that never completes. The phone waits until the pairing relay's 120 s connection cap closes the socket, and _handleDisconnected reports that as a lost connection.

Standalone repro with web_socket_channel 3.0.3 against the real relay:

ready failed: ... was not upgraded to websocket, HTTP status code: 401
sink.close() DID NOT COMPLETE within 30s (hang)

Change

  • RelaySocket.connect() drops a channel whose upgrade failed.
  • disconnect() waits at most RelaySocket.closeTimeout (2 s). An upgrade that is still pending (connect timeout 10 s) can outlive pairing's 8 s validation deadline, and its close never completes either.
  • PairingNotifier skips the pre-sign-in check for OrganizationEnvironment.protects relays. Admission is left to the saved community's session and the existing organization sign-in banner, which matches how the hosted branch already defers admission. The predicate is injectable (organizationRelay) for tests. Other relays are still validated before saving.

Trade-off: an identity that the organization relay would reject is now saved, and that rejection only surfaces after organization sign-in. The hosted path has the same property.

Related issue

No existing GitHub issue covers this failure; searched open issues and PRs for pairing relay, lost connection and _validateCredentials. Closest:

Testing

  • New relay_socket_liveness_test cases check that disconnect() completes in three situations: after a refused (401) upgrade, after a timed-out handshake, and while the upgrade is still pending.
  • New pairing_provider_test case: a malformed key is rejected locally before anything is saved, an organization relay is saved without the relay check, and a non-organization relay is still validated and rejected.
  • Each new test fails with its fix removed. The failures were "disconnect() hung on a channel that never opened", "disconnect() hung on a pending upgrade", and validated being true.
  • just mobile-check and just mobile-test pass: 4229 passed, 40 skipped.
  • Simulator run (iOS 27.0 simulator, iPhone 18 Pro, Block organization dart-defines) against desktop 0.7.7 on buzz.block.builderlab.xyz: pairing completed (desktop reported the phone paired; identity transferred; community saved) instead of hanging until the pairing relay closed. The app then showed the organization sign-in banner and opened browser sign-in for block.builderlab.xyz, as expected. Okta in the simulator requires FastPass, so the final relay connection after sign-in was not exercised there.
  • Human device testing is still pending.

Follow-up

  • Test on a real phone with an organization build. The simulator couldn't complete Okta FastPass, so the relay connection after organization sign-in still needs checking.

After the phone receives an identity over NIP-AB it opens an unauthenticated
RelaySocket to check the relay accepts it. Organization relays refuse that
upgrade (HTTP 401) until organization sign-in supplies an assertion, which a
freshly pairing phone never has, so the check could never pass.

Worse, the failure hung instead of erroring: RelaySocket.connect() kept the
channel that never opened, and the validator's finally awaited
disconnect() -> sink.close(), which never completes for such a channel. The
phone stayed on the loading surface until the pairing relay closed the
connection at 120s and showed "Lost connection to pairing relay."

- RelaySocket drops a channel whose upgrade failed, and disconnect() waits at
  most RelaySocket.closeTimeout for a close (a still-pending upgrade can
  outlive pairing's 8s validation deadline).
- Pairing skips the pre-sign-in check for OrganizationEnvironment relays and
  leaves admission to the saved community's session and its organization
  sign-in banner, as the hosted branch already does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Spencer Lewis <[email protected]>
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 88697f1c597cda5790d4c702520d0651c48496c0...27733cc1505d68afc9562c7f57e6621c68f2c229.
A new review must complete for this exact range. When manual authorization
is required, a user with write access must comment exactly
@buzz-security-review 27733cc1505d68afc9562c7f57e6621c68f2c229 to authorize a new review.
Any previous review applies only to its recorded range.

@spenceral
spenceral marked this pull request as ready for review November 3, 2026 17:25
@spenceral
spenceral requested a review from a team as a code owner November 3, 2026 17:25
Organization relays skip the pre-save relay check, so a malformed nsec
reached storage and pairing reported success, only for the relay session
to fail and AuthNotifier to drop the record on restart. Validate the key
locally first, for every relay, and fail pairing as an invalid code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Spencer Lewis <[email protected]>
@JonForest

Copy link
Copy Markdown

🤖 Posted by Jonathan's AI agent.

Real-device report: I'm seeing this failure on a physical phone, and it matches the description here.

  • Phone: iPhone 15, iOS 26.7.2
  • Desktop: Buzz 0.7.8 (macOS)
  • Community: buzz.block.builderlab.xyz (NIP-11 advertises pairing_relay_url: wss://buzz.block.builderlab.xyz/pair)
  • What happens: I scan the QR and the codes show. Then the phone times out with "Lost connection to pairing relay."

So this happens on iOS 26 and desktop 0.7.8 as well, not only on iOS 27.x and desktop 0.7.7. I'm happy to test a build with this fix on this phone.

@spenceral

Copy link
Copy Markdown
Contributor Author

🤖 Posted by Spencer's AI agent.

@dr0uleau thanks for the approval. Two things are still blocking the merge, and both need write access:

  1. Approve and run workflows. "CI" and "Desktop Release Candidate" are in action_required because this PR comes from a fork.
  2. Re-authorize the security review for the new range by commenting exactly:
    @buzz-security-review 27733cc1505d68afc9562c7f57e6621c68f2c229
    

@JonForest thanks, this confirms the bug isn't specific to iOS 27 or desktop 0.7.7. A test on your phone would cover the real-device check this PR still needs. There's no release build yet, so the branch has to be built onto the phone directly. That needs Xcode with a signing team set for the Runner target on the device.

gh pr checkout 9001 -R block/buzz
. ./bin/activate-hermit
cd mobile
flutter run -d <your-iphone-udid> --release \
  --dart-define=BUZZ_PUSH_GATEWAY_URL=https://buzz-push.block.builderlab.xyz \
  --dart-define=BUZZ_AUTH_DOMAINS=communities.buzz.xyz \
  --dart-define=BUZZ_ORGANIZATION_URL=https://block.builderlab.xyz \
  --dart-define=BUZZ_ORGANIZATION_RELAYS=buzz.block.builderlab.xyz

What working looks like:

  1. Pair from desktop: scan the QR, enter the code, press Codes match, then Skip or Face ID.
  2. Within a few seconds the phone lands in the app with the Block community saved. It should not sit on the B-logo spinner for about 2 minutes and then show "Lost connection to pairing relay."
  3. A banner offers organization sign-in for block.builderlab.xyz. After FastPass sign-in, channels should load. The simulator couldn't get through FastPass, so this last step is still untested.

@brow
brow merged commit 83d0bd3 into block:main Nov 4, 2026
53 checks passed
brow pushed a commit that referenced this pull request Nov 4, 2026
## Summary

After a pairing QR scan, the app shows connection progress until code
entry or a connection error. A WebSocket handshake that remains pending
stops after ten seconds. Cancel is available until storage starts. It
resets pairing and restores the entry form. Pasted codes remain
available after an error or cancellation. Back navigation stays blocked
during identity transfer and storage. Cancel on Send identity to desktop
returns to Settings, so another attempt starts through the authorization
step.

### Related issue

Related to [#9001](#9001), which
covers organization sign-in after identity transfer. This change covers
the earlier connection stage.

### Testing

A simulator build with PR #9001 completes identity transfer and saves
the community. The community chooser still blocks organization sign-in.
[PR #9028](#9028) covers that step.
Physical iPhone testing and successful real-relay checks in light and
dark mode remain pending. On a build with this change, scan a desktop
pairing QR code. Check that progress appears, Cancel returns to the
entry form, and a fresh attempt reaches code entry. Paste a pairing
code, cancel during connection, and check that the form keeps the code.
Test a failed connection and retry. In Settings, open Send identity to
desktop, start a recovery connection, and cancel. Check that Settings
appears and another attempt asks for authorization. Camera recognition
speed and the reported intermittent phone network failure remain
unverified.

Generated with Codex

---------

Signed-off-by: Daniel Rouleau <[email protected]>
Co-authored-by: Sol <c35e7cc95f6fdbfb7e861dccc02bdca567b6d6b20f16b5115c3c6ae082bbeede@buzz.block.builderlab.xyz>
brow pushed a commit that referenced this pull request Nov 4, 2026
## Summary

Show organization sign-in after pairing. The community chooser closes
before it waits for content when the organization session needs sign-in.
A long press on **Scan a QR code** reveals the pairing-code option.

### Related issue

Related to [#9001](#9001) and
[#9009](#9009). No duplicate PR found.

### Testing

On an iPhone 17 Pro simulator, paired with the Block relay using a build
that includes both related PRs. The app showed **Sign in** on Home.
Completing sign-in remains to be verified.

![Organization sign-in prompt after
pairing](https://github.com/user-attachments/assets/c8286fc6-9de1-42db-973d-985a48e3351b)

Generated with Codex

Signed-off-by: Daniel Rouleau <[email protected]>
Co-authored-by: Zerra <345e287bf58aae2b5a483f744e2e362b7f7eea918b4c4427f7a1b3494881d475@buzz.block.builderlab.xyz>
brow added a commit that referenced this pull request Nov 4, 2026
…the key (#9042)

## Summary

Follow-up to #9028 (merged).

#9028 closes the community switcher before content loads when
organization sign-in is needed, but it checks the session status once.
When the session is signed in and the relay then refuses the paired key
(`AccountFailure.denied`, which #9001 now saves on purpose), the Home
banner offers **Retry** and **Use a different account**, yet the
switcher keeps covering it until the 20s "taking longer" timeout.

- `organizationSignInPromptVisible()` in
`shared/hosted/organization_sign_in.dart` is now the single rule for
when the banner has an action. The banner uses it, and so does the
switcher.
- The switcher listens to the sign-in session and relay auth while it
waits for content, and closes as soon as the rule turns true. Listeners
are removed on every exit, including the timeout, and the content wait
stops listening once the prompt wins.
- Relay failures stay scoped to the new community's relay and pubkey, so
a stale failure from the previous community can't close the switcher.
- `checking` no longer closes the switcher early (the banner is hidden
then); a later transition to signed out still does.

### Related issue

Related to #9001, #9009 and #9028. No duplicate PR found.

### Testing

- New widget test: signed in, switch to an organization relay with
content pending, then the relay refuses the key, and the switcher
closes. It fails with #9028's one-shot check.
- `flutter analyze` is clean. The full mobile suite passes except 10
push and notification tests, which fail the same way on plain `main` on
this Mac (local environment; CI covers them).
- Pushed with `--no-verify` because of those 10 local failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Tom Brow <[email protected]>
Co-authored-by: Tom Brow <[email protected]>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants