diff --git a/spec/modules/Simplex/Messaging/Agent/Env/SQLite.md b/spec/modules/Simplex/Messaging/Agent/Env/SQLite.md new file mode 100644 index 000000000..7bfb10bbc --- /dev/null +++ b/spec/modules/Simplex/Messaging/Agent/Env/SQLite.md @@ -0,0 +1,9 @@ +# Simplex.Messaging.Agent.Env.SQLite + +> Agent environment configuration, default values, and worker/supervisor record types. + +**Source**: [`Agent/Env/SQLite.hs`](../../../../../../src/Simplex/Messaging/Agent/Env/SQLite.hs) + +## mkUserServers — silent fallback on all-disabled + +See comment on `mkUserServers`. If filtering servers by `enabled && role` yields an empty list, `fromMaybe srvs` falls back to *all* servers regardless of enabled/role status. This prevents a configuration where all servers are disabled from leaving the user with no servers — but means disabled servers can still be used if every server in a role is disabled. diff --git a/spec/modules/Simplex/Messaging/Agent/Lock.md b/spec/modules/Simplex/Messaging/Agent/Lock.md new file mode 100644 index 000000000..8300266c7 --- /dev/null +++ b/spec/modules/Simplex/Messaging/Agent/Lock.md @@ -0,0 +1,7 @@ +# Simplex.Messaging.Agent.Lock + +> TMVar-based named mutex with concurrent multi-lock acquisition. + +**Source**: [`Agent/Lock.hs`](../../../../../src/Simplex/Messaging/Agent/Lock.hs) + +No non-obvious behavior. See source. See comment on `getPutLock` for the atomicity argument. diff --git a/spec/modules/Simplex/Messaging/Agent/QueryString.md b/spec/modules/Simplex/Messaging/Agent/QueryString.md new file mode 100644 index 000000000..cfcd99451 --- /dev/null +++ b/spec/modules/Simplex/Messaging/Agent/QueryString.md @@ -0,0 +1,7 @@ +# Simplex.Messaging.Agent.QueryString + +> HTTP query string parsing utilities for connection link URIs. + +**Source**: [`Agent/QueryString.hs`](../../../../../src/Simplex/Messaging/Agent/QueryString.hs) + +No non-obvious behavior. See source. diff --git a/spec/modules/Simplex/Messaging/Agent/RetryInterval.md b/spec/modules/Simplex/Messaging/Agent/RetryInterval.md new file mode 100644 index 000000000..dbc5c35f4 --- /dev/null +++ b/spec/modules/Simplex/Messaging/Agent/RetryInterval.md @@ -0,0 +1,35 @@ +# Simplex.Messaging.Agent.RetryInterval + +> Retry-with-backoff combinators for agent reconnection and worker loops. + +**Source**: [`Agent/RetryInterval.hs`](../../../../../src/Simplex/Messaging/Agent/RetryInterval.hs) + +## Overview + +Four retry combinators with increasing sophistication: basic (`withRetryInterval`), counted (`withRetryIntervalCount`), foreground-aware (`withRetryForeground`), and dual-interval with external wake-up (`withRetryLock2`). All share the same backoff curve via `nextRetryDelay`. + +## Backoff curve — nextRetryDelay + +Delay stays constant at `initialInterval` until `elapsed >= increaseAfter`, then grows by 1.5x per step (`delay * 3 / 2`) up to `maxInterval`. The `delay == maxInterval` guard short-circuits the comparison once the cap is reached. + +## updateRetryInterval2 — resume from saved state + +Sets `increaseAfter = 0` on both intervals. This skips the initial constant-delay phase — the next retry will immediately begin increasing from the saved interval. Used to restore retry state across reconnections without restarting from the initial interval. + +## withRetryForeground — reset on foreground/online transition + +The retry loop resets to `initialInterval` when either: +- The app transitions from background to foreground (`not wasForeground && foreground`) +- The network transitions from offline to online (`not wasOnline && online`) + +The STM transaction blocks on three things simultaneously: the `registerDelay` timer, the `isForeground` TVar, and the `isOnline` TVar. Whichever fires first unblocks the retry. On reset, elapsed time is zeroed. + +The `registerDelay` is capped at `maxBound :: Int` (~36 minutes on 32-bit) to prevent overflow. + +## withRetryLock2 — interruptible dual-interval retry + +Maintains two independent backoff states (slow and fast) that the action toggles between by calling the loop continuation with `RISlow` or `RIFast`. Only the chosen interval advances; the other preserves its state. + +The `wait` function is the non-obvious part: it spawns a timer thread that puts `()` into the `lock` TMVar after the delay, while the main thread blocks on `takeTMVar lock`. This means the retry can be woken early by *external code* putting into the same TMVar — the timer is just a fallback. The `waiting` TVar prevents a stale timer from firing after the main thread has already been woken by an external signal. + +**Consumed by**: [Agent/Client.hs](./Client.md) — `reconnectSMPClient` uses the lock TMVar to allow immediate reconnection when new subscriptions arrive, rather than waiting for the full backoff delay. diff --git a/spec/modules/Simplex/Messaging/Agent/Stats.md b/spec/modules/Simplex/Messaging/Agent/Stats.md new file mode 100644 index 000000000..d793564e7 --- /dev/null +++ b/spec/modules/Simplex/Messaging/Agent/Stats.md @@ -0,0 +1,7 @@ +# Simplex.Messaging.Agent.Stats + +> Per-server statistics counters (SMP, XFTP, NTF) with TVar-based live state and serializable snapshots. + +**Source**: [`Agent/Stats.hs`](../../../../../src/Simplex/Messaging/Agent/Stats.hs) + +No non-obvious behavior. See source. diff --git a/spec/modules/Simplex/Messaging/Agent/TSessionSubs.md b/spec/modules/Simplex/Messaging/Agent/TSessionSubs.md new file mode 100644 index 000000000..0274de59d --- /dev/null +++ b/spec/modules/Simplex/Messaging/Agent/TSessionSubs.md @@ -0,0 +1,60 @@ +# Simplex.Messaging.Agent.TSessionSubs + +> Per-session subscription state machine tracking active and pending queue subscriptions. + +**Source**: [`Agent/TSessionSubs.hs`](../../../../../src/Simplex/Messaging/Agent/TSessionSubs.hs) + +## Overview + +TSessionSubs manages the two-tier (active/pending) subscription state for SMP queues, keyed by transport session. Every subscription confirmation from a router is validated against the current session ID before being promoted to active — if the session has changed (reconnect happened), the subscription is demoted to pending for resubscription. + +Service subscriptions (aggregate, router-managed) and queue subscriptions (individual, per-recipient-ID) are tracked separately but follow the same active/pending pattern. + +**Consumed by**: [Agent/Client.hs](./Client.md) — `subscribeSMPQueues`, `subscribeSessQueues_`, `resubscribeSMPSession`, `smpClientDisconnected`. + +## Session ID gating + +The central invariant: a subscription is only active if it was confirmed on the *current* TLS session. Every function that promotes subscriptions to active (`addActiveSub'`, `batchAddActiveSubs`, `setActiveServiceSub`) checks `Just sessId == sessId'` (stored session ID). On mismatch, the subscription goes to pending instead — silently, with no error. + +This means subscription RPCs that succeed but return after a reconnect are safely caught: the response carries the old session ID, which won't match the new one stored by `setSessionId`. + +## setSessionId — silent demotion on reconnect + +`setSessionId` has two behaviors: +- **First call** (stored is `Nothing`): stores the session ID. No side effects. +- **Subsequent call with different ID**: calls `setSubsPending_`, which moves *all* active subscriptions to pending and demotes the active service subscription. The new session ID is stored. +- **Same ID**: no-op (the `unless` guard). + +This is the mechanism by which reconnection invalidates all prior subscriptions. Callers don't need to explicitly move subscriptions — setting the new session ID does it atomically. + +## addActiveSub' — service-associated queue elision + +When `serviceId_` is `Just` and `serviceAssoc` is `True`, the queue is **not** added to `activeSubs`. Instead, `updateActiveService` increments the service subscription's count and XORs the queue's `IdsHash`. The queue is also removed from `pendingSubs`. + +This means service-associated queues have no individual representation in `activeSubs` — they exist only as aggregated count + hash in `activeServiceSub`. The router tracks them via the service subscription; the agent doesn't need per-queue state. + +When `serviceAssoc` is `False` (or no service ID), the queue goes to `activeSubs` normally. + +## updateActiveService — accumulative XOR merge + +`updateActiveService` adds to an existing `ServiceSub` rather than replacing it. It increments the queue count (`n + addN`) and appends the IdsHash (`idsHash <> addIdsHash`). The `<>` on `IdsHash` is XOR — this means the hash is order-independent and can be built incrementally as individual subscription confirmations arrive. + +The guard `serviceId == serviceId'` silently drops updates if the service ID has changed (e.g., credential rotation happened between individual queue confirmations). + +## setSubsPending — mode-dependent redistribution + +`setSubsPending` handles two cases based on whether the transport session mode (entity vs shared) matches the session key shape: + +1. **Mode matches key shape** (`entitySession == isJust connId_`): in-place demotion via `setSubsPending_` — active subs move to pending within the same `SessSubs` entry. Session ID is cleared (`Nothing`). + +2. **Mode mismatch** (e.g., switching from shared session to entity mode): the entire `SessSubs` entry is **deleted** from the map (`TM.lookupDelete`), and all subscriptions are redistributed to new per-entity session keys via `addPendingSub (uId, srv, sessEntId (connId rq))`. This changes the map granularity — one shared entry becomes many entity entries. + +Both paths check `Just sessId == sessId'` first — if the stored session ID doesn't match the one being invalidated, no work is done (returns empty). + +## getSessSubs — lazy initialization + +`getSessSubs` creates a new `SessSubs` entry if none exists for the transport session. This means any write operation (`addPendingSub`, `setSessionId`, etc.) will create map entries as a side effect. Read operations (`hasActiveSub`, `getActiveSubs`) use `lookupSubs` instead, which returns `Nothing`/empty without creating entries. + +## updateClientNotices + +Adjusts the `clientNoticeId` field on pending subscriptions in bulk. Uses `M.adjust`, so missing recipient IDs are silently skipped. Only modifies pending subs — active subs are not touched because they've already been confirmed.