Skip to content

fix(wallet): stop believing a socket that says OPEN after a trip to the wallet - #36

Merged
ehsan6sha merged 2 commits into
mainfrom
fix/relay-wake-after-background
Sep 4, 2026
Merged

fix(wallet): stop believing a socket that says OPEN after a trip to the wallet#36
ehsan6sha merged 2 commits into
mainfrom
fix/relay-wake-after-background

Conversation

@ehsan6sha

Copy link
Copy Markdown
Member

Coming back from MetaMask, the page sat on Connecting Wallet… for several seconds after the user had already approved. Two causes, both ours.

1. The wake trusted a socket that only said it was open

relayer.connected is nothing but the WebSocket's own readyState === 1:

get connected(){ return this.provider?.connection?.socket?.readyState === 1 || false }

Android suspends the TCP connection underneath a socket without telling it, so it keeps reporting OPEN while the wallet's approval sits on the relay waiting for a client that thinks it needs nothing. Nothing in @walletconnect/core catches this from a browser — the ping-based liveness check (startPingTimeout) is gated on isNode(), and the heartbeat's own reconnect fires only on !connected. Our wakeRelay returned early on the very same flag. So on return, everyone looked at a dead socket that said OPEN and did nothing until Chrome's TCP stack gave up. That was the delay.

Now, when the tab has been hidden for at least BACKGROUND_STINT_MS (1 s — a trip to a wallet is never shorter, a flick between desktop tabs usually is), readyState is not consulted and the transport is restarted outright. restartTransport() tears the socket down, dials again, re-subscribes every topic, and its subscriber then calls batchFetchMessages — which is precisely the fetch of whatever the wallet published while we were dead. A socket that was actually healthy pays one reconnect, well under a second; one that was not pays nothing it did not already owe.

2. The wake never ran for the connect round-trip at all

wallet.provider comes from AppKit's ProviderController.setProvider, which runs on connection. So during the connect itself — the first trip every user makes, and the one in the report — it was undefined, and useRelayWake had nothing to act on. The socket the approval arrives over lives on AppKit's UniversalProvider, which exists from the moment the chooser opens. WalletSigner now asks AppKit for it (getUniversalProvider()) and wakes that when there is no session provider yet.

The splash-screen hang

Not fixed by this PR, and I no longer believe a web page can fix it directly — two device tests ruled out user activation and URL shape. What it does is name the likely cause in the stuck-wallet hint: Android restricting MetaMask in the background, so it is suspended or killed on every switch away and cannot handle the request it is resumed for. The fix is the user's — Settings → Apps → MetaMask → Battery → Unrestricted, and the same for the browser. This is the exact setting the mobile FxBlox app always needed for these prompts (it is already in the legacy section of the docs); with it in place the wallet comes forward already showing the prompt. Same guidance goes to docs.fx.land in functionland/docs#179.

Testing

  • npm test --workspaces — 949 passing. New: wakeRelay restarts a "connected" socket after a background stint but not otherwise; a genuinely-down socket still takes the polite path; a relayer without restartTransport is left alone; a failed restart is swallowed. useRelayWake restarts after ≥ 1 s hidden, leaves an open socket alone after a shorter flick, and ignores a visible with no preceding hidden.
  • npm run typecheck --workspaces, npm run lint clean.
  • Not verified on a device yet — that is the reporter's phone, and the ask is: come back from MetaMask after connecting and say how long Connecting Wallet… shows now.

🤖 Generated with Claude Code

https://claude.ai/code/session_013BpqXrkEPA9odTyRdK5Mnx

…he wallet

Coming back from MetaMask, the page sat on "Connecting Wallet..." for several
seconds after the user had already approved. Two causes, both ours.

1. The wake trusted `relayer.connected`, which is nothing but the WebSocket's
   own `readyState === 1`. Android suspends the TCP connection underneath a
   socket without telling it, so it keeps reporting OPEN while the wallet's
   approval sits on the relay waiting for a client that thinks it needs nothing.
   Nothing in @walletconnect/core catches this from a browser: the ping-based
   liveness check (`startPingTimeout`) is gated on `isNode()`, and the
   heartbeat's own reconnect only fires on `!connected`. And our `wakeRelay`
   returned early on exactly the same flag. So on return, everyone looked at a
   dead socket that said OPEN and did nothing until Chrome's TCP stack gave up.

   Now, when the tab has been hidden for at least BACKGROUND_STINT_MS (1s -- a
   trip to a wallet is never shorter, a flick between desktop tabs usually is),
   `readyState` is not consulted and the transport is restarted outright.
   `restartTransport()` tears the socket down, dials again, re-subscribes every
   topic, and its subscriber then calls `batchFetchMessages` -- which is the
   fetch of whatever the wallet published while we were dead. A socket that was
   in fact healthy pays one reconnect, well under a second; one that was not
   pays nothing it did not already owe.

2. The wake never ran for the connect round-trip at all. `wallet.provider`
   comes from AppKit's `ProviderController.setProvider`, which runs on
   connection -- so during the connect itself, the first trip every user
   makes, it was undefined and `useRelayWake` had nothing to act on. The socket
   the approval arrives over lives on AppKit's UniversalProvider, which exists
   from the moment the chooser opens; WalletSigner now asks AppKit for it and
   wakes that when there is no session provider yet.

Also: the stuck-wallet hint names the likely cause of MetaMask wedging on its
splash screen EVERY time -- Android restricting the wallet in the background,
so it is suspended or killed on every switch and cannot handle the request it
is resumed for. The fix is the user's to make (Settings > Apps > MetaMask >
Battery > Unrestricted, and the same for the browser); it is the setting the
mobile app always needed for the same prompts, and a web page cannot set it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BpqXrkEPA9odTyRdK5Mnx
ehsan6sha added a commit to functionland/docs that referenced this pull request Sep 4, 2026
…time, and shorten the wait (#179)

Two updates to the wallet hand-off guidance.

The "give the page a few seconds" note now says "a second or two". As of
functionland/fxblox-web#36 the app restarts its relay socket the moment the tab
comes back from a wallet rather than trusting a socket that only claims to be
open, so the approval is fetched at once instead of after Chrome's TCP stack
notices. The advice not to tap again or reload in the meantime stands.

The stuck-splash-screen tip now says what to do when it happens EVERY time:
Android is restricting MetaMask in the background, so it is suspended or killed
on every switch away and cannot handle the request it is resumed for. Settings >
Apps > MetaMask > Battery > Unrestricted, and the same for the browser. This is
the setting the mobile FxBlox app always needed for the same prompts -- the
legacy section of this page has said so for a long time -- and the web app is
subject to it in exactly the same way. A web page cannot change it for the user,
so the page has to tell them.

Same line added to the troubleshooting list and to Switch Chain Troubleshooting.


Claude-Session: https://claude.ai/code/session_013BpqXrkEPA9odTyRdK5Mnx

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@ehsan6sha
ehsan6sha merged commit 999ca5b into main Sep 4, 2026
2 checks passed
@ehsan6sha
ehsan6sha deleted the fix/relay-wake-after-background branch September 4, 2026 22:09
ehsan6sha added a commit that referenced this pull request Sep 4, 2026
…ke the hand-off measurable (#38)

The reporter still sees about five seconds of "Connecting Wallet..." after
approving in MetaMask, with #36 deployed. Two things in response.

1. The reconnect no longer waits for a dead socket to say goodbye.

   #36 replaced a socket that only claimed to be OPEN by calling
   `restartTransport()`. Read further, that is the polite path: `resetTransport`
   -> `transportDisconnect` -> `provider.disconnect()` under a 2 s timeout --
   two seconds spent waiting for a close handshake that a TCP connection Android
   suspended will never complete, before a single byte of the reconnect is sent.

   The relayer has a faster path, and it is the one IT takes when a socket's
   `close` event fires on its own: `onProviderDisconnect()` -- stop the
   subscriber and dial a FRESH socket 100 ms later. The dead socket is abandoned
   rather than closed; `createProvider()` detaches its listeners first, so its
   eventual close reaches nobody. That is the truthful treatment of a socket
   that is, in fact, gone. The polite restart stays as the fallback if the fresh
   socket is not up within WAKE_TIMEOUT_MS, and the log says which path ran.

   (Checked while here: `batchFetchMessages`, with its 1 s sleep, has no call
   site in the shipped core. The relay pushes queued messages on
   `irn_batchSubscribe`, so nothing else on the path waits on purpose.)

2. The hand-off is now measurable from the phone.

   Every theory about the delay and the splash-screen hang has been argued from
   code reading, because the only evidence was console.log on a phone nobody had
   a debugger on. `wallet/diag.ts` routes the same lines into the clientLogger
   ring buffer, timestamped "+Nms since return" -- the moment every one of those
   seconds is counted from -- and the debug-mode banner now shares the buffer
   (plus the build sha) with one tap. Logged: tab hidden/visible with the hidden
   duration and whether a relay provider was even present; which reconnect path
   ran and how long it took; every AppKit connected/connecting/account/provider
   flip; on the sign tap, the relay state, the stored deep-link choice and
   Chrome's user-activation flag; when the request reached the relay; the exact
   URL of each hop and the activation flag at that moment; when the signature
   arrived. Always on -- the ring is 500 lines in memory -- so the one report
   that matters is not taken with it off.


Claude-Session: https://claude.ai/code/session_013BpqXrkEPA9odTyRdK5Mnx

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant