Skip to content

fix(ios): recover products after the app returns from the background - #974

Merged
pgherveou merged 4 commits into
mainfrom
fix/ws-bridge-listener-recovery
Sep 25, 2026
Merged

pgherveou merged 4 commits into
mainfrom
fix/ws-bridge-listener-recovery

Conversation

@pgherveou

@pgherveou pgherveou commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Products on iOS can stay unusable after the app has been in the background: every call fails with "TrUAPI host connection interrupted" until the product is reopened.

Scenario Fix
iOS drops the product's WebSocket to the host while the app is suspended The SDK client reconnects on the next call or when the page becomes visible (#872)
iOS reclaims the host's localhost listener, so nothing accepts that reconnect The host rebinds the listener on the same port when the app returns to the foreground. Tokens and live connections are kept, so products dial the endpoint they already hold
The page's first reconnect after the rebind is refused for a moment A visible page retries after 250 ms, 1 s and 4 s, then falls back to reconnecting on its next call

Changes

  • SharedWsBridge::relisten, exported as NativeTrUApiHostRuntime::relisten_ws_bridge.
  • TrUAPIHostRuntime calls it on UIApplication.willEnterForegroundNotification, so embedding apps need no change. Android keeps its listener and does not call it.
  • @parity/truapi: the bounded retry of a failed reconnect while the page is visible.

Verification

  • Unit tests: a lost listener comes back on the same port, existing connections keep working, and a failed rebind is retried by the next one (Rust); the foreground notification rebinds the bridge (Swift); a visible page retries a refused reconnect and stops after the bounded attempts (Bun).
  • iOS simulator, playground, faults injected while the app is in the background: with the listener closed and its connections reset, main stays Offline with "TrUAPI host connection interrupted"; this branch rebinds and the page reconnects without a call.
  • iPhone: after a background, iOS reclaimed the bridge sockets (os error 57), the host rebound on the same port and the playground reconnected on its own.

The changeset covers @parity/truapi; the native fix ships with the next iOS host release.

iOS reclaims a suspended app's listening socket; products keep dialing
the same port and token. relisten() replaces only the accept loop.
TrUAPIHostRuntime observes willEnterForeground and calls the new
relisten_ws_bridge UniFFI export.
@pgherveou pgherveou added the no-changeset No published artifact changes; changeset not required label Sep 24, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation javascript Pull requests that update javascript code rust Pull requests that update rust code host-ios Touches the iOS host tree labels Sep 24, 2026
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

CI Status: 23 required jobs green, 20 passed and 3 skipped by path filter.

All job results
job result
android-bindings success
changes success
changeset-guard success
cli-package success
codegen success
e2e skipped
explorer success
headless-install success
host-android-bindings success
host-android-detekt success
host-wasm success
ios-bindings success
ios-swift success
licenses success
playground success
provider-android-bindings skipped
release-guard success
rust success
ts-client success
ts-debugger success
ts-host success
wasm-provider success
workflow-lint skipped

Signing credentials: failure as of 2026-09-25, a release may fail

Commit 2ba126a4 · run log

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

iOS simulator preview

Built from 2ba126a4e, stamped with it in TrUAPICommit.

gh run download 36110238806 --name simulator-preview-2ba126a4e
unzip polkadot-app-*.app.zip
xcrun simctl install booted polkadot-app.app
xcrun simctl launch booted io.parity.polkadotapp.develop

Or download it in a browser, which arrives as a zip wrapping
the .app.zip, so it needs unzipping twice.

An arm64 simulator slice, so it needs an Apple Silicon Mac and does not
install on a device. Kept for 14 days, after which the link stops
resolving and a new push rebuilds it.

@pgherveou
pgherveou force-pushed the fix/ws-bridge-listener-recovery branch from d4ef1b0 to ea2b431 Compare September 24, 2026 15:39
@pgherveou
pgherveou added this pull request to stack #978 September 24, 2026 15:52
@pgherveou
pgherveou marked this pull request as ready for review September 24, 2026 15:53
@pgherveou
pgherveou requested a review from a team September 24, 2026 15:53
Right after the host rebinds its listener, the page's first dial can be
refused. Retry after 250 ms, 1 s and 4 s while visible, then fall back
to #872's next-call behavior.
@pgherveou pgherveou removed the no-changeset No published artifact changes; changeset not required label Sep 25, 2026
@pgherveou

