15 KiB
Client Event System
| Field | Value |
|---|---|
| Status | Accepted |
| Issue | https://github.com/logos-messaging/libchat/issues/97 |
| Date | 2026-05-19 |
Context and Problem
Applications currently learn about new conversations from an is_new_convo: bool flag on ContentData (core/conversations/src/types.rs:16-20). Two problems:
- The flag overloads
ContentData: protocol metadata is smuggled through a content carrier. - The flag assumes every new conversation carries an initial content frame. Protocols such as MLS allow a conversation to begin without one; in that case
handle_payloadreturnsNoneand the application never observes the new conversation.
Issue #97 calls for a proper event system that can signal new conversations, delivery receipts, and reliability failures — without piggy-backing on content — and that provides a clear path for adding new event types later.
This ADR specifies the layered design of the event system and how events reach the application.
Decision Drivers
- Simplicity of the core. Fully synchronous and caller-driven: no background work, no callbacks out. External effects are performed through services injected as method parameters.
- Extensibility. A new event type is a localised change (one enum variant, one emit site) that does not break existing consumers.
- FFI compatibility. Must remain expressible through the existing
safer-ffiboundary incrates/client-ffi. Event payloads are limited to owned, concrete data (bytes, strings, identifiers) — no closures, generics, or non-'staticreferences.
Architecture
The library is organised in three layers. Calls flow downward; events flow upward.
flowchart TB
A["<b>app</b><br/>UI/UX layer<br/>drives the event loop"]
B["<b>client</b><br/>owns services<br/>runs background threads"]
C["<b>core</b><br/>strict sync, caller-driven"]
A -- "method calls" --> B
B -- "method calls" --> C
C -.->|"events (from method returns)"| B
B -.->|"events (sync + background)"| A
Crates: app — bin/chat-cli, future logos-chat-module; client — crates/client, crates/client-ffi; core — core/conversations and friends in libchat.
Design
Core layer
Constraints
- Strict sync, single-threaded.
- No background work, timers, or internal queues.
- External effects (delivery; future registration / identity lookups) are performed through services injected as method parameters.
Approach
Methods receive the services they need and call them directly. Observations (events) are returned so the caller can surface them upward:
impl<S: ChatStore> Context<S> {
pub fn handle_payload<D: DeliveryService>(
&mut self,
delivery: &mut D,
payload: &[u8],
) -> Result<Vec<Event>, ChatError>;
pub fn send_content<D: DeliveryService>(
&mut self,
delivery: &mut D,
convo: ConversationId,
content: &[u8],
) -> Result<Vec<Event>, ChatError>;
pub fn create_private_convo<D: DeliveryService>(
&mut self,
delivery: &mut D,
intro: &Introduction,
content: &[u8],
) -> Result<(ConversationIdOwned, Vec<Event>), ChatError>;
}
Client layer
Responsibility split
The client owns the concrete service implementations (delivery, future registration, identity), polls the transport on a background thread, and processes inbound bytes by calling into the core. The application invokes client methods and consumes events; raw transport bytes (encrypted envelopes off the wire) are handled entirely inside the client.
Constraints
- Owns the concrete service implementations and injects them into core method calls.
- Events from synchronous calls flow through the method's return type, inherited from the core.
- Polls the transport on a background thread and feeds inbound payloads into the core.
- May spawn additional background threads (e.g. for timer-driven retries).
- Background threads emit events that no caller-invoked method can return — for example
DeliveryFailed { reason: Timeout }.
Common shape (all options)
The client invokes core methods with its services; the core publishes envelopes directly through the injected delivery service. Only events flow back as return values.
impl<D: DeliveryService> ChatClient<D> {
pub fn send_message(&mut self, convo: &ConversationIdOwned, content: &[u8])
-> Result<Vec<Event>, ClientError<D::Error>>; // sync events from this send
// Background events (including those from inbound payload processing) reach the
// application through one of the three mechanisms below.
}
The three options differ only in how background events reach the application.
Option A — internal poll queue
The client owns a Mutex<VecDeque<Event>>. Background threads push to it; the application drains via two new methods.
impl<D: DeliveryService> ChatClient<D> {
pub fn poll_event(&mut self) -> Option<Event>;
pub fn drain_events(&mut self) -> Vec<Event>;
}
Prior art: mio's Events (per-Poll instance, drained by the caller); rdkafka's Consumer::poll (background thread fills a queue, caller polls — same domain).
Pros
- Single primitive (mutex-protected queue) with no new dependencies.
- FFI mapping is direct:
client_poll_eventreturns an opaqueOption<Event>, mirroring the existingPushInboundResultshape (crates/client-ffi/src/api.rs:49-55). - Matches the existing chat-cli tick-loop consumer pattern (
bin/chat-cli/src/app.rs:144-180).
Cons
- Requires the application to drain after every operation; events accumulate if it forgets.
- Adds shared mutable state (
Mutex<VecDeque>) inside the client; the queue must be bounded with explicit overflow handling.
Option B — channel handed to the caller (selected)
The client's constructor returns a Receiver<Event> alongside the client handle. Background threads hold a Sender<Event> clone; the application reads from the receiver.
let (client, events): (ChatClient<_>, Receiver<Event>) =
ChatClient::new(name, delivery);
Prior art: most Rust networking libraries; std::sync::mpsc, crossbeam-channel, flume.
Pros
- Channels are the canonical multi-producer/single-consumer primitive in the standard library; the shape is idiomatic in pure Rust.
- The application can park in
recv()from a worker thread, integrate withselect!, or later swap totokio::sync::mpscfor an async wrapper. - Mirrors the inbound-bytes channel chat-cli already uses (
bin/chat-cli/src/app.rs:46).
Cons
Receiver<T>is not#[repr(C)]and cannot crosssafer-fficleanly. The FFI layer must expose a drain function regardless, collapsing Option B into Option A at the boundary.- Forces a channel-crate choice (
std::sync::mpsc,crossbeam-channel, orflume).
Option C — callback registered at construction
The application registers a closure at construction; background threads invoke it directly when events arise.
type EventFn = Box<dyn Fn(&Event) + Send + 'static>;
impl ChatClient<D> {
pub fn new(name: &str, delivery: D, on_event: EventFn) -> Self;
}
Prior art: the existing FFI DeliverFn callback at client_create (crates/client-ffi/src/delivery.rs:8-15); tracing::Subscriber; GTK signals.
Pros
- The codebase already establishes this pattern for outbound delivery; events would extend a familiar contract.
- FFI mapping is direct: register an
EventFnfunction pointer atclient_create. - No internal queue or
Mutexto maintain.
Cons
- The callback fires on the background thread. UI-style consumers (ratatui, GUI toolkits) cannot update state from threads other than the main loop thread and will bridge the callback into a thread-local queue — effectively re-implementing Option A in user code.
- The closure must be
Send + 'static; capturing application state requiresArc<Mutex<…>>or a channel back to the application. - Sync events arrive on the caller's thread; background events arrive on the background thread. The handler must be correct in both threading contexts, or the callback must forward to the main thread (collapsing into Option A).
Comparison
| Criterion | A: poll queue | B: channel | C: callback |
|---|---|---|---|
| Background events delivered via | poll_event / drain_events |
Receiver<Event> |
direct Fn(&Event) invocation |
FFI fit (safer-ffi) |
Native opaque + accessors | Degrades to Option A at the boundary | Native function pointer (matches DeliverFn) |
| New dependencies | None | None (with std::sync::mpsc); otherwise crossbeam-channel or flume |
None |
| Internal state required | Mutex<VecDeque<Event>> |
Channel internals | None |
| Thread on which the application observes the event | Application thread (next drain) | Application thread (next drain) | Background thread |
| Bridges naturally to UI thread | Yes | Yes | No (requires re-bridging) |
| Backpressure if the application is slow | Client-side queue buffers; bounded with overflow handling | Channel buffers; bound configurable | No buffer; slow callbacks block the background thread |
Future Stream adapter |
Wrap poll_event in a Stream |
Swap to async channel (native) | Bridge callback into a channel, then Stream |
App layer
The application drives the event loop. With Option B (selected), each tick drains the Receiver<Event> handed back at client construction:
pub fn tick(&mut self) -> Result<()> {
for event in self.events.try_iter() {
self.handle_event(event);
}
Ok(())
}
For reference, Option A would replace self.events.try_iter() with self.client.drain_events(). Option C moves the drain out of the tick — into the callback — and the callback typically forwards into an application-side channel that is drained on each tick anyway.
Event Taxonomy
The same Event enum is shared across all three client options.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Event {
#[non_exhaustive]
ConversationStarted {
conversation_id: ConversationIdOwned,
},
#[non_exhaustive]
MessageReceived {
conversation_id: ConversationIdOwned,
data: Vec<u8>,
},
#[non_exhaustive]
DeliveryReceipt {
conversation_id: ConversationIdOwned,
envelope_id: EnvelopeId,
},
#[non_exhaustive]
DeliveryFailed {
conversation_id: ConversationIdOwned,
envelope_id: EnvelopeId,
reason: FailureReason,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum FailureReason {
Transport, // synchronous transport error on publish
PeerRejected, // peer signalled rejection (future protocol work)
Timeout, // no receipt within the retry window
}
#[non_exhaustive] on the enum permits new variants; on each struct variant it permits new fields. Both are additive minor-release changes. Future variants (ConversationRekeyed, ParticipantJoined, PresenceChanged, transport health, key-rotation reminders, …) follow this rule.
Mapping of variants to emit sites:
| Variant | Emitted from |
|---|---|
ConversationStarted (responder side) |
core/conversations/src/inbox/handler.rs:155-162 (replaces is_new_convo: true) |
MessageReceived |
core/conversations/src/conversation/privatev1.rs:184-191 (replaces is_new_convo: false) |
DeliveryReceipt |
Context::handle_payload when decoding a PrivateV1Frame::Receipt (future protocol work) |
DeliveryFailed { Transport } |
Core method that invoked delivery.publish and observed a synchronous error |
DeliveryFailed { Timeout } |
Client's background retry thread |
Events are the uniform observation channel: they carry both observations the call itself caused (e.g. a sync DeliveryFailed { Transport } from send_content) and observations from background work (e.g. DeliveryFailed { Timeout } from the retry thread). The only thing kept outside Vec<Event> is an obvious primary result the caller will use immediately — returned directly for ergonomics. This is why the initiator side does not emit ConversationStarted: create_private_convo returns the new ConversationIdOwned directly as part of its return value.
Decisions
-
Sync at the client layer for now. The core stays sync; the client also stays sync. Migrating to async later is non-structural —
std::sync::mpsc::Receiver<Event>swaps totokio::sync::mpsc::Receiver<Event>and gains animpl Streamshape without changing the chosen mechanism (point 2). Option A would migrate to aStreamover a notify primitive; Option C to anasync fncallback. -
Consumer pattern: Option B — channel handed to the caller. Different consumer archetypes could favour different shapes — a polling UI loop suits Option A; a low-latency push-driven consumer (toast notifications, daemons) suits Option C — but Option B is preferred: it is the most Rust-idiomatic of the three, has few drawbacks compared to A or C, and offers the smoothest path to async (point 1).
Event flow
A worked example of the decisions above. Two flows cover everything the application observes: a synchronous send initiated by the app, and a background inbound carried by the client's transport poller.
sequenceDiagram
participant App
participant Client
participant Poller as Client poller (background)
participant Core
participant Delivery as DeliveryService
Note over App,Delivery: Outbound — synchronous send initiated by the app
App->>Client: send_message(convo, content)
Client->>Core: send_content(&mut delivery, ...)
Core->>Delivery: publish(envelope)
Delivery-->>Core: Ok / Err
Core-->>Client: Ok(Vec<Event>)
Client-->>App: Ok(Vec<Event>)
Note over Poller,Delivery: Inbound — background poll loop in the client
Poller->>Poller: poll tick
Poller->>Delivery: poll
Delivery-->>Poller: payload bytes
Poller->>Core: handle_payload(&mut delivery, payload)
Core-->>Poller: Ok(vec![MessageReceived, ...])
Poller-)App: event via Receiver<Event>
Note over App: Next tick — drain the channel
App->>App: for event in events.try_iter() { handle_event(event) }
References
Source references
core/conversations/src/types.rs:9-20— currentContentDataandAddressedEnvelopecore/conversations/src/context.rs:138-185—Context::handle_payload(core inbound entry)core/conversations/src/inbox/handler.rs:124-167— inbox handshake handler (currentis_new_convoset site)core/conversations/src/conversation/privatev1.rs:184-191, 219-260— private-conversation handlercrates/client/src/client.rs:60-92—ChatClientpublic surfacecrates/client/src/delivery.rs—DeliveryServicetraitcrates/client-ffi/src/api.rs:49-55, 220-285— current FFI inbound result shapecrates/client-ffi/src/delivery.rs:8-15— existing FFI callback pattern (DeliverFn)bin/chat-cli/src/app.rs:46, 144-180— current application consumption pattern