Nothing in this app reconnected a websocket. NostrSocketClientImpl caught a
failure, called close(), and fired onSocketConnectionClosed — which only flips a
boolean in relayPoolStatus that nobody reads. A socket that failed stayed
failed, and the only reason that was survivable is that every subscription is
short: the next queued request opens a fresh socket on its way out through
ensureSocketConnectionOrThrow.
That stops being survivable the moment a subscription is meant to outlive the
socket, so this lands first, on its own. It is already a fix without any of
that: a REQ interrupted mid-download used to sit there until SUBSCRIPTION_TIMEOUT
gave up 120s later, having saved whatever partial set arrived before the drop.
Now the socket comes back and the REQ is re-sent.
The socket client:
- a supervised reconnect loop with exponential backoff (1s doubling to 60s)
plus up to 25% jitter, because every relay in the pool drops at once when
the network does and without jitter they all come back in lockstep. The
exponent is capped so a socket failing for hours cannot overflow the
doubling into Infinity, which Duration * Double rejects outright.
- `autoReconnect`, off by default and owned by the pool. Reconnecting a socket
nobody is subscribed on is battery spent on nothing, so the pool turns it on
for exactly as long as it retains a subscription for that relay.
- `closedByClient`, so closePool() is not answered by every socket in it
politely reconnecting. Cleared by the next caller-driven connect.
- onSessionLost() as the single exit point for a session that ended without
the client asking, replacing the close()-from-inside-the-receiver dance. It
identity-checks the session before clearing it, so a reconnect that already
installed a newer one is not torn down by its predecessor's cleanup, and
runs NonCancellable because the receiver job is cancelled as part of a
replacement connect.
- Frame.Close now breaks the receive loop rather than closing by hand. The
relay closing us is not the client closing us, so it earns a reconnect too.
- a new SocketConnectionReopenedCallback, fired only when a session is
established on a socket that had connected before. Kept separate from
"opened" because on a FIRST connect a replay would double-send the very REQ
whose sendMESSAGE opened the socket.
Two bugs fixed in passing, both of the silent kind:
- the compression REQ in the post-connect handshake was written to `wsSession`
before the new session was assigned to it, so it went to the previous
(usually null) session and was dropped. wsSession is now assigned first.
- sendMESSAGE used `wsSession?.send(...)`, so a send on a dropped socket was a
no-op and the caller waited forever for an answer to a message never sent.
It now warns.
The pool:
- retains the REQ text per (relay, subscription id), and replays it when that
relay's socket is re-established. A relay answers a repeated REQ on the same
subscription id by replacing the filter, so replay is a send rather than a
close-and-reopen, and the collector already attached to the socket's message
flow simply starts receiving again.
- retains on query() BEFORE the send, so a socket that dies between there and
the relay's first answer is still covered; releases on closeQuery(), which
every pump already calls from a NonCancellable finally.
- deliberately does NOT retain negentropy. NEG-OPEN carries a fingerprint of
the local set and each round depends on the last, so replaying one
mid-exchange would reconcile against a conversation the relay is no longer
having. An interrupted negentropy request is abandoned and re-queued.
- drops retained work for relays removed by changeRelays/removeRelays/
closePool, so a relay edit does not leave a socket reconnecting for
subscriptions nobody wants.
- collapses the five hand-rolled `socketClients.find { normalize... }` lookups
into socketClientFor(), now that there were about to be several more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>