14 KiB
Client Event System
| Field | Value |
|---|---|
| Status | Proposed (draft for review) |
| Issue | https://github.com/logos-messaging/libchat/issues/97 |
| Date | 2026-05-14 |
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 proposes a layered design and presents the per-layer options.
Decision Drivers
- Simplicity of the core. Fully synchronous and caller-driven: no background work, no callbacks out, no side effects beyond storage I/O.
- 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.
Two open questions affect the options below: async runtime at the client layer, and consumer model. See Open Questions.
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/>convenience wrapper<br/>may run 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.
Considered Options
Core layer
Constraints
- Strict sync, single-threaded.
- No background work, timers, or internal queues.
- Every state mutation is the direct, traceable result of a caller-invoked method.
Approach
Methods that surface state changes return their events directly:
impl<S: ChatStore> Context<S> {
pub fn handle_payload(&mut self, payload: &[u8])
-> Result<HandlePayloadOutput, ChatError>;
pub fn send_content(&mut self, convo: ConversationId, content: &[u8])
-> Result<SendContentOutput, ChatError>;
pub fn create_private_convo(&mut self, intro: &Introduction, content: &[u8])
-> Result<CreatePrivateConvoOutput, ChatError>;
}
pub struct HandlePayloadOutput {
pub events: Vec<Event>, // observations for the app to surface (e.g. MessageReceived)
pub outbound: Vec<AddressedEnvelope>, // responses for the client to dispatch (e.g. acks)
}
Returning both events and outbound envelopes from the same struct keeps every side effect of a core method visible in its return type. The client layer dispatches the envelopes and surfaces the events upward; the core itself performs no I/O beyond caller-initiated storage access.
Client layer
Constraints
- May spawn background threads (e.g. for timer-driven retries).
- Background threads emit events that no caller-invoked method can return — for example
DeliveryFailed { reason: Timeout }. - Events from synchronous calls flow through the method's return type, inherited from the core.
Common shape (all options)
The client mirrors the core's named-output-struct style. Outbound envelopes produced by the core are dispatched internally by the client through its DeliveryService; only events are surfaced to the application.
impl<D: DeliveryService> ChatClient<D> {
pub fn receive(&mut self, payload: &[u8])
-> Result<ReceiveOutput, ClientError<D::Error>>; // events from this payload
pub fn send_message(&mut self, convo: &ConversationIdOwned, content: &[u8])
-> Result<SendMessageOutput, ClientError<D::Error>>; // sync events from this send
// Background events are delivered via 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
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 loop. For all three client options, integration follows the existing chat-cli pattern: one additional drain per tick.
Sketch for Option A:
pub fn tick(&mut self) -> Result<()> {
while let Ok(bytes) = self.inbound.try_recv() {
for event in self.client.receive(&bytes)?.events {
self.handle_event(event);
}
}
for event in self.client.drain_events() {
self.handle_event(event);
}
Ok(())
}
Option B replaces the second drain with for event in self.events.try_iter(). Option C moves the background-event 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 } |
ChatClient::dispatch_all (crates/client/src/client.rs:84-92) on delivery.publish error |
DeliveryFailed { Timeout } |
client's background retry thread |
The initiator side does not emit ConversationStarted: create_conversation returns the new ConversationIdOwned directly.
Open Questions
-
Sync vs async at the client layer. The core stays sync. The client could adopt an async runtime (e.g.
tokio) without changing the option set, but each option's natural shape changes: Option A →Streamover a notify primitive; Option B →tokio::sync::mpsc::Receiver<Event>with animpl Streamshape; Option C →async fncallback. -
Consumer pattern assumed. Different consumer archetypes favour different shapes: a polling UI loop suits Option A; a worker thread that blocks on
recvsuits Option B; a low-latency or push-driven consumer (toast notifications, daemons) suits Option C. Pick one — supporting multiple shapes is a maintenance burden. -
Does the client absorb transport polling? Today the application drives transport polling and feeds bytes into
client.receive(bin/chat-cli/src/app.rs:46-87, 144-180). The client could absorb this as an additional background thread, in which caseDeliveryServicewould become bidirectional and the application would consume events instead of bytes. Orthogonal to the event-system shape, but reshapes the application-layer contract.
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