agent util specs

This commit is contained in:
Evgeny @ SimpleX Chat
2026-03-13 11:18:29 +00:00
parent c940f16f37
commit 8557d2ab29
6 changed files with 125 additions and 0 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.