Skip to content

SwiftUI app keeps retrying a revoked direct pairing instead of asking to pair again when the host has several routes #17591

Open

Description

@gmelvill

What happened

My iPhone runs the SwiftUI TestFlight app and reaches my Windows desktop host three ways: home LAN, Tailscale, and T3 Connect. While trying to recover from an earlier disconnect, I used Settings → Connections → Revoke others on the desktop. That revoked the phone's direct pairing, which is fair enough.

The app never told me to pair again. At times the thread list showed the host as offline (red crossed-out Wi-Fi icon and red host name, while iOS itself showed Wi-Fi connected), and the connection screens said disconnected or reconnecting. Signing out of T3 Connect and back in brought the Connect route back. But the app still keeps trying the revoked pairing over the LAN about every 20 seconds, more than seven hours after the revoke.

Diagnosis

Host records (desktop 0.0.46-nightly.20261009.2873):

  • auth_sessions: the phone's only direct (bearer) pairing was revoked at 2026-10-09T12:01:58Z by a Revoke others batch. No direct session has been issued to the phone since. New T3 Connect sessions were issued at 18:32Z and 19:31Z.
  • server.trace.ndjson, 2026-10-09 19:04–19:35Z, user agent T3Code/58: over the LAN route the app discovers the host fine, then gets 401 on every websocket ticket request. Over the same 30 minutes the T3 Connect route works normally. The host is healthy; only the revoked direct credential fails. (An earlier window, 18:18–18:47Z, had 73 such 401s, 67 on websocket-ticket and 6 on /api/orchestration/shell, before the new Connect session existed.)

Client source (read at t3code/rebuild-mobile-app-swift @ 52912fe7e3; TestFlight build 58 may differ slightly, so line numbers are approximate):

  • The "Pair again" state (needsPairingDetail: "This computer no longer accepts the saved pairing. Pair again.") is gated on the environment having a single route:
    • NativeFeatureClient.swift ~L317: if activeLoad?.credentialRejected == true, environment.routes.count == 1
    • ~L5936: load.credentialRejected && load.environment.kind != .managedDPoP && load.environment.routes.count == 1; otherwise the state falls through to .disconnected with "That server is currently unreachable."
  • With more than one route, recoverEnvironmentRoute (~L10213) re-resolves routes every 15 s. routeRejectionIsFinal (~L10206) only becomes true when that recovery is itself rejected. When another route still works (here T3 Connect), the rejection is never final. The revoked direct route is retried indefinitely, with no prompt and no back-off.

So with several routes a revoked pairing shows up in one of two ways. If no other route is authorised, the host is reported as unreachable. If another route works, the app keeps hammering the dead route in the background. In neither case does it ask the user to pair again.

Steps to reproduce

Not yet run deliberately; this is the sequence from the host records.

  1. Pair the SwiftUI app with a desktop host directly (LAN and/or Tailscale) and also sign in to T3 Connect, so the environment has several routes.
  2. On the desktop, open Settings → Connections → Revoke others.
  3. Reopen the SwiftUI app. While Connect is not yet authorised, the host shows as offline/disconnected rather than "Pair again".
  4. Sign in to T3 Connect again. The app works through Connect, but the host trace shows POST /api/auth/websocket-ticket → 401 on the direct route roughly every 20 s, indefinitely.

Expected: a "Pair again" prompt for the revoked direct route, and retries on that route stop or back off.

Version

SwiftUI TestFlight 0.1.0 (58); desktop 0.0.46-nightly.20261009.2873 (ec80933ac8)

Environment

iPhone, iOS 27; Windows 11 desktop host; routes: LAN, Tailscale Serve HTTPS, T3 Connect

Evidence

# server.trace.ndjson, 2026-10-09 19:04–19:35Z, user agent T3Code/58
LAN route  GET  /.well-known/t3/environment  200  x69
LAN route  POST /api/auth/websocket-ticket    401  x68   (median interval 22.8 s)
Connect    POST /api/auth/websocket-ticket    200  x38
Connect    GET  /ws                           204  x38
Connect    GET  /api/orchestration/shell      200  x26   (0.46-3.56 s)

# auth_sessions (phone, user agent T3Code/58)
direct bearer pairing      revoked 2026-10-09T12:01:58Z (Revoke others)   no replacement issued
T3 Connect sessions        issued  2026-10-09T18:32:22Z, 19:31:42Z        working

Related issues

Fix applied or workaround

