Amarula.Protocol.Messages.ConversationSender (amarula v0.4.2)
View SourcePer-recipient send process. One ConversationSender exists per recipient JID;
all sends to that recipient funnel through it and run one at a time.
Different recipients run in parallel under the DynamicSupervisor.
Why a process per recipient — it is a lock, not a cache
The Sender holds no state of its own — not even the ratchet. Encrypting a
message is a load → advance → store against the shared Signal session in
Storage (see encrypt_for_device/2): load the session record, advance the
ratchet (pure), write the advanced record back. That read-modify-write is not
atomic. If two sends to the same recipient ran concurrently, both could load
the same record, advance from the same point, and store — a lost update that
forks the ratchet and corrupts the session.
So the Sender exists to serialize that read-modify-write, not to hold it. Its
mailbox is the lock: cast #2's load can't begin until cast #1's store has
finished. One process per recipient gives exactly the right granularity — serial
within a recipient (ratchet-safe), parallel across recipients (throughput).
This is why a bare Task per send would be wrong (no per-recipient mutual
exclusion → forked ratchets) and why a single shared process would be wrong (no
cross-recipient parallelism). Because it holds nothing, a Sender is cheap to lose
and respawn, and current credentials are handed to it per send (creds mutate
after login, so a cached snapshot would encrypt stale).
A send is a branchless pipe of ctx -> ctx steps that block on IQ round-trips
through Connection (the sole websocket owner):
ctx
|> resolve_devices() # device-list cache, else a USync query
|> ensure_sessions() # session files, else a prekey-bundle fetch
|> encrypt() # per device; plain vs DSM; advance ratchet
|> relay() # frame + send the <participants> stanzaEach step that needs server data calls Connection.query_iq/2, which
blocks until the matching websocket reply arrives. A step failure crashes the
process (the DynamicSupervisor reaps it); the pipe carries no error branches.
Lifecycle & registry presence
Identity. A sender's identity is its {instance_id, recipient_jid} pair.
It is registered in the app-level Amarula.InstanceRegistry under that key
(namespaced by instance_id so two connections don't collide on a shared
recipient) — so at most one sender per recipient per connection exists at a
time, and deliver/2 is a find-or-start on that key.
Birth (lazy). Started on the first deliver/2 to a recipient with no live
sender: find-or-start via Registry.lookup → else DynamicSupervisor.start_child.
The {:error, {:already_started, pid}} branch makes the start race-safe even
though, in practice, only Connection calls deliver (single process, so no
concurrent start for the same recipient).
Registration. Automatic, via the :via name in start_link — the Registry
registers the pid on start and auto-unregisters it on death (it monitors the
pid). So a dead sender's key vanishes; there are never stale registry entries to
reap.
Life. Serializes all sends to its recipient — one pipe at a time, so the
ratchet's load-modify-store can't interleave (see the lock note above).
Different recipients run in parallel. Holds no durable state: sessions/keys live
in Storage, the consumer's from is parked in Connection. So a sender is cheap
to lose and cheap to respawn.
Death (three ways).
- Idle. Each
:sendre-arms an idle timer (idle_ms, default 1s, overridable viaconfig[:sender_idle_ms]); after that long with no further send it{:stop, :normal}s. It carries no durable state, so lingering buys only warm reuse — a quick follow-up to the same recipient skips a respawn + a session re-read from Storage. The short default keeps a fan-out to N one-shot recipients from leaving a long-lived process tail; a disk-backed store may want a larger value to cut re-reads under bursty traffic. - Crash. A raise in the pipe (Signal error, USync blowup, bad bundle) kills
it.
restart: :temporary⇒ the supervisor does NOT restart it; the nextdeliverrespawns a fresh one. In-flight + queued sends are lost. - Shutdown. The connection's supervision tree going down takes it too. All three auto-unregister the Registry key (the Registry's pid monitor).
Rebirth. The next deliver/2 to that recipient starts a fresh sender — no
carried state; it re-reads sessions from Storage.
Crash ⇒ parked-send recovery. Because the consumer's from lives in
Connection (not in the dying sender), Connection monitors each sender and,
on its :DOWN, fails every parked send for that recipient with
{:error, {:sender_crashed, reason}} — promptly, instead of letting the caller
hang to the ack-timeout. A :normal idle-stop fails nothing (no in-flight
sends). See Amarula.Connection (ensure_sender_monitor / the :DOWN handler)
and docs/plans/SENDER_CRASH_FIX.plan.md.
Summary
Functions
Returns a specification to start this module under a supervisor.
Hand a message to the recipient's sender (start-or-lookup), asynchronously. The send runs on the per-recipient process — serialized per recipient, parallel across recipients — so the CALLING process (Connection) is not blocked.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
Hand a message to the recipient's sender (start-or-lookup), asynchronously. The send runs on the per-recipient process — serialized per recipient, parallel across recipients — so the CALLING process (Connection) is not blocked.
msg carries :msg_id. The sender does NOT reply the consumer: it runs the
pipe and, only on failure, reports back to Connection (state.cm), which
owns the parked consumer from:
- relay succeeded (frame written) → the sender reports nothing. Connection
already parked the
fromand armed an ack-timeout at dispatch, so it simply awaits the server's<ack>(which resolves the caller) — a "frame went out" signal would be inert, so we don't send one. - pipe failed (not_on_whatsapp / IQ timeout / encrypt error / plugin halt)
→
{:send_failed, msg_id, reason}. No frame went out, so no<ack>will ever come; Connection replies the parked caller the failure immediately (instead of letting it hang to the ack-timeout).
Returns {:ok, pid} — the (started or reused) sender pid, so Connection can
monitor it and fail the recipient's parked sends if it crashes mid-pipe — or
{:error, reason} if the sender could not be started (e.g. :max_children).
A start failure is a recoverable send failure: Connection maps it to a
{:send_failed, msg_id, reason} for the parked caller rather than crashing.