Files
mantra-kmp/composeApp
Kgothatso Ngako c3b0c8671b feat(relays): reconnect dropped sockets and replay what they were carrying
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>
2026-09-05 16:02:11 +02:00
..