Nothing changed on the machine. Likely workaround: pair the phone again over the direct route, or remove and re-add the environment (the workaround confirmed in #17014).

Update, 2026-10-10: re-pairing on the LAN did not fix the Tailscale route

A few minutes after filing this, the phone was paired again over the home LAN (new direct session issued 2026-10-09T19:53:57Z, still valid). That fixed the app at home. The next morning, away from home on mobile data, the host was unusable again. Desktop is now 0.0.46-nightly.20261009.2886. Times below are UTC; the phone was on UTC+11.

What the app showed (23:57Z): the thread list header said "JAFFA-11 offline" with the crossed-out icon, and threads on that host showed its name in red. The phone was on mobile data and, as the host logs show, reaching the desktop over Tailscale.

What the host saw (server.trace.ndjson, user agent T3Code/58, 23:48Z to 00:08Z):

  • The phone reached the host the whole time, over both the Tailscale Serve hostname and the raw tailnet IP. GET /.well-known/t3/environment and GET /api/auth/session returned 200 in 1-4 ms, repeatedly.
  • At 23:58:14 to 23:58:29 it sent eight POST /api/auth/websocket-ticket requests over the Tailscale hostname, with growing gaps (0.4 s up to 6 s). All eight returned 401 with SessionTokenRevokedError.
  • After those eight, it made no further websocket-ticket requests until the new pairing 12 minutes later, and only kept polling discovery and session.
  • No T3 Connect requests appear in that window. The phone's last Connect session (issued 19:31Z) had expired at 20:31Z.

The host logs don't record which credential was sent. Because the LAN pairing was valid at the time, the likeliest explanation is that the Tailscale routes were still using the credential revoked on 2026-10-09 and the LAN re-pair didn't update them. That would be the #17014 pattern (other routes keep a dead login after re-pairing) in the SwiftUI app. Throughout, the app showed the host as offline rather than asking to pair again.

Recovery:

  • At 00:03Z I created a fresh pairing for the Tailscale address on the desktop and entered it in the app's Add environment form.
  • 00:10:39Z: POST /oauth/token over Tailscale returned 200, and the host issued a new direct session.
  • 00:10:54Z: a second POST /oauth/token returned 401, consistent with the single-use code having already been redeemed.
  • The form showed: "This iPhone could not reach the server. Check the address and network, then try again." The host was reachable throughout. I can't tell which of the two attempts this message followed, but neither was a network failure.
  • 00:11:00Z: the app connected over Tailscale anyway (websocket-ticket 200, /api/orchestration/shell 200 in 2.7 s), with the new session in use.
  • 00:11:04Z: the app also got a new T3 Connect token and connected through Connect. I had switched T3 Connect off and on in the app earlier and it hadn't reconnected then. From the host side I can't tell whether the toggle or the new pairing triggered it.

What this adds to the report:

  1. Pairing again on one direct route doesn't appear to refresh the credential the other direct routes use. Re-pairing on the LAN left the Tailscale routes failing. The workaround above only fixes the route it was done on.
  2. When the only usable routes reject the credential as revoked, the app reports the host as offline even though it is answering discovery requests within milliseconds. It never shows "Pair again".
  3. The Add environment form says the server couldn't be reached when the code exchange actually succeeded, or failed because the code had already been used.

Filed by

Initial investigation: OpenAI Codex (gpt-6.1-sol) via t3 triage. Host-record checks, Swift source reading and this write-up: Claude (Opus 5.5) in T3 Code. 2026-10-10 update: host-log extraction by OpenAI Codex (gpt-6.1-sol); checks and write-up by Claude (Opus 5.5) in T3 Code.

— Geoff. I'm not a developer; AI agents gathered this evidence from my machine and wrote it up, so please flag anything that doesn't add up.

Activity

  1. juliusmarminge commented on Oct 22, 2026

    @juliusmarminge
    Member

    Classified as a connection-recovery bug; no exploitable vulnerability is reported. Checked open issue titles through the last 90 days: the React Native report #17014 is a different client, and #13994 concerns valid sessions, so neither is a duplicate.

    Read the SwiftUI README and NativeFeatureClient.swift at cached branch commit 4619c0bca6. reconcileEnvironmentLoads still gates needsPairing on routes.count == 1; routeRejectionIsFinal and recoverEnvironmentRoute also control rejected-credential recovery across routes. This is source inspection, not a runtime reproduction. Fetching current source failed with an HTTPS transfer timeout.

    The triage hard stop prohibits changes to authentication or credential handling. Handing this to Julius for that review; no fix was written. SwiftUI device behavior remains unverified on this Linux host.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions