|
| 1 | +# Simplex.Messaging.Client |
| 2 | + |
| 3 | +> Generic protocol client: connection management, command sending/receiving, batching, proxy protocol, reconnection. |
| 4 | +
|
| 5 | +**Source**: [`Client.hs`](../../../../src/Simplex/Messaging/Client.hs) |
| 6 | + |
| 7 | +**Protocol spec**: [`protocol/simplex-messaging.md`](../../../../protocol/simplex-messaging.md) — SimpleX Messaging Protocol. |
| 8 | + |
| 9 | +## Overview |
| 10 | + |
| 11 | +This module implements the client side of the `Protocol` typeclass — connecting to servers, sending commands, receiving responses, and managing connection lifecycle. It is generic over `Protocol v err msg`, instantiated for SMP as `SMPClient` (= `ProtocolClient SMPVersion ErrorType BrokerMsg`). The SMP proxy protocol (PRXY/PFWD/RFWD) is also implemented here. |
| 12 | + |
| 13 | +## Four concurrent threads — teardown semantics |
| 14 | + |
| 15 | +`getProtocolClient` launches four threads via `raceAny_`: |
| 16 | +- `send`: reads from `sndQ` (TBQueue) and writes to TLS |
| 17 | +- `receive`: reads from TLS and writes to `rcvQ` (TBQueue), updates `lastReceived` |
| 18 | +- `process`: reads from `rcvQ` and dispatches to response vars or `msgQ` |
| 19 | +- `monitor`: periodic ping loop (only when `smpPingInterval > 0`) |
| 20 | + |
| 21 | +When ANY thread exits (normally or exceptionally), `raceAny_` cancels all others. `E.finally` ensures the `disconnected` callback always fires. Implication: a single stuck thread (e.g., TLS read blocked on a half-open connection) keeps the entire client alive until `monitor` drops it. There is no per-thread health check — liveness depends entirely on the monitor's timeout logic. |
| 22 | + |
| 23 | +## Request lifecycle and leak risk |
| 24 | + |
| 25 | +`mkRequest` inserts a `Request` into `sentCommands` TMap BEFORE the transmission is written to TLS. If the TLS write fails silently or the connection drops before the response, the entry remains in `sentCommands` until the monitor's timeout counter exceeds `maxCnt` and drops the entire client. There is no per-request cleanup on send failure — individual request entries are only removed by `processMsg` (on response) or by `getResponse` timeout (which sets `pending = False` but doesn't remove the entry). |
| 26 | + |
| 27 | +## getResponse — pending flag race contract |
| 28 | + |
| 29 | +This is the core concurrency contract between timeout and response processing: |
| 30 | + |
| 31 | +1. `getResponse` waits with `timeout` for `takeTMVar responseVar` |
| 32 | +2. Regardless of result, atomically sets `pending = False` and tries `tryTakeTMVar` again (see comment on `getResponse`) |
| 33 | +3. In `processMsg`, when a response arrives for a request where `pending` is already `False` (timeout won), `wasPending` is `False` and the response is forwarded to `msgQ` as `STResponse` rather than discarded |
| 34 | + |
| 35 | +The double-check pattern (`swapTVar pending False` + `tryTakeTMVar`) handles the race window where a response arrives between timeout firing and `pending` being set to `False`. Without this, responses arriving in that gap would be silently lost. |
| 36 | + |
| 37 | +`timeoutErrorCount` is reset to 0 in three places: in `getResponse` when a response arrives, in `receive` on every TLS read, and the monitor uses this count to decide when to drop the connection. |
| 38 | + |
| 39 | +## processMsg — server events vs expired responses |
| 40 | + |
| 41 | +When `corrId` is empty, the message is an `STEvent` (server-initiated). When non-empty and the request was already expired (`wasPending` is `False`), the response becomes `STResponse` — not discarded, but forwarded to `msgQ` with the original command context. Entity ID mismatch is `STUnexpectedError`. |
| 42 | + |
| 43 | +## nonBlockingWriteTBQueue — fork on full |
| 44 | + |
| 45 | +If `tryWriteTBQueue` returns `False`, a new thread is forked for the blocking write. No backpressure mechanism — under sustained overload, thread count grows without bound. This is a deliberate tradeoff: the caller never blocks (preventing deadlock between send and process threads), at the cost of potential unbounded thread creation. |
| 46 | + |
| 47 | +## Batch commands do not expire |
| 48 | + |
| 49 | +See comment on `sendBatch`. Batched commands are written with `Nothing` as the request parameter — the send thread skips the `pending` flag check. Individual commands use `Just r` and the send thread checks `pending` after dequeue. The coupling: if the server stops responding, batched commands can block the send queue indefinitely since they have no timeout-based expiry. |
| 50 | + |
| 51 | +## monitor — quasi-periodic adaptive ping |
| 52 | + |
| 53 | +The ping loop sleeps for `smpPingInterval`, then checks elapsed time since `lastReceived`. If significant time remains in the interval (> 1 second), it re-sleeps for just the remaining time rather than sending a ping. This means ping frequency adapts to actual receive activity — frequent receives suppress pings. |
| 54 | + |
| 55 | +Pings are only sent when `sendPings` is `True`, set by `enablePings` (called from `subscribeSMPQueue`, `subscribeSMPQueues`, `subscribeSMPQueueNotifications`, `subscribeSMPQueuesNtfs`, `subscribeService`). The client drops the connection when `maxCnt` commands have timed out in sequence AND at least `recoverWindow` (15 minutes) has passed since the last received response. |
| 56 | + |
| 57 | +## clientCorrId — dual-purpose random values |
| 58 | + |
| 59 | +`clientCorrId` is a `TVar ChaChaDRG` generating random `CbNonce` values that serve as both correlation IDs and nonces for proxy encryption. When a nonce is explicitly passed (e.g., by `createSMPQueue`), it is used instead of generating a random one. |
| 60 | + |
| 61 | +## Proxy command re-parameterization |
| 62 | + |
| 63 | +`proxySMPCommand` constructs modified `thParams` per-request — setting `sessionId`, `peerServerPubKey`, and `thVersion` to the proxy-relay connection's parameters rather than the client-proxy connection's. A single `SMPClient` connection to the proxy carries commands with different auth parameters per destination relay. The encoding, signing, and encryption all use these per-request params, not the connection's original params. |
| 64 | + |
| 65 | +## proxySMPCommand — error classification |
| 66 | + |
| 67 | +See comment above `proxySMPCommand` for the 9 error scenarios (0-9) mapping each combination of success/error at client-proxy and proxy-relay boundaries. Errors from the destination relay wrapped in `PRES` are thrown as `ExceptT` errors (transparent proxy). Errors from the proxy itself are returned as `Left ProxyClientError`. |
| 68 | + |
| 69 | +## forwardSMPTransmission — proxy-side forwarding |
| 70 | + |
| 71 | +Used by the proxy server to forward `RFWD` to the destination relay. Uses `cbEncryptNoPad`/`cbDecryptNoPad` (no padding) with the session secret from the proxy-relay connection. Response nonce is `reverseNonce` of the request nonce. |
| 72 | + |
| 73 | +## authTransmission — dual auth with service signature |
| 74 | + |
| 75 | +When `useServiceAuth` is `True` and a service certificate is present, the entity key signs over `serviceCertHash <> transmission` (not just the transmission) — see comment on `authTransmission`. The service key only signs the transmission itself. For X25519 keys, `cbAuthenticate` produces a `TAAuthenticator`; for Ed25519/Ed448, `C.sign'` produces a `TASignature`. |
| 76 | + |
| 77 | +The service signature is only added when the entity authenticator is non-empty. If authenticator generation fails silently (returns empty bytes), service signing is silently skipped. This mirrors the [state-dependent parser contract](./Protocol.md#service-signature--state-dependent-parser-contract) in Protocol.hs. |
| 78 | + |
| 79 | +## connectSMPProxiedRelay — combined timeout |
| 80 | + |
| 81 | +The timeout for the `PRXY` command is `netTimeoutInt tcpConnectTimeout nm + netTimeoutInt tcpTimeout nm` — both timeouts are transformed by `netTimeoutInt` before summing. |
| 82 | + |
| 83 | +## ProxiedRelay — stored auth |
| 84 | + |
| 85 | +See comment on `prBasicAuth` — auth is stored to allow reconnecting via the same proxy after `NO_SESSION` error. |
| 86 | + |
| 87 | +## action — weak thread reference |
| 88 | + |
| 89 | +`action` stores a `Weak ThreadId` (via `mkWeakThreadId`) to the main client thread. `closeProtocolClient` dereferences and kills it. The weak reference allows the thread to be garbage collected if all other references are dropped. |
| 90 | + |
| 91 | +## writeSMPMessage — server-side event injection |
| 92 | + |
| 93 | +`writeSMPMessage` writes directly to `msgQ` as `STEvent`, bypassing the entire command/response pipeline. This is used by the server to inject MSG events into the subscription response path. |
0 commit comments