Skip to content

fix(wallet): drop a dead socket instead of asking it to close, and make the hand-off measurable - #38

Merged
ehsan6sha merged 1 commit into
mainfrom
wallet/diagnostics-and-fast-reconnect
Sep 4, 2026
Merged

fix(wallet): drop a dead socket instead of asking it to close, and make the hand-off measurable#38
ehsan6sha merged 1 commit into
mainfrom
wallet/diagnostics-and-fast-reconnect

Conversation

@ehsan6sha

Copy link
Copy Markdown
Member

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: resetTransporttransportDisconnectprovider.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 so far has been argued from code reading, because the only evidence was console.log on a phone nobody had a debugger attached to. wallet/diag.ts routes the same lines into the clientLogger ring 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/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.

How to capture a report

  1. Enable debug mode (Settings → Mode → Debug, or press and hold the Welcome picture for 3 s). A yellow banner appears at the top.
  2. Reproduce: connect, come back, tap Sign with Wallet, come back.
  3. Tap the banner → share/copy → paste.

Testing

  • npm test --workspaces — 951 passing, no unhandled errors. New: fast path preferred when onProviderDisconnect exists 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 lint clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_013BpqXrkEPA9odTyRdK5Mnx

…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
ehsan6sha merged commit 7187fba into main Sep 4, 2026
2 checks passed
@ehsan6sha
ehsan6sha deleted the wallet/diagnostics-and-fast-reconnect branch September 4, 2026 23:08
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>
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