Copy link
Copy Markdown
Collaborator Author

@lore-bot-app review

@lore-bot-app

lore-bot-app Bot commented Sep 25, 2026

Copy link
Copy Markdown

Reading the diff and checking what the record says. Back in a few minutes.

@lore-bot-app lore-bot-app Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


TL;DR
Wires an iOS foreground rebind of the shared localhost WS listener (relisten) plus a bounded visible-page reconnect retry ladder in the SDK. 1 blocking, 3 minor.

Summary
This is PR #974's implementation. It adds SharedWsBridge::relisten() / NativeTrUApiHostRuntime::relisten_ws_bridge() in Rust, which rebinds the shared listener on the same port while keeping tokens and live connections, and refactors WsBridge to extract a Listener type so start/stop/relisten share one accept-loop lifecycle. On the Swift side TrUAPIHostRuntime subscribes to UIApplication.willEnterForegroundNotification and calls relisten_ws_bridge() on each foreground. In the TS SDK, a visible page now retries a failed reconnect after 250 ms / 1 s / 4 s (VISIBLE_RETRY_DELAYS_MS) before falling back to lazy reconnect, with the counter reset on success and on visibilitychange. Docs and a changeset accompany it. Tests are added at all three layers.

What the record says

  • This is the implementation of PR #974, "fix(ios): recover products after the app returns from the background," authored by pgherveou, whose stated design is exactly this: export SharedWsBridge::relisten and call it on UIApplication.willEnterForegroundNotification to rebind on the same port and reuse existing tokens/connections. #974
  • It builds directly on the shared-listener architecture from PR #600 ("Share one localhost WS listener across native product executions"), which established SharedWsBridge and the executor-aware wait-vs-abort discipline this diff preserves. #600
  • It follows PR #872 ("reconnect the localhost bridge after its socket dies"), which introduced the SDK's next-call/on-visible reconnect this diff extends with the retry ladder. #872
  • Most important: a later PR, #983 ("fix(ws-bridge): rebind the port when the listener fails instead of spinning," pgherveou, last active 2026-09-24 18:32, after #974 at 16:08), reworks this same mechanism. Per the record, #983 rebinds the port inside the accept task with exponential backoff on EINVAL/EBADF, explicitly because the lifecycle-signal approach "needs no app lifecycle signal" and Android does not reliably deliver one; it also fixes an accept-loop spin (up to ~1,600 log lines/sec) that the foreground-only trigger does not address. #983 — This bears directly on whether the willEnterForeground trigger here is the intended long-term design or an iOS-only stopgap that #983 partly supersedes. That should be reconciled before merge; see Questions.
  • Related but not about this change: playground-app #504 wires chain/Bulletin socket resets to visibilitychange (same foreground-recovery pattern, different layer) and container #976 reloads pages whose MessagePorts died. Context, not constraints on this diff. https://github.com/paritytech/playground-app/pull/504 , #976
  • Ownership: pgherveou owns this area decisively (who_knows score 0.40, far ahead of the next), with decrypto21 (#600 author) and TarikGul as secondary. Recurring reviewers for the repo are TarikGul, Imod7, pgherveou.

Concerns

  1. (Blocking) ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift:751-758 — the foreground observer is registered with queue: nil, so the block runs synchronously on the thread that posts willEnterForegroundNotification, i.e. the main thread. It calls inner.relistenWsBridge(), which on the Rust side (ws_bridge.rs, WsBridge::relisten → Listener::stop(wait = !on_shared_executor())) takes wait = true when called off the shared executor and does a blocking stopped.recv() on the mpsc channel, waiting for the accept loop to observe shutdown. That is a synchronous, potentially unbounded block on the main thread during app resume. Given the record's note (#983) that a rebind can spin/stall when the socket is in a bad state, this is the path most likely to hang the UI thread on foreground. Consider posting to a background queue (queue: on the observer, or dispatching inside the block) or making relisten non-blocking on this path.

  2. (Minor) ios/.../TrUAPIHost.swift:729-763 — deinit calls notificationCenter.removeObserver(foregroundObserver), correct in isolation. But the observer closure captures inner (the NativeTrUApiHostRuntime) directly rather than self, which is deliberate to avoid a retain cycle. Worth confirming the intended lifecycle: if more than one TrUAPIHostRuntime is ever constructed against .default, each installs its own foreground observer and each fires relisten on the shared bridge. relisten is a no-op when no listener is started and otherwise idempotent, so this is not a correctness bug, but redundant rebinds on every foreground multiply the blocking cost in concern 1. Confirm one-runtime-per-process is guaranteed.

  3. (Minor) js/packages/truapi/src/host-connection.ts:87-92 — the retry ladder advances retries only when page?.visibilityState === "visible", and both visibilitychange→visible and a successful verify reset retries = 0. A page that fails all three retries while visible and then never fires another visibilitychange (stays visible) will not retry again until the next API call — which matches the documented intent, but the stops retrying ... after a bounded number of attempts test asserts exactly 5 sockets. Confirm the fallback-to-lazy behaviour is what's wanted when a host stays unreachable for a long visible span, rather than a slower backoff continuing. This is a design confirmation, not a defect.

  4. (Minor) Rust doc-comment convention: WsBridge::relisten, Listener::serve/stop, and bind_loopback are non-pub (crate/private) so the "doc comments on pub items required" rule (CLAUDE.md/AGENTS.md) does not force docs here, and the diff does document them anyway. No action; noting that the new pub fn relisten on SharedWsBridge and the #[uniffi::export] relisten_ws_bridge are both documented, satisfying the convention.

I checked the added tests against the repo's test conventions (AGENTS.md: a test should encode why the behaviour matters, prefer one assert_eq! over a whole value). The new tests do carry rationale comments and a_failed_relisten_is_retried_by_the_next_one asserts the whole marker vector in one assert_eq! — consistent with house style. No concern.

Questions for the author

  • How does this relate to #983? #983 (later than #974) moves rebind into the accept loop with backoff and states it needs no lifecycle signal because Android does not deliver one reliably. Is the willEnterForeground trigger here meant to coexist with #983 (iOS fast-path + accept-loop safety net), or does #983 supersede it? If they coexist, a foreground relisten and an in-loop rebind could race on the same port — is that handled? (#983)
  • Is TrUAPIHostRuntime guaranteed to be a single instance per process? The answer determines whether concern 2's redundant observers can occur.
  • Was moving relisten off the main thread considered (concern 1)? If foreground relisten must be synchronous for correctness (products dialing immediately on resume), say so and note the expected worst-case block duration.

🤖 Reviewed by Lore (Parity knowledge base) · 116.0s · claude-opus · knowledge as of 2026-09-24

Comment thread ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift
Comment thread js/packages/truapi/src/host-connection.ts
relisten waits for the old accept loop to stop; the SDK retry now
covers a page that dials before the rebind lands.
@pgherveou

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Follow-up in 2ba126a.

1. relisten blocked the main thread (fixed). The foreground observer now hands relistenWsBridge() to a global queue with .noQoS, so the rebind runs at the queue's default QoS, the same as the Rust runtime threads it waits on. Xcode's Thread Performance Checker no longer reports any wait through relisten, neither on the main thread nor as a priority inversion. The rebind does not need to be synchronous: a page that dials before it lands is covered by the visible retry (250 ms, 1 s, 4 s). The Swift test now waits for the relistened marker instead of reading it right after posting the notification.

2. More than one runtime. Each NativeTrUApiHostRuntime owns its own SharedWsBridge (native.rs), so a second runtime's observer rebinds only that runtime's listener, never a shared one. The iOS app also keeps a single cached runtime (TrUAPIHostRuntimeProvider.cachedRuntime).

3. Fallback after the three visible retries. Intended. #872 deliberately avoids a reconnect loop, so after the three retries the next API call or the next return to a visible page tries again.

4. No action needed.

Relation to #983. The two coexist. On iOS the reclaimed listener did not surface as an accept() error in any of our runs (device and simulator), so the in-loop rebind from #983 never triggers there and the foreground signal is what recovers it. #983 is the safety net where the loss does surface as an accept error, which is the Android case. They cannot both own the port: relisten stops the old accept task, including one waiting to rebind, and waits until it has exited and dropped its socket before binding. #983 now also polls shutdown first, so a stopping loop never binds again.

@pgherveou
pgherveou added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 3ef2191 Sep 25, 2026
40 checks passed
@pgherveou
pgherveou deleted the fix/ws-bridge-listener-recovery branch September 25, 2026 15:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation host-ios Touches the iOS host tree javascript Pull requests that update javascript code rust Pull requests that update rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants