Skip to content

fix(wallet): close the relay socket on the way out, so the return is one dial - #39

Merged
ehsan6sha merged 1 commit into
mainfrom
wallet/park-socket-on-hidden
Sep 5, 2026
Merged

fix(wallet): close the relay socket on the way out, so the return is one dial#39
ehsan6sha merged 1 commit into
mainfrom
wallet/park-socket-on-hidden

Conversation

@ehsan6sha

Copy link
Copy Markdown
Member

First diagnostic log from the reporter's phone (build 7187fba, via the debug banner from #38). The return from the connect approval:

[tab] visible after 28875ms hidden
[relay] socket is down (connecting=true) — opening the transport
[relay] transportOpen did not get the socket up within the bound — restarting the transport   (+2503ms)
[relay] transport restart finished in 8272ms, connected=true                                  (+8273ms)
[wallet] connected=true                                                                       (+8651ms)

What the seconds were

Not a zombie socket — the theory #36 was built on. A socket that was down, with a connect attempt pending. While the tab was hidden Android took the network, the socket closed, and the library did what it does on a close: scheduled a reconnect, dialled, failed, slept its backoff, dialled, failed, slept longer. connect() retries five times with a sleep of attempt seconds between. The tab came back in the middle of one of those sleeps with connectPromise pending, and everything awaits that promise — the library's own transportOpen() and every lever in relayWake. Nothing can cancel a setTimeout inside the library. So the return paid the rest of the sleep, then one dial, plus 2.5 s of our own bound waiting on a promise that could not resolve inside it. Nine seconds, none of them a dial that reached the relay.

The third return in the same log landed at a luckier point in the loop and took 1.5 s. That is the floor: one dial on that network.

The fix

transportClose() the moment the tab goes hidden. It sets transportExplicitlyClosed — the one flag every auto-reconnect path checks first — so nothing dials a network that is not there, no backoff accrues, and no promise is pending when the tab returns. connect() clears the flag on the way back in, so transportOpen() on visible is a single clean dial. The wallet's approval or signature, published while we were away, is queued by the relay against its topic and pushed on re-subscribe (hasAnyTopics counts cached subscriptions, so the reopen proceeds). The close happens while the user is in the wallet, where no one is waiting on it.

Expected: return delay drops from ~8.6 s to ~1.5 s on the reporter's network. The log lines will say.

Also from the log

[relay] fresh socket up in 1ms on the second return. #38's fast path checked connected — which the socket being replaced still reports, that being the whole problem — so it congratulated the dead socket. It now requires a new provider object as well as OPEN before declaring success.

Also: the guards route test gets a budget a busy runner can meet

set up → / lands on /blox still missed its 15 s waitFor about one full-suite run in three after #37 — on CI and locally, with nothing else competing. The Blox screen's chunk is the largest in the app and Vitest transforms it in a worker sharing the machine with every other file; alone it takes ~2.4 s. The test asserts which route matched, never how fast, so ROUTE_TIMEOUT goes to 30 s. That is the last known source of red-with-everything-passing on main.

Testing

npm test --workspaces clean. New: parkRelay closes an open or mid-dial socket and leaves a down one alone; the hook parks on hidden and the return is a single transportOpen; without transportClose to park with, a socket still claiming OPEN on return is replaced; the fast-path fallback test now models the dead socket still reporting OPEN and asserts the restart actually replaced the provider.

🤖 Generated with Claude Code

https://claude.ai/code/session_013BpqXrkEPA9odTyRdK5Mnx

…one dial

First diagnostic log from the reporter's phone (build 7187fba), return from the
connect approval:

    [tab] visible after 28875ms hidden
    [relay] socket is down (connecting=true) -- opening the transport
    [relay] transportOpen did not get the socket up within the bound -- restarting  (+2503ms)
    [relay] transport restart finished in 8272ms, connected=true                    (+8273ms)
    [wallet] connected=true                                                         (+8651ms)

Not a zombie socket. A socket that was DOWN with a connect attempt pending. While
the tab was hidden Android took the network, the socket closed, and the library
did what it does on a close: scheduled a reconnect, dialled, failed, slept its
backoff, dialled, failed, slept longer -- `connect()` retries five times with a
sleep of `attempt` seconds between. The tab came back mid-sleep with
`connectPromise` pending, and everything awaits that promise: the library's own
`transportOpen()`, and every lever in relayWake. Nothing cancels a setTimeout
inside the library. The return paid the rest of the sleep, then one dial, plus
2.5 s of our own bound waiting on a promise that could not resolve inside it.
Nine seconds, none of them a dial that reached the relay.

The third return in the same log landed at a luckier point in the loop and took
1.5 s. That is the floor: one dial on that network.

So: `transportClose()` the moment the tab goes hidden. It sets
`transportExplicitlyClosed`, the one flag every auto-reconnect path checks
first, so nothing dials a network that is not there, no backoff accrues, and no
promise is pending when the tab returns. `connect()` clears the flag on the way
back in, so `transportOpen()` on visible is a single clean dial. The wallet's
approval or signature, published while we were away, is queued by the relay
against its topic and pushed on re-subscribe. The close itself happens while the
user is in the wallet, where no one is waiting on it.

Also from the log: "fresh socket up in 1ms" on the second return. The #38 fast
path checked `connected`, which the socket being replaced still reports -- the
dead socket, congratulated. It now requires a NEW provider object as well.

And the `guards` route test gets a 30 s budget instead of 15. It still missed
15 s about one full-suite run in three after #37, with nothing else competing:
the Blox chunk is the largest in the app and is transformed in a worker sharing
the machine with every other file. The test asserts which route matched, never
how fast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BpqXrkEPA9odTyRdK5Mnx
@ehsan6sha
ehsan6sha merged commit 8c5d4d8 into main Sep 5, 2026
2 checks passed
@ehsan6sha
ehsan6sha deleted the wallet/park-socket-on-hidden branch September 5, 2026 15:31
ehsan6sha added a commit that referenced this pull request Sep 5, 2026
…clears a stuck wallet (#42)

Reverts #40 (debug mode: publish the request but do not open the wallet) and
#41 (debug mode: hand the wallet no return address). Both were diagnostics, and
they did their job: four device logs showed MetaMask wedged on its splash screen
on every WARM resume -- by our deep link, by the manual button, by recent apps,
with the redirect on, with it off -- and working on every COLD launch, with or
without a deep link. That isolates the hang to MetaMask's own resume on that
phone. Nothing in the app distinguishes the working case from the failing one.
Left in, the two toggles would give anyone who enables debug mode for support an
altered wallet flow for no further gain.

Untouched, deliberately: the return path. #36/#38's wake and #39's park-on-hidden
are what took the return from the wallet from 8.6 s to under a second, and the
same logs confirm it three times over. `git revert` of exactly the two
experiment commits; relayWake.ts and diag.ts are not in the diff.

The stuck-wallet hint stops blaming Android's battery restriction, which the
same logs ruled out (the wedge appears within seconds of backgrounding; battery
restriction acts over minutes). It now says the thing every log showed working:
close the wallet from recent apps and open it again -- from its icon or with
the button -- and it shows the request.


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