fix(ios): recover products after the app returns from the background - #974
Conversation
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.
|
CI Status: 23 required jobs green, 20 passed and 3 skipped by path filter. All job results
Signing credentials: failure as of 2026-09-25, a release may fail Commit |
iOS simulator previewBuilt from 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.developOr download it in a browser, which arrives as a zip wrapping An arm64 simulator slice, so it needs an Apple Silicon Mac and does not |
d4ef1b0 to
ea2b431
Compare
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.
|
@lore-bot-app review |
|
Reading the diff and checking what the record says. Back in a few minutes. |
There was a problem hiding this comment.
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::relistenand call it onUIApplication.willEnterForegroundNotificationto 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
SharedWsBridgeand 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 thewillEnterForegroundtrigger 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
-
(Blocking)
ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift:751-758— the foreground observer is registered withqueue: nil, so the block runs synchronously on the thread that postswillEnterForegroundNotification, i.e. the main thread. It callsinner.relistenWsBridge(), which on the Rust side (ws_bridge.rs,WsBridge::relisten→Listener::stop(wait = !on_shared_executor())) takeswait = truewhen called off the shared executor and does a blockingstopped.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 makingrelistennon-blocking on this path. -
(Minor)
ios/.../TrUAPIHost.swift:729-763—deinitcallsnotificationCenter.removeObserver(foregroundObserver), correct in isolation. But the observer closure capturesinner(theNativeTrUApiHostRuntime) directly rather thanself, which is deliberate to avoid a retain cycle. Worth confirming the intended lifecycle: if more than oneTrUAPIHostRuntimeis ever constructed against.default, each installs its own foreground observer and each firesrelistenon the shared bridge.relistenis 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. -
(Minor)
js/packages/truapi/src/host-connection.ts:87-92— the retry ladder advancesretriesonly whenpage?.visibilityState === "visible", and bothvisibilitychange→visible and a successful verify resetretries = 0. A page that fails all three retries while visible and then never fires anothervisibilitychange(stays visible) will not retry again until the next API call — which matches the documented intent, but thestops retrying ... after a bounded number of attemptstest 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. -
(Minor) Rust doc-comment convention:
WsBridge::relisten,Listener::serve/stop, andbind_loopbackare non-pub(crate/private) so the "doc comments onpubitems required" rule (CLAUDE.md/AGENTS.md) does not force docs here, and the diff does document them anyway. No action; noting that the newpub fn relistenonSharedWsBridgeand the#[uniffi::export] relisten_ws_bridgeare 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
willEnterForegroundtrigger here meant to coexist with #983 (iOS fast-path + accept-loop safety net), or does #983 supersede it? If they coexist, a foregroundrelistenand an in-loop rebind could race on the same port — is that handled? (#983) - Is
TrUAPIHostRuntimeguaranteed to be a single instance per process? The answer determines whether concern 2's redundant observers can occur. - Was moving
relistenoff the main thread considered (concern 1)? If foregroundrelistenmust 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
relisten waits for the old accept loop to stop; the SDK retry now covers a page that dials before the rebind lands.
|
Thanks for the review. Follow-up in 2ba126a. 1. 2. More than one runtime. Each 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 |
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.
Changes
SharedWsBridge::relisten, exported asNativeTrUApiHostRuntime::relisten_ws_bridge.TrUAPIHostRuntimecalls it onUIApplication.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
mainstays Offline with "TrUAPI host connection interrupted"; this branch rebinds and the page reconnects without a call.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.