Compare commits

..
8 Commits
Author SHA1 Message Date
sh 9a0a85a24c feat(bench): self-contained Docker Compose setup
Build benchmark binary inside container via multi-stage
Dockerfile. All-in-one: docker compose run bench.
2026-03-20 14:51:16 +00:00
sh 2a0af04ab8 feat(bench): add smp-server memory benchmark framework
Layered benchmark that isolates per-component memory cost:
- Phase 1: baseline (no clients)
- Phase 2: TLS connections only
- Phase 3: queue creation (NEW + KEY)
- Phase 4: subscriptions (SUB)
- Phase 5: message send
- Phase 6: message receive + ACK
- Phase 7: sustained load with time-series

Includes Docker Compose (PostgreSQL 17), run.sh with
--compare-rts mode for testing different GC configurations.
2026-03-20 14:48:11 +00:00
sh 4c8ace4db6 update findings 2026-03-20 12:01:01 +00:00
sh 323be9c6a4 feat(smp-server): add per-client and RTS memory metrics
Add clientSubs, clientSndQ, clientMsgQ, clientThreads counts
and GHC RTS large objects, compact, fragmentation metrics
to identify memory growth not explained by server-level maps.
2026-03-20 12:01:01 +00:00
sh c6d6e30e48 add memory-analysis-results based on the produced logs 2026-03-20 12:01:01 +00:00
sh 646476f5fa smp-server: add periodic memory diagnostics logging
Log sizes of all in-memory data structures every 5 minutes
to help identify memory growth root cause on busy servers.
2026-03-20 12:01:01 +00:00
sh 404dd10d4a further analysis 2026-03-20 12:01:01 +00:00
sh 931b1cc725 smp-server: memory usage analysis 2026-03-20 12:01:01 +00:00
241 changed files with 2147 additions and 17725 deletions
+12 -22
View File
@@ -173,7 +173,7 @@ jobs:
-v ${{ github.workspace }}:/project \
build/${{ matrix.os }}:latest
- name: Build smp-server, xftp-server (postgresql) and tests
- name: Build smp-server (postgresql) and tests
if: matrix.should_run == true
shell: docker exec -t builder sh -eu {0}
run: |
@@ -182,12 +182,12 @@ jobs:
cabal update
cabal build --jobs=$(nproc) --enable-tests -fserver_postgres
mkdir -p /out
for i in smp-server xftp-server simplexmq-test; do
for i in smp-server simplexmq-test; do
bin=$(find /project/dist-newstyle -name "$i" -type f -executable)
chmod +x "$bin"
mv "$bin" /out/
done
strip /out/smp-server /out/xftp-server
strip /out/smp-server
- name: Copy simplexmq-test from container
if: matrix.should_run == true
@@ -195,29 +195,19 @@ jobs:
run: |
docker cp builder:/out/simplexmq-test .
- name: Copy smp-server, xftp-server (postgresql) from container and prepare it
- name: Copy smp-server (postgresql) from container and prepare it
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
id: prepare-postgres
shell: bash
run: |
printf 'bins<<EOF\n' > bins.output
printf 'hashes<<EOF\n' > hashes.output
name="smp-server-postgres-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}"
docker cp builder:/out/smp-server $name
for i in smp-server xftp-server; do
name="${i}-postgres-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}"
docker cp builder:/out/$i $name
path="${{ github.workspace }}/$name"
echo "bin=$path" >> $GITHUB_OUTPUT
path="${{ github.workspace }}/$name"
hash="SHA2-256($name)= $(openssl sha256 $path | cut -d' ' -f 2)"
printf '%s\n' "$path" >> bins.output
printf '%s\n\n' "$hash" >> hashes.output
done
printf 'EOF\n' >> bins.output
printf 'EOF\n' >> hashes.output
cat bins.output >> "$GITHUB_OUTPUT"
cat hashes.output >> "$GITHUB_OUTPUT"
hash="SHA2-256($name)= $(openssl sha256 $path | cut -d' ' -f 2)"
printf 'hash=%s' "$hash" >> $GITHUB_OUTPUT
- name: Build everything else (standard)
if: matrix.should_run == true
@@ -267,10 +257,10 @@ jobs:
fail_on_unmatched_files: true
body: |
${{ steps.prepare-regular.outputs.hashes }}
${{ steps.prepare-postgres.outputs.hashes }}
${{ steps.prepare-postgres.outputs.hash }}
files: |
${{ steps.prepare-regular.outputs.bins }}
${{ steps.prepare-postgres.outputs.bins }}
${{ steps.prepare-postgres.outputs.bin }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-36
View File
@@ -1,39 +1,3 @@
# 6.5.1
Version 6.5.1.0
XFTP client:
- backwards compatible file header decoding.
# 6.5.0
Version 6.5.0.17
SMP agent:
- improve subscriptions
- reduce memory usage and retries during initial subscription (#1758)
- fix race resulting in pending subscriptions never subscribed (#1756)
- batch processing of subscription results and errors (#1652)
- reduce memory usage of active subscriptions.
- drop message after N reception attempts (#1762)
- fix possible deadlocks of queue overloading when processing messages (#1713)
- improved APIs for short link management and creation.
- support multiple link owners in link data (#1701)
SMP server:
- store messages in PostgreSQL (#1622).
- reduce memory usage with PostgreSQL database - do not use queue cache (#1637)
- fix in-memory server not restoring queue/service associations after 2+ restarts (#1618)
XFTP server:
- support PostgreSQL database.
- add server page.
- support uploads from web clients.
Servers:
- better socket leak prevention during TLS handshake, NetworkError type to bette diagnose connection errors (#1619)
- use "=" as default INI key-value separator (#1767)
# 6.4.4
Servers:
-210
View File
@@ -1,210 +0,0 @@
# simplexmq — LLM Navigation Guide
This file is the entry point for LLMs working on simplexmq. Read it before making any code changes.
## Three-Layer Architecture
simplexmq maintains three documentation layers alongside source code:
| Layer | Directory | Answers | Audience |
|-------|-----------|---------|----------|
| **Product** | `product/` | What does this do? Who uses it? What must never break? | Anyone reasoning about behavior, privacy, security |
| **Spec** | `spec/` | How does the code work? What does each function do? What are the security invariants? | LLMs and developers modifying code |
| **Protocol** | `protocol/` | What is the wire protocol? What are the message formats and state machines? | Protocol implementors, formal verification |
Additionally:
- `rfcs/` — Protocol evolution: each RFC describes a delta to a protocol spec
- `product/threat-model.md` — Comprehensive threat model across all protocols
- `spec/security-invariants.md` — Every security invariant with enforcement and test coverage
## Navigation Workflow
When modifying code, follow this sequence:
1. **Identify scope** — Find the relevant component in `product/concepts.md`
2. **Load product context** — Read the component file in `product/components/` to understand what users depend on
3. **Load spec context** — Read the relevant `spec/` file(s) for implementation details and call graphs
4. **Check security** — Read `spec/security-invariants.md` for any invariants enforced by the code you're changing
5. **Load source** — Read the actual source files referenced in spec/
6. **Identify impact** — Trace the call graph to understand what your change affects
7. **Implement** — Make the change
8. **Update all layers** — Update spec/, product/, and protocol/ (if wire protocol changed) to stay coherent
## Protocol Specifications
Consolidated protocol specs live in `protocol/`. These describe the wire protocols as originally specified. Code has advanced beyond these versions — Phase 2 of this project will synchronize them.
| File | Protocol | Spec version | Code version |
|------|----------|-------------|--------------|
| `simplex-messaging.md` | SMP (simplex messaging) | v9 | SMP relay v18, SMP client v4 |
| `agent-protocol.md` | Agent (duplex connections) | v5 | Agent v7 |
| `xftp.md` | XFTP (file transfer) | v2 | XFTP v3 |
| `xrcp.md` | XRCP (remote control) | v1 | RCP v1 |
| `push-notifications.md` | Push notifications | v2 | NTF v3 |
| `pqdr.md` | PQDR (post-quantum double ratchet) | v1 | E2E v3 |
| `overview-tjr.md` | Cross-protocol overview | — | — |
Note: SMP has multiple version axes — `VersionSMP` (relay/transport, currently 18), `VersionSMPC` (client protocol, currently 4), and `VersionSMPA` (agent, currently 7). These are negotiated independently.
Protocol specs are amended in place when implementation changes. RFCs in `rfcs/` track the evolution history.
## Source Structure
```
src/Simplex/
Messaging/
Protocol.hs, Protocol/Types.hs — SMP wire protocol types + encoding
Client.hs — SMP client (protocol operations, proxy relay)
Client/Agent.hs — Low-level async SMP agent
Server.hs — SMP server request handling
Server/Env/STM.hs — Server environment + STM state
Server/Main.hs, Server/Main/Init.hs — Server CLI + initialization
Server/QueueStore/ — Queue storage (STM, Postgres)
Server/MsgStore/ — Message storage (STM, Journal, Postgres)
Server/MsgStore/Journal.hs — Journal message store (1000 lines)
Server/StoreLog/ — Store log (append-only write, read-compact-rewrite restore)
Server/NtfStore.hs — Message notification store
Server/Control.hs, Server/CLI.hs — Control protocol + CLI utilities
Server/Stats.hs, Server/Prometheus.hs — Metrics
Server/Information.hs — Server public information / metadata
Agent.hs — SMP agent: duplex connections, queue rotation
Agent/Client.hs — Agent's SMP/XFTP/NTF client management
Agent/Protocol.hs — Agent wire protocol types + encoding (2200 lines)
Agent/Store.hs — Agent storage types (queues, connections, messages)
Agent/Store/AgentStore.hs — Agent storage implementation (3500 lines)
Agent/Store/ — Agent storage backends (SQLite, Postgres)
Agent/Env/SQLite.hs — Agent environment + configuration
Agent/NtfSubSupervisor.hs — Notification subscription management
Agent/TSessionSubs.hs — Transport session subscriptions
Agent/Stats.hs — Agent statistics
Agent/RetryInterval.hs — Retry interval logic
Agent/Lock.hs — Named locks
Agent/QueryString.hs — Query string parsing
Transport.hs — TLS transport abstraction + handshake
Transport/Client.hs, Transport/Server.hs — TLS client + server
Transport/HTTP2.hs — HTTP/2 transport setup
Transport/HTTP2/Client.hs — HTTP/2 client
Transport/HTTP2/Server.hs — HTTP/2 server
Transport/HTTP2/File.hs — HTTP/2 file streaming
Transport/WebSockets.hs — WebSocket adapter
Transport/Buffer.hs — Transport buffering
Transport/KeepAlive.hs — TCP keepalive
Transport/Shared.hs — Certificate chain validation
Transport/Credentials.hs — TLS credential generation
Crypto.hs — All cryptographic primitives
Crypto/File.hs — File encryption (NaCl secret box + lazy)
Crypto/Lazy.hs — Lazy hashing + encryption
Crypto/Ratchet.hs — Double ratchet + PQDR
Crypto/ShortLink.hs — Short link key derivation
Crypto/SNTRUP761.hs — Post-quantum KEM hybrid secret
Crypto/SNTRUP761/Bindings.hs — sntrup761 C FFI bindings
Notifications/Protocol.hs — NTF wire protocol types + encoding
Notifications/Types.hs — NTF agent types (tokens, subscriptions)
Notifications/Transport.hs — NTF transport handshake
Notifications/Client.hs — NTF client operations
Notifications/Server.hs — NTF server
Notifications/Server/Env.hs — NTF server environment + config
Notifications/Server/Store.hs — NTF server storage (STM)
Notifications/Server/Store/Postgres.hs — NTF server storage (Postgres)
Notifications/Server/Push/APNS.hs — Apple push notification integration
Notifications/Server/Push/APNS/Internal.hs — APNS HTTP/2 client
Notifications/Server/Main.hs — NTF server CLI
Notifications/Server/Stats.hs — NTF server metrics
Notifications/Server/Prometheus.hs — NTF Prometheus metrics
Notifications/Server/Control.hs — NTF server control
Encoding.hs, Encoding/String.hs — Binary + string encoding
Version.hs, Version/Internal.hs — Version ranges + negotiation
Util.hs — Utilities (error handling, STM, grouping)
Parsers.hs — Attoparsec parser combinators
TMap.hs — Transactional map (STM)
Compression.hs — Zstd compression
ServiceScheme.hs — Service scheme + server location types
Session.hs — Session variables (TVar-based)
SystemTime.hs — Rounded system time types
FileTransfer/
Protocol.hs — XFTP wire protocol types + encoding
Client.hs — XFTP client operations
Client/Agent.hs — XFTP client agent (connection pooling)
Client/Main.hs — XFTP CLI client implementation
Client/Presets.hs — Default XFTP servers
Server.hs — XFTP server request handling
Server/Env.hs — XFTP server environment + config
Server/Store.hs — XFTP server storage
Server/StoreLog.hs — XFTP server store log
Server/Main.hs — XFTP server CLI
Server/Stats.hs — XFTP server metrics
Server/Prometheus.hs — XFTP Prometheus metrics
Server/Control.hs — XFTP server control
Agent.hs — XFTP agent operations
Description.hs — File description format
Transport.hs — XFTP transport
Crypto.hs — File encryption for transfer
Types.hs — File transfer types
Chunks.hs — Chunk sizing
RemoteControl/
Client.hs — XRCP client (ctrl device)
Invitation.hs — XRCP invitation handling
Discovery.hs — Local network discovery
Discovery/Multicast.hsc — Multicast discovery (C FFI)
Types.hs — XRCP types + version
apps/
smp-server/Main.hs — SMP server executable
smp-server/web/Static.hs — SMP server web static files
xftp-server/Main.hs — XFTP server executable
xftp/Main.hs — XFTP CLI executable
ntf-server/Main.hs — Notification server executable
smp-agent/Main.hs — SMP agent (experimental, not in cabal)
```
## Linking Conventions
### spec → src
Fully qualified exported function names inline in prose: `Simplex.Messaging.Client.connectSMPProxiedRelay`. Use Grep/Glob to locate in source. For app targets: `xftp/Main.main`.
### src → spec
Comment above function:
```haskell
-- spec/crypto-tls.md#certificate-chain-validation
-- Validates relay certificate chain to prevent proxy MITM (SI-XX)
connectSMPProxiedRelay :: ...
```
### spec ↔ spec
Named markdown heading anchors: `spec/crypto.md#ed25519-signing`
### spec ↔ product
Cross-references: `product/rules.md#pr-05`, `spec/security-invariants.md#si-01`
### protocol/ references
`protocol/simplex-messaging.md` with section name
## Build Flags
simplexmq builds with several flag combinations:
| Flag | Effect |
|------|--------|
| (none) | Default: SQLite storage, all executables |
| `-fserver_postgres` | Postgres backend for SMP server |
| `-fclient_postgres` | Postgres backend for agent storage |
| `-fclient_library` | Library-only build (no server executables) |
| `-fswift` | Swift JSON format for mobile bindings |
| `-fuse_crypton` | Use crypton in cryptostore |
All flag combinations must compile with `--enable-tests`. Verify with:
```
cabal build all --ghc-options="-O0" [-flags] [--enable-tests]
```
## Change Protocol
Every code change must maintain coherence across all three layers:
1. **Code change** — Implement in src/
2. **Spec update** — Update the relevant spec/ file(s): types, call graphs, security notes
3. **Product update** — If user-visible behavior changed, update product/ files
4. **Protocol update** — If wire protocol changed, amend protocol/ spec (requires user approval)
5. **Security check** — If the change touches a trust boundary, update spec/security-invariants.md
Protocol spec amendments require explicit user approval before committing.
+284 -60
View File
@@ -1,79 +1,86 @@
# SimpleX Network
# SimpleXMQ
[![GitHub build](https://github.com/simplex-chat/simplexmq/actions/workflows/build.yml/badge.svg)](https://github.com/simplex-chat/simplexmq/actions/workflows/build.yml)
[![GitHub release](https://img.shields.io/github/v/release/simplex-chat/simplexmq)](https://github.com/simplex-chat/simplexmq/releases)
The simplexmq package provides the software for [SimpleX Network](./protocol/overview-tjr.md) — a general-purpose packet routing network where endpoints exchange data through independently operated routers using resource-based addressing. Unlike IP networks, SimpleX addresses identify resources on routers (queues, data packets), not endpoint devices. Participants do not need globally unique identifiers to communicate.
📢 SimpleXMQ v1 is released - with many security, privacy and efficiency improvements, new functionality - see [release notes](https://github.com/simplex-chat/simplexmq/releases/tag/v1.0.0).
The software is organized in three layers:
**Please note**: v1 is not backwards compatible, but it has the version negotiation built into all protocol layers for forwards compatibility of this version and backwards compatibility of the future versions, that will be backwards compatible for at least two versions back.
```
Application (e.g. SimpleX Chat)
+----------------------------------+
| SimpleX Agent | Layer 3 — duplex connections, e2e encryption
+----------------------------------+
| SimpleX Client Libraries | Layer 2 — protocol clients for SMP, XFTP
+----------------------------------+
| SimpleX Routers | Layer 1 — network infrastructure (SMP, XFTP, NTF)
+----------------------------------+
If you have a server deployed please deploy a new server to a new host and retire the previous version once it is no longer used.
## Message broker for unidirectional (simplex) queues
SimpleXMQ is a message broker for managing message queues and sending messages over public network. It consists of SMP server, SMP client library and SMP agent that implement [SMP protocol](./protocol/simplex-messaging.md) for client-server communication and [SMP agent protocol](./protocol/agent-protocol.md) to manage duplex connections via simplex queues on multiple SMP servers.
SMP protocol is inspired by [Redis serialization protocol](https://redis.io/topics/protocol), but it is much simpler - it currently has only 10 client commands and 8 server responses.
SimpleXMQ is implemented in Haskell - it benefits from robust software transactional memory (STM) and concurrency primitives that Haskell provides.
## SimpleXMQ roadmap
- SimpleX service protocol and application template - to enable users building services and chat bots that work over SimpleX protocol stack. The first such service will be a notification service for a mobile app.
- SMP queue redundancy and rotation in SMP agent connections.
- SMP agents synchronization to share connections and messages between multiple agents (it would allow using multiple devices for [simplex-chat](https://github.com/simplex-chat/simplex-chat)).
## Components
### SMP server
[SMP server](./apps/smp-server/Main.hs) can be run on any Linux distribution, including low power/low memory devices. OpenSSL library is required for initialization.
To initialize the server use `smp-server init -n <fqdn>` (or `smp-server init --ip <ip>` for IP based address) command - it will generate keys and certificates for TLS transport. The fingerprint of offline certificate is used as part of the server address to protect client/server connection against man-in-the-middle attacks: `smp://<fingerprint>@<hostname>[:5223]`.
SMP server uses in-memory persistence with an optional append-only log of created queues that allows to re-start the server without losing the connections. This log is compacted on every server restart, permanently removing suspended and removed queues.
To enable store log, initialize server using `smp-server -l` command, or modify `smp-server.ini` created during initialization (uncomment `enable: on` option in the store log section). Use `smp-server --help` for other usage tips.
Starting from version 2.3.0, when store log is enabled, the server would also enable saving undelivered messages on exit and restoring them on start. This can be disabled via a separate setting `restore_messages` in `smp-server.ini` file. Saving messages would only work if the server is stopped with SIGINT signal (keyboard interrupt), if it is stopped with SIGTERM signal the messages would not be saved.
> **Please note:** On initialization SMP server creates a chain of two certificates: a self-signed CA certificate ("offline") and a server certificate used for TLS handshake ("online"). **You should store CA certificate private key securely and delete it from the server. If server TLS credential is compromised this key can be used to sign a new one, keeping the same server identity and established connections.** CA private key location by default is `/etc/opt/simplex/ca.key`.
SMP server implements [SMP protocol](./protocol/simplex-messaging.md).
#### Running SMP server on MacOS
SMP server requires OpenSSL library for initialization. On MacOS OpenSSL library may be replaced with LibreSSL, which doesn't support required algorithms. Before initializing SMP server verify you have OpenSSL installed:
```sh
openssl version
```
[SimpleX Chat](https://github.com/simplex-chat/simplex-chat) is one application built on Layer 3. IoT devices, AI services, monitoring systems, and automated services are other applications that can use Layers 2 or 3 directly.
If it says "LibreSSL", please install original OpenSSL:
The simplexmq package is implemented in Haskell, benefiting from robust software transactional memory (STM) and concurrency primitives.
```sh
brew update
brew install openssl
echo 'PATH="/opt/homebrew/opt/openssl@3/bin:$PATH"' >> ~/.zprofile # or follow whatever instructions brew suggests
. ~/.zprofile # or restart your terminal to start a new session
```
See the [SimpleX Network overview](./protocol/overview-tjr.md) for the full protocol architecture, trust model, and [security analysis](./protocol/security.md).
Now `openssl version` should be saying "OpenSSL". You can now run `smp-server init` to initialize your SMP server.
## Architecture
### SMP client library
### SimpleX Routers
[SMP client](./src/Simplex/Messaging/Client.hs) is a Haskell library to connect to SMP servers that allows to:
Routers are the network infrastructure — they accept, buffer, and deliver packets. Three router types serve different purposes:
- execute commands with a functional API.
- receive messages and other notifications via STM queue.
- automatically send keep-alive commands.
- **SMP routers** provide messaging queues — unidirectional, ordered sequences of fixed-size packets (16,384 bytes). Protocol: [SMP](./protocol/simplex-messaging.md). Module spec: [`Simplex.Messaging.Server`](./spec/modules/Simplex/Messaging/Server.md).
- **XFTP routers** accept and deliver data packets over HTTP/2 — individually addressed blocks in fixed sizes (64KB4MB) for larger payloads. Protocol: [XFTP](./protocol/xftp.md). Module spec: [`Simplex.FileTransfer.Server`](./spec/modules/Simplex/FileTransfer/Server.md).
- **NTF routers** bridge to platform push services (APNS) for mobile notification delivery. Protocol: [Push Notifications](./protocol/push-notifications.md). Module spec: [`Simplex.Messaging.Notifications.Server`](./spec/modules/Simplex/Messaging/Notifications/Server.md).
### SMP agent
[SMP agent library](./src/Simplex/Messaging/Agent.hs) can be used to run SMP agent as part of another application and to communicate with the agent via STM queues, without serializing and parsing commands and responses.
#### Running an SMP router
Haskell type [ACommand](./src/Simplex/Messaging/Agent/Protocol.hs) represents SMP agent protocol to communicate via STM queues.
[SMP server](./apps/smp-server/Main.hs) runs on any Linux distribution. OpenSSL is required for initialization.
See [simplex-chat](https://github.com/simplex-chat/simplex-chat) terminal UI for the example of integrating SMP agent into another application.
Initialize: `smp-server init -n <fqdn>` (or `--ip <ip>`). This generates TLS certificates. The CA certificate fingerprint becomes part of the server address: `smp://<fingerprint>@<hostname>[:5223]`.
[SMP agent executable](./apps/smp-agent/Main.hs) can be used to run a standalone SMP agent process that implements plaintext [SMP agent protocol](./protocol/agent-protocol.md) via TCP port 5224, so it can be used via telnet. It can be deployed in private networks to share access to the connections between multiple applications and services.
By default, the server uses in-memory persistence with an optional append-only store log for queue persistence across restarts. Enable with `smp-server init -l` or in `smp-server.ini`. The log is compacted on every restart.
## Using SMP server and SMP agent
When store log is enabled, undelivered messages are saved on exit (SIGINT only, not SIGTERM) and restored on start. Control this independently with the `restore_messages` setting.
> **Please note:** On initialization, SMP server creates a certificate chain: a self-signed CA certificate ("offline") and a server certificate for TLS ("online"). **Store the CA private key securely and delete it from the server.** If the server TLS credential is compromised, this key can sign a new one while keeping the same server identity. Default location: `/etc/opt/simplex/ca.key`.
See [docs/ROUTERS.md](./docs/ROUTERS.md) for XFTP/NTF router setup, advanced configuration, MacOS notes, and all deployment options (Docker, installation script, building from source, Linode, DigitalOcean).
### SimpleX Client Libraries
[Client libraries](./docs/CLIENT.md) provide low-level protocol access to SimpleX routers. They implement the wire protocols (SMP, XFTP, NTF) and handle connection lifecycle, command authentication, and keep-alive.
The [SMP client](./src/Simplex/Messaging/Client.hs) ([module spec](./spec/modules/Simplex/Messaging/Client.md)) offers a functional Haskell API with STM queues for asynchronous event delivery. The [XFTP client](./src/Simplex/FileTransfer/Client.hs) ([module spec](./spec/modules/Simplex/FileTransfer/Client.md)) sends and receives data packets over HTTP/2 with per-request forward secrecy. The [NTF client](./src/Simplex/Messaging/Notifications/Client.hs) ([module spec](./spec/modules/Simplex/Messaging/Notifications/Client.md)) manages push notification tokens and subscriptions.
Applications that manage their own encryption and connection logic — IoT devices, sensors, simple data pipelines — can use this layer directly. See [docs/CLIENT.md](./docs/CLIENT.md).
### SimpleX Agent
The [Agent](./docs/AGENT.md) builds duplex encrypted connections on top of the client libraries. It manages:
- Duplex connections from simplex queue pairs
- End-to-end encryption with double ratchet and post-quantum extensions
- File transfer with chunking, encryption, and multi-router distribution
- Queue rotation for metadata privacy
- Push notification subscriptions
The [Agent library](./src/Simplex/Messaging/Agent.hs) ([module spec](./spec/modules/Simplex/Messaging/Agent.md)) communicates via STM queues using the [ACommand](./src/Simplex/Messaging/Agent/Protocol.hs) type — no serialization needed. The Agent implements the [Agent protocol](./protocol/agent-protocol.md) for duplex connections and uses the [PQDR protocol](./protocol/pqdr.md) for end-to-end encryption. Cross-device remote control uses the [XRCP protocol](./protocol/xrcp.md).
See [docs/AGENT.md](./docs/AGENT.md).
## Quick start
Public SMP routers for testing:
You can either run your own SMP server locally or deploy using [Linode StackScript](https://cloud.linode.com/stackscripts/748014), or try local SMP agent with the deployed servers:
`smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im`
@@ -81,16 +88,233 @@ Public SMP routers for testing:
`smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im`
## Deploy routers
It's the easiest to try SMP agent via a prototype [simplex-chat](https://github.com/simplex-chat/simplex-chat) terminal UI.
You can run SMP/XFTP routers on any Linux distribution. OpenSSL is required:
## Deploy SMP/XFTP servers on Linux
You can run your SMP/XFTP server as a Linux process, optionally using a service manager for booting and restarts.
Notice that `smp-server` and `xftp-server` requires `openssl` as run-time dependency (it is used to generate server certificates during initialization). Install it with your packet manager:
```sh
# Ubuntu
# For Ubuntu
apt update && apt install openssl
```
See [docs/ROUTERS.md](./docs/ROUTERS.md) for Docker, binary installation, building from source, and cloud deployment (Linode, DigitalOcean).
### Install binaries
#### Using Docker
On Linux, you can deploy smp and xftp server using Docker. This will download image from [Docker Hub](https://hub.docker.com/r/simplexchat).
1. Create directories for persistent Docker configuration:
```sh
mkdir -p $HOME/simplex/{xftp,smp}/{config,logs} && mkdir -p $HOME/simplex/xftp/files
```
2. Run your Docker container.
- `smp-server`
You must change **your_ip_or_domain**. `-e "pass=password"` is optional variable to password-protect your `smp` server:
```sh
docker run -d \
-e "ADDR=your_ip_or_domain" \
-e "PASS=password" \
-p 5223:5223 \
-v $HOME/simplex/smp/config:/etc/opt/simplex:z \
-v $HOME/simplex/smp/logs:/var/opt/simplex:z \
simplexchat/smp-server:latest
```
- `xftp-server`
You must change **your_ip_or_domain** and **maximum_storage**.
```sh
docker run -d \
-e "ADDR=your_ip_or_domain" \
-e "QUOTA=maximum_storage" \
-p 443:443 \
-v $HOME/simplex/xftp/config:/etc/opt/simplex-xftp:z \
-v $HOME/simplex/xftp/logs:/var/opt/simplex-xftp:z \
-v $HOME/simplex/xftp/files:/srv/xftp:z \
simplexchat/xftp-server:latest
```
#### Using installation script
**Please note** that currently, only Ubuntu distribution is supported.
You can install and setup servers automatically using our script:
```sh
curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/install.sh -o simplex-server-install.sh &&\
if echo '53fcdb4ceab324316e2c4cda7e84dbbb344f32550a65975a7895425e5a1be757 simplex-server-install.sh' | sha256sum -c; then
chmod +x ./simplex-server-install.sh
./simplex-server-install.sh
rm ./simplex-server-install.sh
else
echo "SHA-256 checksum is incorrect!"
rm ./simplex-server-install.sh
fi
```
### Build from source
#### Using Docker
> **Please note:** to build the app use source code from [stable branch](https://github.com/simplex-chat/simplexmq/tree/stable).
On Linux, you can build smp server using Docker.
1. Build your images:
```sh
git clone https://github.com/simplex-chat/simplexmq
cd simplexmq
git checkout stable
DOCKER_BUILDKIT=1 docker build -t local/smp-server --build-arg APP="smp-server" --build-arg APP_PORT="5223" . # For xmp-server
DOCKER_BUILDKIT=1 docker build -t local/xftp-server --build-arg APP="xftp-server" --build-arg APP_PORT="443" . # For xftp-server
```
2. Create directories for persistent Docker configuration:
```sh
mkdir -p $HOME/simplex/{xftp,smp}/{config,logs} && mkdir -p $HOME/simplex/xftp/files
```
3. Run your Docker container.
- `smp-server`
You must change **your_ip_or_domain**. `-e "pass=password"` is optional variable to password-protect your `smp` server:
```sh
docker run -d \
-e "ADDR=your_ip_or_domain" \
-e "PASS=password" \
-p 5223:5223 \
-v $HOME/simplex/smp/config:/etc/opt/simplex:z \
-v $HOME/simplex/smp/logs:/var/opt/simplex:z \
simplexchat/smp-server:latest
```
- `xftp-server`
You must change **your_ip_or_domain** and **maximum_storage**.
```sh
docker run -d \
-e "ADDR=your_ip_or_domain" \
-e "QUOTA=maximum_storage" \
-p 443:443 \
-v $HOME/simplex/xftp/config:/etc/opt/simplex-xftp:z \
-v $HOME/simplex/xftp/logs:/var/opt/simplex-xftp:z \
-v $HOME/simplex/xftp/files:/srv/xftp:z \
simplexchat/xftp-server:latest
```
#### Using your distribution
1. Install dependencies and build tools (`GHC`, `cabal` and dev libs):
```sh
# On Ubuntu. Depending on your distribution, use your package manager to determine package names.
sudo apt-get update && apt-get install -y build-essential curl libffi-dev libffi7 libgmp3-dev libgmp10 libncurses-dev libncurses5 libtinfo5 pkg-config zlib1g-dev libnuma-dev libssl-dev
export BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
export BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.3.0
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}"
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
source ~/.ghcup/env
```
2. Build the project:
```sh
git clone https://github.com/simplex-chat/simplexmq
cd simplexmq
git checkout stable
cabal update
cabal build exe:smp-server exe:xftp-server
```
3. List compiled binaries:
`smp-server`
```sh
cabal list-bin exe:smp-server
```
`xftp-server`
```sh
cabal list-bin exe:xftp-server
```
- Initialize SMP server with `smp-server init [-l] -n <fqdn>` or `smp-server init [-l] --ip <ip>` - depending on how you initialize it, either FQDN or IP will be used for server's address.
- Run `smp-server start` to start SMP server, or you can configure a service manager to run it as a service.
- Optionally, `smp-server` can be setup for having an onion address in `tor` network. See: [`scripts/tor`](./scripts/tor/). In this case, the server address can have both public and onion hostname pointing to the same server, to allow two people connect when only one of them is using Tor. The server address would be: `smp://<fingerprint>@<public_hostname>,<onion_hostname>`
See [this section](#smp-server) for more information. Run `smp-server -h` and `smp-server init -h` for explanation of commands and options.
[<img alt="Linode" src="./img/linode.svg" align="right" width="200">](https://cloud.linode.com/stackscripts/748014)
## Deploy SMP server on Linode
\* You can use free credit Linode offers when [creating a new account](https://www.linode.com/) to deploy an SMP server.
Deployment on Linode is performed via StackScripts, which serve as recipes for Linode instances, also called Linodes. To deploy SMP server on Linode:
- Create a Linode account or login with an already existing one.
- Open [SMP server StackScript](https://cloud.linode.com/stackscripts/748014) and click "Deploy New Linode".
- You can optionally configure the following parameters:
- SMP Server store log flag for queue persistence on server restart, recommended.
- [Linode API token](https://www.linode.com/docs/guides/getting-started-with-the-linode-api#get-an-access-token) to attach server address etc. as tags to Linode and to add A record to your 2nd level domain (e.g. `example.com` [domain should be created](https://cloud.linode.com/domains/create) in your account prior to deployment). The API token access scopes:
- read/write for "linodes"
- read/write for "domains"
- Domain name to use instead of Linode IP address, e.g. `smp1.example.com`.
- Choose the region and plan, Shared CPU Nanode with 1Gb is sufficient.
- Provide ssh key to be able to connect to your Linode via ssh. If you haven't provided a Linode API token this step is required to login to your Linode and get the server's fingerprint either from the welcome message or from the file `/etc/opt/simplex/fingerprint` after server starts. See [Linode's guide on ssh](https://www.linode.com/docs/guides/use-public-key-authentication-with-ssh/) .
- Deploy your Linode. After it starts wait for SMP server to start and for tags to appear (if a Linode API token was provided). It may take up to 5 minutes depending on the connection speed on the Linode. Connecting Linode IP address to provided domain name may take some additional time.
- Get `address` and `fingerprint` either from Linode tags (click on a tag and copy it's value from the browser search panel) or via ssh.
- Great, your own SMP server is ready! If you provided FQDN use `smp://<fingerprint>@<fqdn>` as SMP server address in the client, otherwise use `smp://<fingerprint>@<ip_address>`.
Please submit an [issue](https://github.com/simplex-chat/simplexmq/issues) if any problems occur.
[<img alt="DigitalOcean" src="/img/digitalocean.png" align="right" width="300">](https://marketplace.digitalocean.com/apps/simplex-server)
## Deploy SMP server on DigitalOcean
> 🚧 DigitalOcean snapshot is currently not up to date, it will soon be updated 🏗️
\* When creating a DigitalOcean account you can use [this link](https://try.digitalocean.com/freetrialoffer/) to get free credit. (You would still be required either to provide your credit card details or make a confirmation pre-payment with PayPal)
To deploy SMP server use [SimpleX Server 1-click app](https://marketplace.digitalocean.com/apps/simplex-server) from DigitalOcean marketplace:
- Create a DigitalOcean account or login with an already existing one.
- Click 'Create SimpleX server Droplet' button.
- Choose the region and plan according to your requirements (Basic plan should be sufficient).
- Finalize Droplet creation.
- Open "Console" on your Droplet management page to get SMP server fingerprint - either from the welcome message or from `/etc/opt/simplex/fingerprint`. Alternatively you can manually SSH to created Droplet, see [DigitalOcean instruction](https://docs.digitalocean.com/products/droplets/how-to/connect-with-ssh/).
- Great, your own SMP server is ready! Use `smp://<fingerprint>@<ip_address>` as SMP server address in the client.
Please submit an [issue](https://github.com/simplex-chat/simplexmq/issues) if any problems occur.
> **Please note:** SMP server uses server address as a Common Name for server certificate generated during initialization. If you would like your server address to be FQDN instead of IP address, you can log in to your Droplet and run the commands below to re-initialize the server. Alternatively you can use [Linode StackScript](https://cloud.linode.com/stackscripts/748014) which allows this parameterization.
```sh
smp-server delete
smp-server init [-l] -n <fqdn>
```
## SMP server design
![SMP server design](./design/server.svg)
## SMP agent design
![SMP agent design](./design/agent2.svg)
## License
+2 -2
View File
@@ -105,13 +105,13 @@
class="text-[16px] leading-[26px] tracking-[0.01em] nav-link-text text-black dark:text-white before:bg-black dark:before:bg-white">Server
information</span></a>
</li>
<!-- <x-xftpConfig>
<x-xftpConfig>
<li class="nav-link relative"><a href="/file"
class="flex items-center justify-between gap-2 lg:py-5 whitespace-nowrap"><span
class="text-[16px] leading-[26px] tracking-[0.01em] nav-link-text text-black dark:text-white before:bg-black dark:before:bg-white">File
transfer</span></a>
</li>
</x-xftpConfig> -->
</x-xftpConfig>
</ul><a target="_blank" href="https://github.com/simplex-chat/simplex-chat#help-us-with-donations"
class="whitespace-nowrap flex items-center gap-1 self-center text-white dark:text-black text-[16px] font-medium tracking-[0.02em] rounded-[34px] bg-primary-light dark:bg-primary-dark py-3 lg:py-2 px-20 lg:px-5 mb-16 lg:mb-0">Donate</a>
</div>
+3 -3
View File
@@ -34,7 +34,7 @@ xftpMediaContent = $(embedDir "apps/xftp-server/static/media/")
xftpFilePageHtml :: ByteString
xftpFilePageHtml = $(embedFile "apps/xftp-server/static/file.html")
xftpGenerateSite :: XFTPServerConfig s -> Maybe ServerPublicInfo -> Maybe TransportHost -> FilePath -> IO ()
xftpGenerateSite :: XFTPServerConfig -> Maybe ServerPublicInfo -> Maybe TransportHost -> FilePath -> IO ()
xftpGenerateSite cfg info onionHost path = do
let substs = xftpSubsts cfg info onionHost
Web.generateSite embeddedContent (render (Web.indexHtml embeddedContent) substs) [] path
@@ -50,10 +50,10 @@ xftpGenerateSite cfg info onionHost path = do
createDirectoryIfMissing True dir
forM_ content_ $ \(fp, content) -> B.writeFile (dir </> fp) content
xftpServerInformation :: XFTPServerConfig s -> Maybe ServerPublicInfo -> Maybe TransportHost -> ByteString
xftpServerInformation :: XFTPServerConfig -> Maybe ServerPublicInfo -> Maybe TransportHost -> ByteString
xftpServerInformation cfg info onionHost = render (Web.indexHtml embeddedContent) (xftpSubsts cfg info onionHost)
xftpSubsts :: XFTPServerConfig s -> Maybe ServerPublicInfo -> Maybe TransportHost -> [(ByteString, Maybe ByteString)]
xftpSubsts :: XFTPServerConfig -> Maybe ServerPublicInfo -> Maybe TransportHost -> [(ByteString, Maybe ByteString)]
xftpSubsts XFTPServerConfig {fileExpiration, logStatsInterval, allowNewFiles, newFileBasicAuth} information onionHost =
[("smpConfig", Nothing), ("xftpConfig", Just "y")] <> substConfig <> serverInfoSubsts simplexmqSource information <> [("onionHost", strEncode <$> onionHost), ("iniFileName", Just "file-server.ini")]
where
+133
View File
@@ -0,0 +1,133 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module ClientSim
( SimClient (..),
connectClient,
createQueue,
subscribeQueue,
sendMessage,
receiveAndAck,
connectN,
benchKeyHash,
)
where
import Control.Concurrent.Async (mapConcurrently)
import Control.Concurrent.STM
import Control.Monad (forM_)
import Control.Monad.Except (runExceptT)
import Data.ByteString.Char8 (ByteString)
import Data.List (unfoldr)
import qualified Data.List.NonEmpty as L
import Network.Socket (ServiceName)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Protocol
import Simplex.Messaging.Transport
import Simplex.Messaging.Transport.Client
import Simplex.Messaging.Version
data SimClient = SimClient
{ scHandle :: THandleSMP TLS 'TClient,
scRcvKey :: C.APrivateAuthKey,
scRcvId :: RecipientId,
scSndId :: SenderId,
scDhSecret :: C.DhSecret 'C.X25519
}
benchKeyHash :: C.KeyHash
benchKeyHash = "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI="
connectClient :: TransportHost -> ServiceName -> IO (THandleSMP TLS 'TClient)
connectClient host port = do
let tcConfig = defaultTransportClientConfig {clientALPN = Just alpnSupportedSMPHandshakes}
runTransportClient tcConfig Nothing host port (Just benchKeyHash) $ \h ->
runExceptT (smpClientHandshake h Nothing benchKeyHash supportedClientSMPRelayVRange False Nothing) >>= \case
Right th -> pure th
Left e -> error $ "SMP handshake failed: " <> show e
connectN :: Int -> TransportHost -> ServiceName -> IO [THandleSMP TLS 'TClient]
connectN n host port = do
let batches = chunksOf 100 [1 .. n]
concat <$> mapM (\batch -> mapConcurrently (\_ -> connectClient host port) batch) batches
createQueue :: THandleSMP TLS 'TClient -> IO SimClient
createQueue h = do
g <- C.newRandom
(rPub, rKey) <- atomically $ C.generateAuthKeyPair C.SEd448 g
(sPub, sKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(dhPub, dhPriv :: C.PrivateKeyX25519) <- atomically $ C.generateKeyPair g
-- NEW command
Resp "1" NoEntity (Ids rId sId srvDh) <- signSendRecv h rKey ("1", NoEntity, New rPub dhPub)
let dhShared = C.dh' srvDh dhPriv
-- KEY command (secure queue)
Resp "2" _ OK <- signSendRecv h rKey ("2", rId, KEY sPub)
pure SimClient {scHandle = h, scRcvKey = rKey, scRcvId = rId, scSndId = sId, scDhSecret = dhShared}
subscribeQueue :: SimClient -> IO ()
subscribeQueue SimClient {scHandle = h, scRcvKey = rKey, scRcvId = rId} = do
Resp "3" _ (SOK _) <- signSendRecv h rKey ("3", rId, SUB)
pure ()
sendMessage :: THandleSMP TLS 'TClient -> C.APrivateAuthKey -> SenderId -> ByteString -> IO ()
sendMessage h sKey sId body = do
Resp "4" _ OK <- signSendRecv h sKey ("4", sId, SEND noMsgFlags body)
pure ()
receiveAndAck :: SimClient -> IO ()
receiveAndAck SimClient {scHandle = h, scRcvKey = rKey, scRcvId = rId} = do
(_, _, Right (MSG RcvMessage {msgId = mId})) <- tGet1 h
Resp "5" _ OK <- signSendRecv h rKey ("5", rId, ACK mId)
pure ()
-- Helpers (same patterns as ServerTests.hs)
pattern Resp :: CorrId -> EntityId -> BrokerMsg -> Transmission (Either ErrorType BrokerMsg)
pattern Resp corrId queueId command <- (corrId, queueId, Right command)
pattern Ids :: RecipientId -> SenderId -> RcvPublicDhKey -> BrokerMsg
pattern Ids rId sId srvDh <- IDS (QIK rId sId srvDh _ _ Nothing Nothing)
pattern New :: RcvPublicAuthKey -> RcvPublicDhKey -> Command 'Creator
pattern New rPub dhPub = NEW (NewQueueReq rPub dhPub Nothing SMSubscribe (Just (QRMessaging Nothing)) Nothing)
signSendRecv :: (Transport c, PartyI p) => THandleSMP c 'TClient -> C.APrivateAuthKey -> (ByteString, EntityId, Command p) -> IO (Transmission (Either ErrorType BrokerMsg))
signSendRecv h pk t = do
signSend h pk t
(r L.:| _) <- tGetClient h
pure r
signSend :: (Transport c, PartyI p) => THandleSMP c 'TClient -> C.APrivateAuthKey -> (ByteString, EntityId, Command p) -> IO ()
signSend h@THandle {params} (C.APrivateAuthKey a pk) (corrId, qId, cmd) = do
let TransmissionForAuth {tForAuth, tToSend} = encodeTransmissionForAuth params (CorrId corrId, qId, cmd)
authorize t = (,Nothing) <$> case a of
C.SEd25519 -> Just . TASignature . C.ASignature C.SEd25519 $ C.sign' pk t
C.SEd448 -> Just . TASignature . C.ASignature C.SEd448 $ C.sign' pk t
C.SX25519 -> (\THAuthClient {peerServerPubKey = k} -> TAAuthenticator $ C.cbAuthenticate k pk (C.cbNonce corrId) t) <$> thAuth params
Right () <- tPut1 h (authorize tForAuth, tToSend)
pure ()
tPut1 :: Transport c => THandle v c 'TClient -> SentRawTransmission -> IO (Either TransportError ())
tPut1 h t = do
rs <- tPut h (Right t L.:| [])
case rs of
(r : _) -> pure r
[] -> error "tPut1: empty result"
tGet1 :: (ProtocolEncoding v err cmd, Transport c) => THandle v c 'TClient -> IO (Transmission (Either err cmd))
tGet1 h = do
(r L.:| _) <- tGetClient h
pure r
chunksOf :: Int -> [a] -> [[a]]
chunksOf n = unfoldr $ \xs -> if null xs then Nothing else Just (splitAt n xs)
+25
View File
@@ -0,0 +1,25 @@
FROM haskell:9.6.3 AS build
WORKDIR /src
# Copy cabal file first for dependency caching
COPY simplexmq.cabal cabal.project* ./
RUN cabal update && cabal build --only-dependencies -f server_postgres smp-server-bench || true
# Copy full source
COPY . .
RUN cabal build -f server_postgres smp-server-bench \
&& cp $(cabal list-bin -f server_postgres smp-server-bench) /usr/local/bin/smp-server-bench
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libgmp10 libpq5 libffi8 zlib1g ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/local/bin/smp-server-bench /usr/local/bin/smp-server-bench
COPY tests/fixtures /app/tests/fixtures
WORKDIR /app
ENTRYPOINT ["smp-server-bench"]
+243
View File
@@ -0,0 +1,243 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Main where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (async, cancel, forConcurrently_, mapConcurrently, mapConcurrently_)
import Control.Concurrent.STM
import Control.Monad (forever, forM_, void, when)
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.IORef
import Data.List (unfoldr)
import Data.Time.Clock (getCurrentTime, utctDayTime)
import Network.Socket (ServiceName)
import System.Environment (getArgs)
import System.IO (hFlush, stdout)
import ClientSim
import Report
import Crypto.Random (ChaChaDRG)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Server (runSMPServerBlocking)
import Simplex.Messaging.Server.Env.STM as Env
import Simplex.Messaging.Server.Expiration (ExpirationConfig (..))
import Simplex.Messaging.Server.MsgStore.Postgres (PostgresMsgStore)
import Simplex.Messaging.Server.QueueStore.Postgres.Config (PostgresStoreCfg (..))
import Simplex.Messaging.Agent.Store.Postgres.Options (DBOpts (..))
import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..))
import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClientAgentConfig)
import Simplex.Messaging.Transport
import Simplex.Messaging.Transport.Server (ServerCredentials (..), mkTransportServerConfig)
import Simplex.Messaging.Version
import UnliftIO.Exception (bracket)
import Control.Logger.Simple (logInfo, withGlobalLogging, LogConfig (..), setLogLevel, LogLevel (..))
data BenchConfig = BenchConfig
{ numClients :: Int,
sustainedMinutes :: Int,
pgConnStr :: ByteString,
serverPort :: ServiceName,
timeSeriesFile :: FilePath
}
defaultBenchConfig :: BenchConfig
defaultBenchConfig =
BenchConfig
{ numClients = 5000,
sustainedMinutes = 5,
pgConnStr = "postgresql://smp@localhost:15432/smp_bench",
serverPort = "15001",
timeSeriesFile = "bench-timeseries.csv"
}
parseArgs :: IO BenchConfig
parseArgs = do
args <- getArgs
pure $ go args defaultBenchConfig
where
go [] c = c
go ("--clients" : n : rest) c = go rest c {numClients = read n}
go ("--minutes" : n : rest) c = go rest c {sustainedMinutes = read n}
go ("--pg" : s : rest) c = go rest c {pgConnStr = B.pack s}
go ("--port" : p : rest) c = go rest c {serverPort = p}
go ("--timeseries" : f : rest) c = go rest c {timeSeriesFile = f}
go (x : _) _ = error $ "Unknown argument: " <> x
main :: IO ()
main = withGlobalLogging LogConfig {lc_file = Nothing, lc_stderr = True} $ do
setLogLevel LogInfo
bc@BenchConfig {numClients, sustainedMinutes, serverPort, timeSeriesFile, pgConnStr} <- parseArgs
putStrLn $ "SMP Server Memory Benchmark"
putStrLn $ " clients: " <> show numClients
putStrLn $ " sustain: " <> show sustainedMinutes <> " min"
putStrLn $ " pg: " <> B.unpack pgConnStr
putStrLn $ " port: " <> serverPort
putStrLn ""
snapshotsRef <- newIORef []
let snap phase clients = do
s <- takeSnapshot phase clients
modifyIORef' snapshotsRef (s :)
putStrLn $ " [" <> show phase <> "] live=" <> show (snapLive s `div` (1024 * 1024)) <> "MB large=" <> show (snapLarge s `div` (1024 * 1024)) <> "MB"
hFlush stdout
withBenchServer bc $ do
putStrLn "Phase 1: Baseline (no clients)"
snap "baseline" 0
putStrLn $ "Phase 2: Connecting " <> show numClients <> " TLS clients..."
handles <- connectN numClients "localhost" serverPort
putStrLn $ " Connected " <> show (length handles) <> " clients"
snap "tls_connect" (length handles)
putStrLn "Phase 3: Creating queues (NEW + KEY)..."
simClients <- mapConcurrently createQueue handles
putStrLn $ " Created " <> show (length simClients) <> " queues"
snap "queue_create" (length simClients)
putStrLn "Phase 4: Subscribing (SUB)..."
mapConcurrently_ subscribeQueue simClients
snap "subscribe" (length simClients)
-- Pair up clients: first half sends to second half
let halfN = length simClients `div` 2
senders = take halfN simClients
receivers = drop halfN simClients
pairs = zip senders receivers
putStrLn $ "Phase 5: Sending " <> show halfN <> " messages..."
g <- C.newRandom
forConcurrently_ pairs $ \(sender, receiver) -> do
(_, sKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
sendMessage (scHandle sender) sKey (scSndId receiver) "benchmark test message payload 1234567890"
snap "msg_send" (length simClients)
putStrLn "Phase 6: Receiving and ACKing messages..."
forConcurrently_ receivers receiveAndAck
snap "msg_recv" (length simClients)
putStrLn $ "Phase 7: Sustained load (" <> show sustainedMinutes <> " min)..."
writeTimeSeriesHeader timeSeriesFile
-- Logger thread: snapshot every 10s
logger <- async $ forever $ do
threadDelay 10_000_000
s <- takeSnapshot "sustained" (length simClients)
appendTimeSeries timeSeriesFile s
-- Worker threads: continuous send/receive
let loopDurationUs = sustainedMinutes * 60 * 1_000_000
workersDone <- newTVarIO False
workers <- async $ do
deadline <- (+ loopDurationUs) <$> getMonotonicTimeUs
sustainedLoop g pairs deadline
atomically $ writeTVar workersDone True
-- Wait for workers
void $ atomically $ readTVar workersDone >>= \done -> when (not done) retry
cancel logger
cancel workers
snap "sustained_end" (length simClients)
snapshots <- reverse <$> readIORef snapshotsRef
printSummary snapshots
putStrLn $ "\nTime-series written to: " <> timeSeriesFile
sustainedLoop :: TVar ChaChaDRG -> [(SimClient, SimClient)] -> Int -> IO ()
sustainedLoop g pairs deadline = go
where
go = do
now <- getMonotonicTimeUs
when (now < deadline) $ do
forConcurrently_ pairs $ \(sender, receiver) -> do
(_, sKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
sendMessage (scHandle sender) sKey (scSndId receiver) "sustained load message payload"
forConcurrently_ (map snd pairs) receiveAndAck
go
getMonotonicTimeUs :: IO Int
getMonotonicTimeUs = do
t <- getCurrentTime
pure $ round (utctDayTime t * 1_000_000)
withBenchServer :: BenchConfig -> IO a -> IO a
withBenchServer BenchConfig {pgConnStr, serverPort} action = do
started <- newEmptyTMVarIO
let srvCfg = benchServerConfig pgConnStr serverPort
bracket
(async $ runSMPServerBlocking started srvCfg Nothing)
cancel
(\_ -> waitForServer started >> action)
where
waitForServer started = do
r <- atomically $ takeTMVar started
if r
then putStrLn $ "Server started on port " <> serverPort
else error "Server failed to start"
benchServerConfig :: ByteString -> ServiceName -> ServerConfig PostgresMsgStore
benchServerConfig pgConn port =
let storeCfg = PostgresStoreCfg
{ dbOpts = DBOpts {connstr = pgConn, schema = "smp_server", poolSize = 10, createSchema = True},
dbStoreLogPath = Nothing,
confirmMigrations = MCYesUp,
deletedTTL = 86400
}
in ServerConfig
{ transports = [(port, transport @TLS, False)],
smpHandshakeTimeout = 120_000_000,
tbqSize = 128,
msgQueueQuota = 128,
maxJournalMsgCount = 256,
maxJournalStateLines = 16,
queueIdBytes = 24,
msgIdBytes = 24,
serverStoreCfg = SSCDatabase storeCfg,
storeNtfsFile = Nothing,
allowNewQueues = True,
newQueueBasicAuth = Nothing,
controlPortUserAuth = Nothing,
controlPortAdminAuth = Nothing,
dailyBlockQueueQuota = 20,
messageExpiration = Just defaultMessageExpiration,
expireMessagesOnStart = False,
expireMessagesOnSend = False,
idleQueueInterval = 14400,
notificationExpiration = defaultNtfExpiration,
inactiveClientExpiration = Nothing,
logStatsInterval = Nothing,
logStatsStartTime = 0,
serverStatsLogFile = "bench/tmp/stats.log",
serverStatsBackupFile = Nothing,
prometheusInterval = Nothing,
prometheusMetricsFile = "bench/tmp/metrics.txt",
pendingENDInterval = 500_000,
ntfDeliveryInterval = 200_000,
smpCredentials =
ServerCredentials
{ caCertificateFile = Just "tests/fixtures/ca.crt",
privateKeyFile = "tests/fixtures/server.key",
certificateFile = "tests/fixtures/server.crt"
},
httpCredentials = Nothing,
smpServerVRange = supportedServerSMPRelayVRange,
Env.transportConfig = mkTransportServerConfig True (Just alpnSupportedSMPHandshakes) True,
controlPort = Nothing,
smpAgentCfg = defaultSMPClientAgentConfig {persistErrorInterval = 1},
allowSMPProxy = False,
serverClientConcurrency = 16,
information = Nothing,
startOptions = StartOptions {maintenance = False, compactLog = False, logLevel = LogInfo, skipWarnings = True, confirmMigrations = MCYesUp}
}
+113
View File
@@ -0,0 +1,113 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
module Report
( Snapshot (..),
takeSnapshot,
printSummary,
writeTimeSeriesHeader,
appendTimeSeries,
)
where
import Control.Concurrent (threadDelay)
import Data.List (foldl')
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Time.Format.ISO8601 (iso8601Show)
import Data.Word (Word32, Word64)
import GHC.Stats (RTSStats (..), GCDetails (..), getRTSStats)
import System.IO (Handle, IOMode (..), hFlush, hSetBuffering, BufferMode (..), withFile)
import System.Mem (performMajorGC)
data Snapshot = Snapshot
{ snapTime :: UTCTime,
snapPhase :: Text,
snapLive :: Word64,
snapHeap :: Word64,
snapLarge :: Word64,
snapFrag :: Word64,
snapGCs :: Word32,
snapClients :: Int
}
takeSnapshot :: Text -> Int -> IO Snapshot
takeSnapshot phase clients = do
performMajorGC
threadDelay 1_000_000
rts <- getRTSStats
ts <- getCurrentTime
let GCDetails {gcdetails_live_bytes, gcdetails_mem_in_use_bytes, gcdetails_large_objects_bytes, gcdetails_block_fragmentation_bytes} = gc rts
pure
Snapshot
{ snapTime = ts,
snapPhase = phase,
snapLive = gcdetails_live_bytes,
snapHeap = gcdetails_mem_in_use_bytes,
snapLarge = gcdetails_large_objects_bytes,
snapFrag = gcdetails_block_fragmentation_bytes,
snapGCs = gcs rts,
snapClients = clients
}
printSummary :: [Snapshot] -> IO ()
printSummary [] = putStrLn "No snapshots collected."
printSummary snaps = do
putStrLn ""
putStrLn hdr
putStrLn $ replicate (length hdr) '-'
mapM_ printRow (zip (Snapshot {snapLive = 0, snapHeap = 0, snapLarge = 0, snapFrag = 0, snapGCs = 0, snapClients = 0, snapPhase = "", snapTime = snapTime (head snaps)} : snaps) snaps)
where
hdr = padR 20 "Phase" <> padL 12 "live_MB" <> padL 12 "large_MB" <> padL 12 "frag_MB" <> padL 12 "heap_MB" <> padL 10 "clients" <> padL 14 "d_live_MB" <> padL 14 "d_large_MB" <> padL 14 "KB/client"
printRow (prev, cur) =
putStrLn $
padR 20 (T.unpack $ snapPhase cur)
<> padL 12 (showMB $ snapLive cur)
<> padL 12 (showMB $ snapLarge cur)
<> padL 12 (showMB $ snapFrag cur)
<> padL 12 (showMB $ snapHeap cur)
<> padL 10 (show $ snapClients cur)
<> padL 14 (showDeltaMB (snapLive cur) (snapLive prev))
<> padL 14 (showDeltaMB (snapLarge cur) (snapLarge prev))
<> padL 14 (perClient cur)
showMB w = show (w `div` (1024 * 1024))
showDeltaMB a b
| a >= b = "+" <> show ((a - b) `div` (1024 * 1024))
| otherwise = "-" <> show ((b - a) `div` (1024 * 1024))
perClient Snapshot {snapClients, snapLive}
| snapClients > 0 = show (snapLive `div` fromIntegral snapClients `div` 1024)
| otherwise = "-"
padR n s = s <> replicate (max 0 (n - length s)) ' '
padL n s = replicate (max 0 (n - length s)) ' ' <> s
csvHeader :: Text
csvHeader = "timestamp,phase,rts_live,rts_heap,rts_large,rts_frag,rts_gc,clients"
snapshotCsv :: Snapshot -> Text
snapshotCsv Snapshot {snapTime, snapPhase, snapLive, snapHeap, snapLarge, snapFrag, snapGCs, snapClients} =
T.intercalate
","
[ T.pack $ iso8601Show snapTime,
snapPhase,
tshow snapLive,
tshow snapHeap,
tshow snapLarge,
tshow snapFrag,
tshow snapGCs,
tshow snapClients
]
writeTimeSeriesHeader :: FilePath -> IO ()
writeTimeSeriesHeader path = T.writeFile path (csvHeader <> "\n")
appendTimeSeries :: FilePath -> Snapshot -> IO ()
appendTimeSeries path snap =
withFile path AppendMode $ \h -> do
hSetBuffering h LineBuffering
T.hPutStrLn h $ snapshotCsv snap
tshow :: Show a => a -> Text
tshow = T.pack . show
+46
View File
@@ -0,0 +1,46 @@
services:
postgres:
image: postgres:17
environment:
POSTGRES_USER: smp
POSTGRES_DB: smp_bench
POSTGRES_HOST_AUTH_METHOD: trust
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U smp -d smp_bench"]
interval: 2s
timeout: 5s
retries: 10
bench:
build:
context: ..
dockerfile: bench/Dockerfile
depends_on:
postgres:
condition: service_healthy
environment:
BENCH_PG: "postgresql://smp@postgres/smp_bench"
BENCH_CLIENTS: "${BENCH_CLIENTS:-5000}"
BENCH_MINUTES: "${BENCH_MINUTES:-5}"
command:
- "--pg"
- "postgresql://smp@postgres/smp_bench"
- "--clients"
- "${BENCH_CLIENTS:-5000}"
- "--minutes"
- "${BENCH_MINUTES:-5}"
- "--timeseries"
- "/results/timeseries.csv"
- "+RTS"
- "-N"
- "-A16m"
- "-T"
- "-RTS"
volumes:
- ./results:/results
volumes:
pgdata:
+2
View File
@@ -0,0 +1,2 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE SCHEMA IF NOT EXISTS smp_server;
Executable
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
mkdir -p results
reset_db() {
docker compose down -v 2>/dev/null || true
docker compose up -d --wait postgres
echo "PostgreSQL ready."
}
if [ "$1" = "--compare-rts" ]; then
shift
docker compose build bench
for label_flags in \
"default:-N -A16m -T" \
"F1.2:-N -A16m -F1.2 -T" \
"F1.5:-N -A16m -F1.5 -T" \
"A4m:-N -A4m -T" \
"A4m-F1.2:-N -A4m -F1.2 -T" \
"compact:-N -A16m -c -T" \
"nonmoving:-N -A16m -xn -T"; do
label="${label_flags%%:*}"
flags="${label_flags#*:}"
echo ""
echo "=========================================="
echo " RTS config: $label ($flags)"
echo "=========================================="
reset_db
docker compose run --rm \
-e BENCH_CLIENTS="${BENCH_CLIENTS:-1000}" \
-e BENCH_MINUTES="${BENCH_MINUTES:-2}" \
bench \
--pg "postgresql://smp@postgres/smp_bench" \
--clients "${BENCH_CLIENTS:-1000}" \
--minutes "${BENCH_MINUTES:-2}" \
--timeseries "/results/bench-${label}.csv" \
"$@" \
+RTS $flags -RTS
done
echo ""
echo "Done. Results in bench/results/"
elif [ "$1" = "--local" ]; then
# Run natively (not in container) — requires local Postgres
shift
reset_db
cabal run smp-server-bench -f server_postgres -- \
--pg "postgresql://smp@localhost:15432/smp_bench" \
--clients "${BENCH_CLIENTS:-5000}" \
--minutes "${BENCH_MINUTES:-5}" \
"$@" \
+RTS -N -A16m -s -RTS
else
# Run fully in containers
reset_db
docker compose run --rm bench "$@"
fi
docker compose down
-1
View File
@@ -92,7 +92,6 @@ cabal list-bin exe:smp-server
### Cabal Flags
- `swift`: Enable Swift JSON format
- `use_crypton`: Use crypton in cryptostore (default: enabled)
- `client_library`: Build without server code
- `client_postgres`: Use PostgreSQL instead of SQLite for agent persistence
- `server_postgres`: PostgreSQL support for server queue/notification store
+1 -1
View File
@@ -13,7 +13,7 @@ Key components:
- **SMP Client**: Functional API with STM-based message delivery ([code](../src/Simplex/Messaging/Client.hs)).
- **SMP Agent**: High-level duplex connections via multiple simplex queues with E2E encryption ([code](../src/Simplex/Messaging/Agent.hs)). Implements Agent-to-agent protocol ([code](../src/Simplex/Messaging/Agent/Protocol.hs), [spec](../protocol/agent-protocol.md)) via intermediary agent client ([code](../src/Simplex/Messaging/Agent/Client.hs)).
- **XFTP**: SimpleX File Transfer Protocol, server and CLI client ([code](../src/Simplex/FileTransfer/), [spec](../protocol/xftp.md)).
- **XRCP**: SimpleX Remote Control Protocol ([code](../src/Simplex/RemoteControl/), [spec](../protocol/xrcp.md)).
- **XRCP**: SimpleX Remote Control Protocol ([code](`../src/Simplex/RemoteControl/`), [spec](../protocol/xrcp.md)).
- **Notifications**: Push notifications server requires PostgreSQL ([code](../src/Simplex/Messaging/Notifications), [executable](../apps/ntf-server/)). Client protocol is used for clients to communicate with the server ([code](../src/Simplex/Messaging/Notifications/Protocol.hs), [spec](../protocol/push-notifications.md)). For subscribing to SMP notifications the server uses [lightweight SMP client](../src/Simplex/Messaging/Client/Agent.hs).
## Architecture
-92
View File
@@ -1,92 +0,0 @@
# SimpleX Agent
The SimpleX Agent builds duplex encrypted connections on top of [SimpleX client libraries](CLIENT.md). It manages the full lifecycle of secure communication: connection establishment, end-to-end encryption, queue rotation, file transfer, and push notifications.
This is **Layer 3** of the [SimpleX Network architecture](../protocol/overview-tjr.md). Layer 1 is the routers; Layer 2 is the [client libraries](CLIENT.md) that speak the wire protocols. The Agent adds the connection semantics that applications need. For internal architecture diagrams (thread topology, message processing flows), see [`spec/agent.md`](../spec/agent.md).
**Source**: [`Simplex.Messaging.Agent`](../src/Simplex/Messaging/Agent.hs). **Module spec**: [`spec/modules/Simplex/Messaging/Agent.md`](../spec/modules/Simplex/Messaging/Agent.md)
## Connections
The Agent turns simplex (unidirectional) SMP queues into duplex connections, implementing the [Agent protocol](../protocol/agent-protocol.md):
- **Duplex connections**: each connection uses a pair of SMP queues - one for each direction. The queues can be on different routers chosen independently by each party. See the [duplex connection procedure](../protocol/agent-protocol.md) for the full handshake.
- **Connection establishment**: one party creates a connection and generates an invitation (containing router address, queue ID, and public keys). The invitation is passed out-of-band (QR code, link, etc.). The other party joins by creating a reverse queue and completing the handshake.
- **Connection links**: the Agent supports connection links (long and short) for sharing connection invitations via URLs. Short links use a separate SMP queue to store the full invitation, allowing compact QR codes.
- **Queue rotation**: the Agent has API to rotate the SMP queues, to reduce metadata correlation. The connection identity remain the same when the underlying queue changes.
## Encryption
The Agent provides end-to-end encryption with forward secrecy and break-in recovery, specified in the [Post-Quantum Double Ratchet protocol](../protocol/pqdr.md):
- **Double ratchet**: messages are encrypted using a double ratchet protocol. Each message uses a unique key; compromising one key does not reveal past or future messages. See the [PQDR specification](../protocol/pqdr.md) for the full ratchet state machine.
- **Post-quantum extensions**: the ratchet supports hybrid key exchange using SNTRUP761 (a lattice-based KEM) combined with X25519 DH. This provides protection against future quantum computers that could break classical DH. See the [SNTRUP761 module spec](../spec/modules/Simplex/Messaging/Crypto/SNTRUP761.md) and [Ratchet module spec](../spec/modules/Simplex/Messaging/Crypto/Ratchet.md) for implementation details.
- **Ratchet synchronization**: if the ratchet state becomes desynchronized (e.g., due to message loss or device restore), the Agent detects this and can negotiate resynchronization with the peer.
- **Per-queue encryption**: in addition to end-to-end encryption, the [SMP protocol](../protocol/simplex-messaging.md) provides a separate encryption layer on each queue between sender and router, preventing traffic correlation even if TLS is compromised.
## File Transfer
The Agent handles file transfer over [XFTP](../protocol/xftp.md) routers. File transfer orchestration is implemented in the [XFTP Agent module](../spec/modules/Simplex/FileTransfer/Agent.md):
- **Chunking**: files are split into chunks, each sent as a data packet to an XFTP router. Chunk sizes are fixed powers of 2 (64KB to 4MB), hiding the actual file size. See the [file description module spec](../spec/modules/Simplex/FileTransfer/Description.md) for chunk size selection and file descriptor format.
- **Client-side encryption**: files are encrypted and padded before being sent to XFTP routers. The recipient decrypts after receiving all chunks. The encryption key and file metadata are sent through the SMP connection, not through XFTP. See [file crypto module spec](../spec/modules/Simplex/FileTransfer/Crypto.md).
- **Multi-router distribution**: chunks can be sent to different XFTP routers, and each chunk can have multiple replicas on different routers for redundancy.
- **Redirect chains**: for metadata privacy, file descriptors can be sent as XFTP data packets themselves, creating an indirection layer between the SMP message and the actual file location.
## Notifications
The Agent manages push notification subscriptions for mobile devices, using the [Push Notifications protocol](../protocol/push-notifications.md). Notification supervision is handled by the [NtfSubSupervisor](../spec/modules/Simplex/Messaging/Agent/NtfSubSupervisor.md):
- **Token registration**: registers device push tokens with NTF (notification) routers, which bridge to platform push services (APNS). See the [NTF client module spec](../spec/modules/Simplex/Messaging/Notifications/Client.md).
- **Notification subscriptions**: creates NTF subscriptions for SMP queues so that incoming messages trigger push notifications without requiring persistent connections.
- **Privacy preservation**: push notifications contain only a notification ID, not message content. The device wakes, connects to the SMP router, and retrieves the actual message. See the [Push Notifications protocol](../protocol/push-notifications.md) for the full flow.
## Integration
The Agent is designed to be embedded as a Haskell library:
- **STM queues**: the application communicates with the Agent via STM queues. Commands go in (`ACommand`), events come out (`AEvent`). No serialization or parsing - direct Haskell values. The command/event types are defined in the [Agent Protocol module](../spec/modules/Simplex/Messaging/Agent/Protocol.md).
- **Async operation**: all network operations are asynchronous. The Agent manages internal worker threads for each router connection, message processing, and background tasks (cleanup, statistics, notification supervision). See the [Agent Client module spec](../spec/modules/Simplex/Messaging/Agent/Client.md) for worker architecture.
- **Background mode**: on mobile platforms, the Agent can run in a reduced mode with only the message receiver active, minimizing resource usage when the app is backgrounded.
- **Dual database backends**: the Agent supports both SQLite (for mobile/desktop) and PostgreSQL (for server deployments) as persistence backends, selected at compile time. See [Agent Store Interface](../spec/modules/Simplex/Messaging/Agent/Store/Interface.md) and [Agent Store Postgres](../spec/modules/Simplex/Messaging/Agent/Store/Postgres.md).
## Use cases
- **Chat applications**: [SimpleX Chat](https://github.com/simplex-chat/simplex-chat) is the reference application, using the full Agent API for messaging, file sharing, groups, and calls.
- **Bots and automated services**: services that need duplex encrypted communication with SimpleX Chat users or other Agent-based applications.
- **Any application needing secure duplex communication** over the SimpleX Network without implementing the connection management, encryption, and queue rotation logic directly.
## What this layer adds over client libraries
| Capability | Client (Layer 2) | Agent (Layer 3) |
|---|---|---|
| Queue operations | Direct | Managed transparently |
| Connection model | Simplex (unidirectional) queues | Duplex connections |
| Encryption | Application's responsibility | Double ratchet with PQ extensions |
| File transfer | Raw data packet send/receive | Chunking, encryption, reassembly |
| Identity | Per-queue keys | Per-connection, rotatable |
| Notifications | Direct NTF protocol operations | Automated subscription supervision |
## Protocol references
- [Agent Protocol](../protocol/agent-protocol.md) - duplex connection procedure, message format
- [SimpleX Network overview](../protocol/overview-tjr.md) - architecture, trust model
- [PQDR](../protocol/pqdr.md) - post-quantum double ratchet specification
- [SimpleX Messaging Protocol](../protocol/simplex-messaging.md) - SMP queue operations used by the Agent
- [XFTP Protocol](../protocol/xftp.md) - data packet operations for file transfer
- [Push Notifications Protocol](../protocol/push-notifications.md) - NTF token and subscription management
## Peer library: Remote Control
The Agent exposes the [XRCP protocol](../protocol/xrcp.md) API for cross-device remote control (e.g., controlling a mobile app from a desktop). The actual logic is in the standalone [`Simplex.RemoteControl.Client`](../src/Simplex/RemoteControl/Client.hs) library - the Agent provides thin wrappers that pass through its random and multicast state. XRCP is not a managed Agent capability (no workers, persistence, or background supervision). See the [RemoteControl module specs](../spec/modules/Simplex/RemoteControl/Types.md).
## Module specs
- [Agent](../spec/modules/Simplex/Messaging/Agent.md) - main Agent module, connection lifecycle, message processing
- [Agent Client](../spec/modules/Simplex/Messaging/Agent/Client.md) - worker threads, router connections, subscription management
- [Agent Protocol](../spec/modules/Simplex/Messaging/Agent/Protocol.md) - ACommand/AEvent types, connection invitations
- [Agent Store Interface](../spec/modules/Simplex/Messaging/Agent/Store/Interface.md) - database abstraction for SQLite/Postgres
- [Agent Store (AgentStore)](../spec/modules/Simplex/Messaging/Agent/Store/AgentStore.md) - connection, queue, and message persistence
- [NtfSubSupervisor](../spec/modules/Simplex/Messaging/Agent/NtfSubSupervisor.md) - notification subscription management
- [XFTP Agent](../spec/modules/Simplex/FileTransfer/Agent.md) - file transfer orchestration
- [Ratchet](../spec/modules/Simplex/Messaging/Crypto/Ratchet.md) - double ratchet implementation
- [SNTRUP761](../spec/modules/Simplex/Messaging/Crypto/SNTRUP761.md) - post-quantum KEM
-96
View File
@@ -1,96 +0,0 @@
# SimpleX Client Libraries
SimpleX client libraries provide low-level protocol access to SimpleX routers. They implement the wire protocols ([SMP](../protocol/simplex-messaging.md), [XFTP](../protocol/xftp.md), [NTF](../protocol/push-notifications.md)) and handle connection lifecycle, but leave encryption, identity management, and connection orchestration to the application.
This is **Layer 2** of the [SimpleX Network architecture](../protocol/overview-tjr.md). Layer 1 is the routers themselves; Layer 3 is the [Agent](AGENT.md), which builds duplex encrypted connections on top of these libraries. For internal architecture diagrams (thread topology, command processing flows), see [`spec/clients.md`](../spec/clients.md).
## SMP Client
**Source**: [`Simplex.Messaging.Client`](../src/Simplex/Messaging/Client.hs). For architecture and module specs, see [SMP Client](../spec/clients.md#smp-client-protocolclient).
The SMP client connects to SMP routers and manages simplex messaging queues, the fundamental addressing primitive of the SimpleX Network. Each simplex queue is a unidirectional, ordered sequence of fixed-size packets (16,384 bytes) with separate cryptographic credentials for sending and receiving. The queue model and command set are defined in the [SMP protocol](../protocol/simplex-messaging.md).
### Capabilities
- **Queue management**: create, secure, subscribe to, and delete queues on any SMP router. Queue operations use the [SMP command set](../protocol/simplex-messaging.md) (NEW, KEY, SUB, DEL, etc.).
- **Message sending and receiving**: send messages to a queue's sender address; receive messages from a queue's recipient address
- **Command authentication**: each queue operation is authenticated with per-queue cryptographic keys (Ed25519, Ed448, or X25519). See the [SMP protocol security model](../protocol/simplex-messaging.md) for key roles.
- **Keep-alive**: automatic ping loop detects and recovers from half-open connections
- **Proxy forwarding**: send messages through a proxy router via 2-hop onion routing (PRXY/PFWD/RFWD commands), protecting the sender's IP address from the destination router. See [proxy forwarding details](../spec/modules/Simplex/Messaging/Client.md) in the module spec.
- **Batched commands**: multiple commands can be sent in a single transmission for efficiency
### API model
The client uses a functional Haskell API with STM queues for asynchronous event delivery:
- **Commands** are sent via `sendProtocolCommand` (single) or `sendBatch` (multiple). Each returns a result synchronously or via timeout.
- **Router events** (incoming messages, subscription notifications) arrive on `msgQ`, an STM `TBQueue` that the application reads from its own thread.
- **Connection lifecycle** is managed automatically: the client maintains send, receive, process, and monitor threads internally. When any thread fails, all are torn down and the `disconnected` callback fires.
### Router identity
Routers are identified by the SHA-256 hash of their CA certificate fingerprint, not by hostname. The client validates the full X.509 certificate chain on every TLS connection and compares the CA fingerprint against the expected hash from the queue address. This means a DNS or IP-level attacker who cannot produce the correct certificate is detected at connection time.
## SMPClientAgent
**Source**: [`Simplex.Messaging.Client.Agent`](../src/Simplex/Messaging/Client/Agent.hs). For architecture and module specs, see [SMPClientAgent](../spec/clients.md#smpclientagent).
Connection manager that multiplexes multiple SMP client connections. Maintains one ProtocolClient per SMP router, tracks queue and service subscriptions, and handles reconnection with exponential backoff. Used by the SMP router (for proxy forwarding) and the NTF router (for message subscriptions).
### Capabilities
- **Connection pooling**: maintains a pool of ProtocolClient connections keyed by SMP router, creating connections on demand and reusing existing ones
- **Subscription tracking**: tracks active and pending subscriptions (both queue-based and service-based) with automatic state transitions on connect/disconnect
- **Automatic reconnection**: on connection loss, moves subscriptions from active to pending, then spawns a background worker that retries with backoff and resubscribes
- **Session-scoped disconnect handling**: uses session IDs to ensure only subscriptions belonging to the disconnected session are affected, preventing races with newly established connections
## XFTP Client
**Source**: [`Simplex.FileTransfer.Client`](../src/Simplex/FileTransfer/Client.hs). For architecture and module specs, see [XFTP Client](../spec/clients.md#xftp-client).
The XFTP client connects to XFTP routers and manages data packets, individually addressed blocks used for larger payload delivery. Data packets come in fixed sizes (64KB, 256KB, 1MB, 4MB), hiding the actual payload size. The XFTP protocol runs over HTTP/2, simplifying browser integration. The data packet lifecycle and command set are defined in the [XFTP protocol](../protocol/xftp.md).
### Capabilities
- **Data packet creation**: create data packets on routers with sender, recipient, and optional additional recipient credentials. See the [XFTP protocol](../protocol/xftp.md) for credential roles and packet lifecycle.
- **Send** (FPUT): send encrypted data to the router in a single HTTP/2 streaming request (command + body)
- **Receive** (FGET): receive data packets with per-request ephemeral Diffie-Hellman key exchange, providing forward secrecy: compromising one DH key does not reveal other received data packets
- **Acknowledgment and deletion**: recipients acknowledge receipt; senders delete data packets after delivery
## NTF Client
**Source**: [`Simplex.Messaging.Notifications.Client`](../src/Simplex/Messaging/Notifications/Client.hs). For architecture and module specs, see [NTF Client](../spec/clients.md#ntf-client).
The NTF client connects to NTF (notification) routers and manages push notification tokens and subscriptions. It implements the [Push Notifications protocol](../protocol/push-notifications.md).
### Capabilities
- **Token management**: register, verify, replace, and delete push notification tokens on NTF routers
- **Subscription management**: create, check, and delete notification subscriptions that link SMP queues to push tokens
- **Batch operations**: create or check multiple subscriptions in a single request, with per-item error handling for partial success
## Use cases
These libraries are appropriate when the application manages its own encryption and connection logic:
- **IoT sensor data collection**: a sensor creates an SMP queue and sends readings; a collector subscribes and receives them. The queue address (router + queue ID + keys) is provisioned once, out-of-band.
- **Device control**: a controller sends commands to an actuator's queue. Separate queues for commands and telemetry provide unidirectional isolation.
- **Bulk data delivery**: an application encrypts and chunks a file, sends data packets to XFTP routers, and shares the packet addresses with the recipient out-of-band.
- **Custom protocols**: any application that needs unidirectional, router-mediated packet delivery without the overhead of the Agent's connection management.
## What this layer does NOT provide
The following capabilities require the [Agent](AGENT.md) (Layer 3):
- **Duplex connections** - the Agent pairs two simplex queues into a duplex connection
- **End-to-end encryption** - the Agent manages double ratchet with post-quantum extensions
- **File transfer** - the Agent handles chunking, encryption, padding, multi-router distribution, and reassembly
- **Queue rotation** - the Agent transparently rotates queues to limit metadata correlation
- **Connection discovery** - connection links, short links, and contact addresses are Agent-level abstractions
- **Push notifications** - notification token management and subscription is Agent-level
## Protocol references
- [SimpleX Messaging Protocol](../protocol/simplex-messaging.md) - SMP wire format, commands, and security properties
- [XFTP Protocol](../protocol/xftp.md) - XFTP wire format, data packet lifecycle
- [SimpleX Network overview](../protocol/overview-tjr.md) - architecture, trust model, and design rationale
-181
View File
@@ -1,181 +0,0 @@
# SimpleX Routers: Deployment and Configuration
SimpleX routers are the network infrastructure of the [SimpleX Network](../protocol/overview-tjr.md). They accept, buffer, and deliver data packets between endpoints. Each router operates independently and can be run by any party on standard computing hardware.
This document covers deployment and advanced configuration. For an overview of the router architecture and trust model, see the [SimpleX Network overview](../protocol/overview-tjr.md). For internal architecture diagrams (thread topology, command processing flows), see [`spec/routers.md`](../spec/routers.md).
## SMP Router
The SMP router provides messaging queues - unidirectional, ordered sequences of fixed-size packets (16,384 bytes each). It implements the [SimpleX Messaging Protocol](../protocol/simplex-messaging.md). For architecture and module specs, see [SMP Router](../spec/routers.md#smp-router).
### Advanced configuration
`smp-server.ini` is created during initialization and controls all runtime behavior.
**Message persistence**: when store log is enabled (`enable = on`), the server saves undelivered messages on exit and restores them on start. This only works with SIGINT (keyboard interrupt); SIGTERM does not trigger message saving. The `restore_messages` setting can be used to override this behavior independently of the store log setting.
**Tor onion addresses**: the server can have both a public hostname and an onion hostname, allowing two users to connect when only one is using Tor. Configure as: `smp://<fingerprint>@<public_hostname>,<onion_hostname>`. See [`scripts/tor/`](../scripts/tor/) for setup instructions.
### Running on MacOS
SMP server requires OpenSSL for initialization. MacOS may ship LibreSSL instead, which doesn't support the required algorithms.
```sh
openssl version
```
If it says "LibreSSL", install OpenSSL:
```sh
brew update
brew install openssl
echo 'PATH="/opt/homebrew/opt/openssl@3/bin:$PATH"' >> ~/.zprofile
. ~/.zprofile
```
## XFTP Router
The XFTP router accepts and delivers data packets over HTTP/2 - individually addressed blocks in fixed sizes (64KB, 256KB, 1MB, 4MB). It implements the [XFTP protocol](../protocol/xftp.md). Data packets are used for larger payload delivery (files, media) where SMP queue packet sizes would be inefficient. The use of HTTP/2 simplifies browser integration. For architecture and module specs, see [XFTP Router](../spec/routers.md#xftp-router).
Initialize with `xftp-server init` and configure storage quota in `xftp-server.ini`.
## NTF Router
The NTF router bridges SimpleX Network to platform push notification services (APNS). It implements the [Push Notifications protocol](../protocol/push-notifications.md). Mobile clients register push tokens with the NTF router, which subscribes to their SMP queues and sends push notifications when messages arrive. The push notification contains only a notification ID, not message content. For architecture and module specs, see [NTF Router](../spec/routers.md#ntf-router).
Initialize with `ntf-server init` and configure APNS credentials in `ntf-server.ini`.
## Deployment methods
All routers require `openssl` as a runtime dependency for certificate generation during initialization:
```sh
# Ubuntu
apt update && apt install openssl
```
### Docker (prebuilt images)
Prebuilt images are available from [Docker Hub](https://hub.docker.com/r/simplexchat).
1. Create directories for persistent configuration:
```sh
mkdir -p $HOME/simplex/{xftp,smp}/{config,logs} && mkdir -p $HOME/simplex/xftp/files
```
2. Run:
**SMP router** - change `your_ip_or_domain`; `-e "PASS=password"` is optional:
```sh
docker run -d \
-e "ADDR=your_ip_or_domain" \
-e "PASS=password" \
-p 5223:5223 \
-v $HOME/simplex/smp/config:/etc/opt/simplex:z \
-v $HOME/simplex/smp/logs:/var/opt/simplex:z \
simplexchat/smp-server:latest
```
**XFTP router** - change `your_ip_or_domain` and `maximum_storage`:
```sh
docker run -d \
-e "ADDR=your_ip_or_domain" \
-e "QUOTA=maximum_storage" \
-p 443:443 \
-v $HOME/simplex/xftp/config:/etc/opt/simplex-xftp:z \
-v $HOME/simplex/xftp/logs:/var/opt/simplex-xftp:z \
-v $HOME/simplex/xftp/files:/srv/xftp:z \
simplexchat/xftp-server:latest
```
### Installation script (Ubuntu)
```sh
curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/install.sh -o simplex-server-install.sh &&\
if echo '53fcdb4ceab324316e2c4cda7e84dbbb344f32550a65975a7895425e5a1be757 simplex-server-install.sh' | sha256sum -c; then
chmod +x ./simplex-server-install.sh
./simplex-server-install.sh
rm ./simplex-server-install.sh
else
echo "SHA-256 checksum is incorrect!"
rm ./simplex-server-install.sh
fi
```
### Build from source
#### Using Docker
Build from the [stable branch](https://github.com/simplex-chat/simplexmq/tree/stable):
```sh
git clone https://github.com/simplex-chat/simplexmq
cd simplexmq
git checkout stable
DOCKER_BUILDKIT=1 docker build -t local/smp-server --build-arg APP="smp-server" --build-arg APP_PORT="5223" .
DOCKER_BUILDKIT=1 docker build -t local/xftp-server --build-arg APP="xftp-server" --build-arg APP_PORT="443" .
```
Then run with the same Docker commands as above, replacing `simplexchat/smp-server:latest` with `local/smp-server` (and similarly for XFTP).
#### Native build
1. Install dependencies:
```sh
# Ubuntu
sudo apt-get update && apt-get install -y build-essential curl libffi-dev libffi7 libgmp3-dev libgmp10 libncurses-dev libncurses5 libtinfo5 pkg-config zlib1g-dev libnuma-dev libssl-dev
export BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
export BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.3.0
curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}"
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
source ~/.ghcup/env
```
2. Build:
```sh
git clone https://github.com/simplex-chat/simplexmq
cd simplexmq
git checkout stable
cabal update
cabal build exe:smp-server exe:xftp-server
```
3. Find binaries:
```sh
cabal list-bin exe:smp-server
cabal list-bin exe:xftp-server
```
4. Initialize and run:
```sh
smp-server init [-l] -n <fqdn> # or --ip <ip>
smp-server start
```
### Linode StackScript
[Deploy via Linode StackScript](https://cloud.linode.com/stackscripts/748014). Shared CPU Nanode with 1GB is sufficient.
Configuration options:
- SMP Server store log flag for queue persistence (recommended)
- [Linode API token](https://www.linode.com/docs/guides/getting-started-with-the-linode-api#get-an-access-token) for automatic DNS and tagging (scopes: read/write for "linodes" and "domains")
- Domain name (e.g., `smp1.example.com`) - the [domain must exist](https://cloud.linode.com/domains/create) in your Linode account
After deployment (up to 5 minutes), get the server address from Linode tags or SSH: `smp://<fingerprint>@<fqdn>`.
## Monitoring
SMP and XFTP routers expose Prometheus metrics via a control port. The control port also supports commands for runtime inspection (queue counts, client counts, statistics). See module specs linked from each router section in [`spec/routers.md`](../spec/routers.md) (Control, Prometheus, Stats).
## Protocol references
- [SimpleX Messaging Protocol](../protocol/simplex-messaging.md) - SMP wire format and security properties
- [XFTP Protocol](../protocol/xftp.md) - data packet protocol
- [Push Notifications Protocol](../protocol/push-notifications.md) - NTF protocol
- [SimpleX Network overview](../protocol/overview-tjr.md) - architecture and trust model
+100
View File
@@ -0,0 +1,100 @@
## Memory Diagnostics Results
### Data Collection
Server: smp19.simplex.im, PostgreSQL backend, `useCache = False`
RTS flags: `+RTS -N -A16m -I0.01 -Iw15 -s -RTS` (16 cores)
### Mar 20 Data (1 hour, 07:19-08:19)
```
Time rts_live rts_heap rts_large rts_frag clients non-large
07:19 7.5 GB 8.2 GB 5.5 GB 0.03 GB 14,000 2.0 GB
07:24 6.4 GB 10.8 GB 5.2 GB 3.6 GB 14,806 1.2 GB
07:29 8.2 GB 10.8 GB 6.5 GB 1.8 GB 15,667 1.7 GB
07:34 10.0 GB 12.3 GB 7.9 GB 1.4 GB 15,845 2.1 GB
07:39 6.7 GB 13.0 GB 5.3 GB 5.6 GB 16,589 1.4 GB
07:44 8.5 GB 13.0 GB 6.7 GB 3.7 GB 16,283 1.8 GB
07:49 6.5 GB 13.0 GB 5.2 GB 5.8 GB 16,532 1.3 GB
07:54 6.0 GB 13.0 GB 4.8 GB 6.3 GB 16,636 1.2 GB
07:59 6.4 GB 13.0 GB 5.1 GB 5.9 GB 16,769 1.3 GB
08:04 8.3 GB 13.0 GB 6.5 GB 3.9 GB 17,352 1.8 GB
08:09 10.2 GB 13.0 GB 8.0 GB 1.9 GB 17,053 2.2 GB
08:14 5.6 GB 13.0 GB 4.5 GB 6.8 GB 17,147 1.1 GB
08:19 7.6 GB 13.0 GB 6.1 GB 4.6 GB 17,496 1.5 GB
```
non-large = rts_live - rts_large (normal Haskell heap objects: Maps, TVars, closures)
### Mar 19 Data (5.5 hours, 07:49-13:19)
rts_heap grew from 10.1 GB to 20.7 GB over 5.5 hours.
Post-GC rts_live floor rose from 5.5 GB to 9.1 GB.
### Findings
**1. Large/pinned objects dominate live data (60-80%)**
`rts_large` = 4.5-8.0 GB out of 5.6-10.2 GB live. These are allocations > ~3KB that go on GHC's large object heap. They oscillate (not growing monotonically), meaning they are being allocated and freed constantly — transient, not leaked.
**2. Fragmentation is the heap growth mechanism**
`rts_heap ≈ rts_live + rts_frag`. The heap grows because pinned/large objects fragment GHC's block allocator. Once GHC expands the heap, it never shrinks. Growth pattern:
- Large objects allocated → occupy blocks
- Large objects freed → blocks can't be reused if ANY other object shares the block
- New allocations need fresh blocks → heap expands
- Heap never returns memory to OS
**3. Non-large heap data is stable (~1.0-2.2 GB)**
Normal Haskell objects (Maps, TVars, closures, client structures) account for only 1-2 GB. This scales with client count at ~100-130 KB/client and does NOT grow over time.
**4. All tracked data structures are NOT the cause**
- `clientSndQ=0, clientMsgQ=0` — TBQueues empty, no message accumulation
- `smpQSubs` oscillates ~1.0-1.4M — entries are cleaned up, not leaking
- `ntfStore` < 2K entries — negligible
- All proxy agent maps near 0
- `loadedQ=0` — useCache=False confirmed working
**5. Source of large objects is unclear without heap profiling**
The 4.5-8.0 GB of large objects could come from:
- PostgreSQL driver (`postgresql-simple`/`libpq`) — pinned ByteStrings for query results
- TLS library (`tls`) — pinned buffers per connection
- Network socket I/O — pinned ByteStrings for recv/send
- SMP protocol message blocks
Cannot distinguish between these without `-hT` heap profiling (which is too expensive for this server).
### Root Cause
**GHC heap fragmentation from constant churn of large/pinned ByteString allocations.**
Not a data structure leak. The live data itself is reasonable (5-10 GB for 15-17K clients). The problem is that GHC's copying GC cannot compact around pinned objects, so the heap grows with fragmentation and never shrinks.
### Mitigation Options
All are RTS flag changes — no rebuild needed, reversible by restart.
**1. `-F1.2`** (reduce GC trigger factor from default 2.0)
- Triggers major GC when heap reaches 1.2x live data instead of 2x
- Reclaims fragmented blocks sooner
- Trade-off: more frequent GC, slightly higher CPU
- Risk: low — just makes GC run more often
**2. Reduce `-A16m` to `-A4m`** (smaller nursery)
- More frequent minor GC → short-lived pinned objects freed faster
- Trade-off: more GC cycles, but each is smaller
- Risk: low — may actually improve latency by reducing GC pause times
**3. `+RTS -xn`** (nonmoving GC)
- Designed for pinned-heavy workloads — avoids copying entirely
- Available since GHC 8.10, improved in 9.x
- Trade-off: different GC characteristics, less battle-tested
- Risk: medium — different GC algorithm, should test first
**4. Limit concurrent connections** (application-level)
- Since large objects scale per-client, fewer clients = less fragmentation
- Trade-off: reduced capacity
- Risk: low but impacts users
+225
View File
@@ -0,0 +1,225 @@
## Root Cause Analysis: SMP Server Memory Growth (23.5GB)
### Environment
- **Server**: smp19.simplex.im, ~21,927 connected clients
- **Storage**: PostgreSQL backend with `useCache = False`
- **RTS flags**: `+RTS -N -A16m -I0.01 -Iw15 -s -RTS` (16 cores)
- **Memory**: 23.5GB RES / 1031GB VIRT (75% of available RAM)
### Log Summary
- **Duration**: ~22 hours (Mar 16 12:12 → Mar 17 10:20)
- **92,277 proxy connection errors** out of 92,656 total log lines (99.6%)
- **292 unique failing destination servers**, top offender: `nowhere.moe` (12,875 errors)
- Only **145 successful proxy connections**
---
### Known Factor: GHC Heap Sizing
With 16 cores and `-A16m`:
- **Nursery**: 16 × 16MB = **256MB baseline**
- GHC default major GC threshold = **2× live data** — if live data is 10GB, heap grows to ~20GB before major GC
- The server is rarely idle with 22K clients, so major GC is deferred despite `-I0.01`
- This is an amplifier — whatever the actual live data size is, GHC roughly doubles it
---
### Candidate Structures That Could Grow Unboundedly
Analysis of the full codebase identified these structures that either grow without bound or have uncertain cleanup:
#### 1. `SubscribedClients` maps — `Env/STM.hs:378`
Both `subscribers.queueSubscribers` and `ntfSubscribers.queueSubscribers` (and their `serviceSubscribers`) use `SubscribedClients (TMap EntityId (TVar (Maybe (Client s))))`.
Comment at line 376: *"The subscriptions that were made at any point are not removed"*
`deleteSubcribedClient` IS called on disconnect (Server.hs:1112) and DOES call `TM.delete`. But it only deletes if the current stored client matches — if another client already re-subscribed, the old client's disconnect won't remove the entry. This is by design for mobile client continuity, but the net effect on map size over time is unclear without measurement.
#### 2. ProxyAgent's subscription TMaps — `Client/Agent.hs:145-151`
The `SMPClientAgent` has 4 TMaps that accumulate one top-level entry per unique destination server and **never remove** them:
- `activeServiceSubs :: TMap SMPServer (TVar ...)` (line 145)
- `activeQueueSubs :: TMap SMPServer (TMap QueueId ...)` (line 146)
- `pendingServiceSubs :: TMap SMPServer (TVar ...)` (line 149)
- `pendingQueueSubs :: TMap SMPServer (TMap QueueId ...)` (line 150)
Comment at line 262: *"these vars are never removed, they are only added"*
These are only used for the proxy agent (SParty 'Sender), so they grow with each unique destination SMP server proxied to. With 292 unique servers in this log period, these are likely small — but long-running servers may accumulate thousands.
`closeSMPClientAgent` (line 369) does NOT clear these 4 maps.
#### 3. `NtfStore` — `NtfStore.hs:26`
`NtfStore (TMap NotifierId (TVar [MsgNtf]))` — one entry per NotifierId.
`deleteExpiredNtfs` (line 47) filters expired notifications from lists but does **not remove entries with empty lists** from the TMap. Over time, NotifierIds that no longer receive notifications leave zombie `TVar []` entries.
`deleteNtfs` (line 44) does remove the full entry via `TM.lookupDelete` — but only called when a notifier is explicitly deleted.
#### 4. `serviceLocks` in PostgresQueueStore — `Postgres.hs:112,469`
`serviceLocks :: TMap CertFingerprint Lock` — one Lock per unique certificate fingerprint.
`getCreateService` (line 469) calls `withLockMap (serviceLocks st) fp` which calls `getMapLock` (Agent/Client.hs:1029-1032) — this **unconditionally inserts** a Lock into the TMap. There is **no cleanup code** for serviceLocks anywhere. This is NOT guarded by `useCache`.
#### 5. `sentCommands` per proxy client connection — `Client.hs:580`
Each `PClient` has `sentCommands :: TMap CorrId (Request err msg)`. Entries are added per command sent (line 1369) and only removed when a response arrives (line 698). If a connection drops before all responses arrive, entries remain until the `PClient` is GC'd. Since `PClient` is captured by the connection thread which terminates on error, the `PClient` should become GC-eligible — but GC timing depends on heap pressure.
#### 6. `subQ :: TQueue (ClientSub, ClientId)` — `Env/STM.hs:363`
Unbounded `TQueue` for subscription changes. If the subscriber thread (`serverThread`) can't process changes fast enough, this queue grows without backpressure. With 22K clients subscribing/unsubscribing, sustained bursts could cause this queue to bloat.
---
### Ruled Out
1. **PostgreSQL queue cache**: `useCache = False``queues`, `senders`, `links`, `notifiers` TMaps are empty.
2. **`notifierLocks`**: Guarded by `useCache` (Postgres.hs:377,405) — not used with `useCache = False`.
3. **Client structures**: 22K × ~3KB = ~66MB — negligible.
4. **TBQueues**: Bounded (`tbqSize = 128`).
5. **Thread management**: `forkClient` uses weak refs + `finally` blocks. `endThreads` cleared on disconnect.
6. **Proxy `smpClients`/`smpSessions`**: Properly cleaned on disconnect/expiry.
7. **`smpSubWorkers`**: Properly cleaned on worker completion; also cleared in `closeSMPClientAgent`.
8. **`pendingEvents`**: Atomically swapped empty every `pendingENDInterval`.
9. **Stats IORef counters**: Fixed number, bounded.
10. **DB connection pool**: Bounded `TBQueue` with bracket-based return.
---
### Insufficient Data to Determine Root Cause
Without measuring the actual sizes of these structures at runtime, we cannot determine which (if any) is the primary contributor. The following exact logging changes will identify the root cause.
---
### EXACT LOGS TO ADD
Add a new periodic logging thread in `src/Simplex/Messaging/Server.hs`.
Insert at `Server.hs:197` (after `prometheusMetricsThread_`):
```haskell
<> memoryDiagThread_ cfg
```
Then define:
```haskell
memoryDiagThread_ :: ServerConfig s -> [M s ()]
memoryDiagThread_ ServerConfig {prometheusInterval = Just _} =
[memoryDiagThread]
memoryDiagThread_ _ = []
memoryDiagThread :: M s ()
memoryDiagThread = do
labelMyThread "memoryDiag"
Env { ntfStore = NtfStore ntfMap
, server = srv@Server {subscribers, ntfSubscribers}
, proxyAgent = ProxyAgent {smpAgent = pa}
, msgStore_ = ms
} <- ask
let interval = 300_000_000 -- 5 minutes
liftIO $ forever $ do
threadDelay interval
-- GHC RTS stats
rts <- getRTSStats
let liveBytes = gcdetails_live_bytes $ gc rts
heapSize = gcdetails_mem_in_use_bytes $ gc rts
gcCount = gcs rts
-- Server structures
clientCount <- IM.size <$> getServerClients srv
-- SubscribedClients (queue and service subscribers for both SMP and NTF)
smpQSubs <- M.size <$> getSubscribedClients (queueSubscribers subscribers)
smpSSubs <- M.size <$> getSubscribedClients (serviceSubscribers subscribers)
ntfQSubs <- M.size <$> getSubscribedClients (queueSubscribers ntfSubscribers)
ntfSSubs <- M.size <$> getSubscribedClients (serviceSubscribers ntfSubscribers)
-- Pending events
smpPending <- IM.size <$> readTVarIO (pendingEvents subscribers)
ntfPending <- IM.size <$> readTVarIO (pendingEvents ntfSubscribers)
-- NtfStore
ntfStoreSize <- M.size <$> readTVarIO ntfMap
-- ProxyAgent maps
let SMPClientAgent {smpClients, smpSessions, activeServiceSubs, activeQueueSubs, pendingServiceSubs, pendingQueueSubs, smpSubWorkers} = pa
paClients <- M.size <$> readTVarIO smpClients
paSessions <- M.size <$> readTVarIO smpSessions
paActSvc <- M.size <$> readTVarIO activeServiceSubs
paActQ <- M.size <$> readTVarIO activeQueueSubs
paPndSvc <- M.size <$> readTVarIO pendingServiceSubs
paPndQ <- M.size <$> readTVarIO pendingQueueSubs
paWorkers <- M.size <$> readTVarIO smpSubWorkers
-- Loaded queue counts
lc <- loadedQueueCounts $ fromMsgStore ms
-- Log everything
logInfo $
"MEMORY "
<> "rts_live=" <> tshow liveBytes
<> " rts_heap=" <> tshow heapSize
<> " rts_gc=" <> tshow gcCount
<> " clients=" <> tshow clientCount
<> " smpQSubs=" <> tshow smpQSubs
<> " smpSSubs=" <> tshow smpSSubs
<> " ntfQSubs=" <> tshow ntfQSubs
<> " ntfSSubs=" <> tshow ntfSSubs
<> " smpPending=" <> tshow smpPending
<> " ntfPending=" <> tshow ntfPending
<> " ntfStore=" <> tshow ntfStoreSize
<> " paClients=" <> tshow paClients
<> " paSessions=" <> tshow paSessions
<> " paActSvc=" <> tshow paActSvc
<> " paActQ=" <> tshow paActQ
<> " paPndSvc=" <> tshow paPndSvc
<> " paPndQ=" <> tshow paPndQ
<> " paWorkers=" <> tshow paWorkers
<> " loadedQ=" <> tshow (loadedQueueCount lc)
<> " loadedNtf=" <> tshow (loadedNotifierCount lc)
<> " ntfLocks=" <> tshow (notifierLockCount lc)
```
Note: `smpSubs.subsCount` (queueSubscribers size) and `smpSubs.subServicesCount` (serviceSubscribers size) are **already logged** in Prometheus (lines 475-496). The log above adds all other candidate structures plus GHC RTS memory stats.
This produces a single log line every 5 minutes:
```
[INFO] MEMORY rts_live=10737418240 rts_heap=23488102400 rts_gc=4521 clients=21927 smpQSubs=1847233 smpSSubs=42 ntfQSubs=982112 ntfSSubs=31 smpPending=0 ntfPending=0 ntfStore=512844 paClients=12 paSessions=12 paActSvc=0 paActQ=0 paPndSvc=0 paPndQ=0 paWorkers=3 loadedQ=0 loadedNtf=0 ntfLocks=0
```
### What Each Metric Tells Us
| Metric | What it reveals | If growing = suspect |
|--------|----------------|---------------------|
| `rts_live` | Actual live data after last major GC | Baseline — everything else should add up to this |
| `rts_heap` | Total heap (should be ~2× rts_live) | If >> 2× live, fragmentation issue |
| `clients` | Connected client count | Known: ~22K |
| `smpQSubs` | SubscribedClients map size (queue subs) | If >> clients × avg_subs, entries not cleaned |
| `smpSSubs` | SubscribedClients map size (service subs) | Should be small |
| `ntfQSubs` | NTF SubscribedClients map (queue subs) | Same concern as smpQSubs |
| `ntfSSubs` | NTF SubscribedClients map (service subs) | Should be small |
| `smpPending` / `ntfPending` | Pending END/DELD events per client | If large, subscriber thread lagging |
| `ntfStore` | NotifierId count in NtfStore | If growing monotonically, zombie entries |
| `paClients` | Proxy connections to other servers | Should be <= unique dest servers |
| `paSessions` | Active proxy sessions | Should match paClients |
| `paActSvc` / `paActQ` | Proxy active subscriptions | If growing, entries never removed |
| `paPndSvc` / `paPndQ` | Proxy pending subscriptions | If growing, resubscription stuck |
| `paWorkers` | Active reconnect workers | If growing, workers stuck in retry |
| `loadedQ` | Cached queues in store (0 with useCache=False) | Should be 0 |
| `ntfLocks` | Notifier locks in store | Should be 0 with useCache=False |
### Interpretation Guide
**If `smpQSubs` is in the millions**: SubscribedClients is the primary leak. Entries accumulate for every queue ever subscribed to.
**If `ntfStore` grows monotonically**: Zombie notification entries (empty lists after expiration). Fix: `deleteExpiredNtfs` should remove entries with empty lists.
**If `paActSvc` + `paActQ` grow**: Proxy agent subscription maps are the leak. Fix: add cleanup when no active/pending subs exist for a server.
**If `rts_live` is much smaller than `rts_heap`**: GHC heap fragmentation. Fix: tune `-F` flag (GC trigger factor) or use `-c` (compacting GC).
**If `rts_live` ~ 10-12GB**: The live data is genuinely large. Look at which metric is the largest contributor.
**If nothing above is large but `rts_live` is large**: The leak is in a structure not measured here — likely TLS connection buffers, ByteString retention from Postgres queries, or GHC runtime overhead. Next step would be heap profiling with `-hT`.
@@ -1,472 +0,0 @@
# XFTP Server PostgreSQL Backend
## Overview
Add PostgreSQL backend support to xftp-server, following the SMP server pattern. Supports bidirectional migration between STM (in-memory with StoreLog) and PostgreSQL backends.
## Goals
- PostgreSQL-backed file metadata storage as an alternative to STM + StoreLog
- Polymorphic server code via `FileStoreClass` typeclass with IO-based methods (following `QueueStoreClass` pattern)
- Bidirectional migration: StoreLog <-> PostgreSQL via CLI commands
- Shared `server_postgres` cabal flag (same flag enables both SMP and XFTP Postgres support)
- INI-based backend selection at runtime
## Architecture
### FileStoreClass Typeclass
IO-based typeclass following the `QueueStoreClass` pattern — each method is a self-contained IO action, with the implementation responsible for its own atomicity (STM backend wraps in `atomically`, Postgres backend uses database transactions):
```haskell
class FileStoreClass s where
type FileStoreConfig s
-- Lifecycle
newFileStore :: FileStoreConfig s -> IO s
closeFileStore :: s -> IO ()
-- File operations
addFile :: s -> SenderId -> FileInfo -> RoundedFileTime -> ServerEntityStatus -> IO (Either XFTPErrorType ())
setFilePath :: s -> SenderId -> FilePath -> IO (Either XFTPErrorType ())
addRecipient :: s -> SenderId -> FileRecipient -> IO (Either XFTPErrorType ())
getFile :: s -> SFileParty p -> XFTPFileId -> IO (Either XFTPErrorType (FileRec, C.APublicAuthKey))
deleteFile :: s -> SenderId -> IO (Either XFTPErrorType ())
blockFile :: s -> SenderId -> BlockingInfo -> Bool -> IO (Either XFTPErrorType ())
deleteRecipient :: s -> RecipientId -> FileRec -> IO ()
ackFile :: s -> RecipientId -> IO (Either XFTPErrorType ())
-- Expiration (with LIMIT for Postgres; called in a loop until empty)
expiredFiles :: s -> Int64 -> Int -> IO [(SenderId, Maybe FilePath, Word32)]
-- Storage and stats (for init-time computation)
getUsedStorage :: s -> IO Int64
getFileCount :: s -> IO Int
```
- STM backend: each method wraps its STM transaction in `atomically` internally.
- Postgres backend: each method runs its query via `withDB` / database connection internally.
No polymorphic monad or `runStore` dispatcher needed — unlike `MsgStoreClass`, XFTP file operations are individually atomic and don't require grouping multiple operations into backend-dependent transactions.
### PostgresFileStore Data Type
```haskell
data PostgresFileStore = PostgresFileStore
{ dbStore :: DBStore,
dbStoreLog :: Maybe (StoreLog 'WriteMode)
}
```
- `dbStore` — connection pool created via `createDBStore`, runs schema migrations on init.
- `dbStoreLog` — optional parallel log file (enabled by `db_store_log` INI setting). When present, every mutation (`addFile`, `setFilePath`, `deleteFile`, `blockFile`, `addRecipient`, `ackFile`) also writes to this log via a `withLog` wrapper. `withLog` is called AFTER the DB operation succeeds (so the log reflects committed state only). Log write failures are non-fatal (logged as warnings, do not fail the DB operation). This provides an audit trail and enables recovery via export.
`closeFileStore` for Postgres calls `closeDBStore` (closes connection pool) then `mapM_ closeStoreLog dbStoreLog` (flushes and closes the parallel log). For STM, it closes the storeLog. Called from a `finally` block during server shutdown, matching SMP's `stopServer``closeMsgStore``closeQueueStore` pattern.
### STMFileStore Type
After extracting from current `Store.hs`, `STMFileStore` retains the file and recipient maps but no longer owns `usedStorage` (moved to `XFTPEnv`):
```haskell
data STMFileStore = STMFileStore
{ files :: TMap SenderId FileRec,
recipients :: TMap RecipientId (SenderId, RcvPublicAuthKey)
}
```
`closeFileStore` for STM is a no-op (TMaps are garbage-collected; the env-level `storeLog` is closed separately by the server).
### Error Handling
Postgres operations follow SMP's `withDB` / `handleDuplicate` pattern:
```haskell
withDB :: Text -> PostgresFileStore -> (DB.Connection -> IO (Either XFTPErrorType a)) -> ExceptT XFTPErrorType IO a
withDB op st action =
ExceptT $ E.try (withTransaction (dbStore st) action) >>= either logErr pure
where
logErr :: E.SomeException -> IO (Either XFTPErrorType a)
logErr e = logError ("STORE: " <> err) $> Left INTERNAL
where
err = op <> ", withDB, " <> tshow e
handleDuplicate :: SqlError -> IO (Either XFTPErrorType a)
handleDuplicate e = case constraintViolation e of
Just (UniqueViolation _) -> pure $ Left DUPLICATE_
_ -> E.throwIO e
```
- All DB operations wrapped in `withDB` — catches exceptions, logs, returns `INTERNAL`.
- Unique constraint violations caught by `handleDuplicate` and mapped to `DUPLICATE_`.
- UPDATE operations verified with `assertUpdated` — returns `AUTH` if 0 rows affected (matching SMP pattern, prevents silent failures when WHERE clause doesn't match).
- Critical sections (DB write + TVar update) wrapped in `uninterruptibleMask_` to prevent async exceptions from leaving inconsistent state between DB and TVars.
### FileRec and TVar Fields
`FileRec` retains its `TVar` fields (matching SMP's `PostgresQueue` pattern):
```haskell
data FileRec = FileRec
{ senderId :: SenderId,
fileInfo :: FileInfo,
filePath :: TVar (Maybe FilePath),
recipientIds :: TVar (Set RecipientId),
createdAt :: RoundedFileTime,
fileStatus :: TVar ServerEntityStatus
}
```
- **STM backend**: TVars are the source of truth, as currently.
- **Postgres backend**: `getFile` reads from DB and creates a `FileRec` with fresh TVars populated from the DB row (matching SMP's `mkQ` pattern — `newTVarIO` per load). Mutation methods (`setFilePath`, `blockFile`, etc.) update both the DB (persistence) and the TVars (in-session consistency). The `recipientIds` TVar is initialized to `S.empty` — no subquery needed because no server code reads `recipientIds` directly; all recipient operations go through the typeclass methods (`addRecipient`, `deleteRecipient`, `ackFile`), which query the `recipients` table for Postgres.
### usedStorage Ownership
`usedStorage :: TVar Int64` moves from the store to `XFTPEnv`. The store typeclass does **not** manage `usedStorage` — it only provides `getUsedStorage` for init-time computation.
- **STM init**: StoreLog replay calls `setFilePath` (which only sets the filePath TVar — the STM `setFilePath` implementation is changed to **not** update `usedStorage`). Similarly, STM `deleteFile` (Store.hs line 117) and `blockFile` (line 125) are changed to **not** update `usedStorage` — the server handles all `usedStorage` adjustments externally. After replay, `getUsedStorage` computes the sum over all file sizes (matching current `countUsedStorage` behavior).
- **Postgres init**: `getUsedStorage` executes `SELECT COALESCE(SUM(file_size), 0) FROM files`.
- **Runtime**: Server manages `usedStorage` TVar directly for reserve/commit/rollback during uploads, and adjusts after `deleteFile`/`blockFile` calls.
**Note on `getUsedStorage` semantics**: The current STM `countUsedStorage` sums all file sizes unconditionally (including files without `filePath` set, i.e., created but not yet uploaded). The Postgres `getUsedStorage` matches this: `SELECT SUM(file_size) FROM files` (no `WHERE file_path IS NOT NULL`). In practice, orphaned files (created but never uploaded) are rare and short-lived (expired within 48h), so the difference is negligible. A future improvement could filter by `file_path IS NOT NULL` in both backends to reflect actual disk usage more accurately.
### Server.hs Refactoring
`Server.hs` becomes polymorphic over `FileStoreClass s`. Since all typeclass methods are IO, call sites replace `atomically` with direct IO calls to the store.
**Call sites requiring changes** (exhaustive list):
1. **`receiveServerFile`** (line 563): `atomically $ writeTVar filePath (Just fPath)``setFilePath store senderId fPath`. The `reserve` logic (line 551-555) stays as direct TVar manipulation on `usedStorage` from `XFTPEnv`.
2. **`verifyXFTPTransmission`** (line 453): `atomically $ verify =<< getFile st party fId` — the `getFile` call and subsequent `readTVar fileStatus` are in a single `atomically` block. Refactored to: `getFile st party fId` (IO), then `readTVarIO (fileStatus fr)` from the returned `FileRec` (safe for both backends — STM TVar is the source of truth, Postgres TVar is a fresh snapshot from DB).
3. **`retryAdd`** (line 516): Signature `XFTPFileId -> STM (Either XFTPErrorType a)``XFTPFileId -> IO (Either XFTPErrorType a)`. The `atomically` call (line 520) replaced with `liftIO`.
4. **`deleteOrBlockServerFile_`** (line 620): Parameter `FileStore -> STM (Either XFTPErrorType ())``FileStoreClass s => s -> IO (Either XFTPErrorType ())`. The `atomically` call (line 626) removed — the store method is already IO. After the store action, server adjusts `usedStorage` TVar in `XFTPEnv` based on `fileInfo.size`.
5. **`ackFileReception`** (line 605): `atomically $ deleteRecipient st rId fr``deleteRecipient st rId fr`.
6. **Control port `CPDelete`/`CPBlock`** (lines 371, 377): `atomically $ getFile fs SFRecipient fileId``getFile fs SFRecipient fileId`.
7. **`expireServerFiles`** (line 636): Replace per-file `expiredFilePath` iteration with batched `expiredFiles st old batchSize`, which returns `[(SenderId, Maybe FilePath, Word32)]` — the `Word32` file size is needed so the server can adjust the `usedStorage` TVar after each deletion. Called in a loop until the returned list is empty. The `itemDelay` between files applies to the deletion loop over each batch, not the query itself. STM backend ignores the batch size limit (returns all expired files from TMap scan); Postgres uses `LIMIT`.
8. **`restoreServerStats`** (line 694): `FileStore {files, usedStorage} <- asks store` accesses store fields directly. Refactored to: `usedStorage` from `XFTPEnv` via `asks usedStorage`, file count via `getFileCount store`. STM: `M.size <$> readTVarIO files`. Postgres: `SELECT COUNT(*) FROM files`.
### Store Config Selection
GADT in `Env.hs`:
```haskell
data XFTPStoreConfig s where
XSCMemory :: Maybe FilePath -> XFTPStoreConfig STMFileStore
#if defined(dbServerPostgres)
XSCDatabase :: PostgresFileStoreCfg -> XFTPStoreConfig PostgresFileStore
#endif
```
`XFTPEnv` becomes polymorphic:
```haskell
data XFTPEnv s = XFTPEnv
{ config :: XFTPServerConfig,
store :: s,
usedStorage :: TVar Int64,
storeLog :: Maybe (StoreLog 'WriteMode),
...
}
```
The `M` monad (`ReaderT (XFTPEnv s) IO`) and all functions in `Server.hs` gain `FileStoreClass s =>` constraints.
**StoreLog lifecycle per backend:**
- **STM mode**: `storeLog = Just sl` (current behavior — append-only log for persistence and recovery).
- **Postgres mode**: `storeLog = Nothing` (main storeLog disabled — Postgres is the source of truth). The optional parallel `dbStoreLog` inside `PostgresFileStore` provides audit/recovery if enabled via `db_store_log` INI setting.
The existing `withFileLog` pattern in Server.hs continues to work unchanged — it maps over `Maybe (StoreLog 'WriteMode)`, which is `Nothing` in Postgres mode so the calls become no-ops.
### Main.hs Store Type Dispatch
The `Start` CLI command gains a `--confirm-migrations` flag (default `MCConsole` — manual prompt, matching SMP's `StartOptions`). For automated deployments, `--confirm-migrations up` auto-applies forward migrations. The import command uses `MCYesUp` (always auto-apply).
Following SMP's existential dispatch pattern (`AStoreType` + `run`), `Main.hs` selects the store type from INI config and dispatches to the polymorphic server:
```haskell
runServer ini = do
let storeType = fromRight "memory" $ lookupValue "STORE_LOG" "store_files" ini
case storeType of
"memory" -> run $ XSCMemory (enableStoreLog $> storeLogFilePath)
"database" ->
#if defined(dbServerPostgres)
run $ XSCDatabase PostgresFileStoreCfg {..}
#else
exitError "server not compiled with Postgres support"
#endif
_ -> exitError $ "Invalid store_files value: " <> storeType
where
run :: FileStoreClass s => XFTPStoreConfig s -> IO ()
run storeCfg = do
env <- newXFTPServerEnv storeCfg config
runReaderT (xftpServer config) env
```
**`newXFTPServerEnv` refactored signature:**
```haskell
newXFTPServerEnv :: FileStoreClass s => XFTPStoreConfig s -> XFTPServerConfig -> IO (XFTPEnv s)
newXFTPServerEnv storeCfg config = do
(store, storeLog) <- case storeCfg of
XSCMemory storeLogPath -> do
st <- newFileStore ()
sl <- mapM (`readWriteFileStore` st) storeLogPath
pure (st, sl)
XSCDatabase dbCfg -> do
st <- newFileStore dbCfg
pure (st, Nothing) -- main storeLog disabled for Postgres
usedStorage <- newTVarIO =<< getUsedStorage store
...
pure XFTPEnv {config, store, usedStorage, storeLog, ...}
```
### Startup Config Validation
Following SMP's `checkMsgStoreMode` pattern, `Main.hs` validates config before starting:
- **`store_files=database` + StoreLog file exists** (without `db_store_log=on`): Error — "StoreLog file present but store_files is `database`. Use `xftp-server database import` to migrate, or set `db_store_log: on`."
- **`store_files=database` + schema doesn't exist**: Error — "Create schema in PostgreSQL or use `xftp-server database import`."
- **`store_files=memory` + Postgres schema exists**: Warning — "Postgres schema exists but store_files is `memory`. Data in Postgres will not be used."
- **Binary compiled without `server_postgres` + `store_files=database`**: Error — "Server not compiled with Postgres support."
## Module Structure
```
src/Simplex/FileTransfer/Server/
Store.hs -- FileStoreClass typeclass + shared types (FileRec, FileRecipient, etc.)
Store/
STM.hs -- STMFileStore (extracted from current Store.hs)
Postgres.hs -- PostgresFileStore [CPP-guarded]
Postgres/
Migrations.hs -- Schema migrations [CPP-guarded]
Config.hs -- PostgresFileStoreCfg [CPP-guarded]
StoreLog.hs -- Unchanged (interchange format for both backends + migration)
Env.hs -- XFTPStoreConfig GADT, polymorphic XFTPEnv
Main.hs -- Store selection, migration CLI commands
Server.hs -- Polymorphic over FileStoreClass
```
## PostgreSQL Schema
Initial migration (`20260325_initial`):
```sql
CREATE TABLE files (
sender_id BYTEA NOT NULL PRIMARY KEY,
file_size INT4 NOT NULL,
file_digest BYTEA NOT NULL,
sender_key BYTEA NOT NULL,
file_path TEXT,
created_at INT8 NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE recipients (
recipient_id BYTEA NOT NULL PRIMARY KEY,
sender_id BYTEA NOT NULL REFERENCES files ON DELETE CASCADE,
recipient_key BYTEA NOT NULL
);
CREATE INDEX idx_recipients_sender_id ON recipients (sender_id);
CREATE INDEX idx_files_created_at ON files (created_at);
```
- `file_size` is `INT4` matching `Word32` in `FileInfo.size`
- `sender_key` and `recipient_key` stored as `BYTEA` using binary encoding via `C.encodePubKey` / `C.decodePubKey` (matching SMP's `ToField`/`FromField` instances for `APublicAuthKey` — includes algorithm type tag in the binary format)
- `file_path` nullable (set after upload completes via `setFilePath`)
- `ON DELETE CASCADE` for recipients when file is hard-deleted
- `created_at` stores rounded epoch seconds (1-hour precision, `RoundedFileTime`)
- `status` as TEXT via `StrEncoding` (`ServerEntityStatus`: `EntityActive`, `EntityBlocked info`, `EntityOff`)
- Hard deletes (no `deleted_at` column)
- No PL/pgSQL functions needed; `setFilePath` uses `WHERE file_path IS NULL` to prevent duplicate uploads (the `UPDATE` itself acquires a row-level lock)
- `used_storage` computed on startup: `SELECT COALESCE(SUM(file_size), 0) FROM files` (matches STM `countUsedStorage` — all files, see usedStorage Ownership section)
### Migrations Module
Following SMP's `QueueStore/Postgres/Migrations.hs` pattern:
```haskell
module Simplex.FileTransfer.Server.Store.Postgres.Migrations
( xftpServerMigrations,
)
where
import Data.List (sortOn)
import Data.Text (Text)
import Simplex.Messaging.Agent.Store.Shared
import Text.RawString.QQ (r)
xftpSchemaMigrations :: [(String, Text, Maybe Text)]
xftpSchemaMigrations =
[ ("20260325_initial", m20260325_initial, Nothing)
]
xftpServerMigrations :: [Migration]
xftpServerMigrations = sortOn name $ map migration xftpSchemaMigrations
where
migration (name, up, down) = Migration {name, up, down = down}
m20260325_initial :: Text
m20260325_initial =
[r|
CREATE TABLE files (
sender_id BYTEA NOT NULL PRIMARY KEY,
...
);
|]
```
The `Migration` type (from `Simplex.Messaging.Agent.Store.Shared`) has fields `{name :: String, up :: Text, down :: Maybe Text}`. Initial migration has `Nothing` for `down`. Future migrations should include `Just down_migration` for rollback support. Called via `createDBStore dbOpts xftpServerMigrations (MigrationConfig confirmMigrations Nothing)`.
### Postgres Operations
Key query patterns:
- **`addFile`**: `INSERT INTO files (...) VALUES (...)`, return `DUPLICATE_` on unique violation.
- **`setFilePath`**: `UPDATE files SET file_path = ? WHERE sender_id = ? AND file_path IS NULL`, verified with `assertUpdated` (returns `AUTH` if 0 rows affected — file not found or already uploaded). The `WHERE file_path IS NULL` prevents duplicate uploads; the `UPDATE` acquires a row lock implicitly. Only persists the path; `usedStorage` managed by server.
- **`addRecipient`**: `INSERT INTO recipients (...)`, plus check for duplicates. No need for `recipientIds` TVar update — Postgres derives it from the table.
- **`getFile`** (sender): `SELECT ... FROM files WHERE sender_id = ?`, returns auth key from `sender_key` column.
- **`getFile`** (recipient): `SELECT f.*, r.recipient_key FROM recipients r JOIN files f ON ... WHERE r.recipient_id = ?`.
- **`deleteFile`**: `DELETE FROM files WHERE sender_id = ?` (recipients cascade).
- **`blockFile`**: `UPDATE files SET status = ? WHERE sender_id = ?`. When `deleted = True`, the server adjusts `usedStorage` externally (matching current STM behavior where `blockFile` only updates status and storage, not `filePath`).
- **`expiredFiles`**: `SELECT sender_id, file_path, file_size FROM files WHERE created_at + ? < ? LIMIT ?` — batched query replaces per-file iteration, includes `file_size` for `usedStorage` adjustment. Called in a loop until no rows returned.
## INI Configuration
New keys in `[STORE_LOG]` section:
```ini
[STORE_LOG]
enable: on
store_files: memory # memory | database
db_connection: postgresql://xftp@/xftp_server_store
db_schema: xftp_server
db_pool_size: 10
db_store_log: off
expire_files_hours: 48
```
`store_files` selects the backend (`store_files` rather than `store_queues` because XFTP stores files, not queues):
- `memory` -> `XSCMemory` (current behavior)
- `database` -> `XSCDatabase` (requires `server_postgres` build flag)
### INI Template Generation (`xftp-server init`)
The `iniFileContent` function in `Main.hs` must be updated to generate the new keys in the `[STORE_LOG]` section. Following SMP's `iniDbOpts` pattern with `optDisabled'` (prefixes `"# "` when value equals default), Postgres keys are generated commented out by default:
```ini
[STORE_LOG]
enable: on
# File storage mode: `memory` or `database` (PostgreSQL).
store_files: memory
# Database connection settings for PostgreSQL database (`store_files: database`).
# db_connection: postgresql://xftp@/xftp_server_store
# db_schema: xftp_server
# db_pool_size: 10
# Write database changes to store log file
# db_store_log: off
expire_files_hours: 48
```
Reuses `iniDBOptions` from `Simplex.Messaging.Server.CLI` for runtime parsing (falls back to defaults when keys are commented out or missing). `enableDbStoreLog'` pattern (`settingIsOn "STORE_LOG" "db_store_log"`) controls `dbStoreLogPath`.
### PostgresFileStoreCfg
```haskell
data PostgresFileStoreCfg = PostgresFileStoreCfg
{ dbOpts :: DBOpts,
dbStoreLogPath :: Maybe FilePath,
confirmMigrations :: MigrationConfirmation
}
```
No `deletedTTL` (hard deletes).
### Default DB Options
```haskell
defaultXFTPDBOpts :: DBOpts
defaultXFTPDBOpts =
DBOpts
{ connstr = "postgresql://xftp@/xftp_server_store",
schema = "xftp_server",
poolSize = 10,
createSchema = False
}
```
## Migration CLI
Bidirectional migration via StoreLog as interchange format:
```
xftp-server database import [--database DB_CONN] [--schema DB_SCHEMA] [--pool-size N]
xftp-server database export [--database DB_CONN] [--schema DB_SCHEMA] [--pool-size N]
```
No `--table` flag needed (unlike SMP which has queues/messages/all) — XFTP has a single entity type (files + recipients, always migrated together).
CLI options reuse `dbOptsP` parser from `Simplex.Messaging.Server.CLI`.
### Import (StoreLog -> PostgreSQL)
1. Confirm: prompt user with database connection details and StoreLog path
2. Read and replay StoreLog into temporary `STMFileStore`
3. Connect to PostgreSQL, run schema migrations (`createSchema = True`, `confirmMigrations = MCYesUp`)
4. Batch-insert file records into `files` table using PostgreSQL COPY protocol (matching SMP's `batchInsertQueues` pattern for performance). Progress reported every 10k files.
5. Batch-insert recipient records into `recipients` table using COPY protocol
6. Verify counts: `SELECT COUNT(*) FROM files` / `recipients` — warn if mismatch
7. Rename StoreLog to `.bak` (prevents accidental re-import, preserves original for rollback)
8. Report counts
### Export (PostgreSQL -> StoreLog)
1. Confirm: prompt user with database connection details and output path. Fail if output file already exists.
2. Connect to PostgreSQL
3. Open new StoreLog file for writing
4. Fold over all file records, writing per file (in this order, matching existing `writeFileStore`): `AddFile` (with `ServerEntityStatus` — this preserves `EntityBlocked` state), `AddRecipients`, then `PutFile` (if `file_path` is set)
5. Report counts
Note: `AddFile` carries `ServerEntityStatus` which includes `EntityBlocked info`, so blocking state is preserved through export/import without needing separate `BlockFile` log entries.
File data on disk is untouched by migration — only metadata moves between backends.
## Cabal Integration
Shared `server_postgres` flag. New Postgres modules added to existing conditional block:
```cabal
if flag(server_postgres)
cpp-options: -DdbServerPostgres
exposed-modules:
...existing SMP modules...
Simplex.FileTransfer.Server.Store.Postgres
Simplex.FileTransfer.Server.Store.Postgres.Migrations
Simplex.FileTransfer.Server.Store.Postgres.Config
```
CPP guards (`#if defined(dbServerPostgres)`) in:
- `Store.hs` — Postgres `FromField`/`ToField` instances for XFTP-specific types if needed
- `Env.hs``XSCDatabase` constructor
- `Main.hs` — database CLI commands, store selection for `database` mode, Postgres imports
- `Server.hs` — Postgres-specific imports if needed
## Testing
- **Parameterized server tests**: Existing `xftpServerTests` refactored to accept a store type parameter (following SMP's `SpecWith (ASrvTransport, AStoreType)` pattern). The same server tests run against both STM and Postgres backends — STM tests run unconditionally, Postgres tests added under `#if defined(dbServerPostgres)` with `postgressBracket` for database lifecycle (drop → create → test → drop).
- **Unit tests**: `PostgresFileStore` operations — add/get/delete/block/expire, duplicate detection, auth errors
- **Migration round-trip**: STM store → export to StoreLog → import to Postgres → export back → verify StoreLog equality (including blocked file status)
- **Tests location**: in `tests/` alongside existing XFTP tests, guarded by `server_postgres` CPP flag
- **Test database**: PostgreSQL on `localhost:5432`, using a dedicated `xftp_server_test` schema (dropped and recreated per test run via `postgressBracket`, following SMP's test database lifecycle pattern)
- **Test fixtures**: `testXFTPStoreDBOpts :: DBOpts` with `createSchema = True`, `confirmMigrations = MCYesUp`, in `tests/XFTPClient.hs`
@@ -1,648 +0,0 @@
# XFTP PostgreSQL Backend — Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers-extended-cc:subagent-driven-development (if subagents available) or superpowers-extended-cc:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add PostgreSQL backend support to xftp-server as an alternative to STM + StoreLog, with bidirectional migration.
**Architecture:** Introduce `FileStoreClass` typeclass (IO-based, following `QueueStoreClass` pattern). Extract current STM store into `Store/STM.hs`, make `Server.hs` polymorphic, then add `Store/Postgres.hs` behind `server_postgres` CPP flag. `usedStorage` moves from store to `XFTPEnv` so the server manages quota tracking externally.
**Tech Stack:** Haskell, postgresql-simple, STM, fourmolu, cabal with CPP flags
**Design spec:** `plans/2026-03-25-xftp-postgres-backend-design.md`
---
## File Structure
**Existing files modified:**
- `src/Simplex/FileTransfer/Server/Store.hs` — rewritten: becomes typeclass + shared types
- `src/Simplex/FileTransfer/Server/Env.hs` — polymorphic `XFTPEnv s`, `XFTPStoreConfig` GADT
- `src/Simplex/FileTransfer/Server.hs` — polymorphic over `FileStoreClass s`
- `src/Simplex/FileTransfer/Server/StoreLog.hs` — update for IO store functions
- `src/Simplex/FileTransfer/Server/Main.hs` — INI config, dispatch, CLI commands
- `simplexmq.cabal` — new modules
- `tests/XFTPClient.hs` — Postgres test fixtures
- `tests/Test.hs` — Postgres test group
**New files created:**
- `src/Simplex/FileTransfer/Server/Store/STM.hs``STMFileStore` (extracted from current `Store.hs`)
- `src/Simplex/FileTransfer/Server/Store/Postgres.hs``PostgresFileStore` [CPP-guarded]
- `src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs``PostgresFileStoreCfg` [CPP-guarded]
- `src/Simplex/FileTransfer/Server/Store/Postgres/Migrations.hs` — schema SQL [CPP-guarded]
- `tests/CoreTests/XFTPStoreTests.hs` — Postgres store unit tests [CPP-guarded]
---
## Task 1: Move `usedStorage` from `FileStore` to `XFTPEnv`
**Files:**
- Modify: `src/Simplex/FileTransfer/Server/Store.hs`
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
- Modify: `src/Simplex/FileTransfer/Server.hs`
- [ ] **Step 1: Remove `usedStorage` from `FileStore` in `Store.hs`**
1. Remove `usedStorage :: TVar Int64` field from `FileStore` record (line 47).
2. Remove `usedStorage <- newTVarIO 0` from `newFileStore` (line 75) and drop the field from the record construction (line 76).
3. In `setFilePath` (line 92-97): remove `modifyTVar' (usedStorage st) (+ fromIntegral (size fileInfo))` — keep only `writeTVar filePath (Just fPath)`. Change pattern from `\FileRec {fileInfo, filePath}` to `\FileRec {filePath}` (fileInfo is now unused — `-Wunused-matches` error).
4. In `deleteFile` (line 112-119): remove `modifyTVar' usedStorage $ subtract (fromIntegral $ size fileInfo)`. Change outer pattern match from `FileStore {files, recipients, usedStorage}` to `FileStore {files, recipients}`. Change inner pattern from `Just FileRec {fileInfo, recipientIds}` to `Just FileRec {recipientIds}` (`fileInfo` is now unused — `-Wunused-matches` error).
5. In `blockFile` (line 122-127): remove `when deleted $ modifyTVar' usedStorage $ subtract (fromIntegral $ size fileInfo)`. Change pattern match from `st@FileStore {usedStorage}` to `st`. The `deleted` parameter and `fileInfo` in the inner pattern become unused — prefix with `_` or remove from pattern to avoid `-Wunused-matches`.
- [ ] **Step 2: Add `usedStorage` to `XFTPEnv` in `Env.hs`**
1. Add `usedStorage :: TVar Int64` field to `XFTPEnv` record (between `store` and `storeLog`, line 93).
2. In `newXFTPServerEnv` (line 112-126): replace lines 117-118:
```
used <- countUsedStorage <$> readTVarIO (files store)
atomically $ writeTVar (usedStorage store) used
```
with:
```
usedStorage <- newTVarIO =<< countUsedStorage <$> readTVarIO (files store)
```
3. Add `usedStorage` to the `pure XFTPEnv {..}` construction.
- [ ] **Step 3: Update all `usedStorage` access sites in `Server.hs`**
1. Line 552: `us <- asks $ usedStorage . store` → `us <- asks usedStorage`.
2. Line 569: `us <- asks $ usedStorage . store` → `us <- asks usedStorage`.
3. Line 639: `usedStart <- readTVarIO $ usedStorage st` → `usedStart <- readTVarIO =<< asks usedStorage`.
4. Line 647: `usedEnd <- readTVarIO $ usedStorage st` → `usedEnd <- readTVarIO =<< asks usedStorage`.
5. Line 694: `FileStore {files, usedStorage} <- asks store` → split into `FileStore {files} <- asks store` and `usedStorage <- asks usedStorage`.
6. In `deleteOrBlockServerFile_` (line 620): after `void $ atomically $ storeAction st`, add usedStorage adjustment — `us <- asks usedStorage` then `atomically $ modifyTVar' us $ subtract (fromIntegral $ size fileInfo)` when file had a path (check `path` from `readTVarIO filePath` earlier in the function).
- [ ] **Step 4: Build and verify**
Run: `cabal build`
- [ ] **Step 5: Run existing tests**
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
- [ ] **Step 6: Format and commit**
```bash
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs
git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs
git commit -m "refactor(xftp): move usedStorage from FileStore to XFTPEnv"
```
---
## Task 2: Add `getUsedStorage`, `getFileCount`, `expiredFiles` functions
**Files:**
- Modify: `src/Simplex/FileTransfer/Server/Store.hs`
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
- Modify: `src/Simplex/FileTransfer/Server.hs`
- [ ] **Step 1: Add three new functions to `Store.hs`**
1. Add to exports: `getUsedStorage`, `getFileCount`, `expiredFiles`.
2. Remove `expiredFilePath` from exports AND delete the function definition (dead code → `-Wunused-binds` error). Also remove `($>>=)` from import `Simplex.Messaging.Util (ifM, ($>>=))` → `Simplex.Messaging.Util (ifM)` — `$>>=` was only used by `expiredFilePath`.
3. Add import: `qualified Data.Map.Strict as M` (needed for `M.foldl'` in `getUsedStorage` and `M.toList` in `expiredFiles`).
4. Implement:
```haskell
getUsedStorage :: FileStore -> IO Int64
getUsedStorage FileStore {files} =
M.foldl' (\acc FileRec {fileInfo = FileInfo {size}} -> acc + fromIntegral size) 0 <$> readTVarIO files
getFileCount :: FileStore -> IO Int
getFileCount FileStore {files} = M.size <$> readTVarIO files
expiredFiles :: FileStore -> Int64 -> Int -> IO [(SenderId, Maybe FilePath, Word32)]
expiredFiles FileStore {files} old _limit = do
fs <- readTVarIO files
fmap catMaybes . forM (M.toList fs) $ \(sId, FileRec {fileInfo = FileInfo {size}, filePath, createdAt = RoundedSystemTime createdAt}) ->
if createdAt + fileTimePrecision < old
then do
path <- readTVarIO filePath
pure $ Just (sId, path, size)
else pure Nothing
```
5. Add imports: `Data.Maybe (catMaybes)`, `Data.Word (Word32)` (note: `qualified Data.Map.Strict as M` already added in item 3).
- [ ] **Step 2: Replace `countUsedStorage` in `Env.hs`**
1. Replace `countUsedStorage <$> readTVarIO (files store)` with `getUsedStorage store` in `newXFTPServerEnv`.
2. Remove `countUsedStorage` function definition and its export.
3. Remove `qualified Data.Map.Strict as M` import if no longer used.
- [ ] **Step 3: Update `restoreServerStats` in `Server.hs` to use `getFileCount`**
In `restoreServerStats` (line 694-696): replace `FileStore {files} <- asks store` and `_filesCount <- M.size <$> readTVarIO files` with `st <- asks store` and `_filesCount <- liftIO $ getFileCount st` (eliminates the `FileStore` pattern match — `files` binding no longer needed).
- [ ] **Step 4: Replace `expireServerFiles` iteration in `Server.hs`**
1. Replace the body of `expireServerFiles` (lines 636-660). Remove `files' <- readTVarIO (files st)` and the `forM_ (M.keys files')` loop.
2. New body: call `expiredFiles st old 10000` in a loop. For each `(sId, filePath_, fileSize)` in returned list: apply `itemDelay`, remove disk file if present, call `atomically $ deleteFile st sId`, adjust `usedStorage` TVar by `fileSize`, increment `filesExpired` stat. Loop until `expiredFiles` returns `[]`.
3. Remove `Data.Map.Strict` import from Server.hs if no longer needed (was used for `M.size` and `M.keys` — now replaced by `getFileCount` and `expiredFiles`).
- [ ] **Step 5: Build and verify**
Run: `cabal build`
- [ ] **Step 6: Run existing tests**
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
- [ ] **Step 7: Format and commit**
```bash
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs
git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs
git commit -m "refactor(xftp): add getUsedStorage, getFileCount, expiredFiles store functions"
```
---
## Task 3: Change `Store.hs` functions from STM to IO
**Files:**
- Modify: `src/Simplex/FileTransfer/Server/Store.hs`
- Modify: `src/Simplex/FileTransfer/Server.hs`
- Modify: `src/Simplex/FileTransfer/Server/StoreLog.hs`
- [ ] **Step 1: Change all Store.hs function signatures from STM to IO**
For each of: `addFile`, `setFilePath`, `addRecipient`, `getFile`, `deleteFile`, `blockFile`, `deleteRecipient`, `ackFile`:
1. Change return type from `STM (Either XFTPErrorType ...)` to `IO (Either XFTPErrorType ...)` (or `STM ()` to `IO ()` for `deleteRecipient`).
2. Wrap the function body in `atomically $ do ...`.
3. Keep `withFile` and `newFileRec` as internal STM helpers (called inside the `atomically` blocks).
- [ ] **Step 2: Update Server.hs call sites — remove `atomically` wrappers**
1. Line 563 (`receiveServerFile`): change `atomically $ writeTVar filePath (Just fPath)` → add `st <- asks store` then `void $ liftIO $ setFilePath st senderId fPath` (design call site #1 — `store` is not in scope in `receiveServerFile`'s `receive` helper, so bind via `asks`; `void` avoids `-Wunused-do-bind` warning on the `Either` result).
2. Line 453 (`verifyXFTPTransmission`): split `atomically $ verify =<< getFile st party fId` into: `liftIO (getFile st party fId)` (IO→M lift), then pattern match on result, use `readTVarIO (fileStatus fr)` instead of `readTVar`.
3. Lines 371, 377 (control port `CPDelete`/`CPBlock`): change `ExceptT $ atomically $ getFile fs SFRecipient fileId` → `ExceptT $ liftIO $ getFile fs SFRecipient fileId` (inside `unliftIO u $ do` block which runs in M monad — `liftIO` required to lift IO into M).
4. Line 508 (`addFile` in `createFile`): the `ExceptT $ addFile st sId file ts EntityActive` — `addFile` is now IO, `ExceptT` wraps IO directly. Remove any `atomically`.
5. Line 514 (`addRecipient`): same — `ExceptT . addRecipient st sId` works directly in IO.
6. Line 516 (`retryAdd`): change parameter type from `(XFTPFileId -> STM (Either XFTPErrorType a))` to `(XFTPFileId -> IO (Either XFTPErrorType a))`. Line 520: change `atomically (add fId)` to `liftIO (add fId)`.
7. Line 605 (`ackFileReception`): change `atomically $ deleteRecipient st rId fr` to `liftIO $ deleteRecipient st rId fr`.
8. Line 620 (`deleteOrBlockServerFile_`): change third parameter type from `(FileStore -> STM (Either XFTPErrorType ()))` to `(FileStore -> IO (Either XFTPErrorType ()))`. Line 626: change `void $ atomically $ storeAction st` to `void $ liftIO $ storeAction st`.
9. `expireServerFiles` `delete` helper: change `atomically $ deleteFile st sId` to `liftIO $ deleteFile st sId` (deleteFile is now IO; `liftIO` required because the helper runs in M monad, not IO).
- [ ] **Step 3: Update `StoreLog.hs` — remove `atomically` from replay**
In `readFileStore` (line 93), function `addToStore`:
1. Change `atomically (addToStore lr)` to `addToStore lr` — store functions are now IO.
2. The `addToStore` body calls `addFile`, `setFilePath`, `deleteFile`, `blockFile`, `ackFile` — all IO now, no `atomically` needed.
3. For `AddRecipients`: `runExceptT $ mapM_ (ExceptT . addRecipient st sId) rcps` — `addRecipient` returns `IO (Either ...)`, so `ExceptT . addRecipient st sId` works directly.
- [ ] **Step 4: Build and verify**
Run: `cabal build`
- [ ] **Step 5: Run existing tests**
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
- [ ] **Step 6: Format and commit**
```bash
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs
git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs
git commit -m "refactor(xftp): change file store operations from STM to IO"
```
---
## Task 4: Extract `FileStoreClass` typeclass, move STM impl to `Store/STM.hs`
**Files:**
- Rewrite: `src/Simplex/FileTransfer/Server/Store.hs`
- Create: `src/Simplex/FileTransfer/Server/Store/STM.hs`
- Modify: `src/Simplex/FileTransfer/Server/StoreLog.hs`
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
- Modify: `src/Simplex/FileTransfer/Server.hs`
- Modify: `simplexmq.cabal`
- [ ] **Step 1: Create `Store/STM.hs` — move all implementation code**
1. Create directory `src/Simplex/FileTransfer/Server/Store/`.
2. Create `src/Simplex/FileTransfer/Server/Store/STM.hs`.
3. Move from `Store.hs`: `FileStore` data type (rename to `STMFileStore`), all function implementations, internal helpers (`withFile`, `newFileRec`), all STM-specific imports.
4. Rename all `FileStore` references to `STMFileStore` in the new file.
5. Module declaration: `module Simplex.FileTransfer.Server.Store.STM` exporting only `STMFileStore (..)` — do NOT export standalone functions (`addFile`, `setFilePath`, etc.) to avoid name collisions with the typeclass methods from `Store.hs`.
- [ ] **Step 2: Rewrite `Store.hs` as the typeclass module**
1. Add `{-# LANGUAGE TypeFamilies #-}` pragma to `Store.hs` (required for `type FileStoreConfig s` associated type).
2. Keep in `Store.hs`: `FileRec (..)`, `FileRecipient (..)`, `RoundedFileTime`, `fileTimePrecision` definitions and their `StrEncoding` instance.
3. Add `FileStoreClass` typeclass:
```haskell
class FileStoreClass s where
type FileStoreConfig s
-- Lifecycle
newFileStore :: FileStoreConfig s -> IO s
closeFileStore :: s -> IO ()
-- File operations
addFile :: s -> SenderId -> FileInfo -> RoundedFileTime -> ServerEntityStatus -> IO (Either XFTPErrorType ())
setFilePath :: s -> SenderId -> FilePath -> IO (Either XFTPErrorType ())
addRecipient :: s -> SenderId -> FileRecipient -> IO (Either XFTPErrorType ())
getFile :: s -> SFileParty p -> XFTPFileId -> IO (Either XFTPErrorType (FileRec, C.APublicAuthKey))
deleteFile :: s -> SenderId -> IO (Either XFTPErrorType ())
blockFile :: s -> SenderId -> BlockingInfo -> Bool -> IO (Either XFTPErrorType ())
deleteRecipient :: s -> RecipientId -> FileRec -> IO ()
ackFile :: s -> RecipientId -> IO (Either XFTPErrorType ())
-- Expiration
expiredFiles :: s -> Int64 -> Int -> IO [(SenderId, Maybe FilePath, Word32)]
-- Stats
getUsedStorage :: s -> IO Int64
getFileCount :: s -> IO Int
```
4. Do NOT re-export from `Store/STM.hs` — this would create a circular module dependency (Store.hs imports Store/STM.hs, Store/STM.hs imports Store.hs). Consumers must import `Store.STM` directly where they need `STMFileStore`.
5. Remove all STM-specific imports that are no longer needed.
- [ ] **Step 3: Add `FileStoreClass` instance in `Store/STM.hs`**
1. Import `FileStoreClass` from `Simplex.FileTransfer.Server.Store`.
2. Inline all implementations directly in the instance body (do NOT delegate to standalone functions — the standalone names collide with typeclass method names, causing ambiguous occurrences for importers):
```haskell
instance FileStoreClass STMFileStore where
type FileStoreConfig STMFileStore = ()
newFileStore () = do
files <- TM.emptyIO
recipients <- TM.emptyIO
pure STMFileStore {files, recipients}
closeFileStore _ = pure ()
addFile st sId fileInfo createdAt status = atomically $ ...
setFilePath st sId fPath = atomically $ ...
-- ... (each method's body is the existing function body, inlined)
```
3. Remove the standalone top-level function definitions — they are now instance methods. Keep only `withFile` and `newFileRec` as internal helpers used by the instance methods.
- [ ] **Step 4: Update importers**
1. `Env.hs`: add `import Simplex.FileTransfer.Server.Store.STM (STMFileStore (..))`. Change `FileStore` → `STMFileStore` in `XFTPEnv` type and `newXFTPServerEnv`. Change `store <- newFileStore` to `store <- newFileStore ()` (typeclass method now takes `FileStoreConfig STMFileStore` which is `()`). Keep `import Simplex.FileTransfer.Server.Store` for `FileRec`, `FileRecipient`, `FileStoreClass`, etc.
2. `Server.hs`: add `import Simplex.FileTransfer.Server.Store.STM`. Change `FileStore` → `STMFileStore` in any explicit type annotations. Import `FileStoreClass` from `Simplex.FileTransfer.Server.Store`.
3. `StoreLog.hs`: add `import Simplex.FileTransfer.Server.Store.STM` to access concrete `STMFileStore` type and store functions used during log replay. Change `FileStore` → `STMFileStore` in `readWriteFileStore` and `writeFileStore` parameter types.
- [ ] **Step 5: Update cabal file**
Add `Simplex.FileTransfer.Server.Store.STM` to `exposed-modules` in the `!flag(client_library)` section, alongside existing XFTP server modules.
- [ ] **Step 6: Build and verify**
Run: `cabal build`
- [ ] **Step 7: Run existing tests**
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
- [ ] **Step 8: Format and commit**
```bash
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Store/STM.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs
git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Store/STM.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs simplexmq.cabal
git commit -m "refactor(xftp): extract FileStoreClass typeclass, move STM impl to Store.STM"
```
---
## Task 5: Make `XFTPEnv` and `Server.hs` polymorphic over `FileStoreClass`
**Files:**
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
- Modify: `src/Simplex/FileTransfer/Server.hs`
- Modify: `src/Simplex/FileTransfer/Server/Main.hs`
- Modify: `tests/XFTPClient.hs` (if it calls `runXFTPServerBlocking` directly)
- [ ] **Step 1: Make `XFTPEnv` polymorphic in `Env.hs`**
1. Add `XFTPStoreConfig` GADT: `data XFTPStoreConfig s where XSCMemory :: Maybe FilePath -> XFTPStoreConfig STMFileStore`.
2. Change `data XFTPEnv` to `data XFTPEnv s` — field `store :: FileStore` becomes `store :: s`.
3. Change `newXFTPServerEnv :: XFTPServerConfig -> IO XFTPEnv` to `newXFTPServerEnv :: FileStoreClass s => XFTPStoreConfig s -> XFTPServerConfig -> IO (XFTPEnv s)`.
4. Pattern match on `XSCMemory storeLogPath` in `newXFTPServerEnv` body. Create store via `newFileStore ()`, storeLog via `mapM (`readWriteFileStore` st) storeLogPath`.
- [ ] **Step 2: Make `Server.hs` polymorphic**
1. Change `type M a = ReaderT XFTPEnv IO a` to `type M s a = ReaderT (XFTPEnv s) IO a`.
2. Add `FileStoreClass s =>` constraint to all functions using `M s a`. Use `forall s.` in signatures of functions that have `where`-block bindings with `M s` type annotations — `ScopedTypeVariables` requires explicit `forall` to bring `s` into scope for inner type signatures (matching SMP's `smpServer :: forall s. MsgStoreClass s => ...` pattern). Full list: `xftpServer`, `processRequest`, `verifyXFTPTransmission`, `processXFTPRequest` and all its `where`-bound functions (`createFile`, `addRecipients`, `receiveServerFile`, `sendServerFile`, `deleteServerFile`, `ackFileReception`, `retryAdd`, `addFileRetry`, `addRecipientRetry`), `deleteServerFile_`, `blockServerFile`, `deleteOrBlockServerFile_`, `expireServerFiles`, `randomId`, `getFileId`, `withFileLog`, `incFileStat`, `saveServerStats`, `restoreServerStats`, `randomDelay` (inside `#ifdef slow_servers` CPP block). Also update `encodeXftp` (line 236) and `runCPClient` (line 339) which use explicit `ReaderT XFTPEnv IO` instead of the `M` alias — change to `ReaderT (XFTPEnv s) IO`.
3. Change `runXFTPServerBlocking` and `runXFTPServer` to take `XFTPStoreConfig s` parameter.
4. Add `closeFileStore store` call to the server shutdown path (in the `finally` block or `stopServer` equivalent — after saving stats, before logging "Server stopped"). This ensures Postgres connection pool and `dbStoreLog` are properly closed. For STM this is a no-op.
- [ ] **Step 3: Update `Main.hs` dispatch**
1. In `runServer`: construct `XSCMemory (enableStoreLog $> storeLogFilePath)`.
2. Add dispatch function that calls the updated `runXFTPServer` (which creates `started` internally):
```haskell
run :: FileStoreClass s => XFTPStoreConfig s -> IO ()
run storeCfg = runXFTPServer storeCfg serverConfig
```
3. Call `run` with the `XSCMemory` config.
- [ ] **Step 4: Update test helper if needed**
If `tests/XFTPClient.hs` calls `runXFTPServerBlocking` directly, update the call to pass an `XSCMemory` config. Check the `withXFTPServer` / `serverBracket` helper.
- [ ] **Step 5: Build and verify**
Run: `cabal build && cabal build test:simplexmq-test`
- [ ] **Step 6: Run existing tests**
Run: `cabal test --test-show-details=streaming --test-option=--match="/XFTP/"`
- [ ] **Step 7: Format and commit**
```bash
fourmolu -i src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/Main.hs
git add src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/Main.hs tests/XFTPClient.hs simplexmq.cabal
git commit -m "refactor(xftp): make XFTPEnv and server polymorphic over FileStoreClass"
```
---
## Task 6: Add Postgres config, migrations, and store skeleton
**Files:**
- Create: `src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs`
- Create: `src/Simplex/FileTransfer/Server/Store/Postgres/Migrations.hs`
- Create: `src/Simplex/FileTransfer/Server/Store/Postgres.hs`
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
- Modify: `simplexmq.cabal`
- [ ] **Step 1: Create `Store/Postgres/Config.hs`**
```haskell
module Simplex.FileTransfer.Server.Store.Postgres.Config
( PostgresFileStoreCfg (..),
defaultXFTPDBOpts,
)
where
import Simplex.Messaging.Agent.Store.Postgres.Options (DBOpts (..))
import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation)
data PostgresFileStoreCfg = PostgresFileStoreCfg
{ dbOpts :: DBOpts,
dbStoreLogPath :: Maybe FilePath,
confirmMigrations :: MigrationConfirmation
}
defaultXFTPDBOpts :: DBOpts
defaultXFTPDBOpts =
DBOpts
{ connstr = "postgresql://xftp@/xftp_server_store",
schema = "xftp_server",
poolSize = 10,
createSchema = False
}
```
- [ ] **Step 2: Create `Store/Postgres/Migrations.hs`**
Full migration module with `xftpServerMigrations :: [Migration]` and `m20260325_initial` containing CREATE TABLE SQL for `files` and `recipients` tables plus indexes. Follow SMP's `QueueStore/Postgres/Migrations.hs` pattern exactly: tuple list → `sortOn name . map migration`.
- [ ] **Step 3: Create `Store/Postgres.hs` with stub instance**
1. Define `PostgresFileStore` with `dbStore :: DBStore` and `dbStoreLog :: Maybe (StoreLog 'WriteMode)`.
2. `instance FileStoreClass PostgresFileStore` with `error "not implemented"` for all methods except `newFileStore` (calls `createDBStore` + opens `dbStoreLog`) and `closeFileStore` (closes both). `type FileStoreConfig PostgresFileStore = PostgresFileStoreCfg`.
3. Add `withDB`, `handleDuplicate`, `assertUpdated`, `withLog` helpers.
- [ ] **Step 4: Add `XSCDatabase` GADT constructor in `Env.hs` (CPP-guarded)**
```haskell
#if defined(dbServerPostgres)
import Simplex.FileTransfer.Server.Store.Postgres (PostgresFileStore)
import Simplex.FileTransfer.Server.Store.Postgres.Config (PostgresFileStoreCfg)
#endif
data XFTPStoreConfig s where
XSCMemory :: Maybe FilePath -> XFTPStoreConfig STMFileStore
#if defined(dbServerPostgres)
XSCDatabase :: PostgresFileStoreCfg -> XFTPStoreConfig PostgresFileStore
#endif
```
- [ ] **Step 5: Update cabal**
Add to existing `if flag(server_postgres)` block:
```
Simplex.FileTransfer.Server.Store.Postgres
Simplex.FileTransfer.Server.Store.Postgres.Config
Simplex.FileTransfer.Server.Store.Postgres.Migrations
```
- [ ] **Step 6: Build both ways**
Run: `cabal build && cabal build -fserver_postgres`
- [ ] **Step 7: Format and commit**
```bash
fourmolu -i src/Simplex/FileTransfer/Server/Store/Postgres.hs src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs src/Simplex/FileTransfer/Server/Env.hs
git add src/Simplex/FileTransfer/Server/Store/Postgres.hs src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs src/Simplex/FileTransfer/Server/Store/Postgres/Migrations.hs src/Simplex/FileTransfer/Server/Env.hs simplexmq.cabal
git commit -m "feat(xftp): add PostgreSQL store skeleton with schema migration"
```
---
## Task 7: Implement `PostgresFileStore` operations
**Files:**
- Modify: `src/Simplex/FileTransfer/Server/Store/Postgres.hs`
- [ ] **Step 1: Implement `addFile`**
`INSERT INTO files (sender_id, file_size, file_digest, sender_key, file_path, created_at, status) VALUES (?,?,?,?,NULL,?,?)`. Catch unique violation with `handleDuplicate` → `DUPLICATE_`. Call `withLog "addFile"` after.
- [ ] **Step 2: Implement `getFile`**
For `SFSender`: `SELECT ... FROM files WHERE sender_id = ?`. Construct `FileRec` with `newTVarIO` per TVar field. `recipientIds = S.empty`.
For `SFRecipient`: `SELECT f.*, r.recipient_key FROM recipients r JOIN files f ON r.sender_id = f.sender_id WHERE r.recipient_id = ?`.
- [ ] **Step 3: Implement `setFilePath`**
`UPDATE files SET file_path = ? WHERE sender_id = ? AND file_path IS NULL`. Use `assertUpdated`. Call `withLog "setFilePath"`.
- [ ] **Step 4: Implement `addRecipient`**
`INSERT INTO recipients (recipient_id, sender_id, recipient_key) VALUES (?,?,?)`. `handleDuplicate` → `DUPLICATE_`. Call `withLog "addRecipient"`.
- [ ] **Step 5: Implement `deleteFile`, `blockFile`**
`deleteFile`: `DELETE FROM files WHERE sender_id = ?` (CASCADE). `withLog "deleteFile"`.
`blockFile`: `UPDATE files SET status = ? WHERE sender_id = ?`. `assertUpdated`. `withLog "blockFile"`.
- [ ] **Step 6: Implement `deleteRecipient`, `ackFile`**
`deleteRecipient`: `DELETE FROM recipients WHERE recipient_id = ?`. `withLog "deleteRecipient"`.
`ackFile`: same + return `Left AUTH` if 0 rows.
- [ ] **Step 7: Implement `expiredFiles`, `getUsedStorage`, `getFileCount`**
`expiredFiles`: `SELECT sender_id, file_path, file_size FROM files WHERE created_at + ? < ? LIMIT ?`.
`getUsedStorage`: `SELECT COALESCE(SUM(file_size), 0) FROM files`.
`getFileCount`: `SELECT COUNT(*) FROM files`.
- [ ] **Step 8: Add `ToField`/`FromField` instances**
For `RoundedFileTime` (Int64 wrapper), `ServerEntityStatus` (Text via StrEncoding), `C.APublicAuthKey` (Binary via `encodePubKey`/`decodePubKey`). Check SMP's `QueueStore/Postgres.hs` for existing instances to import.
- [ ] **Step 9: Wrap mutation operations in `uninterruptibleMask_`**
Operations that combine a DB write with a TVar update (e.g., `getFile` constructs `FileRec` with `newTVarIO`) must be wrapped in `E.uninterruptibleMask_` to prevent async exceptions from leaving inconsistent state. Follow SMP's `addQueue_`, `deleteStoreQueue` pattern.
- [ ] **Step 10: Build**
Run: `cabal build -fserver_postgres`
- [ ] **Step 11: Format and commit**
```bash
fourmolu -i src/Simplex/FileTransfer/Server/Store/Postgres.hs
git add src/Simplex/FileTransfer/Server/Store/Postgres.hs
git commit -m "feat(xftp): implement PostgresFileStore operations"
```
---
## Task 8: Add INI config, Main.hs dispatch, startup validation
**Files:**
- Modify: `src/Simplex/FileTransfer/Server/Main.hs`
- Modify: `src/Simplex/FileTransfer/Server/Env.hs`
- [ ] **Step 1: Update `iniFileContent` in `Main.hs`**
Add to `[STORE_LOG]` section: `store_files: memory`, commented-out `db_connection`, `db_schema`, `db_pool_size`, `db_store_log` keys. Follow SMP's `optDisabled'` pattern for commented defaults.
- [ ] **Step 2: Add `StartOptions` and `--confirm-migrations` flag**
```haskell
data StartOptions = StartOptions
{ confirmMigrations :: MigrationConfirmation
}
```
Add to `Start` command parser with default `MCConsole`. Thread through to `runServer`.
- [ ] **Step 3: Add store_files INI parsing and CPP-guarded Postgres dispatch**
In `runServer`: read `store_files` from INI (`fromRight "memory" $ lookupValue "STORE_LOG" "store_files" ini`). Add `"database"` branch (CPP-guarded) that constructs `PostgresFileStoreCfg` using `iniDBOptions ini defaultXFTPDBOpts` and `enableDbStoreLog'` pattern. Non-postgres build: `exitError`.
- [ ] **Step 4: Add `XSCDatabase` branch in `newXFTPServerEnv` (`Env.hs`)**
CPP-guarded pattern match on `XSCDatabase dbCfg`: `newFileStore dbCfg`, `storeLog = Nothing`.
- [ ] **Step 5: Add startup config validation**
Add `checkFileStoreMode` (CPP-guarded) before `run`: validate conflicting storeLog file + database mode, missing schema, etc. per design doc.
- [ ] **Step 6: Build both ways**
Run: `cabal build && cabal build -fserver_postgres`
- [ ] **Step 7: Format and commit**
```bash
fourmolu -i src/Simplex/FileTransfer/Server/Main.hs src/Simplex/FileTransfer/Server/Env.hs
git add src/Simplex/FileTransfer/Server/Main.hs src/Simplex/FileTransfer/Server/Env.hs
git commit -m "feat(xftp): add PostgreSQL INI config, store dispatch, startup validation"
```
---
## Task 9: Add database import/export CLI commands
**Files:**
- Modify: `src/Simplex/FileTransfer/Server/Main.hs`
- [ ] **Step 1: Add `Database` CLI command (CPP-guarded)**
Add `Database StoreCmd DBOpts` constructor to `CliCommand`. Add `database` subcommand parser with `import`/`export` subcommands + `dbOptsP defaultXFTPDBOpts`.
- [ ] **Step 2: Implement `importFileStoreToDatabase`**
1. `confirmOrExit` with database details.
2. Create temporary `STMFileStore`, replay StoreLog via `readWriteFileStore`.
3. Create `PostgresFileStore` with `createSchema = True`, `confirmMigrations = MCYesUp`.
4. Batch-insert files using PostgreSQL COPY protocol. Progress every 10k.
5. Batch-insert recipients using COPY protocol.
6. Verify counts: `SELECT COUNT(*)` — warn on mismatch.
7. Rename StoreLog to `.bak`.
8. Report counts.
- [ ] **Step 3: Implement `exportDatabaseToStoreLog`**
1. `confirmOrExit`. Fail if output file exists.
2. Create `PostgresFileStore` from config.
3. Open StoreLog for writing.
4. Fold over file records: write `AddFile` (with status), `AddRecipients`, `PutFile` per file.
5. Close StoreLog, report counts.
- [ ] **Step 4: Build**
Run: `cabal build -fserver_postgres`
- [ ] **Step 5: Format and commit**
```bash
fourmolu -i src/Simplex/FileTransfer/Server/Main.hs
git add src/Simplex/FileTransfer/Server/Main.hs
git commit -m "feat(xftp): add database import/export CLI commands"
```
---
## Task 10: Add Postgres tests
**Files:**
- Modify: `tests/XFTPClient.hs`
- Modify: `tests/Test.hs`
- Create: `tests/CoreTests/XFTPStoreTests.hs`
- [ ] **Step 1: Add test fixtures in `tests/XFTPClient.hs`**
```haskell
testXFTPStoreDBOpts :: DBOpts
testXFTPStoreDBOpts =
DBOpts
{ connstr = "postgresql://test_xftp_server_user@/test_xftp_server_db",
schema = "xftp_server_test",
poolSize = 10,
createSchema = True
}
```
Add `testXFTPDBConnectInfo :: ConnectInfo` matching the connection string.
- [ ] **Step 2: Add Postgres server test group in `tests/Test.hs`**
CPP-guarded block that runs existing `xftpServerTests` with Postgres store config, wrapped in `postgressBracket testXFTPDBConnectInfo`. Parameterize `withXFTPServer` to accept store config if needed.
- [ ] **Step 3: Create `tests/CoreTests/XFTPStoreTests.hs` — unit tests**
Test `PostgresFileStore` operations directly:
- `addFile` + `getFile SFSender` round-trip.
- `addFile` duplicate → `DUPLICATE_`.
- `getFile` nonexistent → `AUTH`.
- `setFilePath` + verify `WHERE file_path IS NULL` guard.
- `addRecipient` + `getFile SFRecipient` round-trip.
- `deleteFile` cascades recipients.
- `blockFile` + verify status.
- `expiredFiles` batch semantics.
- `getUsedStorage`, `getFileCount` correctness.
- [ ] **Step 4: Add migration round-trip test**
Create `STMFileStore` with test data (files + recipients + blocked status) → export to StoreLog → import to Postgres → export back → compare StoreLog files byte-for-byte.
- [ ] **Step 5: Build and run tests**
```bash
cabal build -fserver_postgres test:simplexmq-test
cabal test --test-show-details=streaming --test-option=--match="/XFTP/" -fserver_postgres
```
- [ ] **Step 6: Format and commit**
```bash
fourmolu -i tests/CoreTests/XFTPStoreTests.hs tests/XFTPClient.hs
git add tests/CoreTests/XFTPStoreTests.hs tests/XFTPClient.hs tests/Test.hs
git commit -m "test(xftp): add PostgreSQL backend tests"
```
@@ -1,152 +0,0 @@
# Server: batched SUB command processing
Implementation plan for Part 1 of [RFC 2026-03-28-subscription-performance](../rfcs/2026-03-28-subscription-performance.md).
## Current state
When a batch of ~135 SUB commands arrives, the server already batches:
- Queue record lookups (`getQueueRecs` in `receive`, Server.hs:1151)
- Command verification (`verifyLoadedQueue`, Server.hs:1152)
But command processing is per-command (`foldrM process` in `client`, Server.hs:1372-1375). Each SUB calls `subscribeQueueAndDeliver` which calls `tryPeekMsg` - one DB query per queue. For Postgres, that's ~135 individual `SELECT ... FROM messages WHERE recipient_id = ? ORDER BY message_id ASC LIMIT 1` queries per batch.
## Goal
Replace ~135 individual message peek queries with 1 batched query per batch. No protocol changes.
## Implementation
### Step 1: Add `tryPeekMsgs` to MsgStoreClass
File: `src/Simplex/Messaging/Server/MsgStore/Types.hs`
Add to `MsgStoreClass`:
```haskell
tryPeekMsgs :: s -> [StoreQueue s] -> ExceptT ErrorType IO (Map RecipientId Message)
```
Returns a map from recipient ID to earliest pending message for each queue that has one. Queues with no messages are absent from the map.
### Step 2: Parameterize `deliver` to accept pre-fetched message
File: `src/Simplex/Messaging/Server.hs`
Currently `deliver` (inside `subscribeQueueAndDeliver`, line 1641) calls `tryPeekMsg ms q`. Add a parameter for an optional pre-fetched message:
```haskell
deliver :: Maybe Message -> (Bool, Maybe Sub) -> M s ResponseAndMessage
deliver prefetchedMsg (hasSub, sub_) = do
stats <- asks serverStats
fmap (either ((,Nothing) . err) id) $ liftIO $ runExceptT $ do
msg_ <- maybe (tryPeekMsg ms q) (pure . Just) prefetchedMsg
...
```
When `Nothing` is passed, falls back to individual `tryPeekMsg` (existing behavior). When `Just msg` is passed, uses it directly (batched path).
### Step 3: Pre-fetch messages before the processing loop
File: `src/Simplex/Messaging/Server.hs`
Currently (lines 1372-1375):
```haskell
forever $
atomically (readTBQueue rcvQ)
>>= foldrM process ([], [])
>>= \(rs_, msgs) -> ...
```
Add a pre-fetch step before the existing loop:
```haskell
forever $ do
batch <- atomically (readTBQueue rcvQ)
msgMap <- prefetchMsgs batch
foldrM (process msgMap) ([], []) batch
>>= \(rs_, msgs) -> ...
```
`prefetchMsgs` scans the batch, collects queues from SUB commands that have a verified queue (`q_ = Just (q, _)`), calls `tryPeekMsgs` once, returns the map. For batches with no SUBs it returns an empty map (no DB call).
`process` passes the looked-up message (or Nothing) through to `processCommand` and down to `deliver`.
The `foldrM process` loop, `processCommand`, `subscribeQueueAndDeliver`, and all other command handlers stay structurally the same. Only `deliver` gains one parameter, and the `client` loop gains one pre-fetch call.
### Step 4: Review
Review the typeclass signature and server usage. Confirm the interface has the right shape before implementing store backends.
### Step 5: Implement for each store backend
#### Postgres
File: `src/Simplex/Messaging/Server/MsgStore/Postgres.hs`
Single query using `DISTINCT ON`:
```sql
SELECT DISTINCT ON (recipient_id)
recipient_id, msg_id, msg_ts, msg_quota, msg_ntf_flag, msg_body
FROM messages
WHERE recipient_id IN ?
ORDER BY recipient_id, message_id ASC
```
Build `Map RecipientId Message` from results.
#### STM
File: `src/Simplex/Messaging/Server/MsgStore/STM.hs`
Loop over queues, call `tryPeekMsg` for each, collect into map.
#### Journal
File: `src/Simplex/Messaging/Server/MsgStore/Journal.hs`
Loop over queues, call `tryPeekMsg` for each, collect into map.
### Step 6: Handle edge cases
1. **Mixed batches**: `prefetchMsgs` collects only SUB queues. Non-SUB commands get Nothing for the pre-fetched message and process unchanged.
2. **Already-subscribed queues**: Include in pre-fetch - `deliver` is called for re-SUBs too (delivers pending message).
3. **Service subscriptions**: The pre-fetch doesn't care about service state. `sharedSubscribeQueue` handles service association in STM; message peek is the same.
4. **Error queues**: Verification errors from `receive` are Left values in the batch. `prefetchMsgs` only looks at Right values with SUB commands.
5. **Empty pre-fetch**: If batch has no SUBs (e.g., all ACKs), `prefetchMsgs` returns empty map, no DB call made.
### Step 7: Batch other commands (future, not in scope)
The same pattern (pre-fetch before loop, parameterize handler) can extend to:
- `ACK` with `tryDelPeekMsg` - batch delete+peek
- `GET` with `tryPeekMsg` - same map lookup
Lower priority since these don't have the N-at-once pattern of subscriptions.
## File changes summary
| File | Change |
|---|---|
| `src/Simplex/Messaging/Server/MsgStore/Types.hs` | Add `tryPeekMsgs` to typeclass |
| `src/Simplex/Messaging/Server/MsgStore/Postgres.hs` | Implement `tryPeekMsgs` with batch SQL |
| `src/Simplex/Messaging/Server/MsgStore/STM.hs` | Implement `tryPeekMsgs` as loop |
| `src/Simplex/Messaging/Server/MsgStore/Journal.hs` | Implement `tryPeekMsgs` as loop |
| `src/Simplex/Messaging/Server.hs` | Add `prefetchMsgs`, parameterize `deliver` |
## Testing
1. Existing server tests must pass unchanged (correctness preserved).
2. Add a test that subscribes a batch of queues (some with pending messages, some without) and verifies all get correct SOK + MSG responses.
3. Prometheus metrics: existing `qSub` stat should still increment correctly.
## Performance expectation
For 300K queues across ~2200 batches:
- Before: ~300K individual DB queries
- After: ~2200 batched DB queries (one per batch of ~135)
- ~136x reduction in DB round-trips
@@ -1,126 +0,0 @@
# Server: batch queue service associations
When a batch of SUB or NSUB commands arrives from a service client, each command that needs a new or removed service association calls `setQueueService` individually - one DB write per command. For 135 commands per batch, that's 135 individual `UPDATE msg_queues` queries.
## Goal
Reduce to at most 2 DB queries per batch (one for rcv associations, one for ntf associations), using `UPDATE ... RETURNING recipient_id` to identify which queues were actually updated.
Also fuse message pre-fetch and association batching into a single batch preparation step with a clean contract.
## Contract
```haskell
prepareBatch :: Maybe ServiceId -> NonEmpty (VerifiedTransmission s) -> M s (Either ErrorType (Map RecipientId (Maybe Message, Maybe (Either ErrorType ()))))
```
`Left e` = batch-level failure (message pre-fetch or association query failed entirely). All SUBs/NSUBs in the batch get this error.
`Right map` = per-queue results as a tuple:
- `Maybe Message` - pre-fetched message for SUB queues, `Nothing` for NSUB or no message
- `Maybe (Either ErrorType ())` - association result. `Nothing` = no update needed. `Just (Right ())` = update succeeded. `Just (Left e)` = update failed for this queue.
One map, one lookup per queue. `processCommand` passes both values to `subscribeQueueAndDeliver` / `subscribeNotifications` -> `sharedSubscribeQueue`.
Queues not in the map (non-SUB/NSUB commands, failed verification) are not affected.
## prepareBatch implementation
One accumulating fold over the batch, collecting three lists:
- `subMsgQs :: [StoreQueue s]` - SUB queues for message pre-fetch
- `rcvAssocQs :: [StoreQueue s]` - SUB queues needing `rcv_service_id` update (`clntServiceId /= rcvServiceId qr`)
- `ntfAssocQs :: [StoreQueue s]` - NSUB queues needing `ntf_service_id` update (`clntServiceId /= ntfServiceId` from `NtfCreds`)
Classification reads from the already-loaded `QueueRec` in `VerifiedTransmission` - no extra DB query.
Then three store calls (each skipped if its list is empty):
1. `tryPeekMsgs ms subMsgQs` -> `Map RecipientId Message`
2. `setRcvQueueServices (queueStore ms) clntServiceId rcvAssocQs` -> `Set RecipientId`
3. `setNtfQueueServices (queueStore ms) clntServiceId ntfAssocQs` -> `Set RecipientId`
Then one pass to merge results into `Map RecipientId (Maybe Message, Maybe (Either ErrorType ()))`:
- For each SUB queue: `(M.lookup rId msgMap, assocResult rId rcvUpdated rcvAssocQs)`
- For each NSUB queue: `(Nothing, assocResult rId ntfUpdated ntfAssocQs)`
Where `assocResult rId updated assocQs` = if the queue was in `assocQs` (needed update), then `Just (Right ())` if `rId` is in `updated`, else `Just (Left AUTH)`. If not in `assocQs` (no update needed), `Nothing`.
If any of the three calls fails entirely, return `Left e`.
## Store interface
Replace the polymorphic `setQueueServices` with two plain functions in `QueueStoreClass`:
```haskell
setRcvQueueServices :: s -> Maybe ServiceId -> [q] -> IO (Set RecipientId)
setNtfQueueServices :: s -> Maybe ServiceId -> [q] -> IO (Set RecipientId)
```
No `SParty p` polymorphism. Each function knows its column.
### Postgres implementation
`setRcvQueueServices`:
```sql
UPDATE msg_queues SET rcv_service_id = ?
WHERE recipient_id IN ? AND deleted_at IS NULL
RETURNING recipient_id
```
`setNtfQueueServices`:
```sql
UPDATE msg_queues SET ntf_service_id = ?
WHERE recipient_id IN ? AND notifier_id IS NOT NULL AND deleted_at IS NULL
RETURNING recipient_id
```
After each batch query, for each queue in the returned set:
1. Read QueueRec TVar, update with new serviceId
2. Write store log entry
### STM implementation
Loop over queues, call existing per-item logic, collect succeeded `RecipientId`s into a Set.
## Downstream changes in Server.hs
### processCommand
Gains one parameter: `Map RecipientId (Maybe Message, Maybe (Either ErrorType ()))`.
SUB case: `M.lookup entId prepared` gives `Just (msg_, assocResult)` or `Nothing`. Pass both to `subscribeQueueAndDeliver`.
NSUB case: `M.lookup entId prepared` gives `Just (Nothing, assocResult)` or `Nothing`. Pass `assocResult` to `subscribeNotifications`.
Forwarded commands: pass `M.empty`.
### subscribeQueueAndDeliver
Takes `Maybe Message` and `Maybe (Either ErrorType ())` as before. No change in how it uses them.
### sharedSubscribeQueue
Takes `Maybe (Either ErrorType ())`. On paths needing association update:
- `Just (Left e)` -> return error
- `Just (Right ())` -> skip `setQueueService`, proceed with STM work
- `Nothing` -> no update needed, proceed with existing logic
## Implementation order (top-down)
1. Define the `prepareBatch` contract and thread one map through `processCommand` -> `subscribeQueueAndDeliver` / `subscribeNotifications` -> `sharedSubscribeQueue` (Server.hs)
2. Implement `prepareBatch` with the fold, three calls, and merge (Server.hs)
3. Add `setRcvQueueServices` and `setNtfQueueServices` to `QueueStoreClass` (Types.hs)
4. Implement for Postgres with batch `UPDATE ... RETURNING` (Postgres.hs)
5. Implement for STM as loop (STM.hs)
6. Implement for Journal as delegation (Journal.hs)
At step 2, store functions can initially be stubs returning empty sets. Steps 3-6 fill in the real implementations.
## Files changed
| File | Change |
|---|---|
| `src/Simplex/Messaging/Server.hs` | `prepareBatch` with fold + merge; one map parameter through `processCommand` -> `subscribeQueueAndDeliver` / `subscribeNotifications` -> `sharedSubscribeQueue` |
| `src/Simplex/Messaging/Server/QueueStore/Types.hs` | Add `setRcvQueueServices`, `setNtfQueueServices` to `QueueStoreClass` |
| `src/Simplex/Messaging/Server/QueueStore/Postgres.hs` | Implement with batch `UPDATE ... RETURNING` + per-item TVar/log updates |
| `src/Simplex/Messaging/Server/QueueStore/STM.hs` | Implement as loop |
| `src/Simplex/Messaging/Server/MsgStore/Journal.hs` | Delegate to underlying store |
+2 -2
View File
@@ -225,13 +225,13 @@ For encryption primitives, threat model, and detailed security analysis, see [Se
SimpleX provides these security properties:
- **End-to-end encryption** using Double Ratchet algorithm with forward secrecy and post-quantum cryptography.
- **End-to-end encryption** with forward secrecy via double ratchet protocol, with optional post-quantum protection.
- **No shared identifiers** across connections — contacts cannot prove they communicate with the same user.
- **Sender deniability** — neither routers nor recipients can cryptographically prove message origin.
- **Transport metadata protection** — fixed-size blocks, 2-hop onion routing, and optional connection isolation frustrate traffic correlation.
- **Transport metadata protection** — fixed-size blocks, 2-hop onion routing, and connection isolation frustrate traffic correlation.
- **Out-of-band key exchange** — connection requests passed outside the network protect against MITM attacks.
@@ -1,90 +0,0 @@
# Subscription performance
No protocol changes. This is an implementation RFC addressing subscription performance bottlenecks in both the SMP router and the agent.
## Problem
Subscribing large numbers of queues is slow. A messaging client with ~300K queues per router across 3 routers takes over 1 hour to subscribe. For comparison, the NTF server with ~1M queues per router across 12 routers took 20-30 minutes (prior to NTF client services, now in master).
Even on fast networks (cloud VMs), a client with 1.1M active subscriptions needed ~1.5M attempts (commands sent) to fully subscribe - ~36% retry rate caused by the timeout cascade described below.
### Root causes
#### 1. Router: per-command processing in batches
Batch verification and queue lookups are already done efficiently for the whole batch in `Server.hs`. But `processCommand` is called per-command in a loop - each SUB does its own individual DB query for message peek/delivery. With ~135 SUBs per batch (current SMP version), that's 135 individual DB queries per batch instead of 1 batched query.
For 300K queues, that's ~2200 batches x 135 queries = ~300K individual DB queries on the router, which is the dominant bottleneck when using PostgreSQL storage.
NSUB is cheaper because it just registers for notifications without message delivery - no per-queue DB query.
#### 2. Agent: all queues read and sent at once
`getUserServerRcvQueueSubs` reads all queues for a `(userId, server)` pair in one query with no LIMIT. For 300K queues, the entire result set is loaded into memory, then all ~2200 batches are queued to send without waiting for responses.
The NTF server agent uses cursor-style reading with configurable batch sizes (900 subs per chunk, 90K per DB fetch) and waits for each chunk to be processed before fetching the next.
#### 3. No backpressure on sends
`nonBlockingWriteTBQueue` bypasses the `sndQ` bound by forking a thread when the queue is full. All batches are queued immediately, and all their response timers start simultaneously. A 30-second per-response timeout means later batches time out not because the router is slow to respond to them specifically, but because they're waiting in the router's receive queue behind thousands of earlier commands.
This causes cascading timeouts: timed-out responses trigger `resubscribeSMPSession`, which retries all pending subs. Three consecutive timeouts can trigger connection drop via the monitor thread, causing a full reconnection and retry of everything.
## Solution
### Part 1: Router - batched command processing
Move the per-command processing loop inside command handlers so that commands of the same type within a batch can be processed together.
Current flow:
```
receive batch -> verify all -> lookup queues all -> for each command: processCommand (individual DB query)
```
Proposed flow:
```
receive batch -> verify all -> lookup queues all -> group by command type -> process group:
SUB group: one batched message peek query for all queues
NSUB group: batch registration (already cheap, but can batch DB writes)
other commands: process individually as before
```
For SUB, the batched processing would:
1. Collect all queue IDs from the SUB group
2. Perform a single DB query to peek messages for all queues
3. Distribute results back to individual responses
This reduces ~135 DB queries per batch to 1, cutting router-side DB load by ~100x for subscriptions.
Commands where batching doesn't matter (SEND, ACK, KEY, etc.) continue to be processed individually.
### Part 2: Agent - cursor-based subscription with backpressure
Replace the all-at-once fetch-and-send pattern with cursor-style batching, similar to what the NTF server agent does.
Changes to `subscribeUserServer`:
1. Fetch queues in fixed-size batches (e.g., configurable, default ~1000) using LIMIT/OFFSET or cursor-based pagination.
2. Send each batch and wait for responses before sending the next.
3. Remove the use of `nonBlockingWriteTBQueue` for subscription batches - use blocking writes or structured backpressure so response timers don't start until the batch is actually sent.
This ensures:
- Memory usage is bounded (not 300K queue records in memory at once)
- Response timeouts are meaningful (timer starts when the router receives the batch, not when it's queued locally)
- Retries are scoped to the failed batch, not all pending subs
- Works on slow/lossy networks by naturally pacing sends
### Part 3: Response timeout for batches
The current per-response 30-second timeout doesn't account for batch processing time. Options:
1. **Stagger deadlines**: later responses in a batch get proportionally more time. The `rcvConcurrency` field was designed for this but is never used.
2. **Per-batch timeout**: instead of timing individual responses, timeout the entire batch with a budget proportional to batch size.
3. **No timeout for subscription responses**: since subscriptions are sent as batches with backpressure (Part 2), and the connection is monitored by pings, individual response timeouts may not be needed. A subscription that doesn't get a response will be retried on reconnect.
## Priority and ordering
Part 1 (router batching) gives the biggest improvement and is independent of Parts 2/3.
Part 2 (agent cursor + backpressure) eliminates the retry cascade and is critical for slow networks.
Part 3 (timeout handling) is a refinement that can be addressed after Parts 1 and 2.
+6 -6
View File
@@ -67,14 +67,14 @@ if [ ! -f "${confd}/smp-server.ini" ]; then
# Fix path to certificates
if [ -n "${WEB_MANUAL}" ]; then
sed -i -e 's|^[^#]*https = |#&|' \
-e 's|^[^#]*cert = |#&|' \
-e 's|^[^#]*key = |#&|' \
-e 's|^port = .*|port = 5223|' \
sed -i -e 's|^[^#]*https: |#&|' \
-e 's|^[^#]*cert: |#&|' \
-e 's|^[^#]*key: |#&|' \
-e 's|^port:.*|port: 5223|' \
"${confd}/smp-server.ini"
else
sed -i -e "s|cert = /etc/opt/simplex/web.crt|cert = $cert_path/$ADDR.crt|" \
-e "s|key = /etc/opt/simplex/web.key|key = $cert_path/$ADDR.key|" \
sed -i -e "s|cert: /etc/opt/simplex/web.crt|cert: $cert_path/$ADDR.crt|" \
-e "s|key: /etc/opt/simplex/web.key|key: $cert_path/$ADDR.key|" \
"${confd}/smp-server.ini"
fi
fi
+1 -1
View File
@@ -76,7 +76,7 @@ if [ ! -f "${confd}/file-server.ini" ]; then
# Optionally, set password
if [ -n "${PASS}" ]; then
sed -i -e "/^# create_password =/a create_password = $PASS" \
sed -i -e "/^# create_password:/a create_password: $PASS" \
"${confd}/file-server.ini"
fi
fi
+33 -9
View File
@@ -1,7 +1,7 @@
cabal-version: 1.12
name: simplexmq
version: 6.5.2.0
version: 6.5.0.11
synopsis: SimpleXMQ message broker
description: This package includes <./docs/Simplex-Messaging-Server.html server>,
<./docs/Simplex-Messaging-Client.html client> and
@@ -173,8 +173,7 @@ library
Simplex.Messaging.Agent.Store.Postgres.Migrations.M20251009_queue_to_subscribe
Simplex.Messaging.Agent.Store.Postgres.Migrations.M20251010_client_notices
Simplex.Messaging.Agent.Store.Postgres.Migrations.M20251230_strict_tables
Simplex.Messaging.Agent.Store.Postgres.Migrations.M20260410_receive_attempts
Simplex.Messaging.Agent.Store.Postgres.Migrations.M20260411_service_certs
Simplex.Messaging.Agent.Store.Postgres.Migrations.M20260115_service_certs
else
exposed-modules:
Simplex.Messaging.Agent.Store.SQLite
@@ -225,8 +224,7 @@ library
Simplex.Messaging.Agent.Store.SQLite.Migrations.M20251009_queue_to_subscribe
Simplex.Messaging.Agent.Store.SQLite.Migrations.M20251010_client_notices
Simplex.Messaging.Agent.Store.SQLite.Migrations.M20251230_strict_tables
Simplex.Messaging.Agent.Store.SQLite.Migrations.M20260410_receive_attempts
Simplex.Messaging.Agent.Store.SQLite.Migrations.M20260411_service_certs
Simplex.Messaging.Agent.Store.SQLite.Migrations.M20260115_service_certs
Simplex.Messaging.Agent.Store.SQLite.Util
if flag(client_postgres) || flag(server_postgres)
exposed-modules:
@@ -285,9 +283,6 @@ library
Simplex.Messaging.Notifications.Server.Store.Migrations
Simplex.Messaging.Notifications.Server.Store.Postgres
Simplex.Messaging.Notifications.Server.Store.Types
Simplex.FileTransfer.Server.Store.Postgres
Simplex.FileTransfer.Server.Store.Postgres.Config
Simplex.FileTransfer.Server.Store.Postgres.Migrations
Simplex.Messaging.Server.MsgStore.Postgres
Simplex.Messaging.Server.QueueStore.Postgres
Simplex.Messaging.Server.QueueStore.Postgres.Migrations
@@ -437,6 +432,36 @@ executable smp-server
, text
default-language: Haskell2010
executable smp-server-bench
if flag(client_library)
buildable: False
if flag(server_postgres)
cpp-options: -DdbServerPostgres
main-is: Main.hs
other-modules:
ClientSim
Report
hs-source-dirs:
bench
default-extensions:
StrictData
ghc-options: -O2 -threaded -rtsopts
build-depends:
base
, async
, bytestring
, containers
, crypton
, mtl
, network
, simple-logger
, simplexmq
, stm
, text
, time
, unliftio
default-language: Haskell2010
executable xftp
if flag(client_library)
buildable: False
@@ -530,7 +555,6 @@ test-suite simplexmq-test
if flag(server_postgres)
other-modules:
AgentTests.NotificationTests
CoreTests.XFTPStoreTests
NtfClient
NtfServerTests
if flag(client_postgres) || flag(server_postgres)
-3
View File
@@ -1,3 +0,0 @@
node_modules
dist
dist-test
-93
View File
@@ -1,93 +0,0 @@
# Specifications
Disclaimer: specifications in this folder and its subfolders are generated by LLM. While they were reviewed and appear to be correct, they may contain mistakes, and should not be considered a protocol specification.
## Structure
Spec has two levels:
### `spec/modules/` — Per-module documentation
Mirrors the `src/Simplex/` directory structure exactly. Each `.hs` file has a corresponding `.md` file at the same relative path. Contains only information that is **not obvious from reading the code** and cannot fit in a one-line source comment:
- Non-obvious behavior (subtle invariants, ordering dependencies, concurrency assumptions)
- Usage considerations (when to use X vs Y, common mistakes, caller obligations)
- Relationships to other modules not visible from imports
- Security notes specific to this module
**Not included**: type signatures, code snippets, function-by-function prose that restates the source. If reading the code tells you everything, the module doc says so briefly.
Function references use fully qualified names with markdown links:
```
[Simplex.Messaging.Server.subscribeServiceMessages](./modules/Simplex/Messaging/Server.md#subscribeServiceMessages)
```
Source code links back via comments:
```haskell
-- spec: spec/modules/Simplex/Messaging/Server.md#subscribeServiceMessages
subscribeServiceMessages :: ...
```
### `spec/` root — Topic documentation
Cross-module documentation that follows a feature, mechanism, or concern across the entire stack. Topics answer "how does X work end-to-end?" rather than "what does this file do?"
Topics reference module docs rather than restating implementation details. They focus on:
- End-to-end data flow across modules
- Cross-cutting security analysis and invariants
- Design rationale, risks, test gaps
- Version gates and compatibility concerns
Some topics may migrate to `product/` if they are primarily about user-visible behavior and guarantees rather than implementation mechanics.
### `spec/security-invariants.md` — All security invariants
Cross-referenced from both module docs and topic docs.
## Conventions
Module doc entry format:
```
## functionName
**Purpose**: ...
**Calls**: [Module.a](./modules/path.md#a), [Module.b](./modules/path.md#b)
**Called by**: [Module.c](./modules/path.md#c)
**Invariant**: SI-XX
**Security**: ...
```
## Index
### Architecture
Component topology and message flow diagrams for each layer:
- [routers.md](routers.md) — Layer 1: SMP, XFTP, NTF routers
- [clients.md](clients.md) — Layer 2: protocol client libraries
- [agent.md](agent.md) — Layer 3: connection manager
### Topics
Cross-cutting concerns that span multiple modules:
- [topics/transport.md](topics/transport.md) — TLS, HTTP/2, WebSocket transport layers
- [topics/patterns.md](topics/patterns.md) — Exception handling, encoding, compression, TMap
- [topics/subscriptions.md](topics/subscriptions.md) — Queue subscriptions and delivery
- [topics/notifications.md](topics/notifications.md) — Push notification flow
- [topics/xftp.md](topics/xftp.md) — File transfer protocol
- [topics/client-services.md](topics/client-services.md) — Service certificates for bulk operations
- [topics/encryption.md](topics/encryption.md) — Encryption layers (TODO)
### Agent internals
- [agent/infrastructure.md](agent/infrastructure.md) — Workers, store, operation suspension
- [agent/connections.md](agent/connections.md) — Connection lifecycle and states
- [agent/xrcp.md](agent/xrcp.md) — Remote control protocol
### Modules
See `spec/modules/` — mirrors `src/Simplex/` structure.
### Security
- [security-invariants.md](security-invariants.md) — All security invariants
-69
View File
@@ -1,69 +0,0 @@
# Topic Candidates
> Cross-cutting patterns noticed during module documentation. Each entry may become a topic doc in `spec/` after all module docs are complete.
- **Exception handling strategy**: `catchOwn`/`catchAll`/`tryAllErrors` pattern (defined in Util.hs) used across router, client, and agent modules. The three-category classification (synchronous, own-async, cancellation) and when to use which catch variant is not obvious from any single call site.
- **Padding schemes**: Three different padding formats across the codebase — Crypto.hs uses 2-byte Word16 length prefix (max ~65KB), Crypto/Lazy.hs uses 8-byte Int64 prefix (file-sized), and both use '#' fill character. Ratchet header padding uses fixed sizes (88 or 2310 bytes). All use `pad`/`unPad` but with incompatible formats. The relationship between padding, encryption, and message size limits spans Crypto, Lazy, Ratchet, and the protocol layer.
- **NaCl construction variants**: crypto_box, secret_box, and KEM hybrid secret all use the same XSalsa20+Poly1305 core (Crypto.hs `xSalsa20`), but with different key sources (DH, symmetric, SHA3_256(DH||KEM)). The lazy streaming variant (Lazy.hs) adds prepend-tag vs tail-tag placement. File.hs wraps lazy streaming with handle-based I/O. Full picture requires reading Crypto.hs, Lazy.hs, File.hs, and SNTRUP761.hs together.
- **Transport encryption layering**: Three encryption layers overlap — TLS (Transport.hs), optional block encryption via sbcHkdf chains (Transport.hs tPutBlock/tGetBlock), and SMP protocol-level encryption. Block encryption is disabled for proxy connections (already encrypted), and absent for NTF protocol. The interaction of these layers with proxy version downgrade logic spans Transport.hs, Client.hs, and the SMP proxy module.
- **Certificate chain trust model**: ChainCertificates (Shared.hs) defines 04 cert chain semantics, used by both Client.hs (validateCertificateChain) and Server.hs (validateClientCertificate, SNI credential switching). The 4-length case skipping index 2 (operator cert) and the FQHN-disabled x509validate are decisions that span the entire transport security model.
- **SMP proxy protocol flow**: The PRXY/PFWD/RFWD proxy protocol involves Client.hs (proxySMPCommand with 10 error scenarios, forwardSMPTransmission with sessionSecret encryption), Protocol.hs (command types, version-dependent encoding), Transport.hs (proxiedSMPRelayVersion cap, proxyServer flag disabling block encryption). The double encryption (client-relay via PFWD + proxy-relay via RFWD), combined timeout (tcpConnect + tcpTimeout), nonce/reverseNonce pairing, and version downgrade logic are not visible from any single module.
- **Service certificate subscription model**: Service subscriptions (SUBS/NSUBS) and per-queue subscriptions (SUB/NSUB) coexist with complex state transitions. Client/Agent.hs manages dual active/pending subscription maps with session-aware cleanup. Protocol.hs defines useServiceAuth (only NEW/SUB/NSUB). Client.hs implements authTransmission with dual signing (entity key over cert hash + transmission, service key over transmission only). Transport.hs handles the service certificate handshake extension (v16+). The full subscription lifecycle — from DBService credentials through handshake to service subscription to disconnect/reconnect — spans all four modules.
- **Two agent layers**: Client/Agent.hs ("small agent") is used only in routers — SMP proxy and notification router — to manage client connections to other SMP routers. Agent.hs + Agent/Client.hs ("big agent") is used in client applications. Both manage SMP client connections with subscription tracking and reconnection, but the big agent adds the full messaging agent layer (connections, double ratchet, file transfer). When documenting Agent/Client.hs, Client/Agent.hs should be reviewed for shared patterns and differences.
- **Handshake protocol family**: SMP (Transport.hs), NTF (Notifications/Transport.hs), and XFTP (FileTransfer/Transport.hs) all have handshake protocols with the same structure (version negotiation + session binding + key exchange) but different feature sets. NTF is a strict subset. XFTP doesn't use the TLS handshake at all (HTTP2 layer). The shared types (THandle, THandleParams, THandleAuth) mean changes to the handshake infrastructure affect all three protocols.
- **Router subscription architecture**: The SMP router's subscription model spans Server.hs (serverThread split-STM lifecycle, tryDeliverMessage sync/async, ProhibitSub/ServerSub state machine), Env/STM.hs (SubscribedClients TVar-of-Maybe continuity, Client three-queue architecture), and Client/Agent.hs (small agent dual subscription model). The interaction between service subscriptions, direct queue subscriptions, notification subscriptions, and the serverThread subQ processing is not visible from any single module.
- **Duplex connection handshake**: The SMP duplex connection procedure (standard 10-step and fast 7-step) spans Agent.hs (orchestration, state machine), Agent/Protocol.hs (message types: AgentConfirmation/AgentConnInfoReply/AgentInvitation/HELLO, queue status types), Client.hs (SMP command dispatch), Protocol.hs (SMP-level KEY/SKEY commands). The handshake involves two-layer encryption (per-queue E2E + double ratchet), version-dependent paths (v2+ duplex, v6+ sender auth key, v7+ ratchet on confirmation, v9+ fast handshake with SKEY), and the asymmetry between initiating and accepting parties (different message types, different confirmation processing). The protocol spec (`agent-protocol.md`) defines the procedure but the implementation details — error handling, state persistence across restarts, race conditions between confirmation and message delivery — are only visible by reading the code across these modules.
- **Connection links**: Full connection links (URI format with `#/?` query parameters) and binary-encoded links (`Encoding` instances) serve different contexts — URIs for out-of-band sharing, binary for agent-to-agent messages. Each has independent version-conditional encoding with different backward-compat rules (URI parser adjusts agent version ranges for old contact links, binary parser patches `queueMode` for forward compat). The `VersionI`/`VersionRangeI` typeclasses convert between `SMPQueueInfo` (versioned, in confirmations) and `SMPQueueUri` (version-ranged, in links). Full picture requires Agent/Protocol.hs, Protocol.hs, and agent-protocol.md.
- **Short links**: Short links are a compact representation for sharing via URLs, not a replacement for full connection links — both are used. Short links store encrypted link data on the router and encode only a router hostname, link type character, and key hash in the URL. The link data lifecycle (creation, encryption with key derivation, owner chain-of-trust validation, mutable user data updates) spans Agent/Protocol.hs (types, serialization, owner validation, router shortening/restoration), Agent.hs (link creation and resolution API), and the router-side link storage. The `FixedLinkData`/`ConnLinkData` split (immutable vs mutable), `OwnerAuth` chain validation, and `PreparedLinkParams` pre-computation are not visible from any single module.
- **Agent worker framework**: `getAgentWorker` (lifecycle, restart rate limiting, crash recovery) + `withWork`/`withWork_`/`withWorkItems` (task retrieval with doWork flag atomics) defined in Agent/Client.hs, consumed by Agent.hs (async commands, message delivery), NtfSubSupervisor.hs (notification workers), FileTransfer/Agent.hs (XFTP workers), and simplex-chat. The framework separates two concerns: worker lifecycle (create-or-reuse, fork async, rate-limit restarts, escalate to CRITICAL) and task pattern (get next task, do task, as separate parameters). The doWork TMVar flag choreography (clear before query to prevent race) and the work-item-error vs store-error distinction are not obvious from any single consumer.
- **Agent operation suspension**: Five `AgentOpState` TVars (RcvNetwork, MsgDelivery, SndNetwork, Database, NtfNetwork) with a cascade ordering: ending RcvNetwork suspends MsgDelivery, ending MsgDelivery suspends SndNetwork + Database, ending SndNetwork suspends Database. `beginAgentOperation` retries if suspended, `endAgentOperation` decrements and cascades. All DB access goes through `withStore` which brackets with AODatabase. This ensures graceful shutdown propagates through dependent operations. Defined in Agent/Client.hs, used by Agent.hs subscriber and worker loops.
- **Queue rotation protocol**: Four agent messages (QADD → QKEY → QUSE → QTEST) on top of SMP commands, with asymmetric state machines on receiver side (`RcvSwitchStatus`: 4 states) and sender side (`SndSwitchStatus`: 2 states). Receiver initiates, creates new queue, sends QADD. Sender responds with QKEY. Receiver sends QUSE. Sender sends QTEST to complete. State types in Agent/Protocol.hs, orchestration in Agent.hs, queue creation/deletion in Agent/Client.hs. Protocol spec in agent-protocol.md. The fast variant (v9+ SMP with SKEY) skips the KEY command step.
- **Outside-STM lookup pattern**: Multiple modules use the pattern of looking up TVar references outside STM (via readTVarIO/TM.lookupIO), then reading/modifying the TVar contents inside STM. This avoids transaction re-evaluation from unrelated map changes. Used in: Server.hs (serverThread client lookup, tryDeliverMessage subscriber lookup), Env/STM.hs (deleteSubcribedClient), Client/Agent.hs (removeClientAndSubs, reconnectSMPClient). The safety invariant is that the outer map entries (TVars) are never removed — only their contents change.
- **NTF token lifecycle**: Token registration (TNEW) → verification push → NTConfirmed → TVFY → NTActive, with idempotent re-registration (DH secret check), TRPL (device token replacement reusing DH key), status repair for stuck tokens, and `PPApnsNull` test tokens suppressing stats. The lifecycle spans [Server.hs](modules/Simplex/Messaging/Notifications/Server.md) (command handling, verification push delivery), [Store/Postgres.hs](modules/Simplex/Messaging/Notifications/Server/Store/Postgres.md) (conditional status updates, duplicate registration cleanup), [Types.hs](modules/Simplex/Messaging/Notifications/Types.md) (NtfTknStatus state machine), and [Env.hs](modules/Simplex/Messaging/Notifications/Server/Env.md) (push client lazy initialization).
- **NTF push delivery pipeline**: Bounded TBQueue (`pushQ`) creates backpressure → `ntfPush` thread reads → `checkActiveTkn` gates PNMessage (but not PNVerification or PNCheckMessages) → APNS delivery with single retry on connection errors (new push client on retry) → PPTokenInvalid marks token NTInvalid. Spans [Server.hs](modules/Simplex/Messaging/Notifications/Server.md), [APNS.hs](modules/Simplex/Messaging/Notifications/Server/Push/APNS.md) (DER JWT signing, HTTP/2 serializing queue, fire-and-forget connection), [Env.hs](modules/Simplex/Messaging/Notifications/Server/Env.md) (push client caching with race tolerance).
- **NTF service subscription model**: Service-level subscriptions (SUBS/NSUBS on SMP) vs individual queue subscriptions, with fallback from service to individual when `CAServiceUnavailable`. Service credentials are lazily generated per SMP router with 25h backdating and ~2700yr validity. XOR hash triggers on PostgreSQL maintain subscription aggregate counts. Subscription status tracking uses `ntf_service_assoc` flag to distinguish service-associated from individually-subscribed queues. Spans [Server.hs](modules/Simplex/Messaging/Notifications/Server.md) (subscriber thread, service fallback), [Env.hs](modules/Simplex/Messaging/Notifications/Server/Env.md) (lazy credential generation, Weak ThreadId subscriber cleanup), [Store/Postgres.hs](modules/Simplex/Messaging/Notifications/Server/Store/Postgres.md) (XOR hash triggers, batch status updates, cursor-based pagination).
- **NTF startup resubscription**: `resubscribe` runs as detached `forkIO` (not in `raceAny_` group), uses `mapConcurrently` across SMP routers, each with `subscribeLoop` using 100x database batch multiplier and cursor-based pagination. `ExitCode` exceptions from `exitFailure` on DB error propagate to main thread despite `forkIO`. `getServerNtfSubscriptions` claims subscriptions by batch-updating to `NSPending`. Spans [Server.hs](modules/Simplex/Messaging/Notifications/Server.md), [Store/Postgres.hs](modules/Simplex/Messaging/Notifications/Server/Store/Postgres.md).
- **XFTP file upload pipeline**: Agent-side encryption (streaming 64KB blocks, fixed-size padding) → chunk size selection (75% threshold algorithm) → per-router data packet creation with ID collision retry (3 attempts) → recipient registration (recursive batching up to `maxRecipients` per FADD) → per-router data packet upload (command + data in single HTTP/2 streaming request) → file description generation (cross-product: M chunks × R replicas × N recipients → N descriptions). Spans [Agent.hs](modules/Simplex/FileTransfer/Agent.md) (worker orchestration, description generation), [Client.hs](modules/Simplex/FileTransfer/Client.md) (upload protocol), [Server.hs](modules/Simplex/FileTransfer/Server.md) (quota reservation with rollback, skipCommitted idempotency), [Crypto.hs](modules/Simplex/FileTransfer/Crypto.md) (streaming encryption with embedded header), [Description.hs](modules/Simplex/FileTransfer/Description.md) (validation, first-replica-only digest optimization).
- **XFTP file download pipeline**: Description parsing (ValidFileDescription validation, YAML or web URI) → per-router data packet download with ephemeral DH key pair per download (forward secrecy) → size and digest verification before decryption → streaming decryption with auth tag verification (output deleted on failure) → redirect resolution (depth-1 chain: decrypt redirect YAML, validate size/digest, download actual file). Spans [Agent.hs](modules/Simplex/FileTransfer/Agent.md) (worker orchestration, redirect handling), [Client.hs](modules/Simplex/FileTransfer/Client.md) (ephemeral DH, size-proportional timeout), [Client/Main.hs](modules/Simplex/FileTransfer/Client/Main.md) (web URI decoding, parallel download with router grouping), [Crypto.hs](modules/Simplex/FileTransfer/Crypto.md) (dual decrypt paths, auth tag deletion), [Description.hs](modules/Simplex/FileTransfer/Description.md) (redirect file descriptions).
- **XFTP handshake state machine**: Three-state session-cached handshake (`No entry``HandshakeSent``HandshakeAccepted`) per HTTP/2 session. Web clients use `xftp-web-hello` header and challenge-response identity proof; native clients use standard ALPN. SNI presence gates CORS headers, web serving, and SESSION error for unrecognized connections. Key reuse on re-hello preserves existing DH keys. Spans [Server.hs](modules/Simplex/FileTransfer/Server.md) (handshake logic, CORS, web serving), [Client.hs](modules/Simplex/FileTransfer/Client.md) (ALPN selection, cert chain validation), [Transport.hs](modules/Simplex/FileTransfer/Transport.md) (block size, version).
- **XFTP storage lifecycle**: Quota reservation via atomic `stateTVar` before upload → rollback on failure (subtract + delete partial data packet) → stored data packet deleted before store cleanup (crash risk: store references missing data packet) → `RoundedSystemTime 3600` for privacy-preserving expiration timestamps → expiration with configurable throttling (100ms between data packets) → startup storage reconciliation (override stats from live store). Spans [Server.hs](modules/Simplex/FileTransfer/Server.md), [Server/Store.hs](modules/Simplex/FileTransfer/Server/Store.md), [Server/Env.hs](modules/Simplex/FileTransfer/Server/Env.md), [Server/StoreLog.hs](modules/Simplex/FileTransfer/Server/StoreLog.md) (error-resilient replay, compaction).
- **XFTP worker architecture**: Five worker types in three categories: rcv (per-router data packet download + local decryption), snd (local prepare/encrypt + per-router data packet upload), del (per-router data packet delete). TMVar-based connection sharing with async retry on temporary errors, permanent error cleanup (put Left + delete from TMap). `withRetryIntervalLimit` caps consecutive retries; exhausted temporary errors silently abandon work cycle (chunk stays pending). `assertAgentForeground` dual check (throw if inactive + wait if backgrounded) gates every data packet operation. Spans [Agent.hs](modules/Simplex/FileTransfer/Agent.md), [Client/Agent.hs](modules/Simplex/FileTransfer/Client/Agent.md).
- **SessionVar protocol client lifecycle**: Protocol client connections (SMP, NTF, XFTP) use a lazy singleton pattern: `getSessVar` atomically checks TMap → `newProtocolClient` fills TMVar on success/failure → `waitForProtocolClient` reads with timeout. Error caching via `persistErrorInterval` prevents connection storms (failed connections cache the error with expiry; callers receive cached error without reconnecting). `removeSessVar` uses monotonic `sessionVarId` compare-and-swap to prevent stale disconnect callbacks from removing newer clients. SMP has additional complexity: `SMPConnectedClient` wraps client with per-connection proxied relay map, `updateClientService` synchronizes service credentials post-connect, disconnect callback moves subscriptions to pending with session-ID matching. XFTP always uses `NRMBackground` timing regardless of caller request. Spans [Session.md](modules/Simplex/Messaging/Session.md), [Agent/Client.md](modules/Simplex/Messaging/Agent/Client.md) (lifecycle, disconnect callbacks, reconnection workers), [Agent.md](modules/Simplex/Messaging/Agent.md) (subscriber loop consuming events).
- **Dual-backend agent store**: The agent store (~3700 lines in AgentStore.hs) compiles for both SQLite and PostgreSQL via `#if defined(dbPostgres)` CPP guards. Key behavioral differences: PostgreSQL uses `FOR UPDATE` row locking on reads preceding writes (SQLite relies on single-writer model); PostgreSQL uses `IN ?` with `In` wrapper for batch queries (SQLite falls back to per-row `forM` loops); PostgreSQL uses `constraintViolation` (SQLite checks `SQL.ErrorConstraint`); `createWithRandomId'` uses savepoints on PostgreSQL (failed statement aborts entire transaction without them). One known bug: `checkConfirmedSndQueueExists_` uses `#if defined(dpPostgres)` (typo: `dp` not `db`), so the `FOR UPDATE` clause is never included on any backend. Spans [AgentStore.md](modules/Simplex/Messaging/Agent/Store/AgentStore.md), [SQLite.md](modules/Simplex/Messaging/Agent/Store/SQLite.md).
- **Deferred message encryption**: Message bodies are NOT encrypted at enqueue time. `enqueueMessageB` advances the ratchet header and validates padding, but stores only the body reference (`sndMsgBodyId`) and encryption key. Actual encryption (`rcEncryptMsg`) happens at delivery time in `runSmpQueueMsgDelivery`. This enables body deduplication via `VRValue`/`VRRef` — identical bodies (common for group messages) share one database row, but each connection's delivery encrypts independently with its own ratchet. Confirmation and ratchet key messages bypass deferred encryption (pre-encrypted at enqueue time). Spans [Agent.md](modules/Simplex/Messaging/Agent.md) (enqueue + delivery), [AgentStore.md](modules/Simplex/Messaging/Agent/Store/AgentStore.md) (`snd_message_bodies` storage).
- **NTF agent subscription lifecycle**: The agent-side notification subscription system uses a supervisor-worker architecture with three worker pools (NTF router, SMP router, token deletion). `NSCCreate` triggers a four-way partition (`partitionQueueSubActions`): new sub, reset sub (credential mismatch or null action), continue SMP work, continue NTF work. Workers coordinate with the supervisor via `updated_by_supervisor` flag — workers only update local fields when the flag is set, preventing overwrite of supervisor decisions. The null-action sentinel (`workerErrors` sets action to NULL on permanent failure) bridges worker failure recovery to supervisor-driven re-creation. `retrySubActions` uses a shrinking TVar — each iteration only retries subs with temporary errors, so batches get smaller over time. `rescheduleWork` handles time-scheduled health checks by forking a sleep thread that re-signals `doWork`. Spans [NtfSubSupervisor.md](modules/Simplex/Messaging/Agent/NtfSubSupervisor.md) (supervisor, worker pools), [AgentStore.md](modules/Simplex/Messaging/Agent/Store/AgentStore.md) (updated_by_supervisor, null-action sentinel), [Agent/Client.md](modules/Simplex/Messaging/Agent/Client.md) (worker framework).
- **Session-aware SMP subscription management**: SMP queue subscriptions are tracked per transport session with session-ID validation at multiple points. `subscribeQueues` groups queues by transport session, subscribes concurrently, then validates `activeClientSession` post-RPC — if the client was replaced during the RPC, results are discarded and converted to temporary errors for retry. `removeClientAndSubs` (disconnect cleanup) only demotes subscriptions whose session ID matches the disconnecting client. Batch UP notifications are accumulated across transmissions and deduplicated against already-active subscriptions. When ALL results are temporary errors and no connections were already active, the SMP client is closed to force fresh connection. `maxPending` throttles concurrent pending subscriptions with STM retry backpressure. Spans [Agent/Client.md](modules/Simplex/Messaging/Agent/Client.md) (subscription state, session validation), [Agent.md](modules/Simplex/Messaging/Agent.md) (subscriber loop, processSMPTransmissions, UP accumulation).
- **Agent message envelope**: Agent messages use a two-layer format — outer `AgentMsgEnvelope` (version + type tag C/M/I/R + payload) and inner `AgentMessage` (after double-ratchet decryption, tags I/D/R/M + AMessage). Tag characters deliberately overlap between layers (disambiguated by context). `AgentInvitation` uses only per-queue E2E encryption (no ratchet established yet); `AgentRatchetKey` uses per-queue E2E (can't use ratchet to renegotiate ratchet); `AgentConfirmation` uses double ratchet. PQ support *shrinks* message size budgets (ratchet header + reply link grow with SNTRUP761 keys). `AEvent` is a GADT indexed by `AEntity` — prevents file events on connection entities at the type level. Spans [Agent/Protocol.md](modules/Simplex/Messaging/Agent/Protocol.md) (types, encoding, size budgets), [Agent.md](modules/Simplex/Messaging/Agent.md) (four e2e key states dispatch, message processing).
- **Ratchet synchronization protocol**: When the double ratchet gets out of sync (backup restoration, message loss), both parties exchange `AgentRatchetKey` messages with fresh DH keys. Role determination uses hash-ordering: `rkHash(k1, k2)` is computed by both sides — the party with the lower hash initializes the receiving ratchet, the other initializes sending and sends EREADY. This breaks symmetry when both parties simultaneously initiate. State machine: `RSOk`/`RSAllowed`/`RSRequired` → generate keys + reply; `RSStarted` → use stored keys; `RSAgreed` → error (reset to `RSRequired`). EREADY carries `lastExternalSndId` so the peer knows which messages used the old ratchet. `checkRatchetKeyHashExists` prevents processing the same key twice. Successful message decryption resets sync state to `RSOk` (the recovery signal). Spans [Agent.md](modules/Simplex/Messaging/Agent.md) (newRatchetKey, ereadyMsg, resetRatchetSync), [Agent/Protocol.md](modules/Simplex/Messaging/Agent/Protocol.md) (AgentRatchetKey type, cryptoErrToSyncState classification).
-191
View File
@@ -1,191 +0,0 @@
# Agent Architecture
The SimpleX Agent is the Layer 3 connection manager. It builds duplex encrypted connections on top of Layer 2 client libraries. This document shows its internal architecture: component topology and message processing flows.
For usage and API overview, see [docs/AGENT.md](../docs/AGENT.md). For protocol specifications, see [Agent Protocol](../protocol/agent-protocol.md), [PQDR](../protocol/pqdr.md).
**Split-phase encryption**: Message sending separates ratchet advancement (API thread, serialized) from body encryption (delivery worker, parallel). This prevents ratchet lock contention across queues while maintaining correct message ordering. See [infrastructure.md](agent/infrastructure.md#message-delivery).
**Worker taxonomy**: Three worker families handle background operations - delivery workers (per send queue), async command workers (per connection), and NTF workers (per server). All use the same create-or-reuse pattern with restart rate limiting. See [infrastructure.md](agent/infrastructure.md#worker-framework).
**Suspension cascade**: Operations drain in dependency order: `AORcvNetwork``AOMsgDelivery``AOSndNetwork``AODatabase`. Suspending receive processing cascades through to database access, ensuring clean shutdown. See [infrastructure.md](agent/infrastructure.md#operation-suspension-cascade).
---
**Module specs**: [Agent](modules/Simplex/Messaging/Agent.md) · [Agent Client](modules/Simplex/Messaging/Agent/Client.md) · [Agent Protocol](modules/Simplex/Messaging/Agent/Protocol.md) · [Store Interface](modules/Simplex/Messaging/Agent/Store/Interface.md) · [NtfSubSupervisor](modules/Simplex/Messaging/Agent/NtfSubSupervisor.md) · [XFTP Agent](modules/Simplex/FileTransfer/Agent.md) · [Ratchet](modules/Simplex/Messaging/Crypto/Ratchet.md)
### Agent components
![Agent components](diagrams/agent.svg)
### Message receive flow
```mermaid
sequenceDiagram
participant R as SMP Router
box Agent
participant SC as smpClients<br>(ProtocolClient pool)
participant MQ as msgQ<br>(TBQueue)
participant S as subscriber
participant St as Store
participant SQ as subQ<br>(TBQueue)
end
participant App as Application
R->>SC: MSG (encrypted packet)
SC->>MQ: write batch
S->>MQ: read batch
S->>S: withConnLock<br>(serialize per connection)
S->>St: load ratchet state<br>(lockConnForUpdate)
S->>S: agentRatchetDecrypt<br>(double ratchet)
S->>S: checkMsgIntegrity<br>(sequence + hash chain)
S->>St: store received message,<br>update ratchet
S->>SQ: write AEvt (MSG + metadata)
App->>SQ: read event
Note over App: application processes message
App->>S: ackMessage (agentMsgId)
Note over S,R: ACK is async<br>(enqueued as internal command)
S->>SC: ACK
SC->>R: ACK
```
### Message send flow
```mermaid
sequenceDiagram
participant App as Application
box Agent
participant API as sendMessage
participant St as Store
participant DW as deliveryWorker<br>(per send queue)
participant SC as smpClients<br>(ProtocolClient pool)
end
participant R as SMP Router
App->>API: sendMessage<br>(connId, body)
API->>St: agentRatchetEncryptHeader<br>(advance ratchet, store<br>encrypt key + pending message)
API->>DW: signal doWork (TMVar)
API->>App: return msgId
DW->>St: getPendingQueueMsg
DW->>DW: rcEncryptMsg<br>(encrypt body with stored key)
DW->>DW: encode AgentMsgEnvelope
DW->>SC: sendAgentMessage<br>(per-queue encrypt + SEND)
SC->>R: SEND (encrypted packet)
R->>SC: OK
DW->>St: delete pending message
DW->>App: SENT msgId (via subQ)
```
### Connection establishment flow
```mermaid
sequenceDiagram
participant A as Alice (initiator)
box Agent A
participant AA as Agent
end
participant SMP as SMP Router
box Agent B
participant AB as Agent
end
participant B as Bob (joiner)
A->>AA: createConnection
AA->>SMP: NEW<br>(Alice's receive queue)
SMP->>AA: queue ID + keys
AA->>A: invitation URI<br>(queue address + DH keys)
Note over A,B: invitation passed out-of-band<br>(QR code, link)
B->>AB: joinConnection<br>(invitation)
AB->>AB: initSndRatchet<br>(PQ X3DH key agreement)
AB->>SMP: SKEY (sender auth on<br>Alice's queue)
AB->>SMP: NEW<br>(Bob's receive queue)
SMP->>AB: queue ID
AB->>SMP: SEND confirmation to<br>Alice's queue (Bob's queue<br>address + ratchet keys)
SMP->>AA: MSG (confirmation)
AA->>AA: initRcvRatchet<br>(PQ X3DH key agreement),<br>decrypt confirmation
AA->>A: CONF (request approval)
A->>AA: allowConnection(confId)
AA->>SMP: KEY (register sender key<br>on Alice's rcv queue)
AA->>SMP: SKEY (sender auth on<br>Bob's queue)
AA->>SMP: SEND reply to Bob's queue<br>(Alice's connection info)
SMP->>AB: MSG (reply)
AB->>SMP: KEY (register sender key<br>on Bob's rcv queue)
AB->>SMP: SEND HELLO to Alice
SMP->>AA: MSG (HELLO)
AA->>SMP: SEND HELLO to Bob
AA->>A: CON (connected)
SMP->>AB: MSG (HELLO)
AB->>B: CON (connected)
```
### File delivery flow (XFTP)
```mermaid
sequenceDiagram
participant SA as Sender App
box Sender Agent
participant S as xftpSnd workers
participant SS as Store
end
participant XFTP as XFTP Routers
participant SMP as SMP Router
box Receiver Agent
participant RS as Store
participant R as xftpRcv workers
end
participant RA as Receiver App
SA->>S: xftpSendFile(file)
S->>S: encrypt file<br>(XSalsa20-Poly1305, random key + nonce)
S->>S: split into chunks<br>(fixed sizes: 64KB - 4MB)
S->>SS: store SndFile + chunks
loop each chunk
S->>XFTP: FNEW (create data packet)
XFTP->>S: sender ID + recipient IDs
S->>XFTP: FPUT (upload encrypted chunk)
end
S->>S: assemble FileDescription<br>(chunk locations, replicas,<br>encryption key + nonce)
S->>SA: SFDONE<br>(sender + recipient descriptions)
Note over SA,RA: recipient description sent as<br>SMP message (encrypted, via double ratchet)
SA->>SMP: description in A_MSG
SMP->>RA: description in MSG
RA->>R: xftpReceiveFile<br>(description)
R->>RS: store RcvFile + chunks
loop each chunk (parallel per server)
R->>XFTP: FGET (per-recipient auth key)
XFTP->>R: encrypted chunk stream
end
R->>R: stream chunks through<br>stateful decrypt (key + nonce),<br>verify auth tag at end
R->>RA: RFDONE<br>(decrypted file path)
```
-232
View File
@@ -1,232 +0,0 @@
# Agent Connections
Duplex connection lifecycle: establishment, queue rotation, ratchet synchronization, and message integrity. These cross-module flows span the Agent, protocol client, and store layers.
For per-module details: [Agent](../modules/Simplex/Messaging/Agent.md) · [Agent Protocol](../modules/Simplex/Messaging/Agent/Protocol.md) · [Ratchet](../modules/Simplex/Messaging/Crypto/Ratchet.md) · [Store Interface](../modules/Simplex/Messaging/Agent/Store/Interface.md). For the component diagram, see [agent.md](../agent.md). For protocol specification, see [Agent Protocol](../../protocol/agent-protocol.md) and [PQDR](../../protocol/pqdr.md).
- [Design constraints](#design-constraints)
- [Connection establishment](#connection-establishment)
- [Queue rotation](#queue-rotation)
- [Ratchet synchronization](#ratchet-synchronization)
- [Message envelope hierarchy](#message-envelope-hierarchy)
- [Integrity chain](#integrity-chain)
---
## Design constraints
**Source**: [Agent.hs](../../src/Simplex/Messaging/Agent.hs), [Agent/Client.hs](../../src/Simplex/Messaging/Agent/Client.hs)
Two properties of the protocol drive much of the agent's complexity:
**TOFU retry safety**: Queues and links are secured via trust-on-first-use - the router accepts the first key presented (SKEY, KEY) and rejects any subsequent different key. If a network call succeeds but the response is lost, the client must retry with the *same* key, or the router will reject it. This means all cryptographic keys must be generated and persisted *before* the network call that uses them. The agent's pervasive store-then-execute pattern (`enqueueCommand` persists to DB, then worker executes with stored keys) exists primarily to satisfy this constraint.
**Network asymmetry**: After a client sends a message to a queue, the peer's response can arrive at the agent before the originating API call returns to the application. The application must already know the connection exists when it receives the event, otherwise it gets handshake events for an unknown connection. This drives split-phase APIs where the connection is registered locally before any network call.
Together, these constraints explain why the agent separates key generation from network operations, why commands are persisted before execution, and why connection creation is split into prepare + create phases.
---
## Connection establishment
**Source**: [Agent.hs](../../src/Simplex/Messaging/Agent.hs), [Agent/Protocol.hs](../../src/Simplex/Messaging/Agent/Protocol.hs)
### Split-phase connection creation
Connection creation is split into two phases to satisfy both design constraints:
**`prepareConnectionLink`** (no network, no database): generates root Ed25519 signing key pair and queue-level X25519 DH keys. Derives a short link key as `SHA3_256` of the encoded fixed link data. Returns the connection link URI and `PreparedLinkParams` in memory. The application can now embed the link in link data (e.g., for short link resolution) before the queue exists.
**`createConnectionForLink`** (single network call): uses the prepared parameters to create the queue on the router with NEW (root signing key as owner auth). The sender ID is deterministically derived from the correlation nonce (`SMP.EntityId $ B.take 24 $ C.sha3_384 corrId`), so a lost response can be retried - the router validates the same sender ID.
Without split-phase, the application would need to create the queue first, get the link, then update the queue with link data containing the link - requiring an extra round-trip.
### Standard handshake
The connection establishment flow is shown in [agent.md](../agent.md#connection-establishment-flow). The key non-obvious details:
**Ratchet initialization is asymmetric**: The initiator (Alice) generates X3DH key parameters during `newRcvConnSrv` and stores them via `createRatchetX3dhKeys`, but does not initialize any ratchet yet. The *receiving* ratchet is only initialized later in `smpConfirmation` via `initRcvRatchet` when the confirmation arrives with the responder's parameters. The responder (Bob) initializes a *sending* ratchet during `startJoinInvitation` via `initSndRatchet`. The names `RcvE2ERatchetParams`/`SndE2ERatchetParams` are historical - what matters is that the responder initializes first (sending direction), and the initiator initializes second (receiving direction) using the responder's parameters.
**Confirmation decryption proves key agreement**: In `smpConfirmation`, the initiator creates a fresh receiving ratchet from the responder's parameters and immediately uses it to decrypt the confirmation body. If decryption fails, the entire confirmation is rejected - there is no state where a connection has mismatched ratchets.
**HELLO exchange completes the handshake**: After `allowConnection`, both sides have duplex queues but haven't confirmed liveness. The responder (Bob) sends the first HELLO (with `notification = True` in MsgFlags), triggered by `ICDuplexSecure`. The initiator (Alice) receives it and sends her own HELLO back (also with `notification = True`). The initiator emits `CON` in the *delivery callback* of her HELLO (her rcvQueue is already Active from receiving Bob's HELLO). The responder emits `CON` when he *receives* the initiator's reply HELLO (his sndQueue is already Active from his own HELLO delivery). There are exactly two HELLO messages.
### Contact URI async path
For contact URIs (`joinConnectionAsync` with `CRContactUri`), the join is enqueued as an async command. The connection record is created locally (NewConnection state) before the network call, satisfying the network asymmetry constraint. The background worker then creates the receive queue, sends the invitation, and processes the handshake.
### PQ key agreement
PQ support is negotiated via version numbers: `agentVersion >= pqdrSMPAgentVersion && e2eVersion >= pqRatchetE2EEncryptVersion`. When both sides support PQ, the KEM public key travels in the confirmation body (too large for invitation URI). The responder encapsulates, producing `(kemCiphertext, kemSharedKey)`, and the hybrid key is derived via HKDF-SHA512 over the concatenation of three X3DH shared secrets plus the KEM shared secret, with info string `"SimpleXX3DH"`.
**PQ support is monotonic**: once enabled for a connection (`PQSupport PQSupportOn`), it cannot be downgraded. This affects header padding size (88 bytes without PQ vs 2310 bytes with PQ).
### Connection type state machine
```
NewConnection
+-> RcvConnection (initiator, after newRcvConnSrv)
| +-> DuplexConnection (after allowConnection + connectReplyQueues)
| +-> ContactConnection (contact address case)
+-> SndConnection (responder, before reply queue created)
| +-> DuplexConnection (after reply queue created)
+-> ContactConnection (short link / contact address)
```
---
## Queue rotation
**Source**: [Agent.hs](../../src/Simplex/Messaging/Agent.hs), [Agent/Store.hs](../../src/Simplex/Messaging/Agent/Store.hs)
Queue rotation replaces a receive queue with a new one on a different router, providing forward secrecy for the transport layer. The protocol uses a 4-message handshake.
### Protocol sequence
Rotation is initiated by the switching party calling `switchConnectionAsync` (client API), which sends QADD. The peer responds to QADD by creating a new send queue and replying with QKEY. Preconditions: connection must be duplex, no switch already in progress, ratchet must not be syncing.
```
Receiver (switching party) Sender (peer)
| |
|-- QADD (new queue address) ---------->|
| |-- creates SndQueue to new address
|<--------- QKEY (sender auth key) ----|
| |
|-- secures new queue (ICQSecure) ----->|
|-- QUSE (start using new queue) ----->|
| |-- switches delivery to new queue
|<--------- QTEST (on new queue) ------|
| |
|-- deletes old queue (ICQDelete) ---->|
```
### State machines
**Receiver (RcvQueue switch)**: `RSSwitchStarted` -> `RSSendingQADD` -> `RSSendingQUSE` -> `RSReceivedMessage`. The switch becomes non-abortable at `RSSendingQUSE` - by this point the sender may have already deleted the old queue, so aborting would break the connection. `canAbortRcvSwitch` enforces this.
**Sender (SndQueue switch)**: creates new SndQueue on QADD, sends QKEY, marks old as `SSSendingQKEY`. On QUSE: sends QTEST *only to the new queue*, marks as `SSSendingQTEST`. Completes when QTEST delivery succeeds.
### Consecutive rotation handling
`dbReplaceQueueId` tracks which old queue a new one replaces. Each new queue stores `dbReplaceQueueId = Just oldQueueId`. When QADD is processed, send queues whose `dbReplaceQueueId` points to the current queue's `dbQueueId` are found and deleted in bulk. This handles consecutive rotation requests - only the latest rotation survives.
### Old queue deletion
Three triggers delete the old queue:
1. **Sender-side**: QTEST delivery succeeds - old queue removed from `smpDeliveryWorkers` (worker thread stops)
2. **Receiver-side**: first message arrives on new queue - receiver marks old queue for deletion via `ICQDelete`
3. **Abort cleanup**: `abortConnectionSwitch` explicitly deletes new queues created during a failed switch attempt
---
## Ratchet synchronization
**Source**: [Agent.hs](../../src/Simplex/Messaging/Agent.hs), [Agent/Protocol.hs](../../src/Simplex/Messaging/Agent/Protocol.hs)
When double ratchet state becomes desynchronized (e.g., one side restores from backup), the agent can re-establish the ratchet without breaking the connection.
### State machine
```
RSOk (synchronized)
|
v (crypto error detected)
RSAllowed / RSRequired
|
v (synchronizeRatchet called)
RSStarted (waiting for peer)
|
v (peer responds with own keys)
RSAgreed (both exchanged keys)
|
v (ratchet recreated, EREADY sent/received)
RSOk
```
**Send prohibition**: `ratchetSyncSendProhibited` returns `True` for `RSRequired`, `RSStarted`, and `RSAgreed`. This blocks *all* messages including queue rotation messages - preventing state corruption while the ratchet is being re-established.
### Key exchange protocol
1. Initiator calls `synchronizeRatchet`, which generates new X3DH keys and sends them in an `AgentRatchetKey` envelope (discriminant `'R'`). State becomes `RSStarted`.
2. Peer receives the ratchet key in `newRatchetKey`. If peer hasn't started sync, it generates own keys and sends a reply `AgentRatchetKey`.
3. Both sides now have each other's keys. State becomes `RSAgreed`.
### Hash-ordered role assignment
Both parties compute `rkHash = SHA256(pubKeyBytes k1 || pubKeyBytes k2)` for their own keys. The party with the *smaller* hash initializes the receiving ratchet (`pqX3dhRcv`); the party with the larger hash initializes the sending ratchet (`pqX3dhSnd`) and sends `EREADY`. This deterministic tie-breaking avoids a separate negotiation round.
### EREADY completion
`EREADY` carries `lastExternalSndId` - the ID of the last message the sender received from the peer before switching ratchets. The receiving party uses this to know when the old ratchet's messages are exhausted and the new ratchet is fully active. Until EREADY arrives, messages may arrive encrypted with either the old or new ratchet.
### Error recovery
- **Crypto error during decrypt**: `cryptoErrToSyncState` classifies the error and sets state to `RSAllowed` or `RSRequired`. Client is notified via `RSYNC`.
- **Successful decrypt during non-RSOk state**: if state is not `RSStarted` (which means sync is actively in progress), reset to `RSOk`. A successful message proves the ratchets are synchronized.
- **Duplicate handling**: `rkHash` of received keys is checked against stored hashes to prevent reprocessing the same ratchet key message.
---
## Message envelope hierarchy
**Source**: [Agent/Protocol.hs](../../src/Simplex/Messaging/Agent/Protocol.hs)
Messages use three nesting levels, each adding a layer of structure:
### Level 1: AgentMsgEnvelope (transport)
Four variants with single-character discriminants:
| Variant | Disc. | Encryption | When |
|---------|-------|-----------|------|
| `AgentConfirmation` | `'C'` | Per-queue E2E (outer) + double ratchet (inner `encConnInfo`) | Connection handshake |
| `AgentMsgEnvelope` | `'M'` | Double ratchet | Normal messages |
| `AgentInvitation` | `'I'` | Per-queue E2E only | Contact URI join |
| `AgentRatchetKey` | `'R'` | Per-queue E2E only | Ratchet sync |
`AgentMsgEnvelope` is fully double-ratchet encrypted. `AgentConfirmation` uses per-queue E2E for the outer envelope but also contains `encConnInfo` which is double-ratchet encrypted (the ratchet is initialized during confirmation processing). `AgentInvitation` and `AgentRatchetKey` use only per-queue E2E - the double ratchet is either not yet established or being replaced.
### Level 2: AgentMessage (application)
Inside the decrypted envelope:
- `AgentConnInfo` / `AgentConnInfoReply` - connection info during handshake (double-ratchet encrypted inside `encConnInfo`)
- `AgentRatchetInfo` - ratchet sync payload (not double-ratchet encrypted)
- `AgentMessage APrivHeader AMessage` - user and control messages (double-ratchet encrypted)
The private header (`APrivHeader`) carries `sndMsgId` and `prevMsgHash` for the integrity chain.
### Level 3: AMessage (semantic)
Message types with 1-2 character discriminants:
- User messages: `HELLO_`, `A_MSG_`, `A_RCVD_`, `A_QCONT_`, `EREADY_`
- Queue rotation: `QADD_`, `QKEY_`, `QUSE_`, `QTEST_`
### ACK semantics
- **User messages** (`A_MSG_`): NOT auto-ACKed. Agent returns `ACKPending`; application must call `ackMessage`.
- **Receipts** (`A_RCVD`): returns `ACKPending` when valid receipts are present (application must ACK after processing); auto-ACKed only when all receipts fail.
- **Other control messages** (HELLO, QADD, QKEY, QUSE, QTEST, EREADY, A_QCONT): auto-ACKed by the agent.
- **Error during processing**: `handleNotifyAck` sends `ERR` to the application but still ACKs to the router, preventing re-delivery of a message that will fail again.
---
## Integrity chain
**Source**: [Agent.hs](../../src/Simplex/Messaging/Agent.hs), [Agent/Protocol.hs](../../src/Simplex/Messaging/Agent/Protocol.hs)
Each message in a connection commits to the previous message via two mechanisms:
1. **External sender ID** (`lastExternalSndId`): monotonically increasing counter per connection
2. **Previous message hash** (`prevMsgHash`): SHA256 of the previous message body
`checkMsgIntegrity` produces one of five outcomes:
| Outcome | Condition |
|---------|-----------|
| `MsgOk` | Sequential ID and matching hash |
| `MsgBadId` | ID from the past (less than previous) |
| `MsgDuplicate` | Same ID as previous |
| `MsgSkipped` | Gap in IDs (messages lost) |
| `MsgBadHash` | Sequential ID but hash mismatch |
**Non-rejecting semantics**: the agent does NOT reject messages with integrity failures. The result is reported to the application via `MsgMeta.integrity`. The application decides the policy - warn, ignore, or terminate the connection.
-211
View File
@@ -1,211 +0,0 @@
# Agent Infrastructure
The Agent's internal machinery: worker lifecycle, command dispatch, message delivery, subscription tracking, operation suspension, protocol client management, and dual-backend store. These cross-module patterns are not visible from any single module spec.
This document covers the "big agent" (`Agent.hs` + `Agent/Client.hs`) used in client applications. The "small agent" (`SMPClientAgent`) used in routers is documented in [clients.md](../clients.md).
For per-module details: [Agent](../modules/Simplex/Messaging/Agent.md) · [Agent Client](../modules/Simplex/Messaging/Agent/Client.md) · [Store Interface](../modules/Simplex/Messaging/Agent/Store/Interface.md) · [NtfSubSupervisor](../modules/Simplex/Messaging/Agent/NtfSubSupervisor.md) · [XFTP Agent](../modules/Simplex/FileTransfer/Agent.md). For the component diagram, see [agent.md](../agent.md).
- [Worker framework](#worker-framework)
- [Async command processing](#async-command-processing)
- [Message delivery](#message-delivery)
- [Subscription tracking](#subscription-tracking)
- [Operation suspension cascade](#operation-suspension-cascade)
- [SessionVar lifecycle](#sessionvar-lifecycle)
- [Dual-backend store](#dual-backend-store)
---
## Worker framework
**Source**: [Agent/Client.hs](../../src/Simplex/Messaging/Agent/Client.hs), [Agent/Env/SQLite.hs](../../src/Simplex/Messaging/Agent/Env/SQLite.hs) (Worker type)
All agent background processing - async commands, message delivery, notification workers, XFTP workers - uses a shared worker infrastructure defined in `Agent/Client.hs`.
**Create-or-reuse**: `getAgentWorker` atomically checks a `TMap` for an existing worker keyed by the work item (connection+server, send queue address, etc.). If absent, creates a new `Worker` with a unique monotonic `workerId` from `workerSeq` and inserts it. If present and `hasWork=True`, signals the existing worker via `tryPutTMVar doWork ()`.
**Fork and run**: `runWorkerAsync` uses bracket on the worker's `action` TMVar. If the taken value is `Nothing`, the worker is idle - start it. If `Just _`, it's already running - put it back and return. The `action` TMVar holds `Just (Weak ThreadId)` to avoid preventing GC of the worker thread.
**Task retrieval race prevention**: `withWork` clears the `doWork` flag *before* calling `getWork` (not after). This prevents a race: query finds nothing → another thread adds work + signals → worker clears flag (losing the signal). By clearing first, any signal that arrives during the query is preserved.
**Error classification**: `withWork` distinguishes two failure modes:
- *Work-item error* (`isWorkItemError`): the task itself is broken (likely recurring). Worker stops and sends `CRITICAL False`.
- *Other error*: any non-work-item error (e.g., transient database issue). Worker re-signals `doWork` and reports `INTERNAL` (retry may succeed).
**Restart rate limiting**: On worker exit, `restartOrDelete` checks the `restarts` counter against `maxWorkerRestartsPerMin`. Under the limit: reset action, re-signal, restart. Over the limit: delete the worker from the map and send `CRITICAL True` (escalation to the application). A restart only proceeds if the `workerId` in the map still matches the current worker - a stale restart from a replaced worker is a no-op.
**Consumers**: Four families use this framework:
- Async command workers - keyed by `(ConnId, Maybe SMPServer)`, in `asyncCmdWorkers` TMap
- Delivery workers - keyed by `SndQAddr`, in `smpDeliveryWorkers` TMap, paired with a `TMVar ()` retry lock
- NTF workers - three pools (`ntfWorkers` per NTF server, `ntfSMPWorkers` per SMP server, `ntfTknDelWorkers` for token deletion) in `NtfSubSupervisor`
- XFTP workers - three worker types (rcv, snd, del) with TMVar-based connection sharing
---
## Async command processing
**Source**: [Agent.hs](../../src/Simplex/Messaging/Agent.hs), [Agent/Protocol.hs](../../src/Simplex/Messaging/Agent/Protocol.hs) (command types), [Agent/Store.hs](../../src/Simplex/Messaging/Agent/Store.hs) (internal command types)
Async commands handle state transitions that require network calls but shouldn't block the API thread: securing queues, deleting old queues during rotation, acknowledging messages. The dispatch loop `runCommandProcessing` runs one worker per `(ConnId, Maybe SMPServer)` key.
**Enqueueing**: API functions call `enqueueCommand`, which persists the command to the `commands` table (crash-safe) and spawns/wakes the worker via `getAsyncCmdWorker`. On agent startup, `resumeAllCommands` fetches all pending commands grouped by connection+server and signals their workers.
**Command types**: Two categories share the same dispatch loop:
- *Client commands* (`AClientCommand`): `NEW`, `JOIN`, `LET` (allow connection), `ACK`, `LSET`/`LGET` (set/get connection link data), `SWCH` (switch queue), `DEL`. Triggered by application API calls.
- *Internal commands* (`AInternalCommand`): `ICAck` (ack to router), `ICAckDel` (ack + delete local message), `ICAllowSecure`/`ICDuplexSecure` (secure after confirmation), `ICQSecure` (secure queue during switch), `ICQDelete` (delete old queue after switch), `ICDeleteConn` (delete connection), `ICDeleteRcvQueue` (delete specific receive queue). Generated *during* message processing to handle state transitions asynchronously.
**Retry and movement**: `tryMoveableCommand` wraps execution with `withRetryInterval`. On `temporaryOrHostError`, it retries with backoff. Individual command handlers can return `CCMoved` (e.g., when a queue has moved to a different router) after updating the command's server field in the store - `tryMoveableCommand` then exits cleanly, letting the moved command be picked up by the appropriate worker.
**Locking**: State-sensitive commands use `tryWithLock` / `tryMoveableWithLock`, which acquire `withConnLock` before execution. This serializes operations on the same connection, preventing races between concurrent command processing and message receipt.
**Event overflow**: Events are written directly to `subQ` if there is room. When `subQ` is full, events overflow into a local `pendingCmds` list and are flushed to `subQ` after the command completes, providing backpressure handling.
---
## Message delivery
**Source**: [Agent.hs](../../src/Simplex/Messaging/Agent.hs), [Agent/RetryInterval.hs](../../src/Simplex/Messaging/Agent/RetryInterval.hs)
Message delivery uses a split-phase encryption design: the ratchet advances in the API thread (serialized), while the actual body encryption happens in the per-queue delivery worker (parallel). This avoids ratchet lock contention across queues.
**Phase 1 - API thread** (`enqueueMessageB`):
1. Encode the agent message with `internalSndId` + `prevMsgHash` (for the receiver's integrity chain)
2. Call `agentRatchetEncryptHeader` - advances the double ratchet, produces a message encryption key (MEK), padded length, and PQ encryption status
3. Store `SndMsg` with `SndMsgPrepData` (MEK, paddedLen, sndMsgBodyId) in the database
4. Create `SndMsgDelivery` record for each send queue
5. `submitPendingMsg` - increments `msgDeliveryOp.opsInProgress` (for suspension tracking) and signals delivery workers via `getDeliveryWorker`
**Phase 2 - delivery worker** (`runSmpQueueMsgDelivery`):
1. `throwWhenNoDelivery` - kills the worker thread if the queue's address has been removed from `smpDeliveryWorkers` (prevents delivery to queues replaced during switch)
2. `getPendingQueueMsg` - fetches the next pending message from the store, resolving the `sndMsgBodyId` reference into the actual message body and constructing `PendingMsgPrepData`
3. Re-encode the message with `internalSndId`/`prevMsgHash`, then `rcEncryptMsg` to encrypt with the stored MEK (no ratchet access needed)
4. `sendAgentMessage` - per-queue encrypt + SEND to the router
**Connection info messages** (`AM_CONN_INFO`, `AM_CONN_INFO_REPLY`) skip split-phase encryption entirely - they are sent as per-queue E2E encrypted confirmation bodies via `sendConfirmation` (encrypted with `agentCbEncrypt`, not with the double ratchet).
**Retry with dual intervals**: Delivery uses `withRetryLock2`, which maintains two retry interval states (slow and fast) but only one wait is active at a time. A background thread sleeps for the current interval, then signals the delivery worker via `tryPutTMVar`. When the router sends `QCONT` (queue buffer cleared), the agent calls `tryPutTMVar retryLock ()` to wake the delivery thread immediately, avoiding unnecessary delay.
**Error handling**:
- `SMP QUOTA` - switch to slow retry, don't penalize (backpressure from router)
- `SMP AUTH` - permanent failure: for data messages, notify and delete; for handshake messages, report connection error; for queue-switch messages, report queue error
- `temporaryOrHostError` - retry with backoff
- Other errors - report to application, delete command
---
## Subscription tracking
**Source**: [Agent/TSessionSubs.hs](../../src/Simplex/Messaging/Agent/TSessionSubs.hs), [Agent/Client.hs](../../src/Simplex/Messaging/Agent/Client.hs)
The agent tracks per-queue subscription state in `TSessionSubs` (defined in `Agent/TSessionSubs.hs`), keyed by `SMPTransportSession = (UserId, SMPServer, Maybe ByteString)` where the `ByteString` carries the entity ID in entity-session mode or `Nothing` in shared mode. Each transport session holds:
```
SessSubs
├── subsSessId :: TVar (Maybe SessionId) -- TLS session ID
├── activeSubs :: TMap RecipientId RcvQueueSub
├── pendingSubs :: TMap RecipientId RcvQueueSub
├── activeServiceSub :: TVar (Maybe ServiceSub)
└── pendingServiceSub :: TVar (Maybe ServiceSub)
```
**State machine**: Subscriptions move between three states:
- **Pending → Active**: After subscription RPC succeeds, `addActiveSub'` promotes the queue - but only if the returned session ID matches the stored TLS session ID (`Just sessId == sessId'`). On mismatch (TLS reconnected between RPC send and response), the subscription is silently added to pending instead. No exception - concurrent resubscription paths handle this naturally.
- **Active → Pending**: When `setSessionId` is called with a *different* session ID (TLS reconnect), all active subscriptions are atomically demoted to pending. Session ID is updated to the new value.
- **Pending → Removed**: `failSubscriptions` moves permanently-failed queues (non-temporary SMP errors) to `removedSubs` - a separate `TMap` in `AgentClient`, not part of `TSessionSubs`. The removal is tracked for diagnostic reporting via `getSubscriptions`.
**Service-associated queues**: Queues with `serviceAssoc=True` are *not* added to `activeSubs` individually. Instead, the service subscription's count is incremented and its `idsHash` XOR-accumulates the queue's hash. The router tracks individual queues via the service subscription; the agent only tracks the aggregate. Consequence: `hasActiveSub(rId)` returns `False` for service-associated queues - callers must check the service subscription separately.
**Disconnect cleanup** (`smpClientDisconnected`):
1. `removeSessVar` with CAS check (monotonic `sessionVarId` prevents stale callbacks from removing newer clients)
2. `setSubsPending` - demote active→pending, filtered by matching `SessionId` only
3. Delete proxied relay sessions created by this client
4. Fire `DISCONNECT`, `DOWN` (affected connections), `SERVICE_DOWN` (if service sub existed)
5. Release GET locks for affected queues
6. Resubscribe: either spawn `resubscribeSMPSession` worker (entity-session mode) or directly resubscribe queues and services (other modes)
**Resubscription worker**: Per-transport-session worker with exponential backoff. Loops until `pendingSubs` and `pendingServiceSub` are both empty. Uses `waitForUserNetwork` with bounded wait - proceeds even without network (prevents indefinite blocking). Worker self-cleans via `removeSessVar` on exit.
**UP event deduplication**: After a batch subscription RPC, `UP` events are emitted only for connections that were *not* already in `activeSubs` before the batch. This prevents duplicate notifications for already-subscribed connections.
---
## Operation suspension cascade
**Source**: [Agent/Client.hs](../../src/Simplex/Messaging/Agent/Client.hs)
Five `AgentOpState` TVars track in-flight operations for graceful shutdown. Each holds `{opSuspended :: Bool, opsInProgress :: Int}`.
**Cascade ordering**:
```
AONtfNetwork (independent - no cascading)
AORcvNetwork → AOMsgDelivery → AOSndNetwork → AODatabase
```
**Mechanics**: `endAgentOperation` decrements `opsInProgress`. If the count reaches zero and the operation is suspended, it calls the cascade action: `AORcvNetwork` suspends `AOMsgDelivery`, which suspends `AOSndNetwork`, which suspends `AODatabase`. At the leaf (`AODatabase`), `notifySuspended` writes `SUSPENDED` to `subQ` and sets `agentState = ASSuspended`.
**Blocking**: `beginAgentOperation` blocks (STM `retry`) while `opSuspended == True`. This means new operations of a suspended type cannot start - they wait until the operation is resumed. `agentOperationBracket` provides structured bracketing (begin on entry, end on exit).
**Two wait modes**:
- `waitWhileSuspended` - blocks only during `ASSuspended`, proceeds during `ASSuspending` (allows in-flight operations to complete)
- `waitUntilForeground` - blocks during both `ASSuspending` and `ASSuspended` (stricter, for operations that need full foreground)
**Usage**: `withStore` brackets all database access with `AODatabase`. Message delivery uses `AOSndNetwork` + `AOMsgDelivery`. Receive processing uses `AORcvNetwork`. This ensures that suspending receive processing cascades through delivery to database, and nothing touches the database after all operations drain.
---
## SessionVar lifecycle
**Source**: [Agent/Client.hs](../../src/Simplex/Messaging/Agent/Client.hs)
Protocol client connections (SMP, XFTP, NTF) use a lazy singleton pattern via `SessionVar` - a `TMVar` in a `TMap` keyed by transport session.
**Connection**: `getSessVar` atomically checks the TMap. Returns `Left newVar` (absent - caller must connect) or `Right existingVar` (present - wait for result). `newProtocolClient` wraps the connection attempt: on success, fills the TMVar with `Right client` and writes `CONNECT` event; on failure, fills with `Left (error, maybeRetryTime)` and re-throws.
**Error caching**: Failed connections cache the error with an expiry timestamp based on `persistErrorInterval`. Future attempts during the interval immediately receive the cached error without reconnecting - this prevents connection storms when a router is down. When `persistErrorInterval == 0`, the SessionVar is removed immediately on failure (fresh connection on next attempt).
**Compare-and-swap**: Each SessionVar has a monotonic `sessionVarId` from `workerSeq`. `removeSessVar` only removes if the `sessionVarId` matches the current map entry. This prevents a stale disconnect callback (from an old client) from removing a newer client that connected after the old one disconnected.
**Service credential synchronization** (`updateClientService`): On SMP reconnect, the agent reconciles service credentials between client and router state - updating, creating, or removing service associations as needed. Router version downgrade (router loses service support) triggers client-side service deletion.
**XFTP special case**: `getProtocolServerClient` ignores the caller's `NetworkRequestMode` parameter for XFTP, always using `NRMBackground` timing. XFTP connections always use background retry timing regardless of the caller's request.
---
## Dual-backend store
**Source**: [Agent/Store/SQLite.hs](../../src/Simplex/Messaging/Agent/Store/SQLite.hs), [Agent/Store/Postgres.hs](../../src/Simplex/Messaging/Agent/Store/Postgres.hs), [Agent/Store/AgentStore.hs](../../src/Simplex/Messaging/Agent/Store/AgentStore.hs)
The agent supports SQLite and PostgreSQL via CPP compilation flags (`#if defined(dbPostgres)`). Three wrapper modules (`Interface.hs`, `Common.hs`, `DB.hs`) re-export the appropriate backend. A single binary compiles with one active backend.
**Key behavioral differences**:
| Aspect | SQLite | PostgreSQL |
|--------|--------|------------|
| Row locking | Single-writer model (no locking needed) | `FOR UPDATE` on reads preceding writes |
| Batch queries | Per-row `forM` loops | `IN ?` with `In` wrapper |
| Constraint violations | `SQL.ErrorConstraint` pattern match | `constraintViolation` function |
| Transaction savepoints | Not needed | Used in `createWithRandomId'` (failed statement aborts entire transaction without them) |
| Busy/locked errors | `ErrorBusy`/`ErrorLocked``SEDatabaseBusy``CRITICAL True` | All SQL errors → `SEInternal` |
**Store access bracketing**: `withStore` wraps all database operations with `agentOperationBracket AODatabase`, connecting the store to the suspension cascade. `withStoreBatch` / `withStoreBatch'` run multiple operations in a single transaction with per-operation error catching.
**Known bug**: `checkConfirmedSndQueueExists_` uses `#if defined(dpPostgres)` (typo - should be `dbPostgres`), so the `FOR UPDATE` clause is never included on either backend.
### Migration framework
**Source**: [Agent/Store/Migrations.hs](../../src/Simplex/Messaging/Agent/Store/Migrations.hs), [Agent/Store/Shared.hs](../../src/Simplex/Messaging/Agent/Store/Shared.hs)
Migrations are Haskell modules under `Agent/Store/SQLite/Migrations/` and `Agent/Store/Postgres/Migrations/`. Each has `up` SQL and optional `down` SQL.
**Key behaviors**:
- `migrationsToRun` compares app migrations against the `migrations` table by name. Divergent histories (app has `[a,b]`, DB has `[a,c]`) produce `MTREDifferent` error - manual intervention required.
- Each migration runs in its own transaction with the `migrations` insert *before* the schema change - failure rolls back both.
- Downgrades require all intermediate migrations to have `down` SQL; missing any produces `MTRENoDown`.
- `MigrationConfirmation` controls whether upgrades/downgrades auto-apply, prompt, or error.
**Special case**: `m20220811_onion_hosts` triggers `updateServers` to expand host entries with Tor addresses - this is data migration, not just schema.
-99
View File
@@ -1,99 +0,0 @@
# XRCP - Cross-Device Remote Control
XRCP enables a desktop application to control a mobile device over the local network. The protocol establishes an encrypted session between two devices using TLS, post-quantum hybrid key exchange, and optional multicast discovery.
This document covers the cross-module flows that are not visible from individual module specs. For message formats and cryptographic operations, see [protocol/xrcp.md](../../protocol/xrcp.md). For per-module details: [Client](../modules/Simplex/RemoteControl/Client.md) · [Invitation](../modules/Simplex/RemoteControl/Invitation.md) · [Discovery](../modules/Simplex/RemoteControl/Discovery.md) · [Types](../modules/Simplex/RemoteControl/Types.md).
**Terminology note**: in the code, "host" is the mobile device (being controlled) and "ctrl" is the desktop (controlling). The protocol spec uses the reverse convention - "host" serves, "controller" connects. This document uses the code convention.
- [Session handshake flow](#session-handshake-flow)
- [KEM hybrid key exchange](#kem-hybrid-key-exchange)
- [Multicast discovery](#multicast-discovery)
- [Block framing and padding](#block-framing-and-padding)
---
## Session handshake flow
**Source**: [RemoteControl/Client.hs](../../src/Simplex/RemoteControl/Client.hs), [RemoteControl/Discovery.hs](../../src/Simplex/RemoteControl/Discovery.hs)
The handshake spans `Client.connectRCHost` (controller side, despite the name), `Client.connectRCCtrl` (host side), `Invitation.mkInvitation`, and `Discovery.startTLSServer`. The full sequence:
1. **Controller starts TLS server**: generates ephemeral session keys + DH keys, creates a signed invitation containing the CA fingerprint and identity key, starts a TLS server on an ephemeral port. The TLS hook `onNewHandshake` enforces single-session - a second connection attempt is rejected by checking whether the session TMVar is already filled.
2. **Invitation delivery**: the invitation reaches the host either out-of-band (QR code scan for first pairing) or via encrypted multicast announcement (subsequent sessions - see [Multicast discovery](#multicast-discovery)).
3. **Host connects via TLS**: `connectRCCtrl` establishes a TLS connection. Both sides validate certificate chains. On the controller side, `onClientCertificate` explicitly checks for a 2-certificate chain (leaf + CA root) and validates the host's CA fingerprint against `KnownHostPairing.hostFingerprint` (or stores it on first pairing). On the host side, the controller's CA fingerprint is validated against `RCCtrlPairing.ctrlFingerprint` in `updateCtrlPairing`.
4. **User confirmation barrier**: after TLS connects, both sides extract the TLS channel binding (`tlsUniq`) as a session code. The application displays this code on both devices for the user to verify. On the host side, `confirmCtrlSession` uses a double `putTMVar` - the first put signals the decision (accept/reject), the second blocks until the session thread acknowledges the value, ensuring `confirmCtrlSession` does not return prematurely.
5. **Hello exchange** (asymmetric encryption):
- Host sends `RCHostEncHello` (`prepareHostHello`): DH public key in plaintext + encrypted body containing the KEM encapsulation key, CA fingerprint, and app info. Encrypted with `cbEncrypt` (classical DH secret).
- Controller decrypts the hello, performs KEM encapsulation (see [KEM hybrid key exchange](#kem-hybrid-key-exchange)), derives the hybrid session key, initializes a chain via `sbcInit`, and sends `RCCtrlEncHello` (`prepareHostSession`) encrypted with a key derived from the chain (`sbcHkdf` + `sbEncrypt`).
- The asymmetry is deliberate: at the time the host sends its hello, KEM hasn't completed yet, so only classical DH encryption is available. After the controller encapsulates, both sides have the hybrid key.
6. **Chain key initialization**: both sides call `sbcInit` with the hybrid key to derive send/receive chain keys. The host explicitly **swaps** the key pair (`swap` call in `prepareCtrlSession`, which runs on the host side despite its name) - both sides derive keys in the same order from `sbcInit`, but have opposite send/receive roles, so the host must reverse them. The controller does not swap.
7. **Error path**: if KEM encapsulation fails, the controller sends `RCCtrlEncError` (a variant of `RCCtrlEncHello`) encrypted with the DH key (not the hybrid key, which doesn't exist yet). The host can decrypt the error because it has the DH secret from step 5. Note: this error path is not yet fully implemented in the code.
---
## KEM hybrid key exchange
**Source**: [RemoteControl/Client.hs](../../src/Simplex/RemoteControl/Client.hs)
The session key combines classical Diffie-Hellman with SNTRUP761 (lattice-based KEM) via `SHA3_256(dhSecret || kemSharedKey)` (`kemHybridSecret` in `Crypto/SNTRUP761.hs`). This provides protection against quantum computers while maintaining classical security as a fallback.
The KEM public key is too large for a QR code invitation, so it travels in the encrypted hello body. Fresh KEM keys are generated every session - no KEM state is cached between sessions.
1. Host generates a fresh KEM key pair (`prepareHostHello`), puts the KEM public key in the host hello body
2. Controller decrypts hello with DH secret, extracts KEM public key
3. Controller encapsulates (`sntrup761Enc`): produces `(kemCiphertext, kemSharedKey)`
4. Controller derives hybrid key: `SHA3_256(dhSecret || kemSharedKey)`
5. Controller sends `kemCiphertext` in the ctrl hello body (`RCCtrlEncHello`)
6. Host decapsulates `kemCiphertext` (`sntrup761Dec`) to recover `kemSharedKey`, derives the same hybrid key
The KEM exchange is identical for first and subsequent sessions. The only difference between sessions is how the invitation is delivered (QR code vs multicast) and whether TLS fingerprints are stored for the first time or verified against known pairings.
`updateKnownHost` (called in `prepareHostSession` on the controller) updates the stored host DH public key (`hostDhPubKey` in `KnownHostPairing`) - this is used for encrypting multicast announcements in subsequent sessions, not for KEM.
**Key rotation and `prevDhPrivKey`**: when the host updates its DH key pair for a new session, it retains the previous private key in `RCCtrlPairing.prevDhPrivKey`. This is critical for multicast - during the transition window, the controller may send announcements encrypted with the old public key. `findRCCtrlPairing` tries decryption with both the current and previous DH keys. Without this fallback, key rotation would break multicast discovery.
---
## Multicast discovery
**Source**: [RemoteControl/Client.hs](../../src/Simplex/RemoteControl/Client.hs), [RemoteControl/Invitation.hs](../../src/Simplex/RemoteControl/Invitation.hs), [RemoteControl/Discovery.hs](../../src/Simplex/RemoteControl/Discovery.hs)
For subsequent sessions (after initial QR pairing), the controller announces its presence via UDP multicast so the host can connect without scanning a new QR code. The flow spans `Client.announceRC`, `Client.discoverRCCtrl`, `Client.findRCCtrlPairing`, `Invitation.signInvitation`/`verifySignedInvitation`, and `Discovery.withListener`/`withSender`.
**Announcement creation** (`announceRC`):
1. The invitation is signed with a dual-signature chain: the session key signs the invitation URI, then the identity key signs the concatenation `URI + "&ssig=" + sessionSignature`. This chain means a compromised session key alone cannot forge a valid identity-signed announcement - the identity key must also be compromised.
2. The signed invitation is encrypted with a DH shared secret between the host's known DH public key and the controller's ephemeral DH private key.
3. The encrypted packet is padded to 900 bytes (privacy: all announcements are indistinguishable by size).
4. Sent 60 times at 1-second intervals to multicast group `224.0.0.251:5227`.
5. Runs as a cancellable async task - cancelled in `connectRCHost` after `prepareHostSession` returns, once the session is established.
**Listener and discovery** (`discoverRCCtrl`):
1. Host calls `joinMulticast` to subscribe to the multicast group. A shared `TMVar Int` counter tracks active listeners - OS-level `IP_ADD_MEMBERSHIP` is only issued on 0→1 transition, `IP_DROP_MEMBERSHIP` on 1→0. This prevents duplicate syscalls when multiple listeners are active.
2. For each received packet, `findRCCtrlPairing` iterates over known pairings and tries decryption with the current DH key, falling back to `prevDhPrivKey` if present.
3. After successful decryption, the invitation's `dh` field is verified against the announcement's `dhPubKey` to prevent relay attacks.
4. The source IP address is checked against the invitation's `host` field - prevents re-broadcasting a legitimate announcement from a different host.
5. Dual signatures are verified: session signature first, then identity signature.
6. 30-second timeout on the entire discovery process (`RCENotDiscovered` on expiry).
---
## Block framing and padding
**Source**: [RemoteControl/Client.hs](../../src/Simplex/RemoteControl/Client.hs), [RemoteControl/Types.hs](../../src/Simplex/RemoteControl/Types.hs)
XRCP uses three padding sizes at different protocol layers:
- **16,384 bytes** - XRCP block size for all session messages (hello, commands, responses). Matches SMP's block size. Hides message content size variation within the TLS session.
- **12,288 bytes** - hello body padding within the 16,384-byte block, after encryption overhead.
- **900 bytes** - multicast announcement padding. Constrained by typical UDP MTU to avoid fragmentation.
All padding uses the standard `pad`/`unPad` format (2-byte length prefix + `#` fill). The fixed sizes ensure that an observer monitoring network traffic cannot distinguish different XRCP operations by packet size.
-179
View File
@@ -1,179 +0,0 @@
# Client Architecture
SimpleX clients are the Layer 2 libraries that connect to routers. This document shows their internal architecture: component topology and command processing flows.
For deployment and usage, see [docs/CLIENT.md](../docs/CLIENT.md). For protocol specifications, see [SMP](../protocol/simplex-messaging.md), [XFTP](../protocol/xftp.md), [Push Notifications](../protocol/push-notifications.md).
---
## SMP Client (ProtocolClient)
**Four threads**: Send and receive threads are separate to allow backpressure - a slow receiver doesn't block sending. The process thread decouples parsing from delivery, preventing a slow consumer from stalling the receive loop. The monitor thread provides application-level keepalive beyond TCP - detecting protocol-level stalls. See [transport.md](topics/transport.md#connection-management).
**Correlation ID lifecycle**: IDs are generated before send and removed on response OR timeout. Removal on timeout prevents unbounded growth of `sentCommands` when the router is unresponsive.
**Module specs**: [Client](modules/Simplex/Messaging/Client.md) · [Protocol](modules/Simplex/Messaging/Protocol.md) · [Transport](modules/Simplex/Messaging/Transport.md) · [Crypto](modules/Simplex/Messaging/Crypto.md)
Generic protocol client used for both SMP and NTF connections. Manages a single TLS connection with multiplexed command/response matching via correlation IDs.
### SMP Client components
![SMP Client components](diagrams/smp-client.svg)
### Command/result flow
```mermaid
sequenceDiagram
participant C as Caller<br>(Agent / router)
box ProtocolClient
participant SC as sentCommands<br>(TMap CorrId Request)
participant SQ as sndQ
participant S as send thread
participant R as receive thread
participant RQ as rcvQ
participant P as process thread
end
participant Router as SMP Router
C->>SC: mkTransmission<br/>(generate CorrId, create Request<br/>with empty responseVar)
C->>SQ: write (Request, encoded command)
S->>SQ: read
S-->>S: check pending flag (drop if timed out)
S->>Router: tPutLog (transmit bytes)
Router->>R: tGetClient (receive batch)
R->>RQ: write transmissions
P->>RQ: read
P->>SC: lookup CorrId
alt command response (CorrId matches, pending)
P->>SC: remove CorrId + fill responseVar (TMVar)
else expired response (CorrId matches, already timed out)
P->>C: write to msgQ (STResponse)
else server event (empty CorrId)
P->>C: write to msgQ (STEvent)
end
Note over C: getResponse: takeTMVar with timeout
```
---
## SMPClientAgent
**Dual consumers**: Used by both SMP router (for proxy connections to relays) and NTF router (for NSUB subscriptions to SMP routers). Same connection pooling and reconnection logic, different command sets.
**Session ID gating**: Subscription responses are validated against the current TLS session ID. A response from a stale session (connection dropped and reconnected between send and receive) is discarded rather than corrupting state. See [infrastructure.md](agent/infrastructure.md#subscription-tracking).
**Module specs**: [Client Agent](modules/Simplex/Messaging/Client/Agent.md)
Connection manager that multiplexes multiple ProtocolClient connections. Tracks subscriptions, handles reconnection with backoff, and forwards server messages and connection events upward. Used by SMP router (proxying) and NTF router (subscriptions).
### SMPClientAgent components
![SMPClientAgent components](diagrams/smp-client-agent.svg)
### Connection lifecycle
```mermaid
sequenceDiagram
participant C as Consumer<br>(router / app)
box
participant A as SMPClientAgent
participant PC as ProtocolClient
end
participant Router as SMP Router
C->>A: getSMPServerClient'' (server)
alt client exists in smpClients
A->>C: return existing client
else no client
A->>PC: connectClient (create new ProtocolClient)
PC->>Router: TLS handshake
A->>A: register disconnect handler
A->>C: return new client
end
C->>A: subscribeQueuesNtfs (queueIds)
A->>A: add to pendingQueueSubs
A->>PC: sendProtocolCommands (SUB batch)
PC->>Router: SUB commands
Router->>PC: OK responses
A->>A: move pending → activeQueueSubs
A->>C: CASubscribed (via agentQ)
Note over Router: connection drops
PC->>A: disconnect handler fires
A->>A: filter by SessionId (only remove subs matching disconnected session)
A->>A: move active → pending (queue subs + service subs)
A->>C: CAServiceDisconnected (via agentQ, if service sub existed)
A->>C: CADisconnected (via agentQ, if queue subs existed)
A->>A: spawn smpSubWorker (retry with backoff)
A->>PC: reconnect + resubscribe pending subs
A->>C: CAConnected + CASubscribed (via agentQ)
```
---
## XFTP Client
**No subscriptions**: File operations complete independently - no persistent server-side state to track. This allows XFTPClient to be a thin wrapper with no threads of its own.
**Module specs**: [Client](modules/Simplex/FileTransfer/Client.md) · [Protocol](modules/Simplex/FileTransfer/Protocol.md) · [HTTP/2 Client](modules/Simplex/Messaging/Transport/HTTP2/Client.md)
Stateless wrapper around HTTP2Client. XFTPClient adds no threads of its own. Serialization and multiplexing happen inside HTTP2Client's internal request queue and process thread.
### XFTP Client components
![XFTP Client components](diagrams/xftp-client.svg)
### Packet delivery flow
```mermaid
sequenceDiagram
participant C as Caller<br>(Agent / app)
participant X as XFTPClient
participant H as HTTP2Client
participant Router as XFTP Router
C->>X: createXFTPChunk (FNEW)
X->>H: HTTP/2 POST (encoded command)
H->>Router: request
Router->>H: response (sender ID + recipient IDs)
H->>X: decode response
X->>C: return IDs
C->>X: uploadXFTPChunk (FPUT + file data)
X->>H: HTTP/2 POST (streaming body)
H->>Router: request with file stream
Router->>H: OK
H->>X: OK
X->>C: return OK
C->>X: downloadXFTPChunk (FGET + ephemeral DH key)
X->>H: HTTP/2 POST (command)
H->>Router: request
Router->>H: streaming response (server DH key + nonce + encrypted data)
H->>X: streaming body
X->>X: compute DH secret, decrypt + save to file
X->>C: return ()
```
---
## NTF Client
**Module specs**: [Client](modules/Simplex/Messaging/Notifications/Client.md) · [Protocol](modules/Simplex/Messaging/Notifications/Protocol.md)
Type alias for ProtocolClient - same architecture as SMP Client:
```haskell
type NtfClient = ProtocolClient NTFVersion ErrorType NtfResponse
```
Same threads (send, receive, process, monitor), same queues (sndQ, rcvQ, sentCommands, msgQ), same command/response flow. Different command types: TNEW, TVFY, TCHK, TRPL, TDEL, TCRN, SNEW, SCHK, SDEL, PING.
-288
View File
@@ -1,288 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 780" font-family="monospace" font-size="12">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#333" />
</marker>
<marker id="arr-g" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</marker>
<marker id="arr-x" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#c62828" />
</marker>
</defs>
<!-- ===== ROW 0: APPLICATION (external) ===== -->
<rect x="350" y="10" width="180" height="34" rx="4"
fill="#fce8e6" stroke="#ea4335" />
<text x="440" y="32" text-anchor="middle">Application</text>
<!-- subQ queue box (right of Application) -->
<rect x="560" y="10" width="120" height="34" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="620" y="25" text-anchor="middle" font-weight="bold">subQ</text>
<text x="620" y="39" text-anchor="middle" font-size="9">(TBQueue)</text>
<!-- subQ -> Application -->
<line x1="560" y1="27" x2="532" y2="27"
stroke="#333" marker-end="url(#arr)" />
<!-- ===== subQ BUS: three stubs up from columns → horizontal → up → into subQ ===== -->
<!-- Three vertical stubs from column tops up to bus at y=142 -->
<line x1="172" y1="158" x2="172" y2="142" stroke="#333" />
<line x1="483" y1="158" x2="483" y2="142" stroke="#333" />
<line x1="788" y1="158" x2="788" y2="142" stroke="#333" />
<!-- Bus: horizontal across, right margin up, into subQ -->
<polyline points="172,142 940,142 940,27 682,27"
fill="none" stroke="#333" marker-end="url(#arr)" />
<!-- ===== App -> API arrows (down) ===== -->
<line x1="400" y1="44" x2="170" y2="76"
stroke="#999" marker-end="url(#arr-g)" />
<line x1="440" y1="44" x2="480" y2="76"
stroke="#999" marker-end="url(#arr-g)" />
<line x1="480" y1="44" x2="786" y2="76"
stroke="#999" marker-end="url(#arr-g)" />
<!-- ===== ROW 1: API FUNCTIONS ===== -->
<!-- SMP API -->
<rect x="25" y="76" width="290" height="50" rx="4"
fill="#f4f4f4" stroke="#999" />
<text x="170" y="94" text-anchor="middle" font-size="10">sendMessage, createConnection</text>
<text x="170" y="109" text-anchor="middle" font-size="10">joinConnection, subscribe...</text>
<!-- XFTP API -->
<rect x="335" y="76" width="290" height="50" rx="4"
fill="#f4f4f4" stroke="#999" />
<text x="480" y="94" text-anchor="middle" font-size="10">xftpSendFile</text>
<text x="480" y="109" text-anchor="middle" font-size="10">xftpReceiveFile...</text>
<!-- NTF API -->
<rect x="641" y="76" width="290" height="50" rx="4"
fill="#f4f4f4" stroke="#999" />
<text x="786" y="94" text-anchor="middle" font-size="10">registerNtfToken</text>
<text x="786" y="109" text-anchor="middle" font-size="10">toggleConnectionNtfs...</text>
<!-- ===== ROW 2: THREE PROTOCOL COLUMNS ===== -->
<!-- ====== SMP COLUMN ====== -->
<rect x="20" y="158" width="305" height="260" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="30" y="174" fill="#888" font-size="10">SMP</text>
<!-- msgQ queue box (inside SMP column, left side) -->
<rect x="35" y="180" width="110" height="30" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="90" y="194" text-anchor="middle" font-weight="bold" font-size="10">msgQ</text>
<text x="90" y="206" text-anchor="middle" font-size="8">(TBQueue)</text>
<!-- subscriber (green, singleton, right side) -->
<rect x="160" y="180" width="150" height="44" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="235" y="198" text-anchor="middle">subscriber</text>
<text x="235" y="212" text-anchor="middle" font-size="9" fill="#666">(reads msgQ)</text>
<!-- msgQ -> subscriber -->
<line x1="147" y1="198" x2="158" y2="198"
stroke="#333" marker-end="url(#arr)" />
<!-- SMP worker pools label -->
<text x="35" y="246" fill="#888" font-size="9">worker pools (on-demand, one per queue / conn+server / session)</text>
<!-- delivery worker -->
<rect x="35" y="256" width="128" height="38" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="99" y="273" text-anchor="middle" font-size="11">delivery</text>
<text x="99" y="286" text-anchor="middle" font-size="8" fill="#666">(per send queue)</text>
<!-- asyncCmd worker -->
<rect x="175" y="256" width="135" height="38" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="242" y="273" text-anchor="middle" font-size="11">asyncCmd</text>
<text x="242" y="286" text-anchor="middle" font-size="8" fill="#666">(per conn + server)</text>
<!-- smpSub worker -->
<rect x="35" y="308" width="128" height="38" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="99" y="325" text-anchor="middle" font-size="11">smpSub</text>
<text x="99" y="338" text-anchor="middle" font-size="8" fill="#666">(per session)</text>
<!-- ====== XFTP COLUMN ====== -->
<rect x="335" y="158" width="296" height="260" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="345" y="174" fill="#888" font-size="10">XFTP</text>
<!-- XFTP worker pools label -->
<text x="345" y="246" fill="#888" font-size="9">worker pools (on-demand, one per server)</text>
<!-- xftpRcv worker -->
<rect x="350" y="256" width="130" height="38" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="415" y="273" text-anchor="middle" font-size="11">xftpRcv</text>
<text x="415" y="286" text-anchor="middle" font-size="8" fill="#666">(per server + local)</text>
<!-- xftpSnd worker -->
<rect x="490" y="256" width="130" height="38" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="555" y="273" text-anchor="middle" font-size="11">xftpSnd</text>
<text x="555" y="286" text-anchor="middle" font-size="8" fill="#666">(per server + local)</text>
<!-- xftpDel worker -->
<rect x="350" y="308" width="130" height="38" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="415" y="325" text-anchor="middle" font-size="11">xftpDel</text>
<text x="415" y="338" text-anchor="middle" font-size="8" fill="#666">(per server)</text>
<!-- ====== NTF COLUMN ====== -->
<rect x="641" y="158" width="295" height="260" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="651" y="174" fill="#888" font-size="10">NTF</text>
<!-- ntfSubQ queue box (inside NTF column, left side) -->
<rect x="656" y="180" width="88" height="30" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="700" y="194" text-anchor="middle" font-weight="bold" font-size="10">ntfSubQ</text>
<text x="700" y="206" text-anchor="middle" font-size="8">(TBQueue)</text>
<!-- ntfSupervisor (green, singleton, right side) -->
<rect x="756" y="180" width="168" height="44" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="840" y="198" text-anchor="middle">ntfSupervisor</text>
<text x="840" y="212" text-anchor="middle" font-size="9" fill="#666">(reads ntfSubQ)</text>
<!-- ntfSubQ -> ntfSupervisor -->
<line x1="746" y1="198" x2="754" y2="198"
stroke="#333" marker-end="url(#arr)" />
<!-- NTF API -> ntfSubQ -->
<line x1="700" y1="126" x2="700" y2="178"
stroke="#333" marker-end="url(#arr)" />
<!-- NTF worker pools label -->
<text x="656" y="246" fill="#888" font-size="9">worker pools (on-demand, one per server)</text>
<!-- ntfWorkers -->
<rect x="656" y="256" width="125" height="38" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="718" y="273" text-anchor="middle" font-size="11">ntfWorkers</text>
<text x="718" y="286" text-anchor="middle" font-size="8" fill="#666">(per NTF server)</text>
<!-- ntfSMP worker -->
<rect x="793" y="256" width="128" height="38" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="857" y="273" text-anchor="middle" font-size="11">ntfSMP</text>
<text x="857" y="286" text-anchor="middle" font-size="8" fill="#666">(per SMP server)</text>
<!-- ntfTknDel worker -->
<rect x="656" y="308" width="125" height="38" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="718" y="325" text-anchor="middle" font-size="11">ntfTknDel</text>
<text x="718" y="338" text-anchor="middle" font-size="8" fill="#666">(per NTF server)</text>
<!-- ===== CROSS-PROTOCOL: asyncCmd -> ntfSubQ (ICQDelete) ===== -->
<polyline points="310,272 500,195 654,195"
fill="none" stroke="#c62828" stroke-dasharray="4,3" marker-end="url(#arr-x)" />
<text x="420" y="218" font-size="9" fill="#c62828">ntfSubQ (queue rotation)</text>
<!-- ===== SHARED SINGLETON THREADS ===== -->
<text x="30" y="448" fill="#888" font-size="9">shared singletons (all green run in raceAny_)</text>
<rect x="320" y="436" width="135" height="24" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="387" y="452" text-anchor="middle" font-size="10">cleanupManager</text>
<rect x="470" y="436" width="135" height="24" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="537" y="452" text-anchor="middle" font-size="10">logServersStats</text>
<!-- ===== CROSS-PROTOCOL: ntfSMP -> smpClients ===== -->
<polyline points="857,294 857,482 170,482 170,558"
fill="none" stroke="#c62828" stroke-dasharray="4,3" marker-end="url(#arr-x)" />
<text x="500" y="478" font-size="9" fill="#c62828">ntfSMP uses smpClients</text>
<!-- ===== ROW 3: CENTRAL STATE ===== -->
<rect x="25" y="494" width="450" height="46" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="250" y="514" text-anchor="middle" font-weight="bold">Store</text>
<text x="250" y="530" text-anchor="middle" font-size="10">(SQLite / Postgres)</text>
<rect x="490" y="494" width="445" height="46" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="712" y="514" text-anchor="middle" font-weight="bold">Operation State</text>
<text x="712" y="530" text-anchor="middle" font-size="10">(5-op suspension cascade)</text>
<!-- store access: representative dashed line -->
<line x1="242" y1="294" x2="220" y2="492"
stroke="#555" stroke-dasharray="3,2" marker-end="url(#arr)" />
<text x="248" y="400" font-size="9" fill="#555">store</text>
<!-- ===== ROW 4: PROTOCOL CLIENT POOLS ===== -->
<rect x="25" y="558" width="290" height="46" rx="4"
fill="#fce8e6" stroke="#ea4335" />
<text x="170" y="578" text-anchor="middle">smpClients</text>
<text x="170" y="594" text-anchor="middle" font-size="9" fill="#666">(TMap SMPTransportSession)</text>
<rect x="335" y="558" width="290" height="46" rx="4"
fill="#fce8e6" stroke="#ea4335" />
<text x="480" y="578" text-anchor="middle">xftpClients</text>
<text x="480" y="594" text-anchor="middle" font-size="9" fill="#666">(TMap XFTPTransportSession)</text>
<rect x="641" y="558" width="290" height="46" rx="4"
fill="#fce8e6" stroke="#ea4335" />
<text x="786" y="578" text-anchor="middle">ntfClients</text>
<text x="786" y="594" text-anchor="middle" font-size="9" fill="#666">(TMap NtfTransportSession)</text>
<!-- ===== ROW 5: EXTERNAL ROUTERS ===== -->
<text x="170" y="630" text-anchor="middle" font-size="9" fill="#999">SMP Routers</text>
<line x1="170" y1="604" x2="170" y2="620"
stroke="#999" marker-end="url(#arr-g)" />
<text x="480" y="630" text-anchor="middle" font-size="9" fill="#999">XFTP Routers</text>
<line x1="480" y1="604" x2="480" y2="620"
stroke="#999" marker-end="url(#arr-g)" />
<text x="786" y="630" text-anchor="middle" font-size="9" fill="#999">NTF Routers</text>
<line x1="786" y1="604" x2="786" y2="620"
stroke="#999" marker-end="url(#arr-g)" />
<!-- ===== smpClients -> msgQ (left margin) ===== -->
<polyline points="25,581 8,581 8,195 33,195"
fill="none" stroke="#333" marker-end="url(#arr)" />
<!-- ===== LEGEND ===== -->
<rect x="20" y="654" width="920" height="60" rx="4"
fill="none" stroke="#ddd" />
<rect x="35" y="668" width="14" height="11" rx="2"
fill="#e8f0fe" stroke="#4285f4" />
<text x="55" y="678" font-size="10">on-demand worker</text>
<rect x="185" y="668" width="14" height="11" rx="2"
fill="#e6f4ea" stroke="#34a853" />
<text x="205" y="678" font-size="10">singleton thread</text>
<rect x="325" y="668" width="14" height="11" rx="2"
fill="#fef7e0" stroke="#f9ab00" />
<text x="345" y="678" font-size="10">queue / state</text>
<rect x="445" y="668" width="14" height="11" rx="2"
fill="#fce8e6" stroke="#ea4335" />
<text x="465" y="678" font-size="10">external connection</text>
<rect x="595" y="668" width="14" height="11" rx="2"
fill="#f4f4f4" stroke="#999" />
<text x="615" y="678" font-size="10">API entry point</text>
<line x1="730" y1="673" x2="770" y2="673"
stroke="#c62828" stroke-dasharray="4,3" marker-end="url(#arr-x)" />
<text x="780" y="677" font-size="10">cross-protocol</text>
<text x="35" y="704" font-size="10" fill="#666">
Solid arrows: TBQueue flow. Dashed grey: store access. Dashed red: cross-protocol link. All threads and pools write events to subQ.
</text>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

-210
View File
@@ -1,210 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 830 610" font-family="monospace" font-size="12">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#333" />
</marker>
<marker id="arr-g" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</marker>
</defs>
<!-- ===== PER-CLIENT GROUP (left) ===== -->
<rect x="25" y="40" width="300" height="170" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="35" y="55" fill="#888" font-size="10">per client (raceAny_)</text>
<!-- network in -->
<text x="13" y="78" font-size="9" fill="#999">net</text>
<line x1="12" y1="82" x2="40" y2="82"
stroke="#999" marker-end="url(#arr-g)" />
<!-- receive -->
<rect x="42" y="66" width="78" height="34" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="81" y="88" text-anchor="middle">receive</text>
<!-- rcvQ -->
<line x1="120" y1="83" x2="158" y2="83"
stroke="#333" marker-end="url(#arr)" />
<text x="139" y="77" text-anchor="middle" font-size="10" fill="#555">rcvQ</text>
<!-- client -->
<rect x="160" y="66" width="78" height="34" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="199" y="88" text-anchor="middle">client</text>
<!-- sndQ -->
<line x1="238" y1="83" x2="268" y2="83"
stroke="#333" marker-end="url(#arr)" />
<text x="253" y="77" text-anchor="middle" font-size="10" fill="#555">sndQ</text>
<!-- send -->
<rect x="270" y="66" width="48" height="34" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="294" y="88" text-anchor="middle">send</text>
<!-- network out -->
<text x="326" y="88" font-size="9" fill="#999">net</text>
<!-- commands label -->
<text x="52" y="120" font-size="10" fill="#666">TNEW, TVFY, TCHK, TRPL, TDEL, TCRN</text>
<text x="52" y="134" font-size="10" fill="#666">SNEW, SCHK, SDEL, PING</text>
<!-- client -> store (down) -->
<line x1="199" y1="100" x2="199" y2="278"
stroke="#555" stroke-dasharray="3,2" marker-end="url(#arr)" />
<text x="211" y="230" font-size="10" fill="#555">store</text>
<!-- client -> pushQ (PNVerification on TNEW/TRPL) -->
<line x1="160" y1="100" x2="50" y2="388"
stroke="#333" marker-end="url(#arr)" />
<text x="62" y="260" font-size="9" fill="#555">pushQ</text>
<!-- ===== SMP CLIENT AGENT GROUP (right) ===== -->
<rect x="370" y="40" width="435" height="220" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="380" y="55" fill="#888" font-size="10">SMP Client Agent (connects to SMP routers)</text>
<!-- SMP routers external label -->
<text x="620" y="38" font-size="9" fill="#999">SMP routers</text>
<line x1="620" y1="40" x2="620" y2="62"
stroke="#999" marker-end="url(#arr-g)" />
<!-- SMPClientAgent -->
<rect x="530" y="64" width="220" height="34" rx="4"
fill="#fce8e6" stroke="#ea4335" />
<text x="640" y="86" text-anchor="middle">SMPClientAgent</text>
<!-- msgQ arrow: SMPClientAgent -> receiveSMP -->
<line x1="580" y1="98" x2="510" y2="128"
stroke="#333" marker-end="url(#arr)" />
<text x="530" y="112" font-size="10" fill="#555">msgQ</text>
<!-- agentQ arrow: SMPClientAgent -> receiveAgent -->
<line x1="690" y1="98" x2="690" y2="128"
stroke="#333" marker-end="url(#arr)" />
<text x="702" y="117" font-size="10" fill="#555">agentQ</text>
<!-- ntfSubscriber/receiveSMP -->
<rect x="390" y="130" width="195" height="34" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="487" y="152" text-anchor="middle" font-size="11">ntfSubscriber/receiveSMP</text>
<!-- ntfSubscriber/receiveAgent -->
<rect x="605" y="130" width="185" height="34" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="697" y="152" text-anchor="middle" font-size="11">receiveAgent</text>
<!-- race_ label between them -->
<text x="597" y="144" font-size="9" fill="#888">race_</text>
<!-- receiveSMP -> pushQ (down) -->
<line x1="487" y1="164" x2="487" y2="195"
stroke="#333" marker-end="url(#arr)" />
<text x="500" y="183" font-size="10" fill="#555">pushQ</text>
<!-- receiveAgent -> store (down) -->
<line x1="697" y1="164" x2="697" y2="278"
stroke="#555" stroke-dasharray="3,2" marker-end="url(#arr)" />
<text x="709" y="230" font-size="10" fill="#555">store</text>
<!-- per-SMP-server subscriber -->
<rect x="390" y="200" width="200" height="44" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="490" y="219" text-anchor="middle" font-size="11">runSMPSubscriber</text>
<text x="490" y="236" text-anchor="middle" font-size="9" fill="#666">(one per SMP router)</text>
<!-- subscriberSubQ label -->
<text x="408" y="196" font-size="9" fill="#555">subscriberSubQ</text>
<!-- ===== CENTRAL STORE ===== -->
<rect x="100" y="280" width="620" height="50" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="400" y="301" text-anchor="middle" font-weight="bold">tokens / subscriptions / tokenLastNtfs</text>
<text x="400" y="319" text-anchor="middle" font-size="10">(PostgreSQL)</text>
<!-- ===== PUSH DELIVERY ===== -->
<rect x="25" y="355" width="780" height="90" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="35" y="370" fill="#888" font-size="10">push delivery pipeline</text>
<!-- pushQ hub: store -> ntfPush -->
<line x1="400" y1="330" x2="400" y2="388"
stroke="#333" marker-end="url(#arr)" />
<text x="412" y="365" font-size="10" fill="#555">pushQ</text>
<!-- ntfPush -->
<rect x="350" y="390" width="110" height="34" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="405" y="412" text-anchor="middle">ntfPush</text>
<!-- APNS provider -->
<line x1="460" y1="407" x2="528" y2="407"
stroke="#333" marker-end="url(#arr)" />
<rect x="530" y="390" width="135" height="34" rx="4"
fill="#fce8e6" stroke="#ea4335" />
<text x="597" y="412" text-anchor="middle">APNS provider</text>
<!-- periodicNtfsThread -->
<rect x="45" y="390" width="175" height="34" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="132" y="412" text-anchor="middle" font-size="11">periodicNtfsThread</text>
<!-- periodic -> pushQ -->
<line x1="220" y1="407" x2="348" y2="407"
stroke="#333" marker-end="url(#arr)" />
<text x="284" y="401" font-size="10" fill="#555">pushQ</text>
<!-- periodic reads store -->
<line x1="132" y1="390" x2="132" y2="330"
stroke="#555" stroke-dasharray="3,2" marker-end="url(#arr)" />
<text x="117" y="365" font-size="9" fill="#555">reads</text>
<!-- ===== OPTIONAL ===== -->
<rect x="25" y="462" width="780" height="45" rx="6"
fill="none" stroke="#bbb" stroke-dasharray="4,3" />
<text x="35" y="477" fill="#bbb" font-size="10">optional</text>
<rect x="45" y="484" width="128" height="18" rx="3"
fill="#f5f5f5" stroke="#bbb" />
<text x="109" y="497" text-anchor="middle" font-size="9">logServerStats</text>
<rect x="190" y="484" width="108" height="18" rx="3"
fill="#f5f5f5" stroke="#bbb" />
<text x="244" y="497" text-anchor="middle" font-size="9">prometheus</text>
<rect x="315" y="484" width="108" height="18" rx="3"
fill="#f5f5f5" stroke="#bbb" />
<text x="369" y="497" text-anchor="middle" font-size="9">controlPort</text>
<rect x="440" y="484" width="108" height="18" rx="3"
fill="#f5f5f5" stroke="#bbb" />
<text x="494" y="497" text-anchor="middle" font-size="9">resubscribe</text>
<!-- ===== LEGEND ===== -->
<rect x="25" y="525" width="780" height="55" rx="4"
fill="none" stroke="#ddd" />
<rect x="40" y="540" width="14" height="11" rx="2"
fill="#e8f0fe" stroke="#4285f4" />
<text x="60" y="550" font-size="10">per-client thread</text>
<rect x="195" y="540" width="14" height="11" rx="2"
fill="#e6f4ea" stroke="#34a853" />
<text x="215" y="550" font-size="10">singleton thread</text>
<rect x="350" y="540" width="14" height="11" rx="2"
fill="#fef7e0" stroke="#f9ab00" />
<text x="370" y="550" font-size="10">storage</text>
<rect x="445" y="540" width="14" height="11" rx="2"
fill="#fce8e6" stroke="#ea4335" />
<text x="465" y="550" font-size="10">external connection</text>
<text x="40" y="572" font-size="10" fill="#666">
Solid arrows: TBQueue connections. Dashed: store access.
</text>
</svg>

Before

Width:  |  Height:  |  Size: 8.4 KiB

-143
View File
@@ -1,143 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 740 400" font-family="monospace" font-size="12">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#333" />
</marker>
<marker id="arr-g" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</marker>
</defs>
<!-- ===== CONSUMER (top left) ===== -->
<text x="15" y="35" font-size="9" fill="#999">consumer</text>
<text x="15" y="48" font-size="9" fill="#999">(NTF router /</text>
<text x="15" y="61" font-size="9" fill="#999"> SMP proxy /</text>
<text x="15" y="74" font-size="9" fill="#999"> application)</text>
<!-- ===== agentQ (upper right) ===== -->
<rect x="360" y="15" width="210" height="35" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="465" y="33" text-anchor="middle" font-weight="bold">agentQ</text>
<text x="465" y="46" text-anchor="middle" font-size="10">(TBQueue SMPClientAgentEvent)</text>
<!-- consumer <- agentQ -->
<line x1="360" y1="32" x2="80" y2="32"
stroke="#999" marker-end="url(#arr-g)" />
<!-- agentQ events note -->
<text x="580" y="22" font-size="9" fill="#666">CAConnected / Disconnected</text>
<text x="580" y="33" font-size="9" fill="#666">CASubscribed / SubError</text>
<text x="580" y="44" font-size="9" fill="#666">CAServiceDisconnected</text>
<text x="580" y="55" font-size="9" fill="#666">CAServiceSubscribed / SubError</text>
<text x="580" y="66" font-size="9" fill="#666">CAServiceUnavailable</text>
<!-- ===== msgQ (upper left, below agentQ) ===== -->
<rect x="130" y="55" width="195" height="35" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="227" y="73" text-anchor="middle" font-weight="bold">msgQ</text>
<text x="227" y="86" text-anchor="middle" font-size="10">(TBQueue, server messages)</text>
<!-- consumer <- msgQ -->
<line x1="130" y1="72" x2="80" y2="72"
stroke="#999" marker-end="url(#arr-g)" />
<!-- ===== AGENT CORE ===== -->
<rect x="25" y="100" width="700" height="82" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="35" y="113" fill="#888" font-size="10">SMPClientAgent (connection manager)</text>
<!-- smpClients -->
<rect x="45" y="120" width="170" height="48" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="130" y="141" text-anchor="middle" font-weight="bold">smpClients</text>
<text x="130" y="159" text-anchor="middle" font-size="9">(TMap SMPServer SMPClientVar)</text>
<!-- smpClients -> msgQ (server messages forwarded up) -->
<line x1="145" y1="120" x2="145" y2="92"
stroke="#333" marker-end="url(#arr)" />
<!-- smpSubWorkers -->
<rect x="250" y="120" width="130" height="48" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="315" y="141" text-anchor="middle" font-size="11">smpSubWorkers</text>
<text x="315" y="159" text-anchor="middle" font-size="9" fill="#666">(one per server)</text>
<!-- workers -> smpClients (reconnect, dashed) -->
<line x1="250" y1="144" x2="215" y2="144"
stroke="#555" stroke-dasharray="3,2" marker-end="url(#arr)" />
<text x="222" y="138" font-size="8" fill="#555">reconnect</text>
<!-- subscription tracking -->
<rect x="415" y="118" width="275" height="56" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="552" y="134" text-anchor="middle" font-size="10">activeQueueSubs / pendingQueueSubs</text>
<text x="552" y="146" text-anchor="middle" font-size="9" fill="#666">(TMap SMPServer (TMap QueueId ...))</text>
<text x="552" y="159" text-anchor="middle" font-size="10">activeServiceSubs / pendingServiceSubs</text>
<text x="552" y="171" text-anchor="middle" font-size="9" fill="#666">(TMap SMPServer (TVar (Maybe ...)))</text>
<!-- subscription state -> agentQ (connection events forwarded up) -->
<line x1="465" y1="118" x2="465" y2="52"
stroke="#333" marker-end="url(#arr)" />
<!-- ===== PROTOCOL CLIENTS (bottom) ===== -->
<rect x="25" y="198" width="700" height="72" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="35" y="213" fill="#888" font-size="10">ProtocolClient connections (one per SMP Router)</text>
<!-- Client 1 -->
<rect x="45" y="220" width="140" height="40" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="115" y="238" text-anchor="middle">ProtocolClient</text>
<text x="115" y="253" text-anchor="middle" font-size="10">→ SMP Router A</text>
<!-- Client 2 -->
<rect x="210" y="220" width="140" height="40" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="280" y="238" text-anchor="middle">ProtocolClient</text>
<text x="280" y="253" text-anchor="middle" font-size="10">→ SMP Router B</text>
<!-- Client N -->
<rect x="375" y="220" width="140" height="40" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="445" y="238" text-anchor="middle">ProtocolClient</text>
<text x="445" y="253" text-anchor="middle" font-size="10">→ SMP Router N</text>
<!-- dots -->
<text x="530" y="245" font-size="14" fill="#888">...</text>
<!-- smpClients -> clients -->
<line x1="100" y1="168" x2="100" y2="218"
stroke="#333" marker-end="url(#arr)" />
<line x1="185" y1="168" x2="265" y2="218"
stroke="#333" marker-end="url(#arr)" />
<!-- ===== NOTES ===== -->
<text x="45" y="292" font-size="10" fill="#666">getSMPServerClient'': get or create client</text>
<text x="45" y="306" font-size="10" fill="#666">connectClient: create ProtocolClient, register disconnect handler</text>
<text x="45" y="320" font-size="10" fill="#666">on disconnect: filter by SessionId, move active to pending, notify agentQ, spawn worker</text>
<text x="45" y="334" font-size="10" fill="#666">worker: retry connect with backoff, resubscribe pending subs</text>
<text x="45" y="348" font-size="10" fill="#666">subscribeQueuesNtfs / subscribeServiceNtfs: subscribe + track state</text>
<!-- ===== LEGEND ===== -->
<rect x="25" y="365" width="700" height="30" rx="4"
fill="none" stroke="#ddd" />
<rect x="40" y="374" width="14" height="11" rx="2"
fill="#e8f0fe" stroke="#4285f4" />
<text x="60" y="384" font-size="10">ProtocolClient</text>
<rect x="185" y="374" width="14" height="11" rx="2"
fill="#fef7e0" stroke="#f9ab00" />
<text x="205" y="384" font-size="10">state / queue</text>
<rect x="310" y="374" width="14" height="11" rx="2"
fill="#e6f4ea" stroke="#34a853" />
<text x="330" y="384" font-size="10">background worker</text>
<text x="480" y="384" font-size="10" fill="#666">
Solid arrows: TBQueue flow. Dashed: reconnection.
</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

-170
View File
@@ -1,170 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 380" font-family="monospace" font-size="12">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#333" />
</marker>
<marker id="arr-g" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</marker>
</defs>
<!-- ===== PER-CONNECTION GROUP ===== -->
<rect x="100" y="15" width="435" height="250" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="110" y="30" fill="#888" font-size="10">per connection (raceAny_: any exit tears down all threads)</text>
<!-- ===== MONITOR (optional, above sndQ area) ===== -->
<rect x="275" y="38" width="88" height="24" rx="4"
fill="#f5f5f5" stroke="#bbb" />
<text x="319" y="55" text-anchor="middle" font-size="11">monitor</text>
<text x="370" y="54" font-size="9" fill="#bbb">optional</text>
<!-- monitor -> API (PING via sendProtocolCommand) -->
<line x1="300" y1="62" x2="265" y2="74"
stroke="#555" stroke-dasharray="3,2" marker-end="url(#arr)" />
<text x="305" y="73" font-size="9" fill="#555">PING</text>
<!-- ===== ROW 1: SEND PATH ===== -->
<!-- caller label -->
<text x="15" y="92" font-size="9" fill="#999">caller</text>
<text x="10" y="105" font-size="9" fill="#999">(Agent /</text>
<text x="10" y="118" font-size="9" fill="#999"> router)</text>
<!-- caller -> API -->
<line x1="55" y1="98" x2="143" y2="98"
stroke="#999" marker-end="url(#arr-g)" />
<!-- API functions block -->
<rect x="145" y="76" width="165" height="42" rx="4"
fill="#f4f4f4" stroke="#999" />
<text x="227" y="93" text-anchor="middle" font-size="10">sendProtocolCommand</text>
<text x="227" y="107" text-anchor="middle" font-size="10">mkTransmission</text>
<!-- API -> sndQ -->
<line x1="310" y1="97" x2="335" y2="97"
stroke="#333" marker-end="url(#arr)" />
<!-- sndQ -->
<rect x="337" y="82" width="88" height="34" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="381" y="98" text-anchor="middle" font-weight="bold">sndQ</text>
<text x="381" y="111" text-anchor="middle" font-size="9">(TBQueue, 64)</text>
<!-- sndQ -> send -->
<line x1="425" y1="97" x2="448" y2="97"
stroke="#333" marker-end="url(#arr)" />
<!-- send thread -->
<rect x="450" y="84" width="60" height="28" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="480" y="103" text-anchor="middle">send</text>
<!-- send -> Router (exits per-connection box) -->
<line x1="510" y1="97" x2="543" y2="97"
stroke="#999" marker-end="url(#arr-g)" />
<!-- Router label (outside box) -->
<text x="548" y="88" font-size="9" fill="#999">SMP</text>
<text x="548" y="101" font-size="9" fill="#999">Router</text>
<text x="548" y="114" font-size="9" fill="#999">(TLS)</text>
<!-- ===== ROW 2: RECEIVE PATH + SENTCOMMANDS ===== -->
<!-- API -> sentCommands (insert CorrId + Request) -->
<line x1="227" y1="118" x2="227" y2="148"
stroke="#333" marker-end="url(#arr)" />
<text x="235" y="138" font-size="9" fill="#555">insert</text>
<!-- sentCommands -->
<rect x="145" y="150" width="165" height="38" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="227" y="167" text-anchor="middle" font-weight="bold">sentCommands</text>
<text x="227" y="182" text-anchor="middle" font-size="9">(TMap CorrId Request)</text>
<!-- sentCommands -> caller (responseVar, dashed) -->
<line x1="145" y1="169" x2="55" y2="125"
stroke="#555" stroke-dasharray="3,2" marker-end="url(#arr)" />
<text x="62" y="152" font-size="9" fill="#555">responseVar</text>
<text x="62" y="164" font-size="9" fill="#555">(TMVar)</text>
<!-- Router -> receive (enters per-connection box) -->
<line x1="543" y1="163" x2="510" y2="163"
stroke="#999" marker-end="url(#arr-g)" />
<!-- receive thread -->
<rect x="450" y="150" width="60" height="28" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="480" y="169" text-anchor="middle">receive</text>
<!-- receive -> rcvQ -->
<line x1="450" y1="163" x2="425" y2="163"
stroke="#333" marker-end="url(#arr)" />
<!-- rcvQ -->
<rect x="337" y="148" width="88" height="34" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="381" y="164" text-anchor="middle" font-weight="bold">rcvQ</text>
<text x="381" y="177" text-anchor="middle" font-size="9">(TBQueue, 64)</text>
<!-- ===== ROW 3: PROCESS ===== -->
<!-- rcvQ -> process -->
<line x1="370" y1="182" x2="300" y2="216"
stroke="#333" marker-end="url(#arr)" />
<!-- process -> sentCommands (match CorrId, dashed upward) -->
<line x1="245" y1="218" x2="245" y2="190"
stroke="#555" stroke-dasharray="3,2" marker-end="url(#arr)" />
<text x="252" y="208" font-size="9" fill="#555">match</text>
<!-- process thread -->
<rect x="195" y="220" width="110" height="28" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="250" y="239" text-anchor="middle">process</text>
<!-- ===== msgQ (outside per-connection box) ===== -->
<!-- process -> msgQ -->
<line x1="250" y1="248" x2="250" y2="278"
stroke="#333" marker-end="url(#arr)" />
<text x="258" y="268" font-size="9" fill="#555">events</text>
<!-- msgQ -->
<rect x="175" y="280" width="160" height="33" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="255" y="296" text-anchor="middle" font-weight="bold">msgQ (optional)</text>
<text x="255" y="308" text-anchor="middle" font-size="9">(TBQueue, server events)</text>
<!-- msgQ -> consumer -->
<line x1="175" y1="296" x2="55" y2="296"
stroke="#999" marker-end="url(#arr-g)" />
<text x="65" y="290" font-size="9" fill="#999">to Agent / SMPClientAgent</text>
<!-- ===== LEGEND ===== -->
<rect x="25" y="325" width="590" height="48" rx="4"
fill="none" stroke="#ddd" />
<rect x="40" y="337" width="14" height="11" rx="2"
fill="#e8f0fe" stroke="#4285f4" />
<text x="60" y="347" font-size="10">thread</text>
<rect x="130" y="337" width="14" height="11" rx="2"
fill="#fef7e0" stroke="#f9ab00" />
<text x="150" y="347" font-size="10">queue / state</text>
<rect x="270" y="337" width="14" height="11" rx="2"
fill="#f4f4f4" stroke="#999" />
<text x="290" y="347" font-size="10">API entry point</text>
<rect x="410" y="337" width="14" height="11" rx="2"
fill="#f5f5f5" stroke="#bbb" />
<text x="430" y="347" font-size="10">optional</text>
<text x="40" y="365" font-size="10" fill="#666">
Solid arrows: TBQueue flow. Dashed: STM lookups / TMVar responses.
</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

-193
View File
@@ -1,193 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 590" font-family="monospace" font-size="12">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#333" />
</marker>
<marker id="arr-g" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</marker>
</defs>
<!-- ===== PER-CLIENT GROUP ===== -->
<rect x="25" y="38" width="570" height="210" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="35" y="53" fill="#888" font-size="10">per client connection (raceAny_: any exit tears down connection)</text>
<!-- receive -->
<rect x="45" y="68" width="90" height="34" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="90" y="90" text-anchor="middle">receive</text>
<!-- rcvQ arrow: receive -> client -->
<line x1="135" y1="85" x2="198" y2="85"
stroke="#333" marker-end="url(#arr)" />
<text x="166" y="79" text-anchor="middle" font-size="10" fill="#555">rcvQ</text>
<!-- client -->
<rect x="200" y="68" width="100" height="34" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="250" y="90" text-anchor="middle">client</text>
<!-- sndQ arrow: client -> send -->
<line x1="300" y1="78" x2="388" y2="78"
stroke="#333" marker-end="url(#arr)" />
<text x="344" y="72" text-anchor="middle" font-size="10" fill="#555">sndQ</text>
<!-- send -->
<rect x="390" y="62" width="70" height="34" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="425" y="84" text-anchor="middle">send</text>
<!-- msgQ arrow: client -> sendMsg -->
<line x1="300" y1="93" x2="388" y2="113"
stroke="#333" marker-end="url(#arr)" />
<text x="336" y="112" text-anchor="middle" font-size="10" fill="#555">msgQ</text>
<!-- sendMsg -->
<rect x="390" y="102" width="90" height="34" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="435" y="124" text-anchor="middle">sendMsg</text>
<!-- network in label + arrow -->
<text x="13" y="81" font-size="9" fill="#999">net</text>
<line x1="12" y1="85" x2="45" y2="85"
stroke="#999" marker-end="url(#arr-g)" />
<!-- network out arrows -->
<line x1="460" y1="79" x2="498" y2="79"
stroke="#999" marker-end="url(#arr-g)" />
<line x1="480" y1="119" x2="498" y2="119"
stroke="#999" marker-end="url(#arr-g)" />
<text x="504" y="100" font-size="9" fill="#999">net</text>
<!-- client -> QueueStore (down-left) -->
<line x1="228" y1="102" x2="130" y2="150"
stroke="#333" marker-end="url(#arr)" />
<!-- client -> MsgStore (down-right) -->
<line x1="278" y1="102" x2="395" y2="150"
stroke="#333" marker-end="url(#arr)" />
<!-- QueueStore -->
<rect x="45" y="150" width="155" height="48" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="122" y="171" text-anchor="middle" font-weight="bold">QueueStore</text>
<text x="122" y="189" text-anchor="middle" font-size="10">(STM or Postgres)</text>
<!-- MsgStore -->
<rect x="320" y="150" width="155" height="48" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="397" y="171" text-anchor="middle" font-weight="bold">MsgStore</text>
<text x="397" y="189" text-anchor="middle" font-size="10">(STM or Postgres)</text>
<!-- StoreLog under QueueStore only (queues are logged, msgs stored directly) -->
<rect x="45" y="208" width="155" height="18" rx="3"
fill="#f5f5f5" stroke="#ccc" />
<text x="122" y="221" text-anchor="middle" font-size="9" fill="#888">StoreLog (optional)</text>
<!-- NtfStore under MsgStore (in-memory notification queue, read by deliverNtfs/expireNtfs) -->
<rect x="320" y="208" width="155" height="18" rx="3"
fill="#fef7e0" stroke="#f9ab00" />
<text x="397" y="221" text-anchor="middle" font-size="9" fill="#888">NtfStore (STM TMap)</text>
<!-- subQ arrow: from client bottom-center, through gap between stores, to serverThread -->
<!-- Gap between stores: x=200..320 -->
<line x1="250" y1="102" x2="250" y2="296"
stroke="#4285f4" stroke-width="1.5" stroke-dasharray="5,3"
marker-end="url(#arr)" />
<text x="262" y="238" font-size="10" fill="#4285f4">subQ</text>
<!-- ===== SINGLETON GROUP ===== -->
<rect x="25" y="258" width="810" height="195" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="35" y="273" fill="#888" font-size="10">singleton threads (one instance each, all in raceAny_)</text>
<!-- serverThread (SMP subs) -->
<rect x="45" y="288" width="175" height="46" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="132" y="308" text-anchor="middle">serverThread</text>
<text x="132" y="324" text-anchor="middle" font-size="10">(SMP subscriptions)</text>
<!-- serverThread (NTF subs) -->
<rect x="240" y="288" width="175" height="46" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="327" y="308" text-anchor="middle">serverThread</text>
<text x="327" y="324" text-anchor="middle" font-size="10">(NTF subscriptions)</text>
<!-- pendingEvents: serverThread -> sendPendingEvts -->
<line x1="195" y1="334" x2="230" y2="355"
stroke="#555" stroke-dasharray="3,2" marker-end="url(#arr)" />
<text x="176" y="352" font-size="9" fill="#555">pendingEvents</text>
<!-- deliverNtfs -->
<rect x="45" y="352" width="128" height="30" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="109" y="372" text-anchor="middle" font-size="11">deliverNtfs</text>
<!-- sendPendingEvts -->
<rect x="195" y="352" width="148" height="30" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="269" y="372" text-anchor="middle" font-size="11">sendPendingEvts</text>
<!-- expireMessages -->
<rect x="365" y="352" width="138" height="30" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="434" y="372" text-anchor="middle" font-size="11">expireMessages</text>
<!-- expireNtfs -->
<rect x="45" y="396" width="115" height="30" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="102" y="416" text-anchor="middle" font-size="11">expireNtfs</text>
<!-- proxyAgent -->
<rect x="180" y="396" width="118" height="30" rx="4"
fill="#e6f4ea" stroke="#34a853" />
<text x="239" y="416" text-anchor="middle" font-size="11">proxyAgent</text>
<!-- ===== OPTIONAL (nested inside singleton, right side) ===== -->
<rect x="610" y="278" width="215" height="130" rx="6"
fill="none" stroke="#bbb" stroke-dasharray="4,3" />
<text x="620" y="293" fill="#bbb" font-size="10">optional</text>
<!-- logServerStats -->
<rect x="625" y="302" width="140" height="24" rx="3"
fill="#f5f5f5" stroke="#bbb" />
<text x="695" y="319" text-anchor="middle" font-size="10">logServerStats</text>
<!-- prometheus -->
<rect x="625" y="336" width="140" height="24" rx="3"
fill="#f5f5f5" stroke="#bbb" />
<text x="695" y="353" text-anchor="middle" font-size="10">prometheus</text>
<!-- controlPort -->
<rect x="625" y="370" width="140" height="24" rx="3"
fill="#f5f5f5" stroke="#bbb" />
<text x="695" y="387" text-anchor="middle" font-size="10">controlPort</text>
<!-- ===== LEGEND ===== -->
<rect x="25" y="472" width="810" height="60" rx="4"
fill="none" stroke="#ddd" />
<rect x="40" y="486" width="14" height="11" rx="2"
fill="#e8f0fe" stroke="#4285f4" />
<text x="60" y="496" font-size="10">per-client thread</text>
<rect x="195" y="486" width="14" height="11" rx="2"
fill="#e6f4ea" stroke="#34a853" />
<text x="215" y="496" font-size="10">singleton thread</text>
<rect x="350" y="486" width="14" height="11" rx="2"
fill="#fef7e0" stroke="#f9ab00" />
<text x="370" y="496" font-size="10">storage</text>
<rect x="445" y="486" width="14" height="11" rx="2"
fill="#f5f5f5" stroke="#bbb" />
<text x="465" y="496" font-size="10">optional</text>
<text x="40" y="522" font-size="10" fill="#666">
Solid arrows: TBQueue connections. Dashed blue: subQ linking per-client to singleton threads.
</text>
</svg>

Before

Width:  |  Height:  |  Size: 8.0 KiB

-80
View File
@@ -1,80 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 620 280" font-family="monospace" font-size="12">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#333" />
</marker>
<marker id="arr-g" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</marker>
</defs>
<!-- ===== CLIENT WRAPPER ===== -->
<rect x="120" y="40" width="380" height="130" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="130" y="55" fill="#888" font-size="10">per connection (XFTPClient adds no threads; serialization in HTTP2Client)</text>
<!-- caller label -->
<text x="15" y="95" font-size="9" fill="#999">caller</text>
<text x="10" y="108" font-size="9" fill="#999">(Agent/</text>
<text x="10" y="121" font-size="9" fill="#999"> router)</text>
<!-- caller -> XFTPClient -->
<line x1="55" y1="100" x2="138" y2="100"
stroke="#999" marker-end="url(#arr-g)" />
<!-- XFTPClient -->
<rect x="140" y="68" width="150" height="48" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="215" y="89" text-anchor="middle" font-weight="bold">XFTPClient</text>
<text x="215" y="107" text-anchor="middle" font-size="10">sendXFTPCommand</text>
<!-- XFTPClient -> HTTP2Client -->
<line x1="290" y1="92" x2="328" y2="92"
stroke="#333" marker-end="url(#arr)" />
<!-- HTTP2Client -->
<rect x="330" y="68" width="150" height="48" rx="4"
fill="#fce8e6" stroke="#ea4335" />
<text x="405" y="89" text-anchor="middle" font-weight="bold">HTTP2Client</text>
<text x="405" y="107" text-anchor="middle" font-size="10">(TLS + HTTP/2 streams)</text>
<!-- HTTP2Client -> network -->
<line x1="480" y1="92" x2="518" y2="92"
stroke="#999" marker-end="url(#arr-g)" />
<text x="525" y="88" font-size="9" fill="#999">XFTP</text>
<text x="525" y="101" font-size="9" fill="#999">Router</text>
<text x="525" y="114" font-size="9" fill="#999">(HTTP/2)</text>
<!-- Handshake state -->
<rect x="140" y="130" width="150" height="32" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="215" y="151" text-anchor="middle" font-size="10">thParams (negotiated)</text>
<!-- upload/download note -->
<text x="330" y="143" font-size="10" fill="#666">uploads: streaming request body</text>
<text x="330" y="157" font-size="10" fill="#666">downloads: ephemeral DH + streaming</text>
<text x="330" y="171" font-size="10" fill="#666"> response body (per-chunk forward secrecy)</text>
<!-- ===== LEGEND ===== -->
<rect x="25" y="195" width="570" height="70" rx="4"
fill="none" stroke="#ddd" />
<rect x="40" y="210" width="14" height="11" rx="2"
fill="#e8f0fe" stroke="#4285f4" />
<text x="60" y="220" font-size="10">client wrapper</text>
<rect x="195" y="210" width="14" height="11" rx="2"
fill="#fce8e6" stroke="#ea4335" />
<text x="215" y="220" font-size="10">external connection</text>
<rect x="385" y="210" width="14" height="11" rx="2"
fill="#fef7e0" stroke="#f9ab00" />
<text x="405" y="220" font-size="10">state</text>
<text x="40" y="248" font-size="10" fill="#666">
XFTPClient adds no threads. HTTP2Client has internal reqQ + process thread.
</text>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

-130
View File
@@ -1,130 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 740 460" font-family="monospace" font-size="12">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#333" />
</marker>
<marker id="arr-g" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</marker>
</defs>
<!-- ===== REQUEST HANDLING (no persistent threads) ===== -->
<rect x="25" y="38" width="510" height="280" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="35" y="53" fill="#888" font-size="10">per request (inline HTTP/2 callback, no spawned threads)</text>
<!-- network in -->
<text x="13" y="78" font-size="9" fill="#999">net</text>
<line x1="12" y1="82" x2="45" y2="82"
stroke="#999" marker-end="url(#arr-g)" />
<!-- HTTP/2 Transport -->
<rect x="45" y="66" width="190" height="34" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="140" y="88" text-anchor="middle">HTTP/2 handler</text>
<!-- Handshake state -->
<rect x="280" y="60" width="240" height="48" rx="4"
fill="#fce8e6" stroke="#ea4335" />
<text x="400" y="78" text-anchor="middle" font-size="11">Handshake State (per session)</text>
<text x="400" y="96" text-anchor="middle" font-size="10" fill="#666">None -> Sent -> Accepted</text>
<!-- sessions TMap link -->
<line x1="235" y1="80" x2="278" y2="80"
stroke="#555" stroke-dasharray="3,2" marker-end="url(#arr)" />
<text x="256" y="74" text-anchor="middle" font-size="9" fill="#555">sessions</text>
<!-- HTTP/2 handler -> Command Processing -->
<line x1="140" y1="100" x2="140" y2="118"
stroke="#333" marker-end="url(#arr)" />
<!-- Command Processing -->
<rect x="45" y="120" width="475" height="36" rx="4"
fill="#e8f0fe" stroke="#4285f4" />
<text x="282" y="143" text-anchor="middle">Command Processing (FNEW, FADD, FPUT, FGET, FACK, FDEL, PING)</text>
<!-- Command Processing -> FileStore -->
<line x1="160" y1="156" x2="130" y2="180"
stroke="#333" marker-end="url(#arr)" />
<!-- Command Processing -> Disk Storage -->
<line x1="380" y1="156" x2="390" y2="180"
stroke="#333" marker-end="url(#arr)" />
<!-- FileStore -->
<rect x="45" y="182" width="180" height="48" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="135" y="203" text-anchor="middle" font-weight="bold">FileStore</text>
<text x="135" y="221" text-anchor="middle" font-size="10">(TMap in STM)</text>
<!-- Disk Storage -->
<rect x="300" y="182" width="220" height="48" rx="4"
fill="#fef7e0" stroke="#f9ab00" />
<text x="410" y="203" text-anchor="middle" font-weight="bold">Disk Storage</text>
<text x="410" y="221" text-anchor="middle" font-size="10">filesPath / base64(senderId)</text>
<!-- Quota note -->
<text x="410" y="246" text-anchor="middle" font-size="9" fill="#888">quota-managed via usedStorage TVar</text>
<!-- StoreLog -->
<rect x="45" y="260" width="180" height="18" rx="3"
fill="#f5f5f5" stroke="#ccc" />
<text x="135" y="273" text-anchor="middle" font-size="9" fill="#888">StoreLog (append-only)</text>
<!-- FileStore -> StoreLog -->
<line x1="135" y1="230" x2="135" y2="258"
stroke="#555" stroke-dasharray="3,2" marker-end="url(#arr)" />
<!-- network out (command result) -->
<line x1="520" y1="138" x2="548" y2="138"
stroke="#999" marker-end="url(#arr-g)" />
<text x="555" y="143" font-size="9" fill="#999">net</text>
<!-- ===== BACKGROUND THREADS ===== -->
<rect x="25" y="335" width="690" height="50" rx="6"
fill="none" stroke="#888" stroke-dasharray="6,3" />
<text x="35" y="350" fill="#888" font-size="10">background threads (singleton, in raceAny_)</text>
<!-- expireFiles -->
<rect x="45" y="358" width="118" height="22" rx="3"
fill="#e6f4ea" stroke="#34a853" />
<text x="104" y="374" text-anchor="middle" font-size="10">expireFiles</text>
<!-- logServerStats -->
<rect x="180" y="358" width="128" height="22" rx="3"
fill="#f5f5f5" stroke="#bbb" />
<text x="244" y="374" text-anchor="middle" font-size="10">logServerStats</text>
<!-- prometheus -->
<rect x="325" y="358" width="118" height="22" rx="3"
fill="#f5f5f5" stroke="#bbb" />
<text x="384" y="374" text-anchor="middle" font-size="10">prometheus</text>
<!-- controlPort -->
<rect x="460" y="358" width="118" height="22" rx="3"
fill="#f5f5f5" stroke="#bbb" />
<text x="519" y="374" text-anchor="middle" font-size="10">controlPort</text>
<!-- ===== LEGEND ===== -->
<rect x="25" y="400" width="690" height="50" rx="4"
fill="none" stroke="#ddd" />
<rect x="40" y="414" width="14" height="11" rx="2"
fill="#e8f0fe" stroke="#4285f4" />
<text x="60" y="424" font-size="10">request handler (no threads)</text>
<rect x="250" y="414" width="14" height="11" rx="2"
fill="#fef7e0" stroke="#f9ab00" />
<text x="270" y="424" font-size="10">storage</text>
<rect x="350" y="414" width="14" height="11" rx="2"
fill="#fce8e6" stroke="#ea4335" />
<text x="370" y="424" font-size="10">per-session state</text>
<rect x="500" y="414" width="14" height="11" rx="2"
fill="#e6f4ea" stroke="#34a853" />
<text x="520" y="424" font-size="10">background thread</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

-155
View File
@@ -1,155 +0,0 @@
# Design Notes
Non-bug observations from module specs that are worth tracking. These remain documented in their respective module specs — this file serves as an index.
## Backend Observations
### N-01: SNotifier path doesn't cache
**Location**: `Simplex.Messaging.Server.QueueStore.Postgres``getQueues_` SNotifier branch
**Description**: The SRecipient path caches loaded queues via `cacheRcvQueue` with double-check locking. The SNotifier path does NOT cache — it uses a stale TMap snapshot and `maybe (mkQ False rId qRec) pure`, so concurrent loads for the same notifier can create duplicate ephemeral queue objects. Functionally correct but wasteful.
**Module spec**: [QueueStore/Postgres.md](Simplex/Messaging/Server/QueueStore/Postgres.md)
### N-02: assertUpdated error conflation
**Location**: `Simplex.Messaging.Server.QueueStore.Postgres``assertUpdated`
**Description**: `assertUpdated` returns `AUTH` for zero-rows-affected. This is the same error code used for "not found" (via `readQueueRecIO`) and "duplicate" (via `handleDuplicate`). The actual cause — stale cache, deleted queue, or constraint violation — is indistinguishable in logs.
**Module spec**: [QueueStore/Postgres.md](Simplex/Messaging/Server/QueueStore/Postgres.md)
## Design Characteristics
### N-03: RCVerifiedInvitation constructor exported
**Location**: `Simplex.RemoteControl.Invitation``RCVerifiedInvitation`
**Description**: `RCVerifiedInvitation` is a newtype with constructor exported via `(..)`. It can be constructed without calling `verifySignedInvitation`, bypassing signature verification. The trust boundary is conventional, not enforced by the type system. `connectRCCtrl` accepts only `RCVerifiedInvitation`.
**Module spec**: [RemoteControl/Invitation.md](Simplex/RemoteControl/Invitation.md)
### N-04: smpEncode Word16 silent truncation
**Location**: `Simplex.Messaging.Encoding``Encoding Word16` instance
**Description**: `smpEncode` for ByteString uses a 1-byte length prefix. Maximum encodable length is 255 bytes. Longer values silently wrap via `w2c . fromIntegral`. Callers must ensure ByteStrings fit or use `Large`.
**Module spec**: [Encoding.md](Simplex/Messaging/Encoding.md)
### N-05: writeIORef for period stats — not atomic
**Location**: `Simplex.Messaging.Server.Stats``setPeriodStats`
**Description**: Uses `writeIORef` (not atomic). Only safe during router startup when no other threads are running. If called concurrently, period data could be corrupted.
**Module spec**: [Server/Stats.md](Simplex/Messaging/Server/Stats.md)
### N-06: setStatsByServer orphans old TVars
**Location**: `Simplex.Messaging.Notifications.Server.Stats``setStatsByServer`
**Description**: Builds a fresh `Map Text (TVar Int)` in IO, then atomically replaces the TMap's root TVar. Old per-router TVars are not reused — any other thread holding a reference from a prior `TM.lookupIO` would modify an orphaned counter. Called at startup, but lacks the explicit "not thread safe" comment.
**Module spec**: [Notifications/Server/Stats.md](Simplex/Messaging/Notifications/Server/Stats.md)
### N-07: Lazy.unPad doesn't validate data length
**Location**: `Simplex.Messaging.Crypto.Lazy``unPad` / `splitLen`
**Description**: `splitLen` does not validate that the remaining data is at least `len` bytes — `LB.take len` silently returns a shorter result. The source comment notes this is intentional to avoid consuming all lazy chunks for validation.
**Module spec**: [Crypto/Lazy.md](Simplex/Messaging/Crypto/Lazy.md)
### N-08: Batched commands have no timeout-based expiry
**Location**: `Simplex.Messaging.Client``sendBatch`
**Description**: Batched commands are written with `Nothing` as the request parameter — the send thread skips the `pending` flag check. Individual commands have timeout-based expiry. If the router stops returning results, batched commands can block the send queue indefinitely.
**Module spec**: [Client.md](Simplex/Messaging/Client.md)
### N-09: Postgres MsgStore nanosecond precision
**Location**: `Simplex.Messaging.Server.MsgStore.Postgres``toMessage`
**Description**: `MkSystemTime ts 0` constructs timestamps with zero nanoseconds. Only whole seconds are stored. Messages read from Postgres have coarser timestamps than STM/Journal stores. Not a practical issue — timestamps are typically rounded to hours or days.
**Module spec**: [Server/MsgStore/Postgres.md](Simplex/Messaging/Server/MsgStore/Postgres.md)
### N-10: MsgStore Postgres — error stubs crash at runtime
**Location**: `Simplex.Messaging.Server.MsgStore.Postgres` — multiple `MsgStoreClass` methods
**Description**: Multiple `MsgStoreClass` methods are `error "X not used"`. Required by the type class but not applicable to Postgres. Calling any at runtime crashes. Safe because Postgres overrides the relevant default methods, but a new caller using the wrong method would crash with no compile-time warning.
**Module spec**: [Server/MsgStore/Postgres.md](Simplex/Messaging/Server/MsgStore/Postgres.md)
### N-11: strP default assumes base64url for all types
**Location**: `Simplex.Messaging.Encoding.String``StrEncoding` class default
**Description**: The `MINIMAL` pragma allows defining only `strDecode` without `strP`. The default `strP = strDecode <$?> base64urlP` assumes input is base64url-encoded for any type. A new `StrEncoding` instance that defines only `strDecode` for non-base64 data would get a broken parser.
**Module spec**: [Encoding/String.md](Simplex/Messaging/Encoding/String.md)
## Silent Behaviors
Intentional design choices that are correct but non-obvious. A code modifier who doesn't know these could introduce bugs.
### N-12: Service signing silently skipped on empty authenticator
**Location**: `Simplex.Messaging.Client` — service signature path
**Description**: 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.
**Module spec**: [Client.md](Simplex/Messaging/Client.md)
### N-13: stmDeleteNtfToken — nonexistent token indistinguishable from empty
**Location**: `Simplex.Messaging.Notifications.Server.Store``stmDeleteNtfToken`
**Description**: If the token ID doesn't exist in the `tokens` map, the registration-cleanup branch is skipped and the function returns an empty list. The caller cannot distinguish "deleted a token with no subscriptions" from "token never existed."
**Module spec**: [Notifications/Server/Store.md](Simplex/Messaging/Notifications/Server/Store.md)
### N-14: createCommand silently drops commands for deleted connections
**Location**: `Simplex.Messaging.Agent.Store.AgentStore``createCommand`
**Description**: When `createCommand` encounters a constraint violation (the referenced connection was already deleted), it logs the error and returns successfully. Commands targeting deleted connections are silently dropped.
**Module spec**: [Agent/Store/AgentStore.md](Simplex/Messaging/Agent/Store/AgentStore.md)
### N-15: Redirect chain loading errors silently swallowed
**Location**: `Simplex.Messaging.Agent.Store.AgentStore`
**Description**: When loading redirect chains, errors loading individual redirect files are silently swallowed via `either (const $ pure Nothing) (pure . Just)`. Prevents a corrupt redirect from blocking access to the main file.
**Module spec**: [Agent/Store/AgentStore.md](Simplex/Messaging/Agent/Store/AgentStore.md)
### N-16: BLOCKED encoded as AUTH for old XFTP clients
**Location**: `Simplex.FileTransfer.Protocol``encodeProtocol`
**Description**: If the protocol version is below `blockedFilesXFTPVersion`, a `BLOCKED` result is encoded as `AUTH` instead. The blocking information (reason) is permanently lost for these clients.
**Module spec**: [FileTransfer/Protocol.md](Simplex/FileTransfer/Protocol.md)
### N-17: restore_messages three-valued logic with implicit default
**Location**: `Simplex.Messaging.Server.Main` — INI config
**Description**: The `restore_messages` INI setting has three-valued logic: explicit "on" → restore, explicit "off" → skip, missing → inherits from `enable_store_log`. This implicit default is not captured in the type system — callers see `Maybe Bool`.
**Module spec**: [Server/Main.md](Simplex/Messaging/Server/Main.md)
### N-18: Stats format migration permanently loses precision
**Location**: `Simplex.Messaging.Server.Stats``strP` for `ServerStatsData`
**Description**: The parser handles multiple format generations. Old format `qDeleted=` is read as `(value, 0, 0)`. `qSubNoMsg` is parsed and discarded. `subscribedQueues` is parsed but replaced with empty data. Data loaded from old formats is coerced — precision is permanently lost.
**Module spec**: [Server/Stats.md](Simplex/Messaging/Server/Stats.md)
### N-19: resubscribe exceptions silently lost
**Location**: `Simplex.Messaging.Notifications.Server``resubscribe`
**Description**: `resubscribe` is launched via `forkIO` before `raceAny_` starts — not part of the `raceAny_` group. Most exceptions are silently lost per `forkIO` semantics. `ExitCode` exceptions are special-cased by GHC's runtime and do propagate.
**Module spec**: [Notifications/Server.md](Simplex/Messaging/Notifications/Server.md)
### N-20: closeSMPClientAgent worker cancellation is fire-and-forget
**Location**: `Simplex.Messaging.Client.Agent``closeSMPClientAgent`
**Description**: Executes in order: set `active = False`, close all client connections, swap workers map to empty and fork cancellation threads. Cancel threads use `uninterruptibleCancel` but are fire-and-forget — the function may return before all workers are cancelled.
**Module spec**: [Client/Agent.md](Simplex/Messaging/Client/Agent.md)
### N-21: APNS unknown 410 reasons trigger retry instead of permanent failure
**Location**: `Simplex.Messaging.Notifications.Server.Push.APNS`
**Description**: Unknown 410 (Gone) reasons fall through to `PPRetryLater`, while unknown 400 and 403 reasons fall through to `PPResponseError`. An unexpected APNS 410 reason string triggers retry rather than permanent failure.
**Module spec**: [Notifications/Server/Push/APNS.md](Simplex/Messaging/Notifications/Server/Push/APNS.md)
### N-22: NTInvalid/NTExpired tokens can create subscriptions
**Location**: `Simplex.Messaging.Notifications.Protocol` — token status permissions
**Description**: Token status `NTInvalid` allows subscription commands (SNEW, SCHK, SDEL). A TODO comment explains: invalidation can happen after verification, and existing subscriptions should remain manageable. `NTExpired` is also permitted.
**Module spec**: [Notifications/Protocol.md](Simplex/Messaging/Notifications/Protocol.md)
### N-23: removeInactiveTokenRegistrations doesn't clean up empty inner maps
**Location**: `Simplex.Messaging.Notifications.Server.Store``stmRemoveInactiveTokenRegistrations`
**Description**: `stmDeleteNtfToken` checks whether inner TMap is empty after removal and cleans up the outer key. `stmRemoveInactiveTokenRegistrations` does not — surviving active tokens' registrations remain, but empty inner maps can persist.
**Module spec**: [Notifications/Server/Store.md](Simplex/Messaging/Notifications/Server/Store.md)
### N-24: cbNonce silently truncates or pads
**Location**: `Simplex.Messaging.Crypto``cbNonce`
**Description**: If the input is longer than 24 bytes, it is silently truncated. If shorter, it is silently padded. No error is raised. Callers must ensure correct length.
**Module spec**: [Crypto.md](Simplex/Messaging/Crypto.md)
-197
View File
@@ -1,197 +0,0 @@
# How to Document a Module
> Read this before writing any module doc. It defines what goes in, what stays out, and why.
## Purpose
Module docs exist for one reason: to capture knowledge that **cannot be obtained by reading the source code**. If reading the `.hs` file tells you everything you need to know, the module doc should be brief or empty.
These docs are an investment — their value compounds over time as multiple people (and LLMs) work on the code. Optimize for long-term value, not for looking thorough today.
## Process
**Read every line of the source file.** The non-obvious filter applies to what you *write*, not to what you *read*. Without reading each line, you will produce documentation from inferences rather than facts. Many non-obvious behaviors only become visible when you see a specific line of code and recognize that its implications would surprise a reader who doesn't have the surrounding context.
## File structure
Module docs mirror `src/Simplex/` exactly. Same subfolder structure, `.hs` replaced with `.md`:
```
src/Simplex/Messaging/Server.hs → spec/modules/Simplex/Messaging/Server.md
src/Simplex/Messaging/Crypto.hs → spec/modules/Simplex/Messaging/Crypto.md
src/Simplex/FileTransfer/Agent.hs → spec/modules/Simplex/FileTransfer/Agent.md
```
## What to include
### 1. Non-obvious behavior
Things that would surprise a competent Haskell developer reading the code for the first time:
- Subtle invariants maintained across function calls
- Ordering dependencies ("must call X before Y because...")
- Concurrency assumptions ("this TVar is only written from thread Z")
- Implicit contracts between caller and callee not captured by types
### 2. Usage considerations
- When to use function X vs function Y
- Common mistakes callers make
- Caller obligations not enforced by the type system
- Performance characteristics that affect usage decisions
### 3. Cross-module relationships
- Dependencies on other modules' behavior not visible from import lists
- Assumptions about how other modules use this one
- Coordination patterns (e.g., "Server.hs reads this TVar, Agent.hs writes it")
### 4. Security notes
- Trust boundaries this module enforces or relies on
- What happens if inputs are malicious
- Which functions are security-critical and why (reference SI-XX invariants)
### 5. Design rationale
- Why the code is structured this way (when not obvious)
- Alternatives considered and rejected
- Known limitations and their justification
## Non-obvious threshold
The guiding principle: **non-obvious state machines and flows require documentation; standard things don't.**
Document:
- Multi-step protocols and negotiation flows (e.g., KEM propose/accept round-trips)
- Monotonic or irreversible state transitions (e.g., PQ support can only be enabled, never disabled)
- Silent error behaviors (e.g., `verify` returns `False` on algorithm mismatch instead of an error)
- Design rationale for non-standard choices (e.g., why byte-reverse a nonce, why hash-then-encrypt for authenticators)
Do NOT document:
- Standard algorithm properties (e.g., Ed25519 public key derivable from private key)
- Well-known protocol mechanics (e.g., HKDF usage per RFC 5869, deterministic nonce derivation in double ratchet)
- Implementation details that follow directly from the type signatures
## What NOT to include
- **Type signatures** — the code has them
- **Code snippets** — if you're pasting code, you're making a stale copy
- **Function-by-function prose that restates the implementation** — "this function takes X and returns Y by doing Z" adds nothing
- **Line numbers** — they're brittle and break on every edit
- **Comments that fit in one line in source** — put those in the source file instead as `-- spec:` comments
- **Verbatim quotes of source comments** — reference them instead: "See comment on `functionName`." Then add only what the comment doesn't cover (cross-module implications, what breaks if violated). If the source comment says everything, the function doesn't need a doc entry.
- **Tables that reproduce code structure** — if the information is self-evident from reading the code's pattern matching or type definitions, it doesn't belong in the doc (e.g., per-command credential requirements, version-conditional encoding branches)
## Format
Each module doc has a header, then entries for functions/types that need documentation.
```markdown
# Module.Name
> One-line description of what this module does.
**Source**: [`Path/To/Module.hs`](relative link to source)
## Overview
[Only if the module's purpose or architecture is non-obvious.
Skip for simple modules.]
## functionName
**Purpose**: [What this does that isn't obvious from the name and type]
**Calls**: [Qualified.Name.a](link), [Qualified.Name.b](link)
**Called by**: [Qualified.Name.c](link)
**Invariant**: SI-XX
**Security**: [What this function ensures for the threat model]
[Free-form notes about non-obvious behavior, gotchas, etc.]
## anotherFunction
...
```
**For trivial modules** (< 100 LOC, no non-obvious behavior):
```markdown
# Module.Name
> One-line description.
**Source**: [`Path/To/Module.hs`](relative link to source)
No non-obvious behavior. See source.
```
This is valuable — it confirms someone looked and found nothing to document.
## Linking conventions
### Module doc → protocol docs
When a module implements or is governed by a protocol specification in `protocol/`, link to it near the top of the module doc (after the overview). Do not duplicate protocol content — just reference it:
```markdown
**Protocol spec**: [`protocol/pqdr.md`](../../../../protocol/pqdr.md) — Post-quantum resistant augmented double ratchet algorithm.
```
This is especially important for modules in transport, protocol, client, server, and agent layers where behavior is defined by the protocol spec rather than being self-evident from the code.
### Module doc → other module docs
Use fully qualified names as link text:
```markdown
[Simplex.Messaging.Server.subscribeServiceMessages](./Simplex/Messaging/Server.md#subscribeServiceMessages)
```
### Module doc → topic docs
```markdown
See [rcv-services](../rcv-services.md) for the end-to-end service subscription flow.
```
### Source → module doc
Add `-- spec:` comments as part of the module documentation work — when you document something non-obvious, add the link in source at the same time. Two levels:
**Module-level** (below the module declaration): when the Overview section has value.
```haskell
module Simplex.Messaging.Util (...) where
-- spec: spec/modules/Simplex/Messaging/Util.md
```
**Function-level** (above the function): when that function has a doc entry worth pointing to.
```haskell
-- spec: spec/modules/Simplex/Messaging/Util.md#catchOwn
-- Catches all exceptions except async cancellations (misleading name)
catchOwn :: ...
```
Only add `-- spec:` comments where the module doc actually says something the code doesn't. Don't add links to "No non-obvious behavior" docs or to entries that merely restate the source.
## Topic candidate tracking
While documenting modules, you will notice cross-cutting patterns — behaviors that span multiple modules and can't be understood from any single one. Note these in `spec/TOPICS.md` for later. Don't write the topic doc during module work; just record:
```markdown
- **Queue rotation**: Agent.hs initiates, Client.hs sends commands, Server.hs processes,
Protocol.hs defines types. End-to-end flow not obvious from any single module.
```
## Quality bar
Before finishing a module doc, ask:
1. Does every entry document something NOT in the source code?
2. Would removing any entry lose information? If not, remove it.
3. Are cross-module relationships captured that imports alone don't reveal?
4. Are security-critical functions flagged with invariant IDs?
5. Is this doc short enough that someone will actually read it?
If any answer reveals a problem, fix it and repeat from question 1. Only finish when a full pass produces no changes.
## Terminology — the spec as translation boundary
The protocol documents (`protocol/overview-tjr.md`, `protocol/simplex-messaging.md`, `protocol/agent-protocol.md`) define the canonical terminology. Code uses different names for some of the same concepts. The spec is where the translation happens.
The most important distinction: SimpleX protocol routers are referred to as "servers" in code. The term "server" was adopted historically because SimpleX routers were implemented as Linux-based software that is deployed in the same way as servers. But the similarity is entirely formal. Functionally, servers serve responses to the requests of their users - that is why the term "server" was adopted for computers and software that provide Internet services. SimpleX protocol routers don't serve responses - they route packets between endpoints, and they have no concept of a user. Functionally they are similar to Internet Protocol routers, but with a resource-based addressing scheme. Further, SimpleX protocol routers are hardware and software agnostic. SimpleX protocols are open and documented, so they can be implemented in any language and run on a different architecture. For example, [SimpleGo](https://simplego.dev) is a prototype implementation of the SimpleX protocol stack in C for a microcontroller architecture.
**The rule**: use protocol terms for concepts, code terms for identifiers. Write "router" when describing the network node's role, `SMPServer` or `Server.hs` when referencing code. Similarly, "router identity" for the concept (called "server key hash" or "fingerprint" in code). When the distinction matters, bridge explicitly: "the SMP router (implemented by the `Server` module)" or "the `SMPServer` type (representing a router address)."
## Exclusions
- **Individual migration files** (M20XXXXXX_*.hs): Self-describing SQL. No per-migration docs.
- **Auto-generated files** (GitCommit.hs): Skip.
- **Pure boilerplate** (Prometheus.hs metrics, Web/Embedded.hs static files): Document only if non-obvious.
@@ -1,90 +0,0 @@
# Simplex.FileTransfer.Agent
> XFTP agent: worker-based file send/receive/delete with retry, encryption, redirect chains, and file description generation.
**Source**: [`FileTransfer/Agent.hs`](../../../../src/Simplex/FileTransfer/Agent.hs)
## Terminology
The agent splits a **file** into **chunks** determined by the chunking algorithm. Each chunk is stored on an XFTP router as a **data packet** — the router has no concept of files or chunks, only directly addressable data packets. This document uses "chunk" for the agent's internal tracking and "data packet" when referring to what is transferred to/from or stored on routers.
## Architecture
The XFTP agent uses five worker types organized in three categories:
| Worker | Key (router) | Purpose |
|--------|-------------|---------|
| `xftpRcvWorker` | `Just server` | Download data packets from a specific XFTP router |
| `xftpRcvLocalWorker` | `Nothing` | Decrypt completed downloads locally |
| `xftpSndPrepareWorker` | `Nothing` | Encrypt files and create data packets on routers |
| `xftpSndWorker` | `Just server` | Upload data packets to a specific XFTP router |
| `xftpDelWorker` | `Just server` | Delete data packets from a specific XFTP router |
Workers are created on-demand via `getAgentWorker` and keyed by router address. The local workers (keyed by `Nothing`) handle CPU-bound operations that don't require network access.
## Non-obvious behavior
### 1. startXFTPWorkers vs startXFTPSndWorkers
`startXFTPWorkers` starts all three worker categories (rcv, snd, del). `startXFTPSndWorkers` starts only snd workers. This distinction exists because receiving and deleting require a full agent context, while sending can operate with a partial setup (used when the agent is in send-only mode).
### 2. Download completion triggers local worker
When `downloadFileChunk` determines that all chunks are received (`all chunkReceived chunks`), it calls `getXFTPRcvWorker True c Nothing` to wake the local decryption worker. The `True` parameter signals that work is available. Without this, the local worker would sleep until the next `waitForWork` check.
### 3. Decryption verifies both digest and size before decrypting
`decryptFile` first computes the total size of all encrypted chunk files, then their SHA-512 digest. If either mismatches the expected values, it throws an error *before* starting decryption. This prevents wasting CPU on corrupted or tampered downloads.
### 4. Redirect chain with depth limit
When a received file has a `redirect`, the local worker:
1. Decrypts the redirect file (a YAML file description)
2. Validates the inner description's size and digest against `RedirectFileInfo`
3. Registers the inner file's chunks and starts downloading them
The redirect chain is implicitly limited to depth 1: `createRcvFileRedirect` creates the destination file entry with `redirect = Nothing`, and `updateRcvFileRedirect` does not update the redirect column. So even if the decoded inner description contains a redirect field, the database record for the destination file has no redirect, preventing further chaining.
### 5. Decrypting worker resumes from RFSDecrypting
If the agent restarts while a file is in `RFSDecrypting` status, the local worker detects this and deletes the partially-decrypted output file before restarting decryption. This prevents corrupted output from a previous incomplete decryption attempt.
### 6. Encryption worker resumes from SFSEncrypting
Similarly, `prepareFile` checks `status /= SFSEncrypted` and deletes the partial encrypted file if status is `SFSEncrypting`. This allows clean restart of interrupted encryption.
### 7. Redirect files must be single-chunk
`encryptFileForUpload` for redirect files calls `singleChunkSize` instead of `prepareChunkSizes`. If the redirect file description doesn't fit in a single chunk, it throws `FILE SIZE`. This ensures redirect files are atomic — they either download completely or not at all.
### 8. addRecipients recursive batching
During upload, `addRecipients` recursively calls itself if a data packet needs more recipients than `xftpMaxRecipientsPerRequest`. Each iteration sends an FADD command for up to `maxRecipients` new recipients, accumulates the results, and recurses until all recipients are registered.
### 9. File description generation cross-product
`createRcvFileDescriptions` (in both `Agent.hs` and `Client/Main.hs`) performs a cross-product transformation: M chunks × R replicas × N recipients → N file descriptions, each containing M chunks with R replicas. The `addRcvChunk` accumulator builds a `Map rcvNo (Map chunkNo FileChunk)` to correctly distribute replicas across recipient descriptions.
### 10. withRetryIntervalLimit caps consecutive retries
`withRetryIntervalLimit maxN` allows at most `maxN` total attempts (initial attempt at `n=0` plus `maxN-1` retries). When all attempts are exhausted for temporary errors, the operation is silently abandoned for this work cycle — the chunk remains in pending state and may be retried on the next cycle. Only permanent errors (handled by `retryDone`) mark the file as errored.
### 11. Retry distinguishes temporary from permanent errors
`retryOnError` checks `temporaryOrHostError`: temporary/host errors trigger retry with exponential backoff; permanent errors (AUTH, SIZE, etc.) immediately mark the file as failed. On host errors during retry, a warning notification is sent to the client.
### 12. Delete workers skip files older than rcvFilesTTL
`runXFTPDelWorker` uses `rcvFilesTTL` (not a dedicated delete TTL) to filter pending deletions. Data packets older than this TTL would already be expired on the router, so attempting deletion is pointless. This reuses the receive TTL as a proxy for router-side expiration.
### 13. closeXFTPAgent atomically swaps worker maps
`closeXFTPAgent` uses `swapTVar workers M.empty` to atomically replace each worker map with an empty map, then cancels all retrieved workers. This prevents races where a new worker could be inserted between reading and clearing the map.
### 14. assertAgentForeground dual check
`assertAgentForeground` both throws if the agent is inactive (`throwWhenInactive`) and blocks until it's in the foreground (`waitUntilForeground`). This is called before every chunk operation to ensure the agent isn't suspended or backgrounded during file transfers.
### 15. Per-router stats tracking
Every data packet download, upload, and delete operation increments per-router statistics (`downloads`, `uploads`, `deletions`, `downloadAttempts`, `uploadAttempts`, `deleteAttempts`, and error variants). Size-based stats (`downloadsSize`, `uploadsSize`) track throughput in kilobytes.
@@ -1,37 +0,0 @@
# Simplex.FileTransfer.Client
> XFTP client: connection management, handshake, data packet upload/download with forward secrecy.
**Source**: [`FileTransfer/Client.hs`](../../../../src/Simplex/FileTransfer/Client.hs)
## Non-obvious behavior
### 1. ALPN-based handshake version selection
`getXFTPClient` checks the ALPN result after TLS negotiation:
- **`xftpALPNv1` or `httpALPN11`**: performs v1 handshake with key exchange (`httpALPN11` is used for web port connections)
- **No ALPN or unrecognized**: uses legacy v1 transport parameters without handshake
### 2. Router certificate chain validation
`xftpClientHandshakeV1` validates the router's identity by checking that the CA fingerprint from the certificate chain matches the expected `keyHash` from the router address. The router signs an authentication public key (X25519) with its long-term key. The client verifies this signature against the certificate chain, then extracts the X25519 key for HMAC-based command authentication. This authentication key is distinct from the per-download ephemeral DH keys.
### 3. Ephemeral DH key pair per download
`downloadXFTPChunk` generates a fresh X25519 key pair for each data packet download. The public key is sent with the FGET command; the router returns its own ephemeral key. The derived shared secret encrypts the data packet in transit. This provides forward secrecy — compromising a past DH key doesn't decrypt other downloads.
### 4. Size-proportional download timeout
`downloadXFTPChunk` calculates the timeout as `baseTimeout + (sizeInKB * perKbTimeout)`, where `baseTimeout` is the base TCP timeout and `perKbTimeout` is a per-kilobyte timeout from the network config. Larger data packets get proportionally more time. This prevents premature timeouts on large data packets over slow connections.
### 5. prepareChunkSizes threshold algorithm
`prepareChunkSizes` selects data packet sizes using a 75% threshold: if the remaining payload exceeds 75% of the next larger size, it uses the larger size. Otherwise, it uses the smaller size. `singleChunkSize` returns `Just size` only if the payload fits in a single data packet (used for redirect files which must be single-packet).
### 6. Upload sends data packet after command block
`uploadXFTPChunk` sends the FPUT command and data packet body in the same streaming HTTP/2 request: the protocol command block is sent first, followed immediately by the raw encrypted data via `hSendFile`. The command result (`FROk` or error) is received only after both the command and data have been fully sent. This is a single HTTP/2 round trip, not a two-phase interaction.
### 7. Empty corrId as nonce
`sendXFTPCommand` uses `""` (empty bytestring) as the correlation ID for all commands. XFTP is strictly request-response within a single HTTP/2 stream, so correlation IDs are unnecessary. The empty value is passed to `C.cbNonce` to produce a constant nonce for command authentication (HMAC/signing), not encryption — XFTP authenticates commands but does not encrypt them within the TLS tunnel.
@@ -1,27 +0,0 @@
# Simplex.FileTransfer.Client.Agent
> XFTP client: router connection management with TMVar-based sharing, async retry, and connection lifecycle.
**Source**: [`FileTransfer/Client/Agent.hs`](../../../../../src/Simplex/FileTransfer/Client/Agent.hs)
## Non-obvious behavior
### 1. TMVar-based connection sharing
`getXFTPServerClient` first checks the `TMap XFTPServer (TMVar (Either XFTPClientAgentError XFTPClient))`. If no entry exists, it atomically inserts an empty `TMVar` and initiates connection. Other threads requesting the same router block on `readTMVar` until the connection is established or fails. This prevents duplicate connections to the same router.
### 2. Async retry on temporary errors
When `newXFTPClient` encounters a temporary error, it launches an async retry loop that attempts reconnection with backoff. The `TMVar` remains in the map but is empty until the retry succeeds. Other threads waiting on `readTMVar` block until either the retry succeeds or a permanent error occurs.
### 3. Permanent error cleanup
On permanent error, `newXFTPClient` puts the `Left error` into the `TMVar` (unblocking waiters) AND deletes the entry from the `TMap`. This means the next caller will see no entry and create a fresh connection attempt, rather than reading a stale error. Waiters that already read the `Left` receive the error.
### 4. Connection timeout
`waitForXFTPClient` wraps `readTMVar` in a timeout. If the connection establishment takes too long (e.g., router unreachable and retry loop is slow), the caller gets a timeout error rather than blocking indefinitely. The underlying connection attempt continues in the background.
### 5. closeXFTPServerClient removes from TMap
Closing a router client deletes its entry from the TMap, so the next request will establish a fresh connection. This is called on connection errors during data packet operations to force reconnection.
@@ -1,43 +0,0 @@
# Simplex.FileTransfer.Client.Main
> XFTP CLI client: send, receive, delete files with parallel chunk operations and web URI encoding.
**Source**: [`FileTransfer/Client/Main.hs`](../../../../../src/Simplex/FileTransfer/Client/Main.hs)
## Non-obvious behavior
### 1. Web URI encoding: base64url(deflate(YAML))
`encodeWebURI` compresses the YAML-encoded file description with raw DEFLATE, then base64url-encodes the result. `decodeWebURI` reverses this. The compressed description goes in the URL fragment (after `#`), which is never sent to the router — the file description stays client-side.
### 2. CLI receive accepts both file paths and URLs
`getInputFileDescription` checks if the input starts with `http://` or `https://`. If so, it extracts the URL fragment, decodes it via `decodeWebURI`, and uses the resulting file description. Otherwise, it reads a YAML file from disk. This allows receiving files via web links without a browser.
### 3. Redirect chain depth limited to 1
`receive` tracks a `depth` parameter starting at 1. After following one redirect, `depth` becomes 0. A second redirect throws "Redirect chain too long". This prevents infinite redirect loops from malicious file descriptions.
### 4. Parallel data packet uploads with router grouping
`uploadFile` groups data packets by router via `groupAllOn`, then uses `pooledForConcurrentlyN 16` to process up to 16 router-groups concurrently. Within each group, data packets are uploaded sequentially (`mapM`). Errors from any upload are collected and the first one is thrown.
### 5. Random router selection
`getXFTPServer` selects a random router from the provided list for each chunk. With a single router, it's deterministic. With multiple routers, it uses `StdGen` in a TVar for thread-safe random selection via `stateTVar`.
### 6. withReconnect nests retry with reconnection
`withReconnect` wraps `withRetry` twice: the outer retry reconnects to the router, and the inner operation runs against the connection. On failure, the router connection is explicitly closed before retrying, forcing a fresh connection on the next attempt.
### 7. withRetry rejects zero retries
`withRetry' 0` returns an "internal: no retry attempts" error. `withRetry' 1` executes the action once without retry. This off-by-one convention means `retryCount = 3` (the default) gives 3 total attempts (1 initial + 2 retries).
### 8. File description auto-deletion prompt
After successful receive or delete, `removeFD` either auto-deletes the file description (if `--yes` flag) or prompts the user. This prevents accidental reuse of one-time file descriptions — each receive consumes the description by ACKing data packets on the router.
### 9. Sender description uses first replica's router
`createSndFileDescription` takes the router from the first replica of each chunk for the sender's `FileChunkReplica`. This reflects the current limitation that each data packet is uploaded to exactly one router — the sender description records that single router.
@@ -1,31 +0,0 @@
# Simplex.FileTransfer.Crypto
> File encryption and decryption with streaming, padding, and auth tag verification.
**Source**: [`FileTransfer/Crypto.hs`](../../../../src/Simplex/FileTransfer/Crypto.hs)
## Non-obvious behavior
### 1. Embedded file header in encrypted stream
`encryptFile` prepends the `FileHeader` (containing filename and optional `fileExtra`) to the plaintext before encryption. A total data size field (8 bytes, `fileSizeLen`) is prepended before the header, encoding the combined size of header + file content. The decryptor uses this to distinguish real data from padding. The recipient must parse the header after decryption to recover the original filename — the header is not transmitted separately.
### 2. Fixed-size padding hides actual file size
The encrypted output is padded to `encSize` (the sum of data packet sizes). Since data packet sizes are fixed powers of 2 (64KB, 256KB, 1MB, 4MB), the encrypted file size reveals only which size bucket the file falls into, not the actual size. The encryption streams data with `LC.sbEncryptChunk` in a loop, pads the remaining space, then manually appends the auth tag via `LC.sbAuth`. This manual streaming approach (rather than using the all-at-once `LC.sbEncryptTailTag`) is necessary because encryption is interleaved with file I/O.
### 3. Dual decrypt paths: single-chunk vs multi-chunk
`decryptChunks` takes different paths based on chunk count:
- **Single chunk**: reads the entire file into memory via `LB.readFile`, decrypts in-memory with `LC.sbDecryptTailTag`
- **Multiple chunks**: opens the destination file for writing and streams through each chunk file with `LC.sbDecryptChunkLazy` (lazy bytestring variant), verifying the auth tag from the final chunk
The single-chunk path avoids file handle management overhead for small files.
### 4. Auth tag failure deletes output file
In the multi-chunk streaming path, if `BA.constEq` detects an auth tag mismatch after decrypting all chunks, the partially-written output file is deleted before returning `FTCEInvalidAuthTag`. This prevents consumers from using a file whose integrity is unverified.
### 5. Streaming encryption uses 64KB blocks
`encryptFile` reads plaintext in 65536-byte blocks (`LC.sbEncryptChunk`), regardless of the XFTP data packet size. These are encryption blocks within a single continuous stream — not to be confused with XFTP data packets which are much larger (64KB4MB).
@@ -1,43 +0,0 @@
# Simplex.FileTransfer.Description
> File description: YAML encoding/decoding, validation, URI format, and replica optimization. A file description maps a file's chunks to data packets stored on XFTP routers — each chunk corresponds to one data packet, and each data packet may have multiple replicas on different routers.
**Source**: [`FileTransfer/Description.hs`](../../../../src/Simplex/FileTransfer/Description.hs)
## Non-obvious behavior
### 1. ValidFileDescription non-exported constructor
`ValidFileDescription` is a newtype with a non-exported data constructor (`ValidFD`), but the module exports a bidirectional pattern synonym `ValidFileDescription` that can be used as a constructor. Despite this, `validateFileDescription` provides the canonical validation path, checking:
- Chunk numbers are sequential starting from 1
- Total chunk sizes equal the declared file size
Note: an empty chunk list with size 0 passes validation — there is no explicit "at least one chunk" check.
### 2. First-replica-only digest and chunkSize
When encoding chunks to YAML via `unfoldChunksToReplicas`, the `digest` and non-default `chunkSize` fields are only included on the first replica of each chunk. Subsequent replicas of the same chunk omit these fields. `foldReplicasToChunks` reconstructs them by carrying forward the digest/size from the first replica. If replicas have conflicting digests or sizes, validation fails.
### 3. Default chunkSize elision
The top-level `FileDescription` has a `chunkSize` field. Individual chunk replicas only serialize their `chunkSize` if it differs from this default. This saves space in the common case where most chunks are the same size (only the last chunk may be smaller).
### 4. YAML encoding groups replicas by router
`groupReplicasByServer` groups all data packet replicas by their router, producing `FileServerReplica` records. This is the serialization format — replicas are organized by router, not by chunk. The parser (`foldReplicasToChunks`) reverses this grouping back to per-chunk replica lists.
### 5. FileDescriptionURI uses query-string encoding
`FileDescriptionURI` serializes file descriptions into a compact query-string format (key=value pairs separated by `&`) with `QEscape` encoding for binary values. This is distinct from the YAML format used for file-based descriptions. The URI format is designed for embedding in links.
### 6. QR code size limit
`qrSizeLimit = 1002` bytes limits the maximum size of a file description URI that can be encoded as a QR code. Descriptions exceeding this limit cannot be shared via QR code and require alternative transport.
### 7. Soft and hard file size limits
Two limits exist: `maxFileSize = 1GB` (soft limit, checked by CLI client) and `maxFileSizeHard = 5GB` (hard limit, checked during agent-side encryption). The soft limit is a user-facing guard; the hard limit prevents resource exhaustion during encryption.
### 8. Redirect file descriptions
A `FileDescription` can contain a `redirect` field pointing to another file's metadata (`RedirectFileInfo` with size and digest). The outer description downloads an encrypted YAML data packet that, once decrypted, yields the actual `FileDescription` for the real file. This adds one level of indirection for privacy — the routers hosting the redirect data packet don't know the actual file's routers.
@@ -1,36 +0,0 @@
# Simplex.FileTransfer.Protocol
> XFTP protocol types, commands, command results, and credential verification.
**Source**: [`FileTransfer/Protocol.hs`](../../../../src/Simplex/FileTransfer/Protocol.hs)
## Non-obvious behavior
### 1. Asymmetric credential checks by command
`checkCredentials` enforces different rules per command:
- **FNEW**: requires `auth` (signature) but must NOT have a `fileId` — the sender key from the command body is used for verification
- **PING**: must have NEITHER `auth` NOR `fileId` — actively rejects their presence
- **All others** (FADD, FPUT, FDEL, FGET, FACK): require both `fileId` AND auth key
This asymmetry means FNEW and PING bypass the standard entity-lookup path entirely — they are handled as separate `XFTPRequest` constructors (`XFTPReqNew`, `XFTPReqPing`).
### 2. BLOCKED result downgraded to AUTH for old clients
`encodeProtocol` checks the protocol version: if `v < blockedFilesXFTPVersion`, a `BLOCKED` result is encoded as `AUTH` instead. This prevents old clients that don't understand `BLOCKED` from receiving an unknown error type. The blocking information is silently lost for these clients.
### 3. Single-transmission batch enforcement
`xftpDecodeTServer` calls `xftpDecodeTransmission` which rejects batches containing more than one transmission. Despite using the batch framing format (length-prefixed), XFTP requires exactly one command per request. This differs from SMP where true batching is supported.
### 4. xftpEncodeBatch1 always uses batch framing
Even for single transmissions, `xftpEncodeBatch1` wraps the encoded transmission in batch format (1-byte count prefix + 2-byte length-prefixed transmission). There is no "non-batch" mode in XFTP — all protocol messages use the batch wire format regardless of the negotiated version.
### 5. FileParty GADT partitions command space
Commands are indexed by `FileParty` (`SFSender` / `SFRecipient`) at the type level via `FileCmd`. This ensures at compile time that sender commands (FNEW, FADD, FPUT, FDEL) and recipient commands (FGET, FACK, PING) cannot be confused. The router pattern-matches on `SFileParty` to determine which index (sender vs recipient) to look up in the file store.
### 6. Empty corrId and implicit session ID
`sendXFTPCommand` in the client uses an empty bytestring as `corrId`. This empty value is passed to `C.cbNonce` to produce a constant nonce for command authentication (HMAC/signing). With `implySessId = False` in the default XFTP transport setup, the session ID is not prepended to entity IDs during parsing. Session identity is provided by the TLS connection itself.
@@ -1,87 +0,0 @@
# Simplex.FileTransfer.Server
> XFTP router: HTTP/2 request handling, handshake state machine, data packet operations, and statistics.
**Source**: [`FileTransfer/Server.hs`](../../../../src/Simplex/FileTransfer/Server.hs)
## Architecture
The XFTP router runs several concurrent threads via `raceAny_`:
| Thread | Purpose |
|--------|---------|
| `runServer` | HTTP/2 router accepting data packet transfer requests |
| `expireFiles` | Periodic data packet expiration with throttling |
| `logServerStats` | Periodic stats flush to CSV |
| `savePrometheusMetrics` | Periodic Prometheus metrics dump |
| `runCPServer` | Control port for admin commands |
See [spec/routers.md](../../routers.md) for component and sequence diagrams.
## Non-obvious behavior
### 1. Three-state handshake with session caching
The router maintains a `TMap SessionId Handshake` with three states:
- **No entry**: first request — for non-SNI or `xftp-web-hello` requests, `processHello` generates DH key pair and sends router handshake; for SNI requests without `xftp-web-hello`, returns `SESSION` error
- **`HandshakeSent pk`**: router hello sent, waiting for client handshake with version negotiation
- **`HandshakeAccepted thParams`**: handshake complete, subsequent requests use cached params
Web clients can re-send hello (`xftp-web-hello` header) even in `HandshakeSent` or `HandshakeAccepted` states — the router reuses the existing private key rather than generating a new one.
### 2. Web identity proof via challenge-response
When a web client sends a hello with a non-empty body, the router parses an `XFTPClientHello` containing a `webChallenge`. The router signs `challenge <> sessionId` with its long-term key and includes the signature in the handshake result. This proves router identity to web clients that cannot verify TLS certificates directly.
### 3. skipCommitted drains request body on re-upload
If `receiveServerFile` detects the data packet is already uploaded (`filePath` TVar is `Just`), it cannot simply ignore the request body — the HTTP/2 client would block waiting for the router to consume it. Instead, `skipCommitted` reads and discards the entire body in `fileBlockSize` increments, returning `FROk` when complete. This makes FPUT idempotent from the client's perspective.
### 4. Atomic quota reservation with rollback
`receiveServerFile` uses `stateTVar` to atomically check and reserve storage quota before receiving the data packet. If the upload fails (timeout, size mismatch, IO error), the reserved size is subtracted from `usedStorage` and the partial data packet is deleted on the router. This prevents failed uploads from permanently consuming quota.
### 5. retryAdd generates new IDs on collision
`createFile` and `addRecipient` use `retryAdd` which generates a random ID and makes up to 3 total attempts (initial + 2 retries) on `DUPLICATE_` errors. This handles the astronomically unlikely case of random ID collision without requiring uniqueness checking before insertion.
### 6. Timing attack mitigation on entity lookup
`verifyXFTPTransmission` calls `dummyVerifyCmd` (imported from SMP router) when a data packet entity is not found. This equalizes result timing to prevent attackers from distinguishing "entity doesn't exist" from "signature invalid" based on latency.
### 7. BLOCKED vs EntityOff distinction
When `verifyXFTPTransmission` reads `fileStatus`:
- `EntityActive` → proceed with command
- `EntityBlocked info` → return `BLOCKED` with blocking reason
- `EntityOff` → return `AUTH` (same as entity-not-found)
`EntityOff` is treated identically to missing entities for information-hiding purposes.
### 8. blockServerFile deletes the stored data packet
Despite the name suggesting it only marks a data packet as blocked, `blockServerFile` also deletes the stored data packet from disk via `deleteOrBlockServerFile_`. The `deleted = True` parameter to `blockFile` in the store adjusts `usedStorage`. A blocked data packet returns `BLOCKED` errors on access but has no data on disk.
### 9. Stats restore overrides counts from live store
`restoreServerStats` loads stats from the backup file but overrides `_filesCount` and `_filesSize` with values computed from the live file store (TMap size and `usedStorage` TVar). If the backup values differ, warnings are logged. This handles cases where data packets were expired or deleted while the router was down.
### 10. Data packet expiration with configurable throttling
`expireServerFiles` accepts an optional `itemDelay` (100ms when called from the periodic thread, `Nothing` at router startup). Between each data packet check, `threadDelay itemDelay` prevents expiration from monopolizing IO. At startup, data packets are expired without delay to clean up quickly.
### 11. Stats log aligns to wall-clock midnight
`logServerStats` computes an `initialDelay` to align the first stats flush to `logStatsStartTime` (default 0 = midnight UTC). If the target time already passed today, it adds 86400 seconds for the next day. Subsequent flushes use exact `logInterval` cadence.
### 12. Stored data packet deleted before store cleanup
`deleteOrBlockServerFile_` removes the stored data packet first, then runs the STM store action. If the process crashes between these two operations, the store will reference a data packet that no longer exists on disk. The next access would return `AUTH` (data packet not found on disk), and eventual expiration would clean the store entry.
### 13. SNI-dependent CORS and web serving
CORS headers require both `sniUsed = True` and `addCORSHeaders = True` in the transport config. Static web page serving is enabled when `sniUsed = True`. Non-SNI connections (direct TLS without hostname) skip both CORS and web serving. This separates the web-facing and protocol-facing behaviors of the same router port.
### 14. Control port data packet operations use recipient index
`CPDelete` and `CPBlock` commands look up data packets via `getFile fs SFRecipient fileId`, meaning the control port takes a recipient ID, not a sender ID. This is the ID visible to recipients and contained in data packet descriptions.
@@ -1,24 +0,0 @@
# Simplex.FileTransfer.Server.Env
> XFTP router environment: configuration, storage quota tracking, and request routing.
**Source**: [`FileTransfer/Server/Env.hs`](../../../../../src/Simplex/FileTransfer/Server/Env.hs)
## Non-obvious behavior
### 1. Startup storage accounting with quota warning
`newXFTPServerEnv` computes `usedStorage` by summing data packet sizes from the in-memory store at startup. If the computed usage exceeds the configured `fileSizeQuota`, a warning is logged but the router still starts. This allows the router to come up even if it's over quota (e.g., after a quota reduction), relying on expiration to reclaim space.
### 2. XFTPRequest ADT separates new data packets from commands
`XFTPRequest` has three constructors:
- `XFTPReqNew`: data packet creation (carries `FileInfo`, recipient keys, optional basic auth)
- `XFTPReqCmd`: command on an existing data packet (carries file ID, `FileRec`, and the command)
- `XFTPReqPing`: health check
This separation occurs after credential verification in `Server.hs`. `XFTPReqNew` bypasses entity lookup entirely since the data packet doesn't exist yet.
### 3. fileTimeout for upload deadline
`fileTimeout` in `XFTPServerConfig` sets the maximum time allowed for a single data packet upload (FPUT). The router wraps the receive operation in `timeout fileTimeout`. Default is 5 minutes (for 4MB chunks). This prevents slow or stalled uploads from holding router resources indefinitely.
@@ -1,28 +0,0 @@
# Simplex.FileTransfer.Server.Main
> XFTP router CLI: INI configuration parsing, TLS setup, and default constants.
**Source**: [`FileTransfer/Server/Main.hs`](../../../../../src/Simplex/FileTransfer/Server/Main.hs)
## Non-obvious behavior
### 1. Key router constants
| Constant | Value | Purpose |
|----------|-------|---------|
| `fileIdSize` | 16 bytes | Random data packet/recipient ID length |
| `fileTimeout` | 5 minutes | Maximum upload duration per chunk |
| `logStatsInterval` | 86400s (daily) | Stats CSV flush interval |
| `logStatsStartTime` | 0 (midnight UTC) | First stats flush time-of-day |
### 2. allowedChunkSizes defaults to all four sizes
If not configured, `allowedChunkSizes` defaults to `[kb 64, kb 256, mb 1, mb 4]`. The INI file can restrict this to a subset, controlling which chunk sizes the router accepts.
### 3. Storage quota from INI with unit parsing
`fileSizeQuota` is parsed from the INI `[STORE_LOG]` section using `FileSize` parsing, which accepts byte values with optional unit suffixes (KB, MB, GB). Absence means unlimited quota (`Nothing`).
### 4. Dual TLS credential support
The router supports both primary TLS credentials (`caCertificateFile`/`certificateFile`/`privateKeyFile`) and optional HTTP-specific credentials (`httpCaCertificateFile`/etc.). When HTTP credentials are present, the router uses `defaultSupportedParamsHTTPS` which enables broader TLS compatibility for web clients.
@@ -1,19 +0,0 @@
# Simplex.FileTransfer.Server.Stats
> XFTP router statistics: IORef-based counters with backward-compatible persistence.
**Source**: [`FileTransfer/Server/Stats.hs`](../../../../../src/Simplex/FileTransfer/Server/Stats.hs)
## Non-obvious behavior
### 1. setFileServerStats is not thread safe
`setFileServerStats` directly writes to IORefs without synchronization. It is explicitly intended for router startup only (restoring from backup file), before any concurrent threads are running.
### 2. Backward-compatible parsing
The `strP` parser uses `opt` for newer fields, defaulting missing fields to 0. This allows reading stats files from older router versions that don't include fields like `filesBlocked` or `fileDownloadAcks`.
### 3. PeriodStats for download tracking
`filesDownloaded` uses `PeriodStats` (not a simple `IORef Int`) to track unique data packet downloads over time periods (day/week/month). This enables the CSV stats log to report distinct data packets downloaded per period, not just total download count.
@@ -1,39 +0,0 @@
# Simplex.FileTransfer.Server.Store
> STM-based in-memory file store with dual indices, storage accounting, and privacy-preserving expiration.
**Source**: [`FileTransfer/Server/Store.hs`](../../../../../src/Simplex/FileTransfer/Server/Store.hs)
## Non-obvious behavior
### 1. Dual-index lookup by sender and recipient
The file store maintains two indices: `files :: TMap SenderId FileRec` (by sender ID) and `recipients :: TMap RecipientId (SenderId, RcvPublicAuthKey)` (by recipient ID, storing the sender ID and the recipient's public auth key). `getFile` dispatches on `SFileParty`: sender lookups use `files` directly, recipient lookups use `recipients` to find the `SenderId` then look up the `FileRec` in `files`. This means recipient operations require two TMap lookups.
### 2. addRecipient checks both inner Set and global TMap
`addRecipient` first checks the per-file `recipientIds` Set for duplicates, then inserts into the global `recipients` TMap. If either has a collision, it returns `DUPLICATE_`. The dual check is necessary because the Set tracks per-file membership while the TMap enforces global uniqueness of recipient IDs.
### 3. Storage accounting on upload completion
`setFilePath` adds the data packet size to `usedStorage` and records the file path in the `filePath` TVar. However, during normal FPUT handling, `Server.hs` does NOT call `setFilePath` — it directly writes `filePath` via `writeTVar`. The quota reservation in `Server.hs` (`stateTVar` on `usedStorage`) is the sole `usedStorage` increment during upload. `setFilePath` IS called during store log replay (`StoreLog.hs`), where it increments `usedStorage`; `newXFTPServerEnv` then overwrites with the correct value computed from the live store.
### 4. deleteFile removes all recipients atomically
`deleteFile` atomically removes the sender entry from `files`, all recipient entries from the global `recipients` TMap, and unconditionally subtracts the data packet size from `usedStorage` (regardless of whether the data packet was actually uploaded). The entire operation runs in a single STM transaction.
### 5. RoundedSystemTime for privacy-preserving expiration
Data packet timestamps use `RoundedFileTime` which is `RoundedSystemTime 3600` — system time rounded to 1-hour precision. This means data packets created within the same hour have identical timestamps. An observer with access to the store cannot determine exact data packet creation times, only the hour.
### 6. expiredFilePath returns path only if expired
`expiredFilePath` returns `STM (Maybe (Maybe FilePath))`. The outer `Maybe` is `Nothing` when the data packet doesn't exist or isn't expired; the inner `Maybe` is the file path (present only if the data packet was uploaded). The expiration check adds `fileTimePrecision` (one hour) to the creation timestamp before comparing, providing a grace period. The caller uses the inner path to decide whether to also delete the stored data packet.
### 7. ackFile removes single recipient
`ackFile` removes a specific recipient from both the global `recipients` TMap and the per-file `recipientIds` Set. Unlike `deleteFile` which removes the entire data packet, `ackFile` only removes one recipient's access. The data packet and other recipients remain intact.
### 8. blockFile conditional storage adjustment
`blockFile` takes a `deleted :: Bool` parameter. When `True` (data packet blocked with physical deletion), it subtracts the data packet size from `usedStorage`. When `False` (block without deletion), storage is unchanged. This allows blocking without physical deletion for audit purposes. Currently, both the router's `blockServerFile` and the store log replay path pass `True`.
@@ -1,33 +0,0 @@
# Simplex.FileTransfer.Server.StoreLog
> Append-only store log for XFTP router data packet operations with error-resilient replay and compaction.
**Source**: [`FileTransfer/Server/StoreLog.hs`](../../../../../src/Simplex/FileTransfer/Server/StoreLog.hs)
## Non-obvious behavior
### 1. Error-resilient replay
`readFileStore` parses the store log line-by-line. Lines that fail to parse or fail to process (e.g., referencing a nonexistent sender ID) are logged as errors but do not halt replay. The store is reconstructed from whatever valid entries exist. This allows the router to recover from partial log corruption.
### 2. Sender ID validation on recipient writes
`writeFileStore` during compaction validates that each recipient's sender ID in the `recipients` TMap matches the `senderId` of the corresponding `FileRec`. This guards against in-memory state corruption (e.g., if a bug caused the `recipients` TMap and `FileRec.recipientIds` to get out of sync), not log corruption — the validation happens before writing the compacted log.
### 3. Backward-compatible status parsing
`AddFile` log entries include an `EntityStatus` field. The parser uses `<|> pure EntityActive` as a fallback, defaulting to `EntityActive` when the status field is missing. This allows reading store logs from older router versions that didn't record entity status.
### 4. Compaction on restart
`readFileStore` replays the full log to rebuild the in-memory store. The caller (in `Server/Env.hs`) then writes a fresh, compacted store log containing only the current state. This eliminates deleted entries and redundant operations, keeping the log size proportional to active state rather than total history.
### 5. Log entry types track operation lifecycle
Six log entry types capture the complete data packet lifecycle:
- `AddFile`: data packet creation with sender ID, file info, timestamp, and status
- `AddRecipients`: recipient registration (batched as `NonEmpty FileRecipient`) with sender ID association
- `PutFile`: upload completion with file path
- `DeleteFile`: data packet deletion by sender ID
- `AckFile`: single recipient acknowledgment
- `BlockFile`: data packet blocking with blocking info
@@ -1,23 +0,0 @@
# Simplex.FileTransfer.Transport
> XFTP protocol types, version negotiation, and encrypted file streaming with integrity verification.
**Source**: [`FileTransfer/Transport.hs`](../../../../src/Simplex/FileTransfer/Transport.hs)
## xftpClientHandshakeStub — XFTP doesn't use TLS handshake
`xftpClientHandshakeStub` always fails with `throwE TEVersion`. The source comment states: "XFTP protocol does not use this handshake method." The XFTP handshake is performed at the HTTP/2 layer — `XFTPServerHandshake` and `XFTPClientHandshake` are sent as HTTP/2 request/response bodies (see `FileTransfer/Client.hs` and `FileTransfer/Server.hs`).
## receiveSbFile — constant-time auth tag verification
`receiveSbFile` validates the authentication tag using `BA.constEq` (constant-time byte comparison). The auth tag is collected from the stream after all file data — if the file data ends mid-chunk, the remaining bytes of that chunk are used first, and a follow-up read provides the rest of the tag if needed.
## receiveFile_ — two-phase integrity verification
File reception has two verification phases:
1. **During receive**: either size checking (plaintext via `hReceiveFile`) or auth tag validation (encrypted via `receiveSbFile`)
2. **After receive**: `LC.sha256Hash` of the entire received file is compared to `chunkDigest`
## sendEncFile — auth tag appended after all chunks
`sendEncFile` streams encrypted chunks via `LC.sbEncryptChunk`, then sends `LC.sbAuth sbState` (the authentication tag) as a final frame when the remaining size reaches zero.
@@ -1,27 +0,0 @@
# Simplex.FileTransfer.Types
> Agent-side file transfer types: receive/send file records, status state machines, and chunk/replica structures. Chunks are the agent's view of file pieces; each chunk maps to a data packet on an XFTP router.
**Source**: [`FileTransfer/Types.hs`](../../../../src/Simplex/FileTransfer/Types.hs)
## Non-obvious behavior
### 1. Receive file status state machine
`RcvFileStatus` progresses: `RFSReceiving``RFSReceived``RFSDecrypting``RFSComplete`, with `RFSError` as a terminal state reachable from any non-complete state. The `RFSReceived``RFSDecrypting` transition is significant: all chunks are downloaded but decryption hasn't started. The local worker (server=Nothing) picks up files in `RFSReceived` status.
### 2. Send file status state machine
`SndFileStatus` progresses: `SFSNew``SFSEncrypting``SFSEncrypted``SFSUploading``SFSComplete`, with `SFSError` as terminal. The prepare worker handles `SFSNew``SFSEncrypted` (including retry from `SFSEncrypting`), while per-router upload workers handle `SFSUploading``SFSComplete`.
### 3. Encrypted file path convention
`sndFileEncPath` constructs the path as `prefixPath </> "xftp.encrypted"`. This is a convention shared between the agent (`Agent.hs`) and this module — both must agree on where the encrypted intermediate file lives relative to the prefix directory.
### 4. FileHeader fileExtra for future extension
`FileHeader` contains `fileName` and an optional `fileExtra :: Maybe Text` field. Currently unused (`Nothing` in all callers), it provides a forward-compatible extension point embedded in the encrypted file header without requiring protocol version changes.
### 5. authTagSize = 16 bytes
`authTagSize` is defined as `fromIntegral C.authTagSize` (16 bytes). This is the AES-GCM authentication tag appended to the encrypted file stream. It is included in the payload size calculation (`payloadSize = fileSize' + fileSizeLen + authTagSize`), which is then passed to `prepareChunkSizes` to determine data packet allocation.
-334
View File
@@ -1,334 +0,0 @@
# Simplex.Messaging.Agent
> Orchestration layer: duplex connection lifecycle, message processing dispatch, queue rotation, ratchet synchronization, and async command framework.
**Source**: [`Agent.hs`](../../../../../src/Simplex/Messaging/Agent.hs)
**See also**: [Agent/Client.md](./Agent/Client.md) — the infrastructure layer (AgentClient, worker framework, protocol client lifecycle, subscription state, operation suspension).
**Protocol spec**: [`agent-protocol.md`](../../../../protocol/agent-protocol.md) — duplex connection procedure, agent message syntax.
## Overview
This module is the top-level SimpleX agent, consumed by simplex-chat and other client applications. It passes specific worker bodies, task queries, and handler logic into the frameworks defined in [Agent/Client.hs](./Agent/Client.md), and implements the orchestration policies: duplex handshake, queue rotation, ratchet synchronization, message integrity validation.
### Agent startup — backgroundMode
`getSMPAgentClient_` accepts a `backgroundMode` flag that fundamentally changes agent capabilities:
- **Normal mode** (`backgroundMode = False`): starts four threads raced via `raceAny_``subscriber` (main event loop), `runNtfSupervisor` (notification management), `cleanupManager` (garbage collection), `logServersStats` (statistics). Also restores persisted router statistics. If any thread crashes, all are cancelled; statistics are saved in a `finally` block.
- **Background mode** (`backgroundMode = True`): starts only the `subscriber` thread. No cleanup, no notifications, no stats persistence. Used when the agent needs minimal receive-only operation.
Thread crashes are caught by the `run` wrapper: if the agent is still active (`acThread` is set), the exception is reported as `CRITICAL True` to `subQ`. If the agent is being disposed, crashes are silently ignored.
### Service + entity session mode prohibition
Service certificates and entity transport session mode (`TSMEntity`) are mutually exclusive. This is checked in four places: `getSMPAgentClient_`, `setNetworkConfig`, `createUser'`, `setUserService'`. If violated, throws `CMD PROHIBITED`. The constraint exists because service certificates associate multiple queues under one identity, which contradicts entity session mode's goal of preventing queue correlation.
## Split-phase connection creation
`prepareConnectionLink` and `createConnectionForLink` separate link preparation (key generation, link formatting — no network) from queue creation (single network call). This prevents the race where a link is published before the queue exists on the router.
**Sender ID derivation.** The sender ID is deterministic: `SMP.EntityId $ B.take 24 $ C.sha3_384 corrId` where `corrId` is a random nonce. `createConnectionForLink` validates `actualSndId == sndId` — if the router returns a different sender ID, the connection is rejected. See source comment: "the remaining 24 bytes are reserved, possibly for notifier ID in the new notifications protocol."
**PQ restriction.** `IKUsePQ` is prohibited for prepared links — throws `CMD PROHIBITED`. PQ keys are too large for the short link format.
## Subscriber loop — processSMPTransmissions
The subscriber thread reads batches from `msgQ` (filled by SMP protocol clients) and dispatches to `processSMPTransmissions`. Each batch is processed within `agentOperationBracket c AORcvNetwork waitUntilActive`, tying into the operation suspension cascade.
**Batch UP notification accumulation.** Successful subscription confirmations (`processSubOk`) append to a shared `upConnIds` TVar across the batch. A single `UP` event is emitted after all transmissions are processed, not per-transmission. Similarly, `serviceRQs` accumulates service-associated receive queues for batch processing via `processRcvServiceAssocs`.
**Double validation for subscription results.** `isPendingSub` checks two conditions atomically: the queue must be in the pending map AND the client session must still be active (`activeClientSession`). If either fails, the result is counted as ignored (statistics only). This handles the race where a subscription result arrives after reconnection.
**SUB result piggybacking MSG.** When a SUB result arrives as `Right msg@SMP.MSG {}`, the connection is marked UP (via `processSubOk`) AND the MSG is processed. The UP notification happens even if the MSG processing fails — the connection is up regardless.
**subQ overflow to pendingMsgs.** `processSMP` writes events to `subQ` (bounded TBQueue) but when full, events go into a `pendingMsgs` TVar. After processing, pending messages are drained in reverse order (LIFO). This prevents the message processing thread from blocking on a full queue, which would stall the entire SMP client.
**END/ENDS session validation.** Both check `activeClientSession` before removing subscriptions. If the session doesn't match (stale disconnect), the event is logged but ignored.
## Message processing — processSMP
`processSMP` dispatches on the SMP message type within a per-connection lock (`withConnLock`).
### Four e2e key states
The MSG handler discriminates on `(e2eDhSecret, e2ePubKey_)`:
- `(Nothing, Just key)`**Handshake**: computes DH, decrypts with per-queue E2E. Dispatches to `smpConfirmation` or `smpInvitation`.
- `(Just dh, Nothing)`**Established**: normal message flow. Dispatches to `AgentRatchetKey` or `AgentMsgEnvelope`.
- `(Just dh, Just _)`**Repeated confirmation**: only AgentConfirmation is accepted (ACK for previous one failed), everything else is rejected.
- `(Nothing, Nothing)`**Error**: no keys at all.
### ACK semantics
ACK is NOT automatic for `A_MSG` — the function returns `ACKPending` and the user must call `ackMessage`. ACK IS automatic for all control messages (HELLO, QADD, QKEY, QUSE, QTEST, EREADY, A_RCVD).
`handleNotifyAck` wraps the MSG processing: if any error occurs, it sends `ERR` to the client but still ACKs the SMP message. This prevents a processing error from causing infinite re-delivery.
### agentClientMsg — transactional message processing
Performs ratchet decryption, message parsing, and integrity checking inside a single `withStore` transaction with `lockConnForUpdate`. This serializes all message processing for a given connection, preventing concurrent ratchet state modifications. Returns the pre-decryption ratchet state (`rcPrev`) alongside the message — needed by `ereadyMsg` to decide whether to send EREADY.
### Additional queue status transitions on message receipt
When receiving an `AgentMsgEnvelope` on a non-Active queue, the queue is set to Active. For primary queues during rotation (`dbReplaceQueueId` is set), the new queue is set as primary and the old queue is scheduled for deletion via `ICQDelete`. This is how the receiving side completes queue rotation — any message on the new queue triggers cleanup of the old one.
### Duplicate message handling
Three paths for `A_DUPLICATE` errors:
1. **Stored and user-acked**: `getLastMsg` finds it with `userAck = True``ackDel`.
2. **Stored, A_MSG, not user-acked**: re-notify the user with `MSG` event and return `ACKPending`. The user may not have seen the original.
3. **Not stored or non-A_MSG**: `checkDuplicateHash` verifies the encrypted hash exists in the DB. If not, re-throws (real decryption failure, not duplicate).
For crypto errors (`A_CRYPTO`): if the encrypted hash already exists, suppressed (duplicate). If not, `notifySync` classifies via `cryptoErrToSyncState` (RSAllowed or RSRequired) and updates the connection's ratchet sync state.
### resetRatchetSync on successful decryption
When a double-ratchet message is successfully decrypted and the connection's ratchet sync state is not `RSOk` or `RSStarted`, the state is reset to `RSOk` and `RSYNC RSOk` is notified. Successful message delivery is the recovery signal for ratchet desynchronization.
### updateConnVersion — monotonic upgrade
Every received `AgentMsgEnvelope` triggers `updateConnVersion`. If the message's agent version is higher than the current agreed version and compatible, the agreed version is upgraded. Versions only increase. `safeVersionRange` handles the case where the sender's version exceeds the receiver's maximum — creates a range from `minVersion` to the sender's version.
## Duplex handshake
See [agent-protocol.md](../../../../protocol/agent-protocol.md) for the protocol description. Implementation-specific details:
### Initiating party (RcvConnection)
Receives AgentConfirmation with `e2eEncryption = Just sndParams`. Initializes the receive ratchet from the sender's E2E parameters. **v7+ (ratchetOnConfSMPAgentVersion)**: creates the ratchet immediately on confirmation processing, not later on `allowConnection`. See source comment on `processConf` — this supports decrypting messages that may arrive before `allowConnection` is called. The ratchet creation, E2E secret setup, and confirmation storage all happen in one `withStore` transaction.
### Accepting party (DuplexConnection)
Receives AgentConfirmation with `e2eEncryption = Nothing` and `AgentConnInfo` (not `AgentConnInfoReply`). The ratchet was already initialized during `joinConnection`. If `senderKey` is present, enqueues `ICDuplexSecure` (queue needs securing with SKEY). If absent (sender already secured via LKEY), sends `CON` immediately and sets the queue Active.
### HELLO exchange
HELLO is processed in `helloMsg`. The key dispatch is on `sndStatus`:
- `sndStatus == Active`: this side already sent HELLO, so receiving HELLO means both sides are connected → emit `CON`.
- Otherwise: this side hasn't sent HELLO yet → enqueue HELLO reply.
HELLO is not used in fast duplex connection (v9+ SMP with SKEY).
### startJoinInvitation — retry-safe ratchet creation
When retrying a join (existing `SndQueue`), `startJoinInvitation` tries to get the existing ratchet via `getSndRatchet` before creating a new one. If the ratchet exists, it reuses it. If not (error), it logs a non-blocking error via `nonBlockingWriteTBQueue` and creates a fresh ratchet. This prevents a retry from corrupting an already-established ratchet. The same pattern appears in `mkJoinInvitation` for contact URI joins.
### PQ support negotiation
PQ support is the AND of four conditions: the local client's PQ preference, the peer's agent version (>= `pqdrSMPAgentVersion`), the E2E encryption version (>= `pqRatchetE2EEncryptVersion`), and the connection's current PQ support. This negotiation happens at `joinConn` and `smpConfirmation` time via `versionPQSupport_` and `pqSupportAnd`.
## Queue rotation
Four agent messages implement queue rotation. See [agent-protocol.md](../../../../protocol/agent-protocol.md#rotating-messaging-queue) for the protocol. Implementation-specific details:
**QADD** (processed by sender in `qAddMsg`): Creates a new `SndQueue` with DH key exchange. Deletes any previous pending replacement (`delSqs` partitioned by `dbReplaceQId`). Responds with `QKEY`. The replacement chain means consecutive rotation requests are handled correctly — only the latest survives.
**QKEY** (processed by recipient in `qKeyMsg`): Validates queue is `New` or `Confirmed` and switch status is `RSSendingQADD`. Enqueues `ICQSecure` for async processing.
**QUSE** (processed by sender in `qUseMsg`): Marks new queue `Secured`. Sends `QTEST` **only to the new queue**.
**QTEST** (no handler in processSMP): Any message on the new queue triggers old queue deletion via `dbReplaceQueueId` logic. QTEST exists only to ensure at least one message traverses the new queue.
**Sender-side completion in delivery handler.** When `AM_QTEST_` is successfully sent in `runSmpQueueMsgDelivery`, the old send queue is removed from the connection: pending messages are deleted, the queue record is removed, and the old queue's delivery worker is deleted from `smpDeliveryWorkers` (stopping its thread). This happens inside `withConnLockNotify` to prevent deadlock with the subscriber.
**ICQDelete error tolerance.** In `runCommandProcessing`, if deleting the old receive queue fails with a permanent error (e.g., queue already gone on router), `finalizeSwitch` still runs — the local switch completes. Only temporary errors prevent completion.
**Ratchet sync guard**: All four message handlers check `ratchetSyncSendProhibited` before proceeding.
## Ratchet synchronization — newRatchetKey
When an `AgentRatchetKey` message is received, `newRatchetKey` handles ratchet re-establishment.
### Hash-ordering for initialization role
Both parties generate key pairs and exchange them. The party whose `rkHash(k1, k2)` is **lower** (lexicographic comparison) initializes the **receiving** ratchet; the other initializes **sending** and sends EREADY. This breaks the symmetry when both parties simultaneously request ratchet sync.
### State machine
- `RSOk`, `RSAllowed`, `RSRequired`**receiving client**: generate new keys, send `AgentRatchetKey` reply, then proceed with hash-ordering.
- `RSStarted`**initiating client**: use keys already stored (from `synchronizeRatchet'`), proceed with hash-ordering.
- `RSAgreed`**error**: sets state to `RSRequired`, throws `RATCHET_SYNC`. Handles the edge case where both parties initiate simultaneously and one has completed.
### Deduplication
`checkRatchetKeyHashExists` prevents processing the same ratchet key twice. The hash is stored atomically before processing begins.
### EREADY
Sent when the ratchet was initialized as receiving (`rcSnd` is `Nothing` in the pre-decryption ratchet state). Carries `lastExternalSndId` so the other party knows which messages were sent with the old ratchet.
## Message integrity — checkMsgIntegrity
Sequential external sender ID + previous message hash chain. Five outcomes: `MsgOk` (sequential + hashes match), `MsgBadId` (ID from the past), `MsgDuplicate` (same ID), `MsgSkipped` (gap in sequence), `MsgBadHash` (sequential but hashes differ).
The integrity result is delivered to the client application via `MsgMeta`. The agent does not reject messages with integrity failures — it reports them and continues processing. The client decides the policy.
## Async command processing — runCommandProcessing
Uses the worker framework from [Agent/Client.hs](./Agent/Client.md#worker-framework). Keyed by `(connId, server)` — each connection/server combination gets its own command worker. Uses `AOSndNetwork` for operation suspension.
### Internal commands
- **ICAllowSecure**: User-initiated handshake completion (from `allowConnection`). On DuplexConnection (SKEY retry), if the error is temporary and the send queue's server differs from the command's server, the command is **moved** to the correct server queue via `updateCommandServer` + `getAsyncCmdWorker`. Returns `CCMoved` instead of `CCCompleted`.
- **ICDuplexSecure**: Automatic handshake completion (from receiving AgentConnInfo with senderKey). Secures queue and sends HELLO.
- **ICQSecure / ICQDelete**: Queue rotation — secure the new queue (KEY) and delete the old queue.
- **ICAck / ICAckDel**: Send ACK to the router, optionally deleting the internal message record.
- **ICDeleteConn**: No longer used, but may exist in old databases — cleaned up by deleting the command record.
- **ICDeleteRcvQueue**: Queue cleanup during rotation.
### Retry semantics
`tryMoveableCommand` wraps execution with `withRetryInterval`: waits for `waitWhileSuspended` and `waitForUserNetwork`, then executes. Temporary/host errors trigger retry via `retrySndOp`. On success, the command is deleted. On permanent error, the error is notified and the command is deleted. `retrySndOp` separates `endAgentOperation`/`beginAgentOperation` into separate `atomically` blocks — see source comment: if `beginAgentOperation` blocks, `SUSPENDED` won't be sent.
### withConnLockNotify — deadlock prevention
Returns `Maybe ATransmission` and writes to `subQ` **after** releasing the lock. This prevents deadlock: if the lock holder writes to a full `subQ` while the subscriber thread needs the lock to process a message, both block indefinitely.
## Message delivery — runSmpQueueMsgDelivery
Per-queue delivery loop. Each `SndQueue` has its own worker keyed by queue address in `smpDeliveryWorkers`, paired with a `TMVar ()` retry lock (via `getAgentWorker'`).
### Deferred encryption
Message bodies are NOT encrypted at enqueue time. `enqueueMessageB` advances the ratchet header (`agentRatchetEncryptHeader`) and validates padding (`rcCheckCanPad`), but stores only the body reference (`sndMsgBodyId`) and encryption key (`encryptKey`, `paddedLen`). The actual message body encoding (`encodeAgentMsgStr`) and encryption (`rcEncryptMsg`) happen at delivery time. This allows the same body to be shared across multiple send queues via `sndMsgBodyId` — each delivery encrypts independently with its connection's ratchet.
For confirmation and ratchet key messages (AM_CONN_INFO, AM_CONN_INFO_REPLY, AM_RATCHET_INFO), the body is pre-encrypted and stored in `msgBody` directly — no deferred encryption.
### Per-message-type error handling
**QUOTA**: Checks `internalTs` against `quotaExceededTimeout`. If the message is older than the timeout, expires it and all subsequent expired messages in the queue (via `getExpiredSndMessages` → bulk `MERRS` notification). If not expired, sends `MWARN` and retries with `RISlow`. For confirmation messages (AM_CONN_INFO/AM_CONN_INFO_REPLY), QUOTA is treated as `NOT_AVAILABLE`.
**AUTH**: Per message type:
- `AM_CONN_INFO` / `AM_CONN_INFO_REPLY` / `AM_RATCHET_INFO`: connection error `NOT_AVAILABLE`
- `AM_HELLO_` with receive queue (initiating party): `NOT_AVAILABLE`. Without receive queue (joining party): `NOT_ACCEPTED`.
- `AM_A_MSG_` / `AM_A_RCVD_` / `AM_QCONT_` / `AM_EREADY_`: delete message and notify `MERR`.
- Queue rotation messages (`AM_QADD_` through `AM_QTEST_`): queue error with descriptive string.
**Timeout/network errors**: message-type-aware timeout — `AM_HELLO_` uses `helloTimeout`, all others use `messageTimeout`. If expired, uses `notifyDelMsgs` which expires the current message AND fetches all expired messages for the queue in bulk. If `serverHostError`, sends `MWARN` before retrying. Non-host temporary errors retry silently.
### Delivery success handling
On successful send, per message type:
- `AM_CONN_INFO` with `senderCanSecure` (fast handshake): sends `CON` + sets status `Active`.
- `AM_CONN_INFO` without `senderCanSecure`: sets status `Confirmed` only.
- `AM_CONN_INFO_REPLY`: sets status `Confirmed`.
- `AM_HELLO_`: sets status `Active`. If receive queue exists AND its status is `Active`, sends `CON` (accepting party in v2).
- `AM_A_MSG_`: sends `SENT msgId proxySrv_` to notify the client.
- `AM_QKEY_`: re-reads connection and sends `SWITCH QDSnd SPConfirmed`.
- `AM_QTEST_`: see "Sender-side completion" under Queue rotation above.
- All other types: no notification.
After success, the delivery record is deleted. For `AM_A_MSG_`, `keepForReceipt = True` — the record is kept until a receipt is received.
### withRetryLock2 — external retry signaling
The delivery loop uses `withRetryLock2` which combines the standard retry interval with `qLock` (the `TMVar ()` paired with the worker). When `A_QCONT` is received, the handler puts `()` into the retry lock, causing the retry to fire immediately instead of waiting for the backoff interval. See `continueSending` in `processSMP`.
### submitPendingMsg — operation counting
`submitPendingMsg` increments `opsInProgress` on `msgDeliveryOp` BEFORE spawning the delivery worker. This means the operation is counted even before the worker starts, ensuring the suspension cascade waits for all enqueued deliveries.
## Batch message sending — sendMessagesB_
### MsgReq grouping contract
Messages to the same connection must be contiguous in the traversable, with only the first having a non-empty `connId`. Subsequent messages for the same connection must have empty `connId`. This is validated by `addConnId` which rejects duplicate `connId` values and empty first `connId`. The `getConn_` function uses a `TVar prev` to cache the last connection lookup, avoiding redundant database reads.
### Connection locking
`withConnLocks` takes locks for ALL connections in the batch before processing. This prevents concurrent sends to the same connection from interleaving ratchet state updates.
### PQ support monotonic upgrade
When `pqEnc == PQEncOn` but the connection has `pqSupport == PQSupportOff`, PQ support is upgraded via `setConnPQSupport`. PQ support can only be enabled, never disabled. The upgrade IDs are accumulated via `mapAccumL` and applied in a single batch database write.
### VRValue/VRRef — database body deduplication
VRValue/VRRef deduplication operates at the **database body storage** level, not encryption. `enqueueMessageB` tracks an `IntMap (Maybe Int64, AMessage)` mapping integer indices to database body IDs (`sndMsgBodyId`):
- `VRValue (Just i) body`: stores the body in `snd_message_bodies`, records the `sndMsgBodyId`, and associates it with index `i` for future reference.
- `VRRef i`: looks up index `i` to get the previously stored `sndMsgBodyId`, and creates a new `snd_messages` record linked to the same body.
Encryption is NOT deduplicated — each connection's ratchet header is independently advanced at enqueue time, and each delivery encrypts the body independently. The optimization is purely about avoiding redundant database storage of identical message bodies (common for group messages).
### Error propagation constraint
When a connection type is wrong (e.g., SndConnection, NewConnection), the error is returned per-message but the batch continues. See source comment: "we can't fail here, as it may prevent delivery of subsequent messages that reference the body of the failed message." If a VRValue message fails, subsequent VRRef messages that reference it would break.
## Subscription management
### subscribeConnections_
Partitions connections by type. SndConnection with `Confirmed` status returns success (it's not subscribed, just waiting). SndConnection with `Active` status returns `CONN SIMPLEX` (can't subscribe a send-only connection). After subscribing queues, resumes delivery workers for connections with pending deliveries (via `getConnectionsForDelivery`).
**Multi-queue result combining.** For connections with multiple receive queues, results are combined using a priority system: Active+Success (1) > Active+Error (2) > non-Active+Success (3) > non-Active+Error (4). The highest-priority (lowest number) result is used. This ensures that if at least one Active queue subscribes successfully, the connection reports success.
### subscribeAllConnections'
**Active user priority.** If `activeUserId_` is provided, that user's subscriptions are processed first (`sortOn`).
**Service subscription with fallback.** Service subscriptions are attempted first. If a service subscription fails with `SSErrorServiceId` or zero subscribed queues, the queues are unassociated from the service and subscribed individually. If the error is a client-level error (not a service-specific error), the same fallback applies.
**Pending throttle.** `maxPending` limits concurrent pending subscriptions. The counter is incremented inside the database transaction (before leaving `withStore'`) and decremented in a `finally` block. When the count exceeds the limit, `subscribeUserServer` blocks in STM via `retry`.
### resubscribeConnections'
Filters out connections that already have active subscriptions (via `hasActiveSubscription`). For store errors, returns `True` for `isActiveConn` — this causes the error to be processed by `subscribeConnections_` which will report it.
## Notification token lifecycle
`registerNtfToken'` is a complex state machine. Key non-obvious behavior: on `NTF AUTH` error during token operations, the token is removed and re-registered from scratch (see `withToken` catch of `NTF AUTH`). Device token changes trigger `replaceToken`, which attempts an in-place replacement; if that fails with a permanent error, the token is removed and recreated.
## Cleanup manager
Runs periodically with a `cleanupStepInterval` delay BETWEEN each cleanup operation (not just between cycles). This prevents cleanup from monopolizing database access.
Additional cleanup not previously mentioned:
- **Expired receive message hashes**: `deleteRcvMsgHashesExpired`
- **Expired send messages**: `deleteSndMsgsExpired`
- **Expired ratchet key hashes**: `deleteRatchetKeyHashesExpired`
- **Expired notification tokens**: `deleteExpiredNtfTokensToDelete`
- **Expired send chunk replicas**: `deleteDeletedSndChunkReplicasExpired`
## Agent suspension
`suspendAgent` has two modes:
- **Immediate** (`maxDelay = 0`): sets `ASSuspended` and suspends all operations immediately.
- **Gradual** (`maxDelay > 0`): sets `ASSuspending` and triggers the cascade (NtfNetwork independent; RcvNetwork → MsgDelivery → SndNetwork → Database). A timeout thread fires after `maxDelay` and forces suspension of sending and database if still suspending.
`foregroundAgent` resumes in reverse order: database → sending → delivery → receiving → notifications.
## connectReplyQueues — background duplex upgrade
Used during async command processing to complete the duplex handshake. Handles two cases:
- **Fresh connection** (`sq_ = Nothing`): upgrades `RcvConnection` to `DuplexConnection`.
- **SKEY retry** (`sq_ = Just sq`): connection is already duplex. See source comment: "in case of SKEY retry the connection is already duplex."
## secureConfirmQueue vs secureConfirmQueueAsync
- **secureConfirmQueue** (synchronous): secures queue and sends confirmation directly via network. Used in `joinConnection`.
- **secureConfirmQueueAsync** (asynchronous): secures queue, stores confirmation, submits to delivery worker. Used in `allowConnection` (via `ICAllowSecure`).
Both call `agentSecureSndQueue`, which returns `initiatorRatchetOnConf` — whether the initiator's ratchet should be created on confirmation (v7+ behavior). When the queue was already secured (retry), returns the same flag without re-securing.
## smpConfirmation — version compatibility
The confirmation handler accepts messages where the agent version or client version is either within the configured range OR at-or-below the already-agreed version. See source comment. This means a downgraded client can still complete in-progress handshakes.
## smpInvitation — contact address handling
Invitation messages received on a contact address are passed through even if version-incompatible. See source comment. The client application sees `REQ` with `PQSupportOff` when incompatible.
## ackMessage' — receipt sending
After ACKing a message, if the user provides receipt info (`rcptInfo_`), a receipt message (`A_RCVD`) is enqueued. Receipts are only allowed for `AM_A_MSG_` type. If the user ACKs without receipt info and the message already has a receipt with `MROk` status, the corresponding sent message is deleted from the database — it's confirmed delivered.
## acceptContactAsync' — rollback on failure
See source comment. Unlike the synchronous `acceptContact'` which takes a lock first, `acceptContactAsync'` marks the invitation as accepted before joining. On failure, `unacceptInvitation` rolls back. The comment notes this could be improved with an invitation lock map.
## prepareConnectionToJoin — race prevention
See source comment. Creates a connection record without queues, returning a `ConnId`. The caller saves this ID before the peer can send a confirmation. Without this, the sequence "joinConnection → peer sends confirmation → caller saves ConnId" could result in the confirmation arriving before the caller has the ID.
@@ -1,300 +0,0 @@
# Simplex.Messaging.Agent.Client
> Agent infrastructure layer: protocol client lifecycle, worker framework, subscription management, operation suspension, and concurrency primitives.
**Source**: [`Agent/Client.hs`](../../../../../../src/Simplex/Messaging/Agent/Client.hs)
**See also**: [Agent.hs](./Agent.md) — the orchestration layer that consumes these primitives.
## Overview
This module defines `AgentClient`, the central state container for the SimpleX agent, and all reusable infrastructure that Agent.hs and other consumers (NtfSubSupervisor.hs, FileTransfer/Agent.hs, simplex-chat) build upon. It covers:
- **Protocol client lifecycle**: lazy singleton connections to SMP/NTF/XFTP routers via `SessionVar` pattern, with disconnect callbacks and reconnection workers
- **Worker framework**: `getAgentWorker` (lifecycle, restart rate limiting, crash recovery) + `withWork`/`withWork_`/`withWorkItems` (task retrieval with doWork flag atomics)
- **Subscription state**: active/pending/removed queues, session-aware cleanup on disconnect, batch subscription RPCs with post-hoc session validation
- **Operation suspension**: five `AgentOpState` TVars with cascade ordering for graceful shutdown
- **Concurrency primitives**: per-connection locks, transport session batching, proxy routing
The module is consumed by Agent.hs (which passes specific worker bodies, task queries, and handler logic into these frameworks) and by external consumers that reuse the worker and protocol client infrastructure.
## AgentClient — central state container
`AgentClient` has ~43 fields, almost all TVars or TMaps. Key architectural groupings:
- **Event queues**: `subQ` (events to client application), `msgQ` (messages from SMP routers)
- **Protocol client pools**: `smpClients`, `ntfClients`, `xftpClients` — all are TMaps of `TransportSession``SessionVar`, implementing lazy singletons via `getSessVar`
- **Subscription tracking**: `currentSubs` (TSessionSubs, active+pending per transport session), `removedSubs` (failed subscriptions with errors), `subscrConns` (set of connection IDs currently subscribed)
- **Worker pools**: `smpDeliveryWorkers`, `asyncCmdWorkers` — TMaps keyed by work address/connection. `smpSubWorkers` — TMaps keyed by transport session for resubscription.
- **Operation states**: `ntfNetworkOp`, `rcvNetworkOp`, `msgDeliveryOp`, `sndNetworkOp`, `databaseOp`
- **Locking**: `connLocks`, `invLocks`, `deleteLock`, `getMsgLocks`, `clientNoticesLock`
- **Service state**: `useClientServices` (per-user boolean controlling whether service certificates are used)
- **Proxy routing**: `smpProxiedRelays` (maps destination transport session → proxy router used)
- **Network state**: `userNetworkInfo`, `userNetworkUpdated`, `useNetworkConfig` (slow/fast pair)
All TVars are initialized in `newAgentClient`. The `active` TVar is the global kill switch — `closeAgentClient` sets it to `False`, and all protocol client getters check it first.
## Protocol client lifecycle — SessionVar singleton pattern
Protocol client connections (SMP, NTF, XFTP) use a lazy singleton pattern implemented by [Session.hs](../../../Session.md):
1. **`getSessVar`** atomically checks the TMap. Returns `Left newVar` if absent (caller must connect), `Right existingVar` if present (caller waits for the TMVar).
2. **`newProtocolClient`** wraps the connection attempt. On success, fills the `sessionVar` TMVar with `Right client` and writes a `CONNECT` event to `subQ`. On failure, fills with `Left (error, maybeRetryTime)` and re-throws.
3. **`waitForProtocolClient`** reads the TMVar with a timeout. If the stored error has an expiry time that has passed, it removes the SessionVar and retries from scratch — this is the `persistErrorInterval` retry mechanism.
### Error caching with persistErrorInterval
When `newProtocolClient` fails and `persistErrorInterval > 0`, the error is cached with an expiry timestamp (`Just ts`). Future connection attempts during the interval immediately receive the cached error from `waitForProtocolClient` without attempting a connection. When `persistErrorInterval == 0`, the SessionVar is removed immediately on failure, so the next attempt starts a fresh connection. This prevents connection storms to unreachable routers.
### SessionVar compare-and-swap
`removeSessVar` (Session.hs) only removes a SessionVar from the map if its `sessionVarId` matches the current entry. The `sessionVarId` is a monotonically increasing counter from `workerSeq`. This prevents a stale disconnection callback from removing a *new* client that was created after the old one disconnected. Without this, the sequence "client A disconnects → client B connects → client A's callback runs" would incorrectly remove client B.
### SMP connection — service credentials and session setup
`smpConnectClient` connects an SMP client, with two important post-connection steps:
1. **Session ID registration**: `SS.setSessionId` records the TLS session ID in `currentSubs`, linking the transport session to the actual TLS connection for later session validation.
2. **Service credential synchronization** (`updateClientService`): After connecting, compares client-side and router-side service state. Four cases:
- Both have service and IDs match → update DB (no-op if same)
- Both have service but IDs differ → update DB and remove old queue-service associations
- Client has service, router doesn't → delete client service (handles router version downgrade)
- Router has service, client doesn't → log error (should not happen in normal flow)
On connection failure, `smpConnectClient` triggers `resubscribeSMPSession` before re-throwing the error. This ensures pending subscriptions get retry logic even when the initial connection attempt fails.
### SMP disconnect callback
`smpClientDisconnected` is the most complex disconnect handler (NTF/XFTP have simpler versions that remove the SessionVar and write a `DISCONNECT` event):
1. `removeSessVar` atomically removes the client if still current
2. If `active`, moves active subscriptions to pending (only those matching the disconnecting client's `sessionId` — see next section)
3. Removes proxied relay sessions that this client created
4. Fires `DISCONNECT`, `DOWN`, and `SERVICE_DOWN` events for affected connections
5. Releases GET locks for affected queues
6. Triggers resubscription (see below)
**Resubscription mode switching**: The disconnect handler chooses between two resubscription paths based on whether the session mode matches the entity presence: `(mode == TSMEntity) == isJust cId`. When they match, it calls `resubscribeSMPSession` which handles both service and queue resubscription in a single worker. When they don't match (e.g., entity-mode session disconnects but there's also a shared session), it separately resubscribes the service and queues, because they belong to different transport sessions.
### Session-aware subscription cleanup
`removeClientAndSubs` (inside `smpClientDisconnected`) uses `SS.setSubsPending` with the disconnecting client's `sessionId`. Only subscriptions whose session ID matches the disconnecting client are moved to pending. If a new client already connected and made its own subscriptions active, those are *not* disturbed. This prevents the race: "old client disconnects → new client subscribes → old client's cleanup incorrectly demotes new client's subscriptions."
## ProtocolServerClient typeclass
Unifies SMP/NTF/XFTP client management with associated types:
- `Client msg` — the connected client type (SMP wraps in `SMPConnectedClient` with proxied relay map; NTF and XFTP use the raw protocol client)
- `ProtoClient msg` — the underlying protocol client for logging/closing
SMP is special: `SMPConnectedClient` bundles the protocol client with `proxiedRelays :: TMap SMPServer ProxiedRelayVar`, a per-connection map of relay sessions for proxy routing.
XFTP is special in a different way: its `getProtocolServerClient` ignores the `NetworkRequestMode` parameter and always uses `NRMBackground` for `waitForProtocolClient`. This means XFTP connections always use background timing regardless of the caller's request mode.
## Worker framework
Defined here, consumed by Agent.hs, NtfSubSupervisor.hs, FileTransfer/Agent.hs, and simplex-chat. Two separable parts:
### getAgentWorker — lifecycle management
Creates or reuses a worker for a given key. Workers are stored in a TMap keyed by their work address.
- **Create-or-reuse**: atomically checks the map. If absent, creates a new `Worker` (with `doWork` TMVar pre-filled with `()`). If present and `hasWork=True`, signals the existing worker.
- **Fork**: `runWorkerAsync` takes the `action` TMVar. If `Nothing` (worker idle), it starts work. If `Just weakThreadId` (worker running), it puts the value back and returns. This bracket ensures at-most-one concurrent execution.
- **Restart rate limiting**: on worker exit (success or error), `restartOrDelete` checks `restartCount` against `maxWorkerRestartsPerMin`. If under the limit, resets `action` to `Nothing` (idle), signals `hasWorkToDo`, and reports `INTERNAL` error. If over the limit, deletes the worker from the map and sends a `CRITICAL True` error. The restart only happens if the worker's `workerId` still matches the map entry — a stale restart from a replaced worker silently no-ops.
`getAgentWorker'` is the generic version with custom worker wrapper — used by `smpDeliveryWorkers` which pairs each Worker with a `TMVar ()` retry lock.
### withWork / withWork_ / withWorkItems — task retrieval
Takes `getWork` (fetch next task) and `action` (process it) as separate parameters. The consumer's worker body loops: `waitForWork doWork``withWork doWork getTask handleTask`.
**Critical: doWork flag race prevention.** `noWorkToDo` (clearing the flag) happens BEFORE `getWork` (querying for tasks), not after. This prevents the race where: (1) worker queries, finds nothing, (2) another thread adds work and sets the flag, (3) worker clears the flag — losing the signal. By clearing first, any concurrent signal after the query will be preserved.
**Error classification**: `withWork_` distinguishes work-item errors from store errors:
- **Work item error** (`isWorkItemError`): the worker stops and sends `CRITICAL False`. The next iteration would likely produce the same error, so stopping prevents infinite loops.
- **Store error**: the flag is re-set and an `INTERNAL` error is reported. The assumption is that store errors are transient (e.g., DB busy) and retrying may succeed.
`withWorkItems` handles batched work — a list of items where some may have individual errors. If all items are work-item errors, the worker stops. If only some are, the worker continues with the successful items and reports errors via `ERRS` event.
### runWorkerAsync — at-most-one execution
Uses a bracket on the `action` TMVar:
- `takeTMVar action` — blocks if another thread is starting the worker (TMVar empty during start)
- If the taken value is `Nothing` — worker is idle, start it. Store `Just weakThreadId` in the TMVar via `forkIO`.
- If `Just _` — worker is already running, put it back and return.
The `Weak ThreadId` in `action` is a weak reference — it doesn't prevent the worker thread from being garbage collected. It is used by `cancelWorker`, which calls `deRefWeak` to get the thread ID and kills it; if the thread was already GC'd, the kill is a no-op. The primary lifecycle management is through the `restartOrDelete` chain in `getAgentWorker'`, not the weak reference.
### throwWhenNoDelivery — delivery worker self-termination
Delivery workers call `throwWhenNoDelivery` to check if their entry still exists in the `smpDeliveryWorkers` map. If the worker was removed (delivery complete), it throws `ThreadKilled` to terminate the worker thread. This is distinct from `throwWhenInactive` (which checks global `active` state) — it allows individual workers to be stopped without shutting down the entire agent.
## Operation suspension cascade
Five `AgentOpState` TVars track whether each operation category is suspended and how many operations are in-flight:
```
AONtfNetwork (independent)
AORcvNetwork → AOMsgDelivery → AOSndNetwork → AODatabase
```
The cascade means:
- `endAgentOperation AORcvNetwork` suspends `AOMsgDelivery`, which cascades to `AOSndNetwork``AODatabase`
- `endAgentOperation AOMsgDelivery` suspends `AOSndNetwork``AODatabase`
- `endAgentOperation AOSndNetwork` suspends `AODatabase`
- Each leaf in the cascade calls `notifySuspended` (writes `SUSPENDED` to `subQ`, sets `agentState` to `ASSuspended`)
**`beginAgentOperation`** retries (blocks in STM) if the operation is suspended. This provides backpressure: new operations wait until the operation is resumed.
**`agentOperationBracket`** wraps an operation with begin/end. It takes a `check` function that runs before `beginAgentOperation` — typically `throwWhenInactive`, which throws `ThreadKilled` if the agent is inactive. All database access goes through `withStore` which brackets with `AODatabase`. This ensures graceful shutdown propagates: suspending `AORcvNetwork` eventually suspends all downstream operations, and `notifySuspended` only fires when all in-flight operations have completed.
**`waitWhileSuspended`** vs **`waitUntilForeground`**: `waitWhileSuspended` proceeds during `ASSuspending` (allowing in-flight operations to complete), while `waitUntilForeground` blocks during both `ASSuspending` and `ASSuspended`.
**`waitForUserNetwork`**: bounded wait for network — if the network doesn't come online within `userNetworkInterval`, proceeds anyway. Uses `registerDelay` for the timeout.
## Subscription management
### subscribeQueues — batch-by-transport-session
`subscribeQueues` is the main entry point for subscribing to receive queues:
1. `checkQueues` filters out queues with active GET locks (prevents concurrent GET + SUB on the same queue)
2. `batchQueues` groups queues by transport session
3. `addPendingSubs` marks all queues as pending before the RPC
4. `mapConcurrently` subscribes each session batch in parallel
### subscribeSessQueues_ — post-hoc session validation and atomicity
After the subscription RPC completes, `subscribeSessQueues_` validates `activeClientSession` — checking that the SessionVar still holds the same client that was used for the RPC. If the client was replaced during the RPC (reconnection happened), the results are discarded (errors converted to temporary `BROKER NETWORK` to ensure retry) and resubscription is triggered.
The post-RPC processing runs under `uninterruptibleMask_` for atomicity. The sequence is:
1. **Atomically**: `processSubResults` partitions results and updates subscription state; if there are client notices, takes `clientNoticesLock` TMVar
2. **IO**: `processRcvServiceAssocs` updates service associations in the DB
3. **IO**: `processClientNotices` updates notice state, always releases `clientNoticesLock` in `finally`
The `clientNoticesLock` TMVar serializes notice processing across concurrent subscription batches.
**UP events for newly-active connections only**: After processing, UP events are sent only for connections that were NOT already active before this batch — existing active subscriptions (from `SS.getActiveSubs`) are excluded to prevent duplicate notifications.
**Client close on all-temporary-error**: When ALL subscription results are temporary errors, no connections were already active, and the session is still current, the SMP client session is closed. This forces a fresh connection on the next attempt rather than reusing a potentially broken one.
### processSubResults — partitioning
Subscription results are partitioned into five categories:
1. **Failed with client notice** — error has an associated router-side notice (e.g., queue status change). Queue is treated as failed (removed from pending, added to `removedSubs`) AND the notice is recorded for processing.
2. **Failed permanently** — non-temporary error without notice, queue is removed from pending and added to `removedSubs`
3. **Failed temporarily** — error is transient, queue stays in pending unchanged for retry on reconnect
4. **Subscribed** — moved from pending to active. Further split into: queues whose service ID matches the session service (added as service-associated) and others. If the queue had a tracked `clientNoticeId`, it is cleared (notice resolved by successful subscription).
5. **Ignored** — queue was not in the pending map (already activated by a concurrent path), counted for statistics only
### Resubscription worker
`resubscribeSMPSession` spawns a worker per transport session that retries pending subscriptions with exponential backoff (`withRetryForeground`). The worker:
1. Reads pending subs and pending service sub
2. Waits for foreground and network
3. Resubscribes service and queues
4. Loops until no pending subs remain
**Spawn guard**: Before creating a new worker, `resubscribeSMPSession` checks `SS.hasPendingSubs`. If there are no pending subs, it returns without spawning. This prevents creating idle workers.
**Cleanup blocks on TMVar fill** — the `cleanup` STM action retries (`whenM (isEmptyTMVar $ sessionVar v) retry`) until the async handle is inserted. This prevents the race where cleanup runs before the worker async is stored, which would leave a terminated worker in the map.
## Proxy routing — sendOrProxySMPCommand
Implements SMP proxy/direct routing with fallback:
1. `shouldUseProxy` checks `smpProxyMode` (Always/Unknown/Unprotected/Never) and whether the destination router is "known" (in the user's router list)
2. If proxying: `getSMPProxyClient` creates or reuses a proxy connection, then `connectSMPProxiedRelay` establishes the relay session. On `NO_SESSION` error, re-creates the relay session through the same proxy.
3. If proxying fails with a host error and `smpProxyFallback` allows it: falls back to direct connection
4. `deleteRelaySession` carefully validates that the current relay session matches the one that failed before removing it (prevents removing a concurrently-created replacement session)
**NO_SESSION retry limit**: On `NO_SESSION`, `sendViaProxy` is called recursively with `Just proxySrv` to reuse the same proxy router. If the recursive call also gets `NO_SESSION`, it throws `proxyError` instead of recursing again — `proxySrv_` is `Just`, so the `Nothing` branch (which recurses) is not taken. This limits retry to exactly one attempt.
**Proxy selection caching** (`smpProxiedRelays`): When `getSMPProxyClient` selects a proxy for a destination, it atomically inserts the proxy→destination mapping into `smpProxiedRelays`. If a mapping already exists (another thread selected a proxy for the same destination), the existing mapping is used. On relay creation failure with non-host errors, both the relay session and proxy mapping are removed. On host errors, they are preserved to allow fallback logic.
## Service credentials lifecycle
`getServiceCredentials` manages per-user, per-router service certificate credentials:
1. Checks `useClientServices` — if the user has services disabled, returns `Nothing`
2. Looks up existing credentials in DB via `getClientServiceCredentials`
3. If none exist, generates new TLS credentials on-the-fly (`genCredentials`) and stores them
4. Extracts the private signing key from the X.509 certificate
The generated credentials are Ed25519 self-signed certificates with `simplex` organization, valid for ~2740 years. The certificate chain and hash are bundled into `ServiceCredentials` for the SMP handshake.
## withStore — database access bracket
`withStore` wraps database access with `agentOperationBracket c AODatabase`, ensuring the operation suspension cascade is respected. SQLite errors are classified:
- `ErrorBusy`/`ErrorLocked``SEDatabaseBusy``CRITICAL True` (prompts user restart)
- Other SQL errors → `SEInternal`
`SEAgentError` is a special wrapper that allows agent-level errors to be threaded through store operations — used when "transaction-like" access is needed but the operation involves agent logic, not just DB queries. See source comment: "network IO should NOT be used inside AgentStoreMonad."
`withStoreBatch` / `withStoreBatch'` run multiple DB operations in a single transaction, catching exceptions per-operation to report individual failures. The entire batch is within one `agentOperationBracket`.
## Router selection — getNextServer / withNextSrv
Router selection has two-level diversity:
1. **Operator diversity**: prefer routers from operators not already used (tracked by `usedOperators` set)
2. **Host diversity**: prefer routers with hosts not already used (tracked by `usedHosts` set)
`filterOrAll` ensures that if all routers are "used," the full list is returned rather than an empty one.
`withNextSrv` is designed for retry loops — it re-reads user routers on each call (allowing configuration changes during retries) and tracks `triedHosts` across attempts. When all hosts are tried, the tried set is reset (`S.empty`), creating a round-robin effect.
## Locking primitives
**`withConnLock`**: Per-connection lock via `connLocks` TMap. Non-obvious: `withConnLock'` with empty `ConnId` is a no-op (identity function) — allows agent operations on entities without real connection IDs to skip locking.
**`withConnLocks`**: Takes a `Set ConnId` and acquires locks for all connections. Uses `withGetLocks` which acquires all locks concurrently via `forConcurrently`. Note: concurrent acquisition of overlapping lock sets from different threads could theoretically deadlock, so callers must ensure non-overlapping lock sets or use a higher-level coordination.
**`getMapLock`**: Creates a lock on first access and caches it in the TMap. Locks are never removed — the TMap grows monotonically.
## Network configuration — slow/fast selection
`getNetworkConfig` selects between slow and fast network configs based on `userNetworkInfo`:
- `UNCellular` or `UNNone` → slow config (1.5× timeouts via `slowNetworkConfig`)
- `UNWifi`, `UNEthernet`, `UNOther` → fast config
Both configs are stored together in `useNetworkConfig :: TVar (NetworkConfig, NetworkConfig)`. The slow config is derived from the fast config in `newAgentClient`.
## closeAgentClient — shutdown sequence
1. Sets `active = False` — all protocol client getters will throw `INACTIVE`
2. Closes all protocol server clients (SMP, NTF, XFTP) by swapping maps to empty and forking close threads
3. Clears proxied relays
4. Cancels resubscription workers — forks cancellation threads (fire-and-forget, `closeAgentClient` may return before all workers are cancelled)
5. Clears delivery and async command workers (delivery workers are also cancelled via `cancelWorker`)
6. Clears subscription state
The cancellation of resubscription workers reads the TMVar first (to get the Async handle), then calls `uninterruptibleCancel`. This is wrapped in a forked thread to avoid blocking the shutdown sequence.
**`closeClient_` edge case**: When closing individual clients, `closeClient_` handles `BlockedIndefinitelyOnSTM` — which occurs if the SessionVar TMVar was never filled (connection attempt in progress when shutdown started). The exception is caught and treated as a no-op.
**`reconnectServerClients` vs `closeProtocolServerClients`**: `closeProtocolServerClients` swaps the map to empty and closes all clients — no new connections can be made to those sessions. `reconnectServerClients` reads the map without clearing it and closes current clients — the disconnect callbacks trigger reconnection, effectively forcing fresh connections while keeping the session entries.
## Transport session modes
`TransportSessionMode` (`TSMEntity` vs other) determines whether the transport session key includes the entity ID (connection/queue ID). When `TSMEntity`, each queue gets its own TLS connection to the router. When not, queues to the same router share a connection. This is controlled by `sessionMode` in the network config.
`mkSMPTSession` and related functions compute the transport session key based on the current mode. This affects connection multiplexing — entity-mode sessions provide better privacy (router can't correlate queues) at the cost of more connections.
## getMsgLocks — GET exclusion
`getQueueMessage` creates a TMVar lock keyed by `(server, rcvId)` and takes it before sending GET. This prevents concurrent GET and SUB on the same queue (SUB is checked via `hasGetLock` in `checkQueues`). The lock is released by `releaseGetLock` after ACK or on error.
The lock creation uses `TM.alterF` to atomically create-or-reuse: if no lock exists, creates a new `TMVar ()` and immediately takes it; if one exists, takes it. This avoids a race between two concurrent GET attempts on the same queue.
## Error classification — temporaryAgentError
Classifies errors as temporary (retryable) or permanent. Notable non-obvious classifications:
- `TEHandshake BAD_SERVICE` is temporary — it indicates a DB error on the router, not a permanent rejection
- `CRITICAL True` is temporary — `True` means the error shows a restart button, implying the user should retry. `CRITICAL False` is permanent.
- `INACTIVE` is temporary — the agent may be reactivated
- `SMP.PROXY NO_SESSION` via proxy is temporary — session can be re-established
- `SMP.STORE _` is temporary — router-side store error, not a client issue
`temporaryOrHostError` extends `temporaryAgentError` to also include host-related errors (`HOST`, `TRANSPORT TEVersion`). Used in subscription management where host errors should trigger resubscription rather than permanent failure.
@@ -1,9 +0,0 @@
# 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 routers by `enabled && role` yields an empty list, `fromMaybe srvs` falls back to *all* routers regardless of enabled/role status. This prevents a configuration where all routers are disabled from leaving the user with no routers — but means disabled routers can still be used if every router in a role is disabled.
@@ -1,7 +0,0 @@
# 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.
@@ -1,95 +0,0 @@
# Simplex.Messaging.Agent.NtfSubSupervisor
> Supervisor-worker architecture for notification subscription lifecycle management.
**Source**: [`Agent/NtfSubSupervisor.hs`](../../../../../src/Simplex/Messaging/Agent/NtfSubSupervisor.hs)
## Architecture
The notification system uses a supervisor with **three worker pools**, each keyed by router address:
| Pool | Key | Purpose |
|------|-----|---------|
| `ntfWorkers` | NtfServer | Create/check/delete/rotate subscriptions on notification router |
| `ntfSMPWorkers` | SMPServer | Create/delete notifier credentials on messaging router |
| `ntfTknDelWorkers` | NtfServer | Delete tokens on notification router (background cleanup) |
The supervisor (`runNtfSupervisor`) reads commands from `ntfSubQ` and dispatches work to the appropriate pools. Workers are created lazily via `getAgentWorker` and process batches from the database.
## Non-obvious behavior
### 1. NSCCreate four-way partition
`partitionQueueSubActions` classifies each (queue, subscription) pair into one of four buckets:
- **New sub**: no existing subscription record — create from scratch
- **Reset sub**: credentials mismatch (SMP router changed, notifier ID changed, action was nulled by error, or action is a delete) — wipe and restart from SMP key exchange
- **Continue SMP work**: existing action is `NSASMP` and credentials are consistent — kick the SMP worker
- **Continue NTF work**: existing action is `NSANtf` and credentials are consistent — kick the NTF worker
The key decision point: when `subAction_` is `Nothing` (set by `workerErrors` after permanent failures), the subscription is treated as needing a full reset. This interacts with the null-action sentinel pattern from `AgentStore`.
### 2. retrySubActions shrinking retry with TVar
`retrySubActions` holds the list of subs-to-retry in a `TVar`. Each iteration, the action function returns only the subs that got temporary errors (via `splitResults`). The `TVar` is overwritten with this shrinking list. On success or permanent error, subs drop out. This means retry batches get smaller over time.
`splitResults` implements a three-way partition: temporary or host errors → retry, permanent errors → null the action + notify, successes → continue pipeline.
### 3. rescheduleWork deferred wake-up
When the NTF worker finds that all pending `NSACheck` actions have future timestamps, it does not spin-wait. Instead it:
1. Takes itself out of the `doWork` TMVar (so the worker blocks on `waitForWork`)
2. Forks a thread that sleeps until the first action's timestamp
3. The forked thread re-signals `doWork` when the time arrives
This is the mechanism for time-scheduled subscription health checks.
### 4. checkSubs AUTH triggers full recreation
When the notification router returns `AUTH` for a subscription check, the subscription is not simply marked as failed — it is fully recreated from scratch by resetting to `NSASMP NSASmpKey` state. This handles the case where the notification router has lost its subscription state (restart, data loss). The SMP worker is kicked to re-establish notifier credentials.
Successful check results with statuses not in `subscribeNtfStatuses` also trigger recreation via `recreateNtfSub`.
### 5. deleteToken two-phase with restart survival
Token deletion splits into two phases:
1. **Store phase**: Remove token from active store, persist `(server, privateKey, tokenId)` to a deletion queue via `addNtfTokenToDelete`
2. **Network phase**: `runNtfTknDelWorker` reads from the queue and performs the actual router-side deletion
On supervisor startup, `startTknDelete` scans for any pending deletion queue entries and launches workers. This ensures token cleanup survives agent restarts.
If the token has no router-side ID (`ntfTokenId = Nothing`), only the store phase runs — no worker is launched.
### 6. workerErrors nulls subscription action
When permanent (non-temporary, non-host) errors occur in batch operations, `workerErrors` sets the subscription's action to `NULL` in the database and notifies the client. The next `NSCCreate` for that connection will see `subAction_ = Nothing` in `contOrReset` and trigger a full subscription reset.
This null-action sentinel is the bridge between worker failure recovery and supervisor-driven re-creation.
### 7. NSADelete and NSARotate are deprecated
These NTF worker actions are no longer generated by current code but are kept for processing legacy database records. They are explicitly not batched (processed one at a time via `mapM`). `NSARotate` deletes the subscription then re-queues `NSCCreate` back to the supervisor.
### 8. Stats counting groups by userId
`incStatByUserId` groups batch subscriptions by `userId` before incrementing stats counters, ensuring per-user counts are accurate even when a single batch contains subscriptions from multiple users.
### 9. sendNtfSubCommand — gated on instant mode
`sendNtfSubCommand` only enqueues work if instant notifications are active (`hasInstantNotifications` checks `NTActive` status + `NMInstant` mode). In periodic mode, the entire subscription creation pipeline is dormant — no commands reach the supervisor.
### 10. deleteNotifierKeys — credential reset before disable
`resetCredsGetQueue` clears the queue's notification credentials in the store *before* sending the disable command to the SMP router. This "clean first" ordering means local state is already consistent even if the network call fails.
### 11. runNtfTknDelWorker — permanent error discards record
When token deletion gets a permanent (non-temporary, non-host) error, the deletion record is removed from the queue rather than retried. This prevents stuck deletion records from blocking the worker. The error is reported to the client.
### 12. getNtfServer — random selection from multiple
When multiple notification routers are configured, one is selected randomly using `randomR` with a session-stable `TVar` generator. Single-router configurations skip the randomness.
### 13. closeNtfSupervisor — atomic swap then cancel
`swapTVar` atomically replaces the workers map with empty, then cancels all extracted workers. This ensures all existing workers at the point of shutdown are captured for cancellation. Prevention of new work is handled by the supervisor loop termination and operation bracket lifecycle, not by the swap itself.
@@ -1,118 +0,0 @@
# Simplex.Messaging.Agent.Protocol
> Agent protocol types, wire formats, connection link serialization, and error taxonomy.
**Source**: [`Agent/Protocol.hs`](../../../../../../src/Simplex/Messaging/Agent/Protocol.hs)
**Protocol spec**: [`protocol/agent-protocol.md`](../../../../../protocol/agent-protocol.md) — duplex connection procedure, agent message syntax, connection link formats.
## Overview
This module defines the agent-level protocol: the types exchanged between agents (via SMP routers) and between agent and client application. It contains no IO — purely types, serialization, and validation logic.
The module carries two independent version scopes: `SMPAgentVersion` (agent-to-agent protocol, currently v2v7) and `SMPClientVersion` (agent-to-router protocol, imported from `Protocol.hs`). These version scopes interact but are negotiated independently — see [Protocol.md](../Protocol.md#two-separate-version-scopes).
## Two-layer message format
Agent messages use a two-layer envelope structure:
1. **Outer envelope** (`AgentMsgEnvelope`): version + single-char type tag (`C`/`M`/`I`/`R`) + type-specific payload. The `C` (confirmation) and `M` (message) variants carry double-ratchet encrypted content. The `I` (invitation) variant is encrypted only with per-queue E2E. The `R` (ratchet key) variant carries ratchet renegotiation parameters.
2. **Inner message** (`AgentMessage`): after double-ratchet decryption, discriminated by tags `I`/`D`/`R`/`M`. The `M` variant contains `APrivHeader` (sequential message ID + previous message hash for integrity) followed by `AMessage` (the actual command: `HELLO`, `A_MSG`, queue rotation, etc).
The tag characters overlap between layers (`I` means confirmation-conninfo in inner, invitation in outer; `M` means message-envelope in outer, agent-message in inner). These are disambiguated by context — outer parsing happens first, then decryption, then inner parsing.
## e2eEncConnInfoLength / e2eEncAgentMsgLength — PQ-dependent size budgets
Connection info and agent message size limits depend on both agent version and PQ support. When PQ is enabled (v5+), the limits are *smaller* — not larger — because the ratchet header and reply link grow with PQ keys (SNTRUP761), consuming space from the fixed SMP message block. The specific reductions (3726 for conninfo, 2222 for messages) are documented in source comments.
## AgentMsgEnvelope — connInfo encryption asymmetry
`AgentInvitation` encrypts `connInfo` only with per-queue E2E (no double ratchet) — see source comment. This is because invitations are sent to contact address queues where no ratchet has been established. `AgentConfirmation` encrypts with double ratchet. `AgentRatchetKey` uses per-queue E2E for the ratchet parameters themselves (bootstrapping problem: can't use ratchet to renegotiate the ratchet).
## AgentMessageType — dual encoding paths
`AgentMessageType` and `AMsgType` encode the same set of message types but serve different purposes: `AgentMessageType` includes the envelope types (`AM_CONN_INFO`, `AM_CONN_INFO_REPLY`, `AM_RATCHET_INFO`) for database storage, while `AMsgType` only covers the inner `AMessage` types. Both share the same wire tags for the overlapping types (`H`, `M`, `V`, `QC`, `QA`, `QK`, `QU`, `QT`, `E`). The `Q`-prefixed types use two-character tags (prefix dispatch), all others use single characters.
## HELLO — sent once after securing
`HELLO` is sent exactly once, when the queue is known to be secured (duplex handshake). Not used at all in fast duplex connection (v9+ SMP). The v1 slow handshake (which sent HELLO multiple times until securing succeeded) is no longer supported — `minSupportedSMPAgentVersion = duplexHandshakeSMPAgentVersion` (v2).
## AEvent entity type system
`AEvent` is a GADT indexed by `AEntity` (phantom type: `AEConn`, `AERcvFile`, `AESndFile`, `AENone`). This prevents the type system from allowing file events on connection entities and vice versa. The existential wrapper `AEvt` erases the entity type for storage in heterogeneous collections — equality comparison (`Eq AEvt`) uses `testEquality` on the singleton witness to recover type information.
`AENone` is used for events that aren't associated with any specific entity (e.g., `DOWN`, `UP`, `SUSPENDED`, `DEL_USER`). These are router-level or agent-level notifications, not connection-level.
## ConnectionMode singleton pattern
`ConnectionMode` / `SConnectionMode` / `ConnectionModeI` implement the singleton pattern: `SConnectionMode` is the type-level witness, `ConnectionModeI` is the typeclass that lets you recover the singleton from a type parameter. Many types are parameterized by `ConnectionMode` (`ConnectionRequestUri m`, `ConnShortLink m`, `ConnectionLink m`, etc.) to prevent mixing invitation and contact types at compile time.
`checkConnMode` is the runtime escape hatch — it uses `testEquality` to cast between mode-parameterized types, returning `Left "bad connection mode"` on mismatch. This is used extensively in parsers where the mode is determined at parse time.
## ConnReqUriData smpP — queueMode patch
The binary parser for `ConnReqUriData` applies `patchQueueMode` to all queues, setting `queueMode = Just QMContact` when it's `Nothing`. See source comment: this compensates for `QMContact` not being included in queue encoding until min SMP client version >= 3. This patch is safe because the binary encoding path was not used before SMP client version 4.
## Connection link URI parsing — version range adjustment
`connReqUriP` adjusts the agent version range for contact links: `adjustAgentVRange` clamps the minimum to `minSupportedSMPAgentVersion`. This preserves compatibility with old contact links published online — they may advertise version ranges starting below the current minimum, and clamping prevents negotiation from failing on an unsupported version.
The semicolon separator for SMP queues in the URI query string is deliberate — commas are used within server addresses to separate hostnames, so semicolons separate queues to avoid ambiguity.
## Short link encoding — contactConnType as URL path character
Short links encode `ContactConnType` as a single lowercase letter in the URL path: `a` (contact), `c` (channel), `g` (group), `r` (relay). Invitation links use `i`. The parser uses `toUpper` before dispatching to `ctTypeP` (which expects uppercase), while the encoder uses `toLower` on `ctTypeChar` output. This case dance happens because the wire format wants lowercase URLs but the internal representation uses uppercase.
## Short link router shortening
`shortenShortLink` strips port and key hash from preset routers, leaving only the hostname (`SMPServerOnlyHost` pattern). This makes short links shorter for well-known routers. `restoreShortLink` reverses this by looking up the full router definition from the preset list. Both functions match on primary hostname only (first in the `NonEmpty` list).
`isPresetServer` has a non-obvious port matching rule: empty port in the preset matches `"443"` or `"5223"` in the link. This handles servers that use default ports without explicitly listing them.
## OwnerAuth — chain-of-trust validation
`OwnerAuth` is double-encoded: the inner fields are `smpEncode`d, then the result is encoded as a `ByteString` (with length prefix). See source comment: "additionally encoded as ByteString to have known length and allow OwnerAuth extension." The parser uses `parseOnly` on the inner bytes, which silently ignores trailing data — providing forward compatibility for future field additions.
`validateLinkOwners` enforces a chain-of-trust: each owner must be signed by either the root key or any *preceding* owner in the list. Order matters — an owner signed by a later owner in the list will fail validation. Duplicate keys or IDs are rejected. An owner key matching the root key is rejected (prevents trivial self-authorization).
## UserLinkData — length-prefix switchover
`UserLinkData` uses a 1-byte length prefix for data ≤ 254 bytes, switching to a `\255` sentinel byte followed by a 2-byte (`Large`) length prefix for longer data. This is a backward-compatible extension of the standard `smpEncode` string format (which uses 1-byte length, capping at 255 bytes).
## FixedLinkData / ConnLinkData — forward-compatible parsing
Both `FixedLinkData` and `ConnLinkData` (invitation variant) consume trailing bytes with `A.takeByteString` after parsing known fields. See source comment: "ignoring tail for forward compatibility with the future link data encoding." This allows newer agents to add fields without breaking older parsers.
## AgentErrorType — BlockedIndefinitely promotion
`fromSomeException` in the `AnyError` instance promotes `BlockedIndefinitelyOnSTM` and `BlockedIndefinitelyOnMVar` to `CRITICAL` errors (with `offerRestart = True`) rather than generic `INTERNAL`. These are thread deadlock signals from the GHC runtime — they indicate a program bug, not a transient error. The `CRITICAL` classification with restart offer means the client application should prompt the user.
## cryptoErrToSyncState — error severity classification
Maps crypto errors to ratchet sync states: `DECRYPT_AES`, `DECRYPT_CB`, and `RATCHET_EARLIER` map to `RSAllowed` (sync is optional, may self-recover). `RATCHET_HEADER`, `RATCHET_SKIPPED`, and `RATCHET_SYNC` map to `RSRequired` (sync must happen before communication can continue). This classification determines whether the agent automatically initiates ratchet resynchronization.
## extraSMPServerHosts — hardcoded onion mappings
Maps clearnet hostnames of preset SMP routers to their `.onion` addresses. `updateSMPServerHosts` adds the onion host as a second hostname when parsing legacy queue URIs that only have one host. This is used for backward compatibility with queue URIs created before multi-host support — modern URIs include all hosts directly.
## Queue rotation state machines
`RcvSwitchStatus` and `SndSwitchStatus` encode the two sides of the queue rotation protocol:
- **Receiver side**: `RSSwitchStarted``RSSendingQADD``RSSendingQUSE``RSReceivedMessage`
- **Sender side**: `SSSendingQKEY``SSSendingQTEST`
The asymmetry reflects the protocol: the receiver initiates rotation and sends more messages (QADD, QUSE), while the sender responds (QKEY, QTEST). These states are persisted to the database — the `StrEncoding` instances use snake_case strings as the canonical serialization format. See [agent-protocol.md — Rotating messaging queue](../../../../../protocol/agent-protocol.md#rotating-messaging-queue).
## SMPQueueInfo / SMPQueueUri — version duality
`SMPQueueInfo` (single version) and `SMPQueueUri` (version range) represent the same queue address but in different contexts. `VersionI` / `VersionRangeI` typeclasses convert between them — `toVersionT` pins a version range to a specific version, `toVersionRangeT` wraps a versioned type in a range. See source comment on `VersionI SMPClientVersion SMPQueueInfo`: the current conversion is trivial (just swapping the version/range field) but the typeclass exists so that future field additions can have version-dependent conversion logic.
`SMPQueueInfo` encoding has four version-conditional paths: v1 (legacy server encoding), v2+ (standard encoding), v3+ with secure sender (appends `sndSecure` bool), v4+ (appends `queueMode`). The parser uses `clientVersion` to select between `legacyServerP` and standard `smpP` for the server field, and `updateSMPServerHosts` backfills onion addresses for legacy URIs.
## ACommand — binary body parsing
`commandP` takes a custom body parser. `dbCommandP` uses `A.take =<< A.decimal <* "\n"` — length-prefixed binary read. This is for commands stored in the database where the body must be fully parsed (not left as unparsed trailing bytes). The standard command parser uses `A.takeByteString` for bodies, consuming remaining input.
`pqIKP` defaults to `IKLinkPQ PQSupportOff` when PQ support is not specified, and `pqSupP` defaults to `PQSupportOff`. These defaults maintain backward compatibility with commands serialized before PQ support was added.
@@ -1,7 +0,0 @@
# 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.
@@ -1,35 +0,0 @@
# 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.
@@ -1,7 +0,0 @@
# Simplex.Messaging.Agent.Stats
> Per-router 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.
@@ -1,72 +0,0 @@
# Simplex.Messaging.Agent.Store
> Domain entity types for agent persistence — queues, connections, messages, commands, and store errors.
**Source**: [`Agent/Store.hs`](../../../../../src/Simplex/Messaging/Agent/Store.hs)
## Overview
This module defines the data types that represent agent state. It contains no database operations — those are in [AgentStore.hs](./Store/AgentStore.md). The key abstractions are:
- **Queue types** (`StoredRcvQueue`, `StoredSndQueue`) parameterized by `DBStored` phantom type for new vs persisted distinction
- **Connection GADT** (`Connection'`) encoding the connection state machine at the type level
- **Message containers** (`RcvMsgData`, `SndMsgData`, `PendingMsgData`) for the message lifecycle
- **Store errors** (`StoreError`) including two sentinel errors with special semantics
## Connection' — type-level state machine
The `Connection'` GADT encodes connection lifecycle as a type parameter: `CNew``CRcv`/`CSnd``CDuplex`, plus `CContact` for reusable contact connections. `SomeConn` wraps an existential to store connections of unknown type.
`TestEquality SConnType` deliberately omits `SCNew``testEquality SCNew SCNew` returns `Nothing`. This is intentional: `NewConnection` has no queues and is not a valid target for type-level connection matching in store operations.
## canAbortRcvSwitch — race condition boundary
See comments on `canAbortRcvSwitch`. The `RSSendingQUSE` and `RSReceivedMessage` states cannot be aborted because the sender may have already deleted the original queue. Aborting (deleting the new queue) at that point would break the connection with no recovery path.
## ratchetSyncAllowed / ratchetSyncSendProhibited — cross-repo contract
See comments on `ratchetSyncAllowed`. Both functions carry the comment "this function should be mirrored in the clients" — simplex-chat must implement identical logic. The agent enforces these state checks, but the chat client also needs them for UI decisions (e.g., disabling send when `ratchetSyncSendProhibited`).
## SEWorkItemError — worker suspension sentinel
`SEWorkItemError` is a sentinel error that triggers worker suspension when encountered during work item retrieval. The `AnyStoreError` typeclass exposes `isWorkItemError` for the worker framework ([Agent/Client.hs](./Client.md)) to detect this case. The comment "do not use!" means it should not be thrown for normal error conditions — only when the work item itself is corrupt/unreadable and the worker should stop rather than retry.
## SEAgentError — store-level error wrapping
`SEAgentError` wraps `AgentErrorType` inside store operations. This allows store functions to return agent-level errors (e.g., connection state violations detected during a DB transaction) without breaking the `ExceptT StoreError` type. The "to avoid race conditions" rationale: checking a condition and acting on it must happen in the same DB transaction, so the agent error is returned through the store error channel.
## InvShortLink — secure-on-read semantics
See comment on `InvShortLink`. Stored separately from the connection because 1-time invitation short links have a "secure-on-read" property: accessing the link data on the router marks it as read, preventing undetected observation. The `sndPrivateKey` is persisted to allow retries of the link creation without generating new keys.
## RcvQueueSub — subscription-optimized projection
`RcvQueueSub` strips cryptographic fields from `RcvQueue`, keeping only what's needed for subscription tracking in [TSessionSubs](./TSessionSubs.md). This reduces memory pressure when tracking thousands of subscriptions in STM.
## rcvSMPQueueAddress exposes sender-facing ID
`rcvSMPQueueAddress` constructs the `SMPQueueAddress` from a receive queue using `sndId` (not `rcvId`). The address shared with senders in connection requests contains the sender ID, the public key derived from `e2ePrivKey`, and `queueMode`. The `rcvId` is never exposed externally.
## enableNtfs is duplicated between queue and connection
`enableNtfs` exists on both `StoredRcvQueue` and `ConnData`. The comment marks it as "duplicated from ConnData." The queue-level copy enables subscription operations (which work at the queue level) to check notification status without loading the full connection.
## deleteErrors — queue deletion retry counter
`StoredRcvQueue` has a `deleteErrors :: Int` field that counts failed deletion attempts. This allows the agent to give up on queue deletion after repeated failures rather than retrying indefinitely.
## Two-level message preparation
`SndMsgData` optionally carries `SndMsgPrepData` with a `sndMsgBodyId` reference to a separately stored message body. `PendingMsgData` optionally carries `PendingMsgPrepData` with the actual `AMessage` body. This split allows large message bodies to be stored once and referenced by ID during the send pipeline, avoiding redundant serialization.
## Per-message retry backoff
`PendingMsgData` includes `msgRetryState :: Maybe RI2State` — each pending message independently tracks its retry backoff state. This means messages that fail to send don't reset the retry timers of other pending messages in the same connection.
## Soft deletion and optional contact connection
`ConnData` has `deleted :: Bool` for soft deletion — connections are marked deleted before queue cleanup completes. `Invitation` has `contactConnId_ :: Maybe ConnId` (note the trailing underscore) — invitations can outlive their originating contact connection.
## SEBadQueueStatus is vestigial
`SEBadQueueStatus` is documented in the source as "Currently not used." It was intended for queue status transition validation but was never implemented.
@@ -1,124 +0,0 @@
# Simplex.Messaging.Agent.Store.AgentStore
> Core CRUD operations for agent persistence — users, connections, queues, messages, ratchets, notifications, and file transfers.
**Source**: [`Agent/Store/AgentStore.hs`](../../../../../../src/Simplex/Messaging/Agent/Store/AgentStore.hs)
## Overview
At ~3700 lines, this is the largest module in the codebase. It implements all database operations for the agent, compiled with CPP for both SQLite and PostgreSQL backends. Most functions are straightforward SQL CRUD, but several patterns are non-obvious.
The module re-exports `withConnection`, `withTransaction`, `withTransactionPriority`, `firstRow`, `firstRow'`, and `maybeFirstRow` from the backend-specific Common module. It also exports `fromOnlyBI` (a local helper) and `getWorkItem`/`getWorkItems`.
## Dual-backend compilation
The module uses `#if defined(dbPostgres)` throughout. Key behavioral differences:
- **Row locking**: PostgreSQL uses `FOR UPDATE` on reads that precede writes (e.g., `getConnForUpdate`, `getRatchetForUpdate`, `retrieveLastIdsAndHashRcv_`). SQLite relies on its single-writer model instead.
- **Batch queries**: PostgreSQL uses `IN ?` with `In` wrapper for batch operations. SQLite falls back to per-row `forM` loops.
- **Constraint handling**: PostgreSQL uses `constraintViolation`, SQLite checks `SQL.ErrorConstraint`.
## getWorkItem / getWorkItems — worker store pattern
`getWorkItem` implements the store-side pattern for the [worker framework](../Client.md): `getId → getItem → markFailed`. If `getId` throws an IO exception, `handleWrkErr` wraps it as `SEWorkItemError` (via `mkWorkItemError`), which signals the worker to suspend rather than retry. If `getItem` fails (returning Left or throwing), `tryGetItem` calls `markFailed` (also wrapped by `handleWrkErr`) and rethrows the original error. This prevents crash loops on corrupt data.
`getWorkItems` extends this to batch work items, where each item failure is independent.
**Consumed by**: `getPendingQueueMsg`, `getPendingServerCommand`, `getNextNtfSubNTFActions`, `getNextNtfSubSMPActions`, `getNextDeletedSndChunkReplica`, `getNextNtfTokenToDelete`, `getNextRcvChunkToDownload`, `getNextRcvFileToDecrypt`, `getNextSndChunkToUpload`, `getNextSndFileToPrepare`.
## Notification subscription — supervisor/worker coordination
`updateNtfSubscription`, `setNullNtfSubscriptionAction`, and `deleteNtfSubscription` all check `updated_by_supervisor` before writing. When `True`, the worker only updates local fields (ntf IDs, status) and skips action/server fields that the supervisor may have changed. This prevents the worker from overwriting supervisor decisions during concurrent execution.
`markUpdatedByWorker` resets the flag to `False` before each work item is processed, so the worker "claims" the subscription for the duration of its operation.
## createServer / getServerKeyHash_ — key hash migration
`createServer` returns `Maybe KeyHash`: `Nothing` means the server was newly created with the passed hash; `Just kh` means the server already existed and the passed hash differs from the stored one. This `Just` value is stored as `server_key_hash` on queues to allow per-queue key hash overrides.
The `COALESCE(q.server_key_hash, s.key_hash)` pattern appears throughout queries — queues can override the server-level hash, enabling gradual migration when a router's identity key changes.
## updateRcvMsgHash / updateSndMsgHash — race condition guard
Both functions include `AND last_internal_*_msg_id = ?` in their UPDATE WHERE clause. This prevents a race: if another message was processed between `updateIds` and `updateHash` (incrementing the last ID), the hash update is silently skipped rather than corrupting the chain. See comments on these functions.
## deleteConn — conditional delivery wait
Four paths:
1. No timeout: immediate delete.
2. Timeout + no pending deliveries: immediate delete.
3. Timeout + pending deliveries + `deleted_at_wait_delivery` expired: delete.
4. Timeout + pending deliveries + not expired: return `Nothing` (skip deletion).
This allows graceful delivery completion before connection cleanup.
## createSndConn — confirmed queue guard
See comment on `createSndConn`. Checks `checkConfirmedSndQueueExists_` before creating, because `insertSndQueue_` uses `ON CONFLICT DO UPDATE` which would silently replace an existing confirmed send queue. The pre-check prevents this destructive upsert.
## insertRcvQueue_ / insertSndQueue_ — queue ID preservation
Both functions check if a queue already exists (by server + queue ID) and reuse the existing database `queue_id`. If not found, they generate the next sequential ID (`MAX + 1`). This preserves database IDs across retries of queue creation.
## createClientService — service_id reset on upsert
The `ON CONFLICT DO UPDATE` clause sets `service_id = NULL` when credentials are updated. This forces re-registration with the router after credential rotation — the old service ID is invalidated.
## deleteSndMsgDelivery — conditional message retention
After removing the delivery record, checks whether any pending deliveries remain for the message. If none remain and the receipt status is `MROk`, the entire message is deleted. Otherwise, if `keepForReceipt` is true, only the message body is cleared (for debugging receipt mismatches). Handles shared `snd_message_bodies` with `FOR UPDATE` locking on PostgreSQL to prevent concurrent deletion races.
## createWithRandomId' — bounded retry
Generates random 12-byte IDs (base64url encoded) and retries up to 3 times on constraint violations (unique ID collision). Returns `SEUniqueID` if all attempts fail.
## setRcvQueuePrimary / setSndQueuePrimary — two-step primary swap
First clears primary flag on all queues in the connection, then sets it on the target queue. Also clears `replace_*_queue_id` on the new primary — this completes the queue rotation by removing the "replacing" marker.
## createCommand — silent drop for deleted connections
When `createCommand` encounters a constraint violation (the referenced connection was already deleted), it logs the error and returns successfully rather than throwing. This means commands targeting deleted connections are silently dropped. The rationale: the connection is already gone, so there's nothing useful to do with the error.
## updateNewConnRcv — retry tolerance
`updateNewConnRcv` accepts both `NewConnection` and `RcvConnection` connection states. The `RcvConnection` case is explicitly commented as "to allow retries" — if the initial queue insertion succeeded but the caller didn't get the response, a retry would find the connection already upgraded. `updateNewConnSnd` does not have this tolerance.
## setLastBrokerTs — monotonic advance
The WHERE clause includes `AND (last_broker_ts IS NULL OR last_broker_ts < ?)`, which ensures the timestamp only moves forward. Out-of-order message processing (e.g., from different queues) cannot regress the broker timestamp.
## deleteDeliveredSndMsg — FOR UPDATE + count zero check
On PostgreSQL, acquires a `FOR UPDATE` lock on the message row before counting pending deliveries. This prevents a race where two concurrent delivery completions both see count > 0 before either deletes, then both try to delete. Only deletes the message when the count reaches exactly 0.
## createWithRandomId' — savepoint-based retry
Uses `withSavepoint` around each insertion attempt rather than bare execute. This is critical for PostgreSQL: a failed statement within a transaction aborts the entire transaction, but savepoints allow rolling back just the failed INSERT and retrying with a new ID.
## Explicit row-lock functions
`lockConnForUpdate`, `lockRcvFileForUpdate`, and `lockSndFileForUpdate` are PostgreSQL-only explicit lock acquisition that compile to no-ops on SQLite. They acquire `FOR UPDATE` locks on rows that need serialized access without modifying them.
## XFTP work item retry ordering
`getNextRcvChunkToDownload` and `getNextSndChunkToUpload` order by `retries ASC, created_at ASC`. This prioritizes chunks with fewer retries, ensuring a repeatedly-failing chunk doesn't starve others. Same pattern for `getNextDeletedSndChunkReplica`.
## getRcvFileRedirects — error resilience
When loading redirect chains, errors loading individual redirect files are silently swallowed (`either (const $ pure Nothing) (pure . Just)`). This prevents a corrupt redirect from blocking access to the main file.
## enableNtfs defaults to True when NULL
Both `toRcvQueue` and `rowToConnData` default `enableNtfs` to `True` when the database value is NULL (`maybe True unBI enableNtfs_`). This is a backward-compatibility default for connections created before the field existed.
## primaryFirst — queue ordering
The `primaryFirst` comparator sorts queues with the primary queue first (`Down` on primary flag), then by `dbReplaceQId` to place the "replacing" queue second. This ensures all queue lists are consistently ordered for connection reconstruction.
## getAnyConn_ — connection GADT reconstruction
Reconstructs the type-level `Connection'` GADT by combining connection mode with the presence/absence of receive and send queues. The `CMContact` mode only maps to `ContactConnection` (receive-only); all other combinations use `CMInvitation` mode. When neither rcv nor snd queues exist, the result is always `NewConnection` regardless of mode.
## deleteNtfSubscription — soft delete when supervisor active
When `updated_by_supervisor` is true, `deleteNtfSubscription` doesn't actually delete the row. Instead, it nulls out the IDs and sets status to `NASDeleted`, preserving the row for the supervisor to observe. Only when the supervisor has not intervened does it perform a real DELETE.
@@ -1,7 +0,0 @@
# Simplex.Messaging.Agent.Store.Common
> CPP-conditional re-export of backend-specific common utilities (DBStore, withConnection, withTransaction).
**Source**: [`Agent/Store/Common.hs`](../../../../../../src/Simplex/Messaging/Agent/Store/Common.hs)
No non-obvious behavior. See source. One of three CPP re-export wrappers (Interface, Common, DB).
@@ -1,7 +0,0 @@
# Simplex.Messaging.Agent.Store.DB
> CPP-conditional re-export of backend-specific database primitives (Connection, FromField, ToField).
**Source**: [`Agent/Store/DB.hs`](../../../../../../src/Simplex/Messaging/Agent/Store/DB.hs)
No non-obvious behavior. See source. One of three CPP re-export wrappers (Interface, Common, DB).
@@ -1,7 +0,0 @@
# Simplex.Messaging.Agent.Store.Entity
> Phantom-typed database entity IDs distinguishing new (unsaved) from stored records.
**Source**: [`Agent/Store/Entity.hs`](../../../../../../src/Simplex/Messaging/Agent/Store/Entity.hs)
No non-obvious behavior. See source.
@@ -1,7 +0,0 @@
# Simplex.Messaging.Agent.Store.Interface
> CPP-conditional re-export of the active database backend (SQLite or PostgreSQL).
**Source**: [`Agent/Store/Interface.hs`](../../../../../../src/Simplex/Messaging/Agent/Store/Interface.hs)
No non-obvious behavior. See source. One of three CPP re-export wrappers (Interface, Common, DB) that select the active backend at compile time via `dbPostgres`.
@@ -1,23 +0,0 @@
# Simplex.Messaging.Agent.Store.Postgres
> PostgreSQL backend — dual-pool connection management, schema lifecycle, and migration.
**Source**: [`Agent/Store/Postgres.hs`](../../../../../../src/Simplex/Messaging/Agent/Store/Postgres.hs)
## Dual pool architecture
`connectPostgresStore` creates two connection pools (`dbPriorityPool` and `dbPool`), each with `poolSize` connections. Priority pool is used by `withTransactionPriority` for operations that shouldn't be blocked by regular queries. Both pools are TBQueue-based — connections are taken and returned after use.
All connections are created eagerly at initialization, not lazily on demand.
## uninterruptibleMask_ — pool atomicity invariant
See comment on `connectStore`. `uninterruptibleMask_` prevents async exceptions from interrupting pool filling or draining. The invariant: when `dbClosed = True`, queues are empty; when `False`, queues are full (or connections are in-flight with threads that will return them). Interruption mid-fill would break this invariant.
## Schema creation — fail-fast on missing
If the PostgreSQL schema doesn't exist and `createSchema` is `False`, the process logs an error and calls `exitFailure`. This prevents silent operation against the wrong schema.
## execSQL — not implemented
`execSQL` throws "not implemented" — the PostgreSQL client doesn't support raw SQL execution via the agent API. The function exists only to satisfy the shared interface.
@@ -1,30 +0,0 @@
# Simplex.Messaging.Agent.Store.SQLite
> SQLite backend — store creation, encrypted connection management, migration, and custom SQL functions.
**Source**: [`Agent/Store/SQLite.hs`](../../../../../../src/Simplex/Messaging/Agent/Store/SQLite.hs)
## Security-relevant PRAGMAs
`connectDB` sets PRAGMAs at connection time:
- `secure_delete = ON`: data is overwritten (not just unlinked) on DELETE
- `auto_vacuum = FULL`: freed pages are reclaimed immediately
- `foreign_keys = ON`: referential integrity enforced
These are set per-connection, not per-database — every new connection (including re-opens) gets them.
## simplex_xor_md5_combine — custom SQLite function
A C-exported SQLite function registered at connection time. Takes an existing `IdsHash` and a `RecipientId`, XORs the hash with the MD5 of the ID. This is the SQLite implementation of the accumulative IdsHash used by service subscriptions (see [TSessionSubs.md](../TSessionSubs.md#updateActiveService--accumulative-xor-merge)). PostgreSQL uses `pgcrypto`'s `digest()` function for MD5 and a custom `xor_combine` PL/pgSQL function for the XOR.
## openSQLiteStore_ — connection swap under MVar
Uses `bracketOnError` with `takeMVar`/`tryPutMVar`: takes the connection MVar, creates a new connection, and puts the new one back. If connection fails, `tryPutMVar` restores the old connection. The `dbClosed` TVar is flipped atomically with the key update.
## storeKey — conditional key retention
`storeKey key keepKey` stores the encryption key in the `dbKey` TVar if `keepKey` is true or if the key is empty (no encryption). This means unencrypted stores can always be reopened. If `keepKey` is false and the key is non-empty, `reopenDBStore` fails with "no key".
## dbBusyLoop — initial connection retry
`connectSQLiteStore` wraps `connectDB` in `dbBusyLoop` to handle database locking during initial connection. All transactions (`withTransactionPriority`) are also wrapped in `dbBusyLoop` as a retry layer on top of the `busy_timeout` PRAGMA.
@@ -1,7 +0,0 @@
# Simplex.Messaging.Agent.Store.Shared
> Migration types, error reporting, and confirmation modes shared across database backends.
**Source**: [`Agent/Store/Shared.hs`](../../../../../../src/Simplex/Messaging/Agent/Store/Shared.hs)
No non-obvious behavior. See source.
@@ -1,60 +0,0 @@
# 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 result 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.
-85
View File
@@ -1,85 +0,0 @@
# Simplex.Messaging.Client
> Generic protocol client: connection management, command sending/receiving, batching, proxy protocol, reconnection.
**Source**: [`Client.hs`](../../../../src/Simplex/Messaging/Client.hs)
**Protocol spec**: [`protocol/simplex-messaging.md`](../../../../protocol/simplex-messaging.md) — SimpleX Messaging Protocol.
## Overview
This module implements the client side of the `Protocol` typeclass — connecting to SMP routers, sending commands, receiving command results, 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.
## Four concurrent threads — teardown semantics
`getProtocolClient` launches four threads via `raceAny_`:
- `send`: reads from `sndQ` (TBQueue) and writes to TLS
- `receive`: reads from TLS and writes to `rcvQ` (TBQueue), updates `lastReceived`
- `process`: reads from `rcvQ` and dispatches to result vars or `msgQ`
- `monitor`: periodic ping loop (only when `smpPingInterval > 0`)
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.
## Request lifecycle and leak risk
`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 result arrives, 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 result) or by `getResponse` timeout (which sets `pending = False` but doesn't remove the entry).
## getResponse — pending flag race contract
This is the core concurrency contract between timeout and result processing:
1. `getResponse` waits with `timeout` for `takeTMVar responseVar`
2. Regardless of result, atomically sets `pending = False` and tries `tryTakeTMVar` again (see comment on `getResponse`)
3. In `processMsg`, when a result arrives for a request where `pending` is already `False` (timeout won), `wasPending` is `False` and the result is forwarded to `msgQ` as `STResponse` rather than discarded
The double-check pattern (`swapTVar pending False` + `tryTakeTMVar`) handles the race window where a result arrives between timeout firing and `pending` being set to `False`. Without this, results arriving in that gap would be silently lost.
`timeoutErrorCount` is reset to 0 in three places: in `getResponse` when a result arrives, in `receive` on every TLS read, and the monitor uses this count to decide when to drop the connection.
## processMsg — router events vs expired results
When `corrId` is empty, the message is an `STEvent` (router-initiated). When non-empty and the request was already expired (`wasPending` is `False`), the result becomes `STResponse` — not discarded, but forwarded to `msgQ` with the original command context. Entity ID mismatch is `STUnexpectedError`.
## nonBlockingWriteTBQueue — fork on full
If `tryWriteTBQueue` returns `False` (queue full), a new thread is forked for the blocking write. The caller never blocks, preventing deadlock between send and process threads.
## Batch commands do not expire
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 router stops returning results, batched commands can block the send queue indefinitely since they have no timeout-based expiry.
## monitor — quasi-periodic adaptive ping
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.
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 result.
## clientCorrId — dual-purpose random values
`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.
## Proxy command re-parameterization
`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.
## proxySMPCommand — error classification
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`.
## forwardSMPTransmission — proxy-side forwarding
Used by the proxy router to forward `RFWD` to the destination relay. Uses `cbEncryptNoPad`/`cbDecryptNoPad` (no padding) with the session secret from the proxy-relay connection. Result nonce is `reverseNonce` of the request nonce.
## authTransmission — dual auth with service signature
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`.
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.
## action — weak thread reference
`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.
## writeSMPMessage — router-side event injection
`writeSMPMessage` writes directly to `msgQ` as `STEvent`, bypassing the entire command/result pipeline. This is used by the router to inject MSG events into the subscription result path.
@@ -1,92 +0,0 @@
# Simplex.Messaging.Client.Agent
> SMP client connections with subscription management, reconnection, and service certificate support.
**Source**: [`Client/Agent.hs`](../../../../../src/Simplex/Messaging/Client/Agent.hs)
## Overview
This is the "small agent" — used only in routers (SMP proxy, notification router) to manage client connections to other SMP routers. The "big agent" in `Simplex.Messaging.Agent` + `Simplex.Messaging.Agent.Client` serves client applications and adds the full messaging agent layer. See [Two agent layers](../../../../TOPICS.md) topic.
`SMPClientAgent` manages `SMPClient` connections via `smpClients :: TMap SMPServer SMPClientVar` (one per router), tracks active and pending subscriptions, and handles automatic reconnection. It is parameterized by `Party` (`p`) and uses the `ServiceParty` constraint to support both `RecipientService` and `NotifierService` modes.
## Dual subscription model
Four TMap fields track subscriptions in two dimensions:
| | Active | Pending |
|---|---|---|
| **Service** | `activeServiceSubs` (TMap SMPServer (TVar (Maybe (ServiceSub, SessionId)))) | `pendingServiceSubs` (TMap SMPServer (TVar (Maybe ServiceSub))) |
| **Queue** | `activeQueueSubs` (TMap SMPServer (TMap QueueId (SessionId, C.APrivateAuthKey))) | `pendingQueueSubs` (TMap SMPServer (TMap QueueId C.APrivateAuthKey)) |
See comments on `activeServiceSubs` and `pendingServiceSubs` for the coexistence rules. Key constraint: only one service subscription per router. Active subs store the `SessionId` that established them.
## SessionVar compare-and-swap — core concurrency safety
`removeSessVar` (in Session.hs) uses `sessionVarId` (monotonically increasing counter from `sessSeq`) to prevent stale removal. When a disconnected client's cleanup runs after a new client already replaced the map entry, the ID mismatch causes removal to silently no-op. See comment on `removeSessVar`. This is used throughout: `removeClientAndSubs` for client map, `cleanup` for worker map.
## removeClientAndSubs — outside-STM lookup optimization
See comment on `removeClientAndSubs`. Subscription TVar references are obtained outside STM (via `TM.lookupIO`), then modified inside `atomically`. This is safe because the invariant is that subscription TVar entries for a router are never deleted from the outer TMap, only their contents change. Moving lookups inside the STM transaction would cause excessive re-evaluation under contention.
## Disconnect preserves others' subscriptions
`updateServiceSub` only moves active→pending when `sessId` matches the disconnected client (see its comment). If a new client already established different subscriptions on the same router, those are preserved. Queue subs use `M.partition` to split by SessionId — only matching subs move to pending, non-matching remain active.
## Pending never reset to Nothing on disconnect
See comment on `updateServiceSub`. After clearing an active service sub, the code sets pending to the cleared value but does NOT reset pending to `Nothing`. This avoids the race where a concurrent new client session has already set a different pending subscription. Implication: pending subs can only grow (be set) during disconnect, never shrink (be cleared).
## persistErrorInterval — delayed error cleanup
When `connectClient` calls `newSMPClient` and it fails, the error is stored with an expiry timestamp. `waitForSMPClient` checks expiry before retrying. When `persistErrorInterval` is 0, the error is stored without timestamp and the SessionVar is immediately removed from the map.
## Session validation after subscription RPC
Both `smpSubscribeQueues` and `smpSubscribeService` validate `activeClientSession` AFTER the subscription RPC completes, before committing results to state. If the session changed during the RPC (client reconnected), results are discarded and reconnection is triggered. This is optimistic execution with post-hoc validation — the RPC may succeed but its results are thrown away if the session is stale.
## groupSub — subscription result classification
Each queue result is classified by a `foldr` over the (subs, results) zip:
- **Success with matching serviceId**: counted as service-subscribed (`sQs` list)
- **Success without matching serviceId**: counted as queue-only (`qOks` list with SessionId and key)
- **Not in pending map**: silently skipped (handles concurrent activation by another path)
- **Temporary error** (network, timeout): sets the `tempErrs` flag but does NOT remove from pending — queue stays pending for retry on reconnect
- **Permanent error**: removes from pending and added to `finalErrs` — terminal, no automatic retry
Even if multiple temporary errors occur in a batch, only one `reconnectClient` call is made (via the boolean accumulator flag).
## updateActiveServiceSub — accumulative merge
When serviceId and sessionId match the existing active subscription, queue count is added (`n + n'`) and IdsHash is XOR-merged (`idsHash <> idsHash'`). This accumulates across multiple subscription batches for the same service. When they don't match, the subscription is replaced entirely (silently drops old data).
## CAServiceUnavailable — cascade to queue resubscription
When `smpSubscribeService` detects service ID or role mismatch with the connection, it fires `CAServiceUnavailable`. See comment on `CAServiceUnavailable` for the full implication: the app must resubscribe all queues individually, creating new associations. This can happen if the SMP router reassigns service IDs (e.g., after downgrade and upgrade).
## getPending — polymorphic over STM/IO
`getPending` uses rank-2 polymorphism to work in both STM (for the "should we spawn a worker?" check, providing a consistent snapshot) and IO (for the actual reconnection data read, providing fresh data). Between these two calls, new pending subs could be added — the worker loop handles this by re-checking on each iteration.
## Reconnect worker lifecycle
### Spawn decision
`reconnectClient` checks `active` outside STM, then atomically checks for pending subs and gets/creates a worker SessionVar. If no pending subs exist, no worker is spawned — this prevents race with cleanup and adding pending queues in another call.
### Worker cleanup blocks on TMVar fill
See comment on `cleanup`. The STM `retry` loop waits until the async handle is inserted into the TMVar before removing the worker from the map. Without this, cleanup could race ahead of the `putTMVar` in `newSubWorker`, leaving a terminated worker in the map.
### Double timeout on reconnection
`runSubWorker` wraps the entire reconnection in `System.Timeout.timeout` using `tcpConnectTimeout` in addition to the network-layer timeout. Two layers — network for the connection attempt, outer for the entire operation including subscription.
### Reconnect filters already-active queues
During reconnection, `reconnectSMPClient` reads current active queue subs (outside STM, same "vars never removed" invariant) and filters them out before resubscribing. Subscription is chunked by `agentSubsBatchSize` — partial success is possible across chunks.
## Agent shutdown ordering
`closeSMPClientAgent` executes in order: set `active = False`, close all client connections, then swap workers map to empty and fork cancellation threads. The cancel threads use `uninterruptibleCancel` but are fire-and-forget — `closeSMPClientAgent` may return before all workers are actually cancelled.
## addSubs_ — left-biased union
`addSubs_` uses `TM.union` which delegates to `M.union` (left-biased). If a queue subscription already exists, the new auth key from the incoming map wins. Service subs use `writeTVar` (overwrite) since only one service sub exists per router.
@@ -1,17 +0,0 @@
# Simplex.Messaging.Compression
> Zstd compression with passthrough for short messages.
**Source**: [`Compression.hs`](../../../../src/Simplex/Messaging/Compression.hs)
## compress1
Messages <= 180 bytes are wrapped as `Passthrough` (no compression). The threshold is empirically derived from real client data — messages above 180 bytes rapidly gain compression ratio.
## decompress1
**Security**: decompression bomb protection. Requires `decompressedSize` to be present in the zstd frame header AND within the caller-specified `limit`. If the compressed data doesn't declare its decompressed size (non-standard zstd frames), decompression is refused entirely. This prevents memory exhaustion from malicious compressed payloads.
## Wire format
Tag byte `'0'` (0x30) = passthrough (1-byte length prefix, raw data). Tag byte `'1'` (0x31) = compressed (2-byte `Large` length prefix, zstd data). The passthrough path uses the standard `ByteString` encoding (255-byte limit); the compressed path uses `Large` (65535-byte limit).
-94
View File
@@ -1,94 +0,0 @@
# Simplex.Messaging.Crypto
> Core cryptographic primitives: key types, NaCl crypto_box/secret_box, AEAD-GCM, signing, padding, X509, HKDF.
**Source**: [`Crypto.hs`](../../../../src/Simplex/Messaging/Crypto.hs)
## Overview
This is the largest crypto module (~1540 lines). It defines the type-level algorithm system (GADTs + type families), all key types, and the fundamental encrypt/decrypt/sign/verify operations used throughout the protocol stack. Higher-level modules ([Ratchet](./Crypto/Ratchet.md), [Lazy](./Crypto/Lazy.md), [File](./Crypto/File.md)) build on these primitives.
## Algorithm type system
Four algorithms (`Ed25519`, `Ed448`, `X25519`, `X448`) are encoded as a promoted data kind `Algorithm`. Type families constrain which algorithms support which operations:
- `SignatureAlgorithm`: only `Ed25519`, `Ed448`
- `DhAlgorithm`: only `X25519`, `X448`
- `AuthAlgorithm`: `Ed25519`, `Ed448`, `X25519` (but NOT `X448`)
Using the wrong algorithm produces a **compile-time error** via `TypeError`. The runtime bridge uses `Dict` from `Data.Constraint` — functions like `signatureAlgorithm :: SAlgorithm a -> Maybe (Dict (SignatureAlgorithm a))` allow dynamic dispatch while preserving type safety.
## PrivateKeyEd25519 StrEncoding deliberately omitted
The `StrEncoding` instance for `PrivateKey Ed25519` is commented out with the note "Do not enable, to avoid leaking key data." Only `PrivateKey X25519` has `StrEncoding`, used specifically for the notification store log. This is a deliberate security decision — Ed25519 signing keys should never appear in human-readable formats.
## Two AEAD initialization paths
- **`initAEAD`**: Takes 16-byte `IV`, transforms it internally via `cryptonite_aes_gcm_init`. Used by the double ratchet.
- **`initAEADGCM`**: Takes 12-byte `GCMIV`, does NOT transform. Used for WebRTC frame encryption.
These are **not interchangeable** — using the wrong IV size or init function produces silent corruption. The code comments note that WebCrypto compatibility requires `initAEADGCM`, and the ratchet may need to migrate away from `initAEAD` in the future.
## cbNonce — silent truncation/padding
`cbNonce` adjusts any ByteString to exactly 24 bytes:
- If longer: silently truncates to first 24 bytes
- If shorter: silently pads with zero bytes
No error is raised for incorrect input lengths. This means a programming error passing the wrong-length nonce will produce valid but wrong encryption, not a failure.
## pad / unPad — 2-byte length prefix
`pad` prepends a 2-byte big-endian `Word16` length, then the message, then `'#'` padding characters to fill `paddedLen`. Maximum message length is `2^16 - 3 = 65533` bytes. The `'#'` padding character is a convention, not verified on decode — `unPad` only reads the length prefix and extracts that many bytes.
Contrast with [Simplex.Messaging.Crypto.Lazy.pad](./Crypto/Lazy.md#padding-8-byte-length-prefix) which uses an 8-byte `Int64` prefix for file-sized data.
## crypto_box / secret_box
Both use the same underlying `xSalsa20` + `Poly1305.auth` implementation. The difference is only in the key:
- **crypto_box** (`cbEncrypt`/`cbDecrypt`): uses a DH shared secret (`DhSecret X25519`)
- **secret_box** (`sbEncrypt`/`sbDecrypt`): uses a symmetric key (`SbKey`, 32 bytes)
Both apply `pad`/`unPad` by default. The `NoPad` variants skip padding.
## xSalsa20
The XSalsa20 implementation splits the 24-byte nonce into two 8-byte halves. The first half initializes the cipher state (prepended with 16 zero bytes), the second derives a subkey. The first 32 bytes of output become the Poly1305 one-time key (`rs`), then the rest encrypts the message. This is the standard NaCl construction.
## Secret box chains (sbcInit / sbcHkdf)
HKDF-based key chains for deriving sequential key+nonce pairs:
- `sbcInit`: derives two 32-byte chain keys from a salt and shared secret using `HKDF(salt, secret, "SimpleXSbChainInit", 64)`
- `sbcHkdf`: advances a chain key, producing a new chain key (32 bytes), an SbKey (32 bytes), and a CbNonce (24 bytes) from `HKDF("", chainKey, "SimpleXSbChain", 88)`
## Key encoding
All keys are encoded as ASN.1 DER (X.509 SubjectPublicKeyInfo for public, PKCS#8 for private). The algorithm is determined by the encoded key length on decode — `decodePubKey` / `decodePrivKey` parse the ASN.1 structure, then dispatch on the X.509 key type.
## Signature algorithm detection
`decodeSignature` determines the algorithm by signature length: Ed25519 signatures are 64 bytes, Ed448 signatures are 114 bytes. Any other size is rejected.
## GCMIV constructor not exported
`GCMIV` constructor is not exported — only `gcmIV :: ByteString -> Either CryptoError GCMIV` is available, which validates that the input is exactly 12 bytes. This prevents construction of invalid IVs.
## verify silently returns False on algorithm mismatch
`verify :: APublicVerifyKey -> ASignature -> ByteString -> Bool` uses `testEquality` on the algorithm singletons. If the key is Ed25519 but the signature is Ed448 (or vice versa), `testEquality` fails and `verify` returns `False` — no error, no indication of a type mismatch. A correctly-formed signature can "fail" simply because the wrong algorithm key was passed.
## dh' returns raw DH output — no key derivation
`dh'` returns the raw X25519/X448 shared point with no hashing or HKDF. Callers must apply their own KDF: [SNTRUP761](./Crypto/SNTRUP761.md) hashes with SHA3-256, the [ratchet](./Crypto/Ratchet.md#kdf-functions) uses HKDF-SHA512. Not all DH libraries behave this way — some hash the output automatically.
## reverseNonce
`reverseNonce` creates a "reply" nonce by byte-reversing the original 24-byte nonce. Used for bidirectional communication where both sides need distinct nonces derived from the same starting value. The two nonces are guaranteed distinct unless the original is a byte palindrome, which is astronomically unlikely for random 24-byte values.
## CbAuthenticator
An authentication scheme that encrypts the SHA-512 hash of the message using crypto_box, rather than the message itself. The result is 80 bytes (64 hash + 16 auth tag). This is the djb-recommended authenticator scheme: it proves knowledge of the shared secret and the message content, without requiring the message to fit in a single crypto_box, and without revealing message content even to someone who compromises the shared key after verification.
## generateKeyPair is STM
Key generation uses `TVar ChaChaDRG` and runs in `STM`, not `IO`. This allows key generation inside `atomically` blocks, which is used extensively in handshake and ratchet initialization code.
@@ -1,25 +0,0 @@
# Simplex.Messaging.Crypto.File
> Streaming encrypted file I/O using NaCl secret_box with tail auth tag.
**Source**: [`Crypto/File.hs`](../../../../../src/Simplex/Messaging/Crypto/File.hs)
## Overview
`CryptoFileHandle` wraps a file `Handle` with an optional `TVar SbState` for streaming encryption/decryption. When `cryptoArgs` is `Nothing`, the file is plaintext and all operations pass through directly.
## Auth tag position
The auth tag is written/read **at the end of the file** (tail tag pattern), not prepended. This is important for streaming: `hPut` encrypts chunks as they arrive, accumulating the Poly1305 state in the TVar, and `hPutTag` finalizes and writes the 16-byte tag only after all data is written.
## hGetTag
**Security**: Uses `BA.constEq` for constant-time tag comparison, preventing timing side-channels. Must be called after reading all content bytes — it reads exactly `authTagSize` (16) remaining bytes and compares against the finalized Poly1305 state. Caller must know the file size and read only the content portion before calling this.
## getFileContentsSize
Subtracts `authTagSize` from the file size when crypto args are present. This gives the content size without the tag, which is needed to know how many bytes to read before calling `hGetTag`.
## readFile / writeFile
Whole-file variants that read/write everything at once. `readFile` uses `sbDecryptChunk` (encrypt-then-MAC verification — feeds ciphertext to Poly1305), while `writeFile` uses `sbEncryptChunk`. Both use the tail tag layout via [Simplex.Messaging.Crypto.Lazy](./Lazy.md) functions.
@@ -1,40 +0,0 @@
# Simplex.Messaging.Crypto.Lazy
> Streaming NaCl secret_box (XSalsa20 + Poly1305) for lazy ByteStrings.
**Source**: [`Crypto/Lazy.hs`](../../../../../src/Simplex/Messaging/Crypto/Lazy.hs)
## Overview
Lazy counterpart to the strict NaCl operations in [Simplex.Messaging.Crypto](../Crypto.md). Processes data chunk-by-chunk via `SbState = (XSalsa.State, Poly1305.State)`, enabling streaming encryption of large files without loading everything into memory.
## Encrypt-then-MAC asymmetry
`sbEncryptChunk` and `sbDecryptChunk` both use XSalsa20 for the cipher operation, but feed different data to Poly1305:
- **Encrypt**: feeds the **ciphertext** to Poly1305 (`Poly1305.update authSt c`)
- **Decrypt**: feeds the **original ciphertext** (the input chunk) to Poly1305 (`Poly1305.update authSt chunk`), not the decrypted plaintext
This is the correct encrypt-then-MAC pattern: the MAC is always computed over ciphertext, so both sides compute the same tag.
## Padding: 8-byte length prefix
`pad` uses an 8-byte `Int64` length prefix (via `smpEncode`), unlike [Simplex.Messaging.Crypto.pad](../Crypto.md#pad) which uses a 2-byte `Word16` prefix. This is because lazy operations handle file-sized data that can exceed 65535 bytes.
`unPad` / `splitLen` does not validate that the remaining data is at least `len` bytes — it uses `LB.take len` which silently returns a shorter result. The comment notes this is intentional to avoid consuming all chunks for validation.
## Auth tag placement: prepend vs tail
Two families of functions:
- **`sbEncrypt` / `sbDecrypt`**: tag is **prepended** (first 16 bytes of output). Used for message-sized data.
- **`sbEncryptTailTag` / `sbDecryptTailTag`**: tag is **appended** (last 16 bytes). More efficient for large files because you don't need to buffer the tag before the content.
The tail-tag variants also support `KEMHybridSecret` via `kcbEncryptTailTag` / `kcbDecryptTailTag`.
## sbDecryptTailTag validity
`sbDecryptTailTag` returns `(Bool, LazyByteString)` — the `Bool` indicates whether the auth tag was valid, but the decrypted data is returned regardless. This allows the caller to decide how to handle invalid tags (e.g., [Simplex.Messaging.Crypto.File](./File.md) uses strict `unless` checks).
## fastReplicate
Optimizes large padding by building the lazy ByteString from 64KB chunks (minus GHC overhead for `Int` size) rather than one enormous strict ByteString. This avoids allocating a single contiguous buffer for multi-megabyte padding.
@@ -1,125 +0,0 @@
# Simplex.Messaging.Crypto.Ratchet
> Double ratchet with post-quantum KEM extension (PQ X3DH + header encryption).
**Source**: [`Crypto/Ratchet.hs`](../../../../../src/Simplex/Messaging/Crypto/Ratchet.hs)
## Overview
Implements the Signal double ratchet protocol extended with:
- **Header encryption** (HE variant): message headers are encrypted with separate header keys, hiding the ratchet public key and message counters from observers.
- **Post-quantum KEM** (PQ variant): SNTRUP761 key encapsulation is folded into each ratchet step, providing PQ-resistance alongside X448 DH.
The ratchet uses X448 (not X25519) for DH operations — `type RatchetX448 = Ratchet 'X448`.
**Protocol spec**: [`protocol/pqdr.md`](../../../../protocol/pqdr.md) — Post-quantum resistant augmented double ratchet algorithm.
## PQ X3DH key agreement
`pqX3dhSnd` / `pqX3dhRcv` perform the extended X3DH:
- Standard triple DH: `DH(rk1, spk2)`, `DH(rk2, spk1)`, `DH(rk2, spk2)`
- Optional KEM shared secret from SNTRUP761 encapsulation
- Combined via `HKDF(salt=64_zeroes, DHs || KEMss, "SimpleXX3DH", 96)` → root key, header key, next-header key
The roles (who is "Alice" vs "Bob") are **reversed from the double ratchet spec**: the party initiating the connection is Bob (`generateRcvE2EParams`, `initRcvRatchet`), and the party accepting is Alice (`generateSndE2EParams`, `initSndRatchet`). Comments in the source explicitly note this.
## KDF functions
- **rootKdf**: `HKDF(rootKey, DH(pubKey, privKey) || KEMss, "SimpleXRootRatchet", 96)` → new root key (32), chain key (32), next header key (32)
- **chainKdf**: `HKDF("", chainKey, "SimpleXChainRatchet", 96)` → new chain key (32), message key (32), two IVs (16 + 16)
All use HKDF-SHA512 via [Simplex.Messaging.Crypto.hkdf](../Crypto.md).
## Header encryption and padding
Headers are encrypted with AEAD-GCM using the header key. The padded header length depends on whether PQ is supported:
- **Without PQ**: 88 bytes (fits DH key + counters)
- **With PQ**: 2310 bytes (fits DH key + KEM params + counters, with reserve for future extension)
The actual header is ~69 bytes without PQ, ~2288 with PQ. The padding ensures all messages have identical header sizes regardless of content.
## Version negotiation in headers
Each message header carries `msgMaxVersion` (the sender's max supported ratchet version). On decryption, the receiver upgrades its `current` version to `min(msgMaxVersion, maxSupported)` but never downgrades. The current version determines:
- Whether KEM params are included in headers (v3+)
- Whether 2-byte length prefixes are used for headers (v3+)
## largeP — backward-compatible length prefix parsing
`largeP` detects the length-prefix format by peeking at the first byte: if < 32, it's a 2-byte `Large` prefix (new format); otherwise it's a 1-byte prefix (old format). This allows upgrading the header encoding format in a single message without a version bump.
## maxSkip = 512 — DoS protection
`maxSkip` is a hardcoded constant (not configurable). Messages claiming to be more than 512 positions ahead of the current counter are rejected with `CERatchetTooManySkipped`. This prevents an attacker from forcing the receiver to compute and store an unbounded number of skipped message keys.
## Skipped message keys
When messages arrive out of order, the ratchet computes and stores the message keys for skipped messages (up to `maxSkip`). Skipped keys are stored in a `Map HeaderKey (Map Word32 MessageKey)` — keyed first by header key, then by message number.
The `SkippedMsgDiff` type represents changes to the skipped key store as a diff rather than a full replacement — this is persisted to the database, and the full state is loaded for the next message. `applySMDiff` is only used in tests.
## rcDecrypt flow
Decryption tries three strategies in order:
1. **Skipped message keys**: try all stored header keys to decrypt the header, then look up the message number in skipped keys
2. **Current receiving ratchet**: decrypt header with `rcHKr`
3. **Next header key**: decrypt header with `rcNHKr` (triggers a ratchet advance)
If strategy 1 decrypts the header but the message number isn't in skipped keys, it checks whether this header key corresponds to the current or next ratchet to decide whether to advance.
### decryptSkipped — linear scan through all stored header keys
`decryptSkipped` iterates through ALL `(HeaderKey, SkippedHdrMsgKeys)` pairs, attempting header decryption with each key. When header decryption succeeds but the message number is NOT in the skipped keys for that header, the result is `SMHeader` — which includes whether the key matches the current ratchet (`rcHKr``SameRatchet`) or the next ratchet (`rcNHKr``AdvanceRatchet`). This falls through to normal decryption processing rather than producing an error.
### decryptMessage — ratchet advances even on failure
`decryptMessage` returns `Either CryptoError ByteString` inside the `ExceptT` monad — a message decryption failure does NOT abort the ratchet state update. The ratchet counter advances (`rcNr + 1`) and chain key updates (`rcCKr'`) regardless of whether the message body decrypts successfully. This preserves ratchet state consistency for retransmission and error recovery.
## rcEncryptHeader — separated from rcEncryptMsg
Encryption is split into two steps: `rcEncryptHeader` produces a `MsgEncryptKey` (containing the encrypted header and message key), then `rcEncryptMsg` uses that key to encrypt the message body. This separation allows the ratchet state to be updated (persisted) before the message is encrypted, which is important for crash recovery — if the process crashes after encrypting but before sending, the ratchet state must already reflect the advanced counter.
## PQ ratchet step
During each ratchet advance (`pqRatchetStep`), the PQ KEM is folded in:
1. Receive: if the header contains a KEM ciphertext and we have the decapsulation key, compute the shared secret
2. Send: generate a new KEM keypair, encapsulate against the received public key, include in the next header
3. The KEM shared secret is concatenated with the DH shared secret before `rootKdf`
PQ can be enabled/disabled per-message via `pqEnc_` parameter. `rcSupportKEM` can only be enabled (never disabled) — once PQ headers are used, the larger header size is permanent.
## PQSupport vs PQEncryption
Two distinct newtypes with identical structure (`Bool` wrapper):
- `PQSupport`: whether PQ **can** be used (determines header padding size, cannot be disabled once enabled)
- `PQEncryption`: whether PQ **is** being used for the current send/receive ratchet
### pqEnableSupport is monotonic
`pqEnableSupport v sup enc = PQSupport $ sup || (v >= pqRatchetE2EEncryptVersion && enc)`. The `||` means once PQ support is `True`, it stays `True` regardless of subsequent messages. PQ encryption (usage) can be toggled per-message; PQ support (capability / header size) only ratchets up. This prevents the larger header format from being downgraded once negotiated.
## replyKEM_ — two-step KEM negotiation
KEM establishment requires two message round-trips, as described in the [PQDR KEM state machine](../../../../protocol/pqdr.md#kem-state-machine):
1. **Propose**: if the sender has no KEM in their header but the replier supports PQ at sufficient version, the replier includes a KEM proposal (`RKParamsProposed` — their encapsulation public key)
2. **Accept**: if the sender proposed KEM, the replier accepts by encapsulating against the proposed key and including the ciphertext + their own new encapsulation key (`RKParamsAccepted`)
After acceptance, both sides have a shared KEM secret that is folded into the root KDF. Subsequent ratchet steps continue the KEM exchange with fresh keypairs on each side.
## Error semantics
- `CERatchetEarlierMessage n`: message number is `n` positions before the next expected (already processed or skipped-and-consumed)
- `CERatchetDuplicateMessage`: message number is the most recently received (exact repeat)
- `CERatchetTooManySkipped n`: would need to skip `n` messages, exceeding `maxSkip`
- `CERatchetHeader`: header decryption failed with all available keys
- `CERatchetState`: no sending chain (ratchet not initialized for sending)
- `CERatchetKEMState`: KEM state mismatch between parties
## InitialKeys
Controls PQ key inclusion in connection establishment:
- `IKUsePQ`: always include PQ keys (used in contact requests and short link data)
- `IKLinkPQ pq`: include PQ keys only in short link data, if `pq` is enabled
`initialPQEncryption` resolves this based on whether it's a short link context.
@@ -1,13 +0,0 @@
# Simplex.Messaging.Crypto.SNTRUP761
> Hybrid KEM+DH shared secret combining SNTRUP761 and X25519.
**Source**: [`Crypto/SNTRUP761.hs`](../../../../../src/Simplex/Messaging/Crypto/SNTRUP761.hs)
## kemHybridSecret
The hybrid secret is `SHA3_256(DHSecret || KEMSharedKey)` — not a simple concatenation, not HKDF. This follows the approach in draft-josefsson-ntruprime-hybrid. The result is a `ScrubbedBytes` value used as a symmetric key for NaCl crypto_box operations via `sbEncrypt_`/`sbDecrypt_`.
## kcbEncrypt / kcbDecrypt
These delegate directly to `sbEncrypt_` / `sbDecrypt_` from [Simplex.Messaging.Crypto](../Crypto.md), using the hybrid secret as the symmetric key. The hybrid secret is 32 bytes (SHA3-256 output), matching the expected key size for XSalsa20.
@@ -1,36 +0,0 @@
# Simplex.Messaging.Crypto.ShortLink
> Short link key derivation, encryption, and signature verification for contact/invitation links.
**Source**: [`Crypto/ShortLink.hs`](../../../../../src/Simplex/Messaging/Crypto/ShortLink.hs)
## Overview
Short links encode connection data in two encrypted blobs: fixed data (2048 bytes padded) and user data (13824 bytes padded). Both are encrypted with `sbEncrypt` using a key derived from the link key via HKDF.
## KDF schemes
Two distinct HKDF derivations with different info strings:
- **contactShortLinkKdf**: `HKDF("", linkKey, "SimpleXContactLink", 56)` → splits into 24-byte LinkId + 32-byte SbKey. The LinkId is used as the router-side identifier.
- **invShortLinkKdf**: `HKDF("", linkKey, "SimpleXInvLink", 32)` → 32-byte SbKey only. No LinkId because invitation links don't use router-side lookup.
## Fixed padding lengths
- `fixedDataPaddedLength = 2008` (2048 - 24 nonce - 16 auth tag)
- `userDataPaddedLength = 13784` (13824 - 24 - 16)
These are chosen so the encrypted output (with prepended nonce and appended auth tag) fits exactly in round sizes.
## decryptLinkData
**Security**: Performs three-layer verification in order:
1. Hash check: `SHA3_256(fixedData) == linkKey` — ensures data integrity
2. Root key signature: `verify(rootKey, sig1, fixedData)` — ensures authenticity
3. User data signature: `verify(rootKey, sig2, userData)` for invitations, or verify against any owner key for contact links
For contact links, also calls `validateLinkOwners` to verify the owner chain of trust (each owner is signed by the root key).
## encodeSign
Prepends the Ed25519 signature to the data: `smpEncode(sign(pk, data)) <> data`. This is the format expected by `decryptLinkData`'s parser.
@@ -1,39 +0,0 @@
# Simplex.Messaging.Encoding
> Binary wire-format encoding for SMP protocol transmission.
**Source**: [`Encoding.hs`](../../../../src/Simplex/Messaging/Encoding.hs)
## Overview
`Encoding` is the binary wire format — fixed-size or length-prefixed, no delimiters between fields. Contrast with [Simplex.Messaging.Encoding.String](./Encoding/String.md) which is the human-readable, space-delimited, base64url format used in URIs and logs.
The two encoding classes share some instances (`Char`, `Bool`, `SystemTime`) but differ fundamentally: `Encoding` is self-delimiting via length prefixes, `StrEncoding` is delimiter-based (spaces, commas).
## ByteString instance
**Length prefix is 1 byte.** Maximum encodable length is 255 bytes. If a ByteString exceeds 255 bytes, the length silently wraps via `w2c . fromIntegral` — a 300-byte string encodes length as 44 (300 mod 256). Callers must ensure ByteStrings fit in 255 bytes, or use `Large` for longer values.
## Large
2-byte length prefix (`Word16`). Use for ByteStrings that may exceed 255 bytes. Maximum 65535 bytes.
## Maybe instance
Tags are ASCII characters `'0'` (0x30) and `'1'` (0x31), not bytes 0x00/0x01. `Nothing` encodes as the single byte 0x30; `Just x` encodes as 0x31 followed by `smpEncode x`.
## Tail
Consumes all remaining input. Must be the last field in any composite encoding — placing it elsewhere silently eats subsequent fields.
## Tuple instances
Sequential concatenation with no separators. Works because each element's encoding is self-delimiting (length-prefixed ByteString, fixed-size Word16/Word32/Int64/Char, etc.). If an element type isn't self-delimiting, the tuple won't round-trip.
## SystemTime
Only seconds are encoded (as Int64); nanoseconds are discarded on encode and set to 0 on decode.
## smpEncodeList / smpListP
1-byte length prefix for lists — same 255-item limit as ByteString's 255-byte limit.
@@ -1,43 +0,0 @@
# Simplex.Messaging.Encoding.String
> Human-readable, URI-friendly string encoding for SMP and agent protocols.
**Source**: [`Encoding/String.hs`](../../../../../src/Simplex/Messaging/Encoding/String.hs)
## Overview
`StrEncoding` is the human-readable counterpart to [Simplex.Messaging.Encoding](../Encoding.md)'s binary `Encoding`. Key differences:
| Aspect | `Encoding` (binary) | `StrEncoding` (string) |
|--------|---------------------|------------------------|
| ByteString | 1-byte length prefix, raw bytes | base64url encoded |
| Tuple separator | none (self-delimiting) | space-delimited |
| List separator | 1-byte count prefix | comma-separated |
| Default parser fallback | `smpP` via `parseAll` | `strP` via `base64urlP` |
## ByteString instance
Encodes as base64url. The parser (`strP`) only accepts non-empty strings — empty base64url input fails.
## String instance
Inherits from ByteString via `B.pack` / `B.unpack`. Only Char8 (Latin-1) characters round-trip.
## strToJSON / strParseJSON
`strToJSON` uses `decodeLatin1`, not `decodeUtf8'`. This preserves arbitrary byte sequences (e.g., base64url-encoded binary data) as JSON strings without UTF-8 validation errors, but means the JSON representation is Latin-1, not UTF-8.
## Class default: strP assumes base64url for all types
The `MINIMAL` pragma allows defining only `strDecode` without `strP`. But the default `strP = strDecode <$?> base64urlP` then assumes input is base64url-encoded — for *any* type, not just ByteString. Two consequences:
1. The type's `strDecode` receives raw decoded bytes, not the base64url text. Easy to confuse when implementing a new instance.
2. `base64urlP` requires non-empty input (`takeWhile1`), so the default `strP` cannot parse empty values — even if `strDecode ""` would succeed. Types that can encode to empty output must define `strP` explicitly.
## listItem
Items are delimited by `,`, ` `, or `\n`. List items cannot contain these characters in their `strEncode` output. No escaping mechanism exists.
## Str newtype
Plain text (no base64). Delimited by spaces. `strP` consumes the trailing space — this is unusual and means `Str` parsing has a side effect on the input position that other `StrEncoding` parsers don't.
@@ -1,23 +0,0 @@
# Simplex.Messaging.Notifications.Client
> Typed wrappers around `ProtocolClient` for NTF protocol commands.
**Source**: [`Notifications/Client.hs`](../../../../../src/Simplex/Messaging/Notifications/Client.hs)
## Non-obvious behavior
### 1. Subscription operations always use NRMBackground
`ntfCreateSubscription`, `ntfCheckSubscription`, `ntfDeleteSubscription`, and their batch variants hardcode `NRMBackground` as the network request mode. Token operations (`ntfRegisterToken`, `ntfVerifyToken`, etc.) accept the mode as a parameter. This reflects that subscription management is a background activity driven by the supervisor, while token operations can be user-initiated.
### 2. Batch operations return per-item errors
`ntfCreateSubscriptions` and `ntfCheckSubscriptions` return `NonEmpty (Either NtfClientError result)` — individual items in a batch can fail independently. Callers must handle partial success (some created, some failed). The singular variants throw on any error.
### 3. Default port is 443
`defaultNTFClientConfig` sets the default transport to `("443", transport @TLS)`. Unlike the SMP protocol which typically uses port 5223, the NTF protocol defaults to the standard HTTPS port.
### 4. okNtfCommand parameter ordering
`okNtfCommand` has an unusual parameter order — the command comes first, then client, mode, key, entityId. This enables partial application in the `ntfDeleteToken`, `ntfVerifyToken` etc. definitions, where the command is fixed and the remaining parameters flow through.
@@ -1,55 +0,0 @@
# Simplex.Messaging.Notifications.Protocol
> NTF protocol entities, commands, command results, and wire encoding for the notification system.
**Source**: [`Notifications/Protocol.hs`](../../../../../src/Simplex/Messaging/Notifications/Protocol.hs)
## Non-obvious behavior
### 1. Asymmetric credential validation
`checkCredentials` enforces different rules per command category:
| Category | Signature required | Entity ID |
|----------|-------------------|-----------|
| TNEW, SNEW | Yes | Must be empty (new entity) |
| PING | No | Must be empty |
| All others | Yes | Must be present |
For command results, the rule inverts: `NRTknId`, `NRSubId`, and `NRPong` must NOT have entity IDs (they are returned before/without entity context), while `NRErr` optionally has one (errors can occur with or without entity context).
### 2. PNMessageData semicolon separator
`encodePNMessages` uses `;` as the separator between push notification message items instead of the standard `,` used by `NonEmpty` `strEncode`. This is because `SMPQueueNtf` contains an `SMPServer` whose host list encoding already uses commas, which would create ambiguous parsing.
### 3. NTInvalid reason is version-gated
When encoding `NRTkn` results, the `NTInvalid` reason is only included if the negotiated protocol version is >= `invalidReasonNTFVersion` (v3). Older clients receive `NTInvalid Nothing`. This prevents parse failures on clients that don't understand the reason field.
### 4. subscribeNtfStatuses migration invariant
The comment on `subscribeNtfStatuses` (`[NSNew, NSPending, NSActive, NSInactive]`) warns that changing these statuses requires a new database migration for queue ID hashes (see `m20250830_queue_ids_hash`). This is a cross-module invariant between protocol types and router storage.
### 5. allowNtfSubCommands permits NTInvalid and NTExpired
Token status `NTInvalid` allows subscription commands (SNEW, SCHK, SDEL), which is counterintuitive. The rationale (noted in a TODO comment) is that invalidation can happen after verification, and existing subscriptions should remain manageable. `NTExpired` is also permitted for the same reason.
### 6. PPApnsNull test provider
`PPApnsNull` is a push provider that never communicates with APNS. It's used for end-to-end testing of the notification router from clients without requiring actual push infrastructure.
### 7. DeviceToken hex validation
`DeviceToken` string parsing has two paths: a hardcoded literal match for `"apns_null test_ntf_token"` (test tokens), and hex string validation for real tokens (must be even-length hex). The wire encoding (`smpP`) does not perform this validation — it accepts any `ByteString`.
### 8. SMPQueueNtf parsing applies updateSMPServerHosts
Both `smpP` and `strP` for `SMPQueueNtf` apply `updateSMPServerHosts` to the parsed SMP server. This normalizes router host addresses on deserialization, ensuring consistent comparison even if the on-wire format uses different host representations.
### 9. NRTknId result tag comment
The `NRTknId_` tag encodes as `"IDTKN"` with a source comment: "it should be 'TID', 'SID'". This indicates a naming inconsistency that was preserved for backward compatibility — the tag names don't follow the pattern of other NTF protocol tags.
### 10. useServiceAuth is False
The `Protocol` instance explicitly returns `False` for `useServiceAuth`, meaning the NTF protocol never uses service-level authentication. All authentication is entity-level (per token/subscription).
@@ -1,133 +0,0 @@
# Simplex.Messaging.Notifications.Server
> NTF router: manages tokens, subscriptions, SMP subscriber connections, and push notification delivery.
**Source**: [`Notifications/Server.hs`](../../../../../src/Simplex/Messaging/Notifications/Server.hs)
## Architecture
The NTF router runs several concurrent threads via `raceAny_`:
| Thread | Purpose |
|--------|---------|
| `ntfSubscriber` | Receives SMP messages (NMSG, END, DELD) and agent events (connect/disconnect/subscribe) |
| `ntfPush` | Reads push queue and delivers via APNS provider |
| `periodicNtfsThread` | Sends periodic "check messages" push notifications (cron) |
| `runServer` (per transport) | Accepts client connections and runs NTF protocol |
| Stats/Prometheus/Control | Optional monitoring and admin threads |
Each client connection spawns `receive`, `send`, and `client` threads via `raceAny_`.
See [spec/routers.md](../../../routers.md) for component and sequence diagrams.
## Non-obvious behavior
### 1. Timing attack mitigation on entity lookup
When `verifyNtfTransmission` encounters an AUTH error (entity not found), it calls `dummyVerifyCmd` to equalize result timing before returning the error. This prevents attackers from distinguishing "entity doesn't exist" from "signature invalid" based on result latency.
### 2. TNEW idempotent re-registration
When TNEW is received for an already-registered token, the router:
1. Looks up the existing token via `findNtfTokenRegistration` (matches on push provider, device token, AND verify key)
2. Verifies the DH secret matches (recomputed from the new `dhPubKey` and stored `tknDhPrivKey`)
3. If DH secrets differ → AUTH error (prevents token hijacking)
4. If they match → re-sends verification push notification
If the verify key doesn't match in step 1, the lookup returns `Nothing` and a new token is created instead — the DH secret check never runs. This makes TNEW safe for client retransmission after connection drops.
### 3. SNEW idempotent subscription
When SNEW is received for an existing subscription (same token + SMP queue), the router returns the existing `ntfSubId` if the notifier key matches. If keys differ, AUTH error. New subscriptions are only created when no match exists in `findNtfSubscription`.
### 4. PPApnsNull suppresses statistics
`incNtfStatT` skips all stat increments when the device token uses `PPApnsNull` provider. This prevents test tokens from polluting production metrics.
### 5. END requires active session validation
SMP END messages are only processed when the originating session is the currently active session for that router (`activeClientSession'` check). This prevents stale END messages from previous (reconnected) sessions from incorrectly marking subscriptions as ended.
### 6. waitForSMPSubscriber two-phase wait
`waitForSMPSubscriber` first tries a non-blocking `tryReadTMVar`. If the subscriber isn't ready yet, it falls back to a blocking `readTMVar` with a 10-second timeout. This avoids creating an extra timeout thread in the common case where the subscriber is already available.
### 7. CAServiceUnavailable triggers individual resubscription
When a service subscription becomes unavailable (SMP router rejects service credentials), the NTF router:
1. Removes the service association from the database
2. Resubscribes all individual queues for that router via `subscribeSrvSubs`
This is the fallback path from service-level to queue-level SMP subscriptions.
### 8. Push delivery single retry
`deliverNotification` retries exactly once on connection errors (`PPConnection`) or `PPRetryLater`:
1. Creates a new push client (`newPushClient`) to get a fresh connection
2. Retries the delivery
On the second failure, the error is logged and returned. `PPTokenInvalid` marks the token as `NTInvalid` on either the first or retry attempt.
### 9. TCRN minimum interval enforcement
Cron notification interval has a hard minimum of 20 minutes. `TCRN 0` disables cron notifications. `TCRN n` where `1 <= n < 20` returns `QUOTA` error.
### 10. Startup resubscription is concurrent per router
`resubscribe` uses `mapConcurrently` to resubscribe to all known SMP routers in parallel. Within each router, subscriptions are paginated via `subscribeLoop` using cursor-based pagination (`afterSubId_`).
### 11. receive separates error results from commands
The `receive` function processes incoming transmissions and partitions results: malformed/unauthorized requests are written directly to `sndQ` as error results, while valid commands go to `rcvQ` for processing. This ensures protocol errors get immediate results without competing for the command processing queue.
### 12. Maintenance mode saves state then exits immediately
When `maintenance` is set in `startOptions`, the router restores stats, calls `stopServer` (closes DB, saves stats), and exits with `exitSuccess`. It never starts transport listeners, subscriber threads, or resubscription. This provides a way to run database migrations without the router serving traffic.
### 13. Resubscription runs as a detached fork
`resubscribe` is launched via `forkIO` before `raceAny_` starts — it is **not part of the `raceAny_` group**. Most exceptions are silently lost per `forkIO` semantics. However, `ExitCode` exceptions (like `exitFailure` from pattern 20) are special-cased by GHC's runtime and propagate to the main thread, terminating the process.
### 14. TNEW re-registration resets status for non-verifiable tokens
When a re-registration TNEW matches on DH secret but `allowTokenVerification tknStatus` is `False` (token is `NTNew`, `NTInvalid`, or `NTExpired`), the router resets status to `NTRegistered` before sending the verification push. This makes TNEW a "status repair" mechanism — clients with stuck tokens can restart the verification flow by re-registering with the same DH key.
### 15. DELD unconditionally updates status (no session validation)
Unlike `SMP.END` which checks `activeClientSession'` to prevent stale session messages from changing state, `SMP.DELD` updates subscription status to `NSDeleted` unconditionally. This is correct because DELD means the queue was permanently deleted on the SMP router — the information is valid regardless of which session reports it.
### 16. TRPL generates new code but reuses the DH key
`TRPL` (token replace) creates a new registration code and resets status to `NTRegistered`, but does NOT generate a new router DH key pair. The existing `tknDhPrivKey` and `tknDhSecret` are preserved — only the push provider token and registration code change. The encrypted channel between client and NTF router persists across device token replacements.
### 17. PNMessage delivery requires NTActive, verification and cron do not
`ntfPush` applies `checkActiveTkn` only to `PNMessage` notifications. Verification pushes (`PNVerification`) and cron check-messages pushes (`PNCheckMessages`) are delivered regardless of token status. This is necessary because verification pushes must be sent before NTActive, and cron pushes are already filtered at the database level.
### 18. CAServiceSubscribed validates count and hash with warning-only behavior
When a service subscription is confirmed, the NTF router compares expected and confirmed subscription count and IDs hash. Mismatches in either are logged as warnings but no corrective action is taken. Only when both match is an informational message logged.
### 19. subscribeLoop uses 100x database batch multiplier
`dbBatchSize = batchSize * 100` reads subscriptions from the database in chunks 100 times larger than the SMP subscription batches. This reduces database round-trips during resubscription while keeping individual SMP batches small enough to avoid overwhelming SMP routers.
### 20. subscribeLoop calls exitFailure on database error
If `getServerNtfSubscriptions` returns `Left _` during startup resubscription, the router terminates via `exitFailure`. Since `resubscribe` runs in a forked thread (pattern 13), this `exitFailure` terminates the entire process — a transient database error during startup resubscription kills the router.
### 21. Stats log aligns to wall-clock time of day
The stats logging thread calculates an `initialDelay` to synchronize the first flush to `logStatsStartTime`. If the target time already passed today, it adds 86400 seconds to schedule for the next day. Subsequent flushes occur at exact `logInterval` cadence from that aligned start point.
### 22. NMSG AUTH errors silently counted, not logged
When `addTokenLastNtf` returns `Left AUTH` (notification for a queue whose subscription/token association is invalid), the router increments `ntfReceivedAuth` but takes no corrective action. Other error types are silently ignored. This is expected — subscriptions may be deleted while messages are in-flight.
### 23. PNVerification delivery transitions token to NTConfirmed
When a verification push is successfully delivered to the push provider, `setTknStatusConfirmed` transitions the token to `NTConfirmed`, but only if not already `NTConfirmed` or `NTActive`. This creates a two-phase confirmation: push delivery confirms the channel works (`NTConfirmed`), then TVFY confirms the client received it (`NTActive`).
### 24. disconnectTransport always passes noSubscriptions = True
Unlike the SMP router which checks active subscriptions before disconnecting idle clients, the NTF router always returns `True` for the "no subscriptions" check. NTF clients are disconnected purely on inactivity timeout — the NTF protocol has no long-lived client subscriptions.
@@ -1,7 +0,0 @@
# Simplex.Messaging.Notifications.Server.Control
> Control port command protocol for NTF router administration.
**Source**: [`Notifications/Server/Control.hs`](../../../../../../src/Simplex/Messaging/Notifications/Server/Control.hs)
No non-obvious behavior. See source.
@@ -1,45 +0,0 @@
# Simplex.Messaging.Notifications.Server.Env
> NTF router environment: configuration, subscriber state, and push provider management.
**Source**: [`Notifications/Server/Env.hs`](../../../../../../src/Simplex/Messaging/Notifications/Server/Env.hs)
## Non-obvious behavior
### 1. Service credentials are lazily generated
`mkDbService` in `newNtfServerEnv` generates service credentials on demand: when `getCredentials` is called for an SMP router, it checks the database. If the router is known and already has credentials, they are reused. If the router is known but has no credentials yet (first connection), new credentials are generated via `genCredentials`, stored in the database, and returned. If the router is not in the database at all, `PCEServiceUnavailable` is thrown (this case should not occur in practice, as clients only connect to routers already tracked in the database).
Service credentials are only used when `useServiceCreds` is enabled in the config.
### 2. PPApnsNull creates a no-op push client
`newPushClient` checks `apnsProviderHost` for the push provider. `PPApnsNull` returns `Nothing`, which creates a no-op client (`\_ _ -> pure ()`). Real providers create an actual APNS connection. This is the mechanism that allows `PPApnsNull` tokens to function without push infrastructure.
### 3. getPushClient lazy initialization
`getPushClient` looks up the push client by provider in `pushClients` TMap. If not found, it calls `newPushClient` to create and register one. Push provider connections are established on first use, not at router startup.
### 4. Service credential validity: 25h backdating, ~2700yr forward
`genCredentials` creates self-signed Ed25519 certificates valid from 25 hours in the past to `24 * 999999` hours (~2,739 years) in the future. The 25-hour backdating protects against clock skew between NTF and SMP routers. The near-permanent forward validity avoids the need for credential rotation infrastructure.
### 5. newPushClient race creates duplicate clients
`newPushClient` atomically inserts into `pushClients` after creating the client. A concurrent `getPushClient` call between creation start and TMap insert will see `Nothing`, create a second client, and overwrite the first. This race is tolerable — APNS connections are cheap and the overwritten client is garbage collected.
### 6. Bidirectional activity timestamps
`NtfServerClient` has separate `rcvActiveAt` and `sndActiveAt` TVars, both initialized to connection time and updated independently. `disconnectTransport` considers both — a client that only receives (or only sends) is still considered active.
### 7. pushQ bounded TBQueue creates backpressure
`pushQ` in `NtfPushServer` is a `TBQueue` sized by `pushQSize`. When full, any thread writing to it (NMSG processing, periodic cron, verification) blocks in STM until space is available. This prevents the push delivery pipeline from being overwhelmed.
### 8. subscriberSeq provides monotonic session variable ordering
The `subscriberSeq` TVar is used by `getSessVar` to assign monotonically increasing IDs to subscriber session variables. `removeSessVar` uses compare-and-swap with this ID — only the variable with the matching ID can be removed, preventing stale removal when a new subscriber has already replaced the old one.
### 9. SMPSubscriber holds Weak ThreadId for GC-based cleanup
`subThreadId` is `Weak ThreadId`, not `ThreadId`. Using `Weak ThreadId` allows the GC to collect thread resources when no strong references remain. `stopSubscriber` uses `deRefWeak` to obtain the `ThreadId` (if the thread hasn't been GC'd) before calling `killThread`. The `Nothing` case (thread already collected) is simply skipped.
@@ -1,7 +0,0 @@
# Simplex.Messaging.Notifications.Server.Main
> CLI interface and INI configuration parsing for the NTF router.
**Source**: [`Notifications/Server/Main.hs`](../../../../../../src/Simplex/Messaging/Notifications/Server/Main.hs)
No non-obvious behavior. Standard CLI/config boilerplate. Notable defaults: `subsBatchSize = 900`, `periodicNtfsInterval = 5 minutes`, `pushQSize = 32768`, `persistErrorInterval = 0` (disables SMP client reconnection error persistence).
@@ -1,75 +0,0 @@
# Simplex.Messaging.Notifications.Server.Push.APNS
> Apple Push Notification Service (APNS) client: JWT authentication, HTTP/2 delivery, and e2e encryption.
**Source**: [`Notifications/Server/Push/APNS.hs`](../../../../../../../src/Simplex/Messaging/Notifications/Server/Push/APNS.hs)
## Non-obvious behavior
### 1. PNCheckMessages is not encrypted
`PNVerification` and `PNMessage` notifications are encrypted with the shared DH secret (`C.cbEncrypt`) and padded to `paddedNtfLength` (3072 bytes) to prevent metadata leakage. `PNCheckMessages` is sent as a plain `{"checkMessages": true}` background notification — it carries no sensitive data and doesn't need e2e encryption.
### 2. Fixed-length encryption padding
All encrypted notifications are padded to `paddedNtfLength` (3072 bytes) regardless of actual content size. This prevents notification size from revealing whether it's a verification code (small) or a message batch (larger).
### 3. JWT token caching with TTL refresh
`getApnsJWTToken` caches the signed JWT and only regenerates it when the token age exceeds `tokenTTL` (30 minutes). No locking is used — if two threads race to refresh, last writer wins, which is acceptable since both produce valid tokens.
### 4. HTTP/2 reconnect-on-use
`createAPNSPushClient` registers a disconnect callback that sets `https2Client` to `Nothing`. `getApnsHTTP2Client` lazily reconnects on the next push delivery attempt. The connection is not proactively maintained.
### 5. 503 triggers active disconnect before retry
When APNS returns 503 (Service Unavailable), the client actively closes the HTTP/2 connection (`disconnectApnsHTTP2Client`) before throwing `PPRetryLater`. This ensures a fresh connection is established on retry rather than reusing a potentially degraded connection.
### 6. ExpiredProviderToken is permanent
403 errors for `ExpiredProviderToken` and `InvalidProviderToken` are classified as `PPPermanentError` rather than retryable. Since `getApnsJWTToken` just refreshed the JWT before the request, retrying with the same key would produce the same error. This indicates a configuration problem (wrong key/team ID).
### 7. EC key type assumption
`readECPrivateKey` uses a specific pattern match for EC keys (`PrivKeyEC_Named`). It will crash at runtime if the APNS key file contains a different key type. The comment acknowledges this limitation.
### 8. JWT signature uses DER-encoded ASN.1, not raw r||s
`signedJWTToken` serializes the ECDSA signature as a DER-encoded ASN.1 SEQUENCE of two INTEGERs, then base64url-encodes it. RFC 7518 Section 3.4 requires raw concatenation of fixed-length r and s values instead. This deviation works because Apple's APNS server accepts DER-encoded signatures, but it would break if Apple enforced strict JWS compliance.
### 9. Two different base64url encodings
The encryption path uses `U.encode` (base64url **with** padding `=`), while the JWT path uses `U.encodeUnpadded` (base64url **without** padding). JWT requires unpadded base64url per RFC 7515, but the encrypted notification ciphertext is padded before being embedded as a JSON text value.
### 10. Error response defaults to empty string on parse failure
If the APNS error response body is empty, malformed, or not JSON, `decodeStrict'` returns `Nothing` and the reason defaults to `""`. This empty string never matches named error patterns, so unparseable error bodies fall through to the catch-all of whichever status code branch matches. For 410, this means a malformed body is treated as `PPRetryLater` rather than a token invalidation.
### 11. 410 unknown reasons are retryable, unlike 400/403 unknowns
Unknown 410 (Gone) reasons fall through to `PPRetryLater`, while unknown 400 and 403 reasons fall through to `PPResponseError`. This means an unexpected APNS 410 reason string triggers retry behavior rather than permanent failure.
### 12. 429 TooManyRequests is not explicitly handled
There is a commented-out note but no actual 429 handler. A rate-limiting response falls through to the `otherwise` branch and becomes `PPResponseError`, surfacing as a generic error rather than a retryable condition.
### 13. Nonce generation is STM-atomic, separate from encryption
The per-notification nonce is generated inside `atomically` using the `ChaChaDRG` TVar, guaranteeing uniqueness under concurrent delivery. The nonce is then used by `cbEncrypt` outside STM. This separation means the nonce is committed to the DRG state even if encryption or send subsequently fails — correct behavior since nonce reuse would be catastrophic.
### 14. Background notifications use priority 5, alerts use default 10
`apnsRequest` conditionally appends `apns-priority: 5` only for `APNSBackground` notifications. Alert and mutable-content notifications omit the header, relying on APNS's default priority of 10. Apple requires background pushes to use priority 5 — using 10 can cause APNS to reject them.
### 15. APNSErrorResponse is data, not newtype
The comment explicitly states `APNSErrorResponse` is `data` rather than `newtype` "to have a correct JSON encoding as a record." With `deriveFromJSON`, a newtype around `Text` would serialize as a bare string, not `{"reason": "..."}`. The `data` wrapper forces record encoding matching APNS's JSON error format.
### 16. HTTP/2 requests go through a serializing queue
`sendRequest` routes through the HTTP2Client's `reqQ` (a `TBQueue`), serializing all requests through a single sender thread. Concurrent push deliveries are implicitly serialized at the HTTP/2 layer, meaning high-throughput scenarios bottleneck on this queue rather than utilizing HTTP/2's multiplexing.
### 17. Connection initialization is fire-and-forget
`createAPNSPushClient` calls `connectHTTPS2` and discards the result with `void`. If the initial connection fails, the error is only logged — the client is still created. The first push delivery triggers `getApnsHTTP2Client` which reconnects. This means the router can start even if APNS is unreachable.
@@ -1,7 +0,0 @@
# Simplex.Messaging.Notifications.Server.Push.APNS.Internal
> APNS HTTP header constants and JSON encoding options.
**Source**: [`Notifications/Server/Push/APNS/Internal.hs`](../../../../../../../../src/Simplex/Messaging/Notifications/Server/Push/APNS/Internal.hs)
No non-obvious behavior. See source. Defines APNS header names and JSON options (`UntaggedValue` sum encoding, `camelTo2 '-'` for hyphenated field names like `content-available`, `mutable-content`).
@@ -1,39 +0,0 @@
# Simplex.Messaging.Notifications.Server.Stats
> NTF router statistics collection with own-router breakdown and backward-compatible persistence.
**Source**: [`Notifications/Server/Stats.hs`](../../../../../../src/Simplex/Messaging/Notifications/Server/Stats.hs)
## Non-obvious behavior
### 1. incServerStat double lookup
`incServerStat` performs a non-STM IO lookup first. On cache hit, the STM transaction only touches the per-router `TVar Int` without reading the shared TMap, avoiding contention. On cache miss, the STM block re-checks the map to handle races (another thread may have inserted between the IO lookup and STM entry).
### 2. setNtfServerStats is not thread safe
`setNtfServerStats` is explicitly documented as non-thread-safe and intended for router startup only (restoring from backup file).
### 3. Backward-compatible parsing
The `strP` parser uses `opt` which defaults missing fields to 0. This allows reading stats files from older router versions that don't include newer fields (`ntfReceivedAuth`, `ntfFailed`, `ntfVrf*`, etc.).
### 4. getNtfServerStatsData is a non-atomic snapshot
`getNtfServerStatsData` reads each `IORef` and `TMap` field sequentially in plain `IO`, not inside a single STM transaction. The returned `NtfServerStatsData` is not a consistent point-in-time snapshot — invariants like "received >= delivered" may not hold. The same applies to `getStatsByServer`, which does one `readTVarIO` for the map root TVar, then a separate `readTVarIO` for each per-router TVar. This is acceptable for periodic reporting where approximate consistency suffices.
### 5. Mixed IORef/TVar concurrency primitives
Aggregate counters (`ntfReceived`, `ntfDelivered`, etc.) use `IORef Int` incremented via `atomicModifyIORef'_`, while per-router breakdowns use `TMap Text (TVar Int)` incremented atomically via STM in `incServerStat`. Although both individual operations are atomic, the aggregate and per-router increments are separate operations, so their values can drift: a thread could increment the aggregate `IORef` before `incServerStat` runs, or vice versa.
### 6. setStatsByServer replaces TMap atomically but orphans old TVars
`setStatsByServer` builds a fresh `Map Text (TVar Int)` in IO via `newTVarIO`, then atomically replaces the TMap's root TVar. Old per-router TVars are not reused — any other thread holding a reference from a prior `TM.lookupIO` would modify an orphaned counter. Safe only because it's called at startup (like `setNtfServerStats`), but lacks the explicit "not thread safe" comment.
### 7. Positional parser format despite key=value appearance
The parser is strictly positional: fields must appear in exactly the serialization order. The `opt` alternatives only handle entirely absent fields (defaulting to 0), not reordered fields. Despite the `key=value` on-disk appearance, this is a sequential format — the named prefixes are for human readability, not key-lookup parsing.
### 8. B.unlines trailing newline asymmetry
`strEncode` uses `B.unlines`, which appends `\n` after every element including the last. The parser compensates with `optional A.endOfLine` on the last field. The file always ends with `\n`, but the parser tolerates its absence.

Some files were not shown because too many files have changed in this diff Show More