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>
This is a Kotlin Multiplatform project targeting Android, iOS, Desktop (JVM).
-
/composeApp is for code that will be shared across your Compose Multiplatform applications. It contains several subfolders:
- commonMain is for code that’s common for all targets.
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, the iosMain folder would be the right place for such calls. Similarly, if you want to edit the Desktop (JVM) specific part, the jvmMain folder is the appropriate location.
-
/iosApp contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform, you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
Build and Run Android Application
To build and run the development version of the Android app, use the run configuration from the run widget in your IDE’s toolbar or build it directly from the terminal:
- on macOS/Linux
./gradlew :composeApp:assembleDebug - on Windows
.\gradlew.bat :composeApp:assembleDebug
Build and Run Desktop (JVM) Application
To build and run the development version of the desktop app, use the run configuration from the run widget in your IDE’s toolbar or run it directly from the terminal:
- on macOS/Linux
./gradlew :composeApp:run - on Windows
.\gradlew.bat :composeApp:run
Build and Run iOS Application
To build and run the development version of the iOS app, use the run configuration from the run widget in your IDE’s toolbar or open the /iosApp directory in Xcode and run it from there.
Learn more about Kotlin Multiplatform…