fix(wallet): close the relay socket on the way out, so the return is one dial - #39
Merged
Merged
Conversation
…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
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First diagnostic log from the reporter's phone (build
7187fba, via the debug banner from #38). The return from the connect approval: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 withconnectPromisepending, and everything awaits that promise — the library's owntransportOpen()and every lever inrelayWake. Nothing can cancel asetTimeoutinside 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 setstransportExplicitlyClosed— 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, sotransportOpen()onvisibleis 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 (hasAnyTopicscounts 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 1mson the second return. #38's fast path checkedconnected— 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
guardsroute test gets a budget a busy runner can meetset up → / lands on /bloxstill missed its 15 swaitForabout 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, soROUTE_TIMEOUTgoes to 30 s. That is the last known source of red-with-everything-passing onmain.Testing
npm test --workspacesclean. New:parkRelaycloses an open or mid-dial socket and leaves a down one alone; the hook parks onhiddenand the return is a singletransportOpen; withouttransportCloseto 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