fix(wallet): drop a dead socket instead of asking it to close, and make the hand-off measurable - #38
Merged
Conversation
…ke the hand-off measurable 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. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013BpqXrkEPA9odTyRdK5Mnx
ehsan6sha
added a commit
that referenced
this pull request
Sep 5, 2026
…one dial (#39) 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. Claude-Session: https://claude.ai/code/session_013BpqXrkEPA9odTyRdK5Mnx Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.
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
closeevent 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 withinWAKE_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 onirn_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 so far has been argued from code reading, because the only evidence was
console.logon a phone nobody had a debugger attached to.wallet/diag.tsroutes the same lines into theclientLoggerring buffer, timestamped "+N ms since return" — the moment every one of those seconds is counted from — and the debug-mode banner now shares that 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/providerflip; 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.How to capture a report
Testing
npm test --workspaces— 951 passing, no unhandled errors. New: fast path preferred whenonProviderDisconnectexists and the fresh socket comes up; polite restart as fallback when it does not; polite restart when the fast path is absent.npm run typecheck --workspaces,npm run lintclean.🤖 Generated with Claude Code
https://claude.ai/code/session_013BpqXrkEPA9odTyRdK5Mnx