mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-06-29 04:29:57 +00:00
feat(client): add threaded transport polling (#125)
The client, not the app, now drives the transport; events are delivered asynchronously, per ADR 0001. - ChatClient owns Arc<Mutex<Core>> + a worker thread. - The worker select!s over the inbound and shutdown channels; Drop joins it. Outbound runs on the caller's thread. - A single Transport (DeliveryService + inbound()) owns both directions of the boundary, so the client takes one transport rather than a (delivery, inbound) pair. InProcessDelivery::new, CDelivery, and chat-cli's transports implement it. - FFI replaces client_receive with client_push_inbound + client_poll_events. - chat-cli drains Receiver<Event>; inbound and event channels are both crossbeam. - Corrects ADR 0001's inbound sequence to push — the worker parks on select!, it never polls.
This commit is contained in:
parent
a610117e81
commit
7838d43b30
14
Cargo.lock
generated
14
Cargo.lock
generated
@ -356,6 +356,7 @@ dependencies = [
|
|||||||
"arboard",
|
"arboard",
|
||||||
"base64",
|
"base64",
|
||||||
"clap",
|
"clap",
|
||||||
|
"crossbeam-channel",
|
||||||
"crossterm 0.29.0",
|
"crossterm 0.29.0",
|
||||||
"logos-chat",
|
"logos-chat",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
@ -441,6 +442,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
|||||||
name = "client-ffi"
|
name = "client-ffi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"crossbeam-channel",
|
||||||
"libchat",
|
"libchat",
|
||||||
"logos-chat",
|
"logos-chat",
|
||||||
"safer-ffi",
|
"safer-ffi",
|
||||||
@ -534,6 +536,15 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-channel"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@ -2124,10 +2135,13 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chat-sqlite",
|
"chat-sqlite",
|
||||||
"components",
|
"components",
|
||||||
|
"crossbeam-channel",
|
||||||
"libchat",
|
"libchat",
|
||||||
"logos-account",
|
"logos-account",
|
||||||
|
"parking_lot",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -44,6 +44,7 @@ storage = { path = "core/storage" }
|
|||||||
|
|
||||||
# External Workspace dependency declarations (sorted)
|
# External Workspace dependency declarations (sorted)
|
||||||
blake2 = "0.10"
|
blake2 = "0.10"
|
||||||
|
crossbeam-channel = "0.5"
|
||||||
|
|
||||||
# Panicking across FFI boundaries is UB; abort is the correct strategy for a
|
# Panicking across FFI boundaries is UB; abort is the correct strategy for a
|
||||||
# C FFI library.
|
# C FFI library.
|
||||||
|
|||||||
@ -9,6 +9,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Workspace dependencies (sorted)
|
# Workspace dependencies (sorted)
|
||||||
|
crossbeam-channel = { workspace = true }
|
||||||
logos-chat = { workspace = true }
|
logos-chat = { workspace = true }
|
||||||
|
|
||||||
# External dependencies (sorted)
|
# External dependencies (sorted)
|
||||||
@ -16,7 +17,6 @@ anyhow = "1.0"
|
|||||||
arboard = "3"
|
arboard = "3"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::mpsc;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use arboard::Clipboard;
|
use arboard::Clipboard;
|
||||||
|
use crossbeam_channel::Receiver;
|
||||||
use logos_chat::{ChatClient, DeliveryService, EphemeralRegistry, Event, RegistrationService};
|
use logos_chat::{ChatClient, DeliveryService, EphemeralRegistry, Event, RegistrationService};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@ -41,9 +41,9 @@ pub struct AppState {
|
|||||||
pub active_chat: Option<String>,
|
pub active_chat: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ChatApp<D: DeliveryService, R: RegistrationService = EphemeralRegistry> {
|
pub struct ChatApp<T: DeliveryService, R: RegistrationService = EphemeralRegistry> {
|
||||||
pub client: ChatClient<D, R>,
|
pub client: ChatClient<T, R>,
|
||||||
inbound: mpsc::Receiver<Vec<u8>>,
|
events: Receiver<Event>,
|
||||||
pub state: AppState,
|
pub state: AppState,
|
||||||
/// Ephemeral command output — not persisted, cleared on chat switch.
|
/// Ephemeral command output — not persisted, cleared on chat switch.
|
||||||
command_output: Vec<DisplayMessage>,
|
command_output: Vec<DisplayMessage>,
|
||||||
@ -53,14 +53,14 @@ pub struct ChatApp<D: DeliveryService, R: RegistrationService = EphemeralRegistr
|
|||||||
state_path: PathBuf,
|
state_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<D, R> ChatApp<D, R>
|
impl<T, R> ChatApp<T, R>
|
||||||
where
|
where
|
||||||
D: DeliveryService + 'static,
|
T: DeliveryService + Send + 'static,
|
||||||
R: RegistrationService + 'static,
|
R: RegistrationService + Send + 'static,
|
||||||
{
|
{
|
||||||
pub fn new(
|
pub fn new(
|
||||||
client: ChatClient<D, R>,
|
client: ChatClient<T, R>,
|
||||||
inbound: mpsc::Receiver<Vec<u8>>,
|
events: Receiver<Event>,
|
||||||
user_name: &str,
|
user_name: &str,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
@ -80,7 +80,7 @@ where
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client,
|
client,
|
||||||
inbound,
|
events,
|
||||||
state,
|
state,
|
||||||
command_output: Vec::new(),
|
command_output: Vec::new(),
|
||||||
input: String::new(),
|
input: String::new(),
|
||||||
@ -146,19 +146,13 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn process_incoming(&mut self) -> Result<()> {
|
pub fn process_incoming(&mut self) -> Result<()> {
|
||||||
while let Ok(payload) = self.inbound.try_recv() {
|
let mut received = false;
|
||||||
match self.client.receive(&payload) {
|
while let Ok(event) = self.events.try_recv() {
|
||||||
Ok(events) => {
|
self.handle_event(event);
|
||||||
for event in events {
|
received = true;
|
||||||
self.handle_event(event);
|
}
|
||||||
}
|
if received {
|
||||||
self.save_state()?;
|
self.save_state()?;
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("receive error: {e:?}");
|
|
||||||
self.status = format!("Could not decrypt incoming message: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -195,6 +189,9 @@ where
|
|||||||
timestamp: now(),
|
timestamp: now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Event::InboundError { message } => {
|
||||||
|
self.status = format!("Could not process incoming message: {message}");
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,13 @@ mod ui;
|
|||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::mpsc;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, ValueEnum};
|
use clap::{Parser, ValueEnum};
|
||||||
use logos_chat::{ChatClient, DeliveryService, HttpRegistry, RegistrationService, StorageConfig};
|
use crossbeam_channel::Receiver;
|
||||||
|
use logos_chat::{
|
||||||
|
ChatClient, DeliveryService, Event, HttpRegistry, RegistrationService, StorageConfig, Transport,
|
||||||
|
};
|
||||||
|
|
||||||
use app::ChatApp;
|
use app::ChatApp;
|
||||||
|
|
||||||
@ -72,9 +74,9 @@ fn main() -> Result<()> {
|
|||||||
match cli.transport {
|
match cli.transport {
|
||||||
TransportKind::File => {
|
TransportKind::File => {
|
||||||
let transport_dir = cli.data.join("transport");
|
let transport_dir = cli.data.join("transport");
|
||||||
let (transport, inbound) = transport::file::FileTransport::new(&transport_dir)
|
let transport = transport::file::FileTransport::new(&transport_dir)
|
||||||
.context("failed to create file transport")?;
|
.context("failed to create file transport")?;
|
||||||
run(transport, inbound, &cli)
|
run(transport, &cli)
|
||||||
}
|
}
|
||||||
#[cfg(logos_delivery)]
|
#[cfg(logos_delivery)]
|
||||||
TransportKind::LogosDelivery => {
|
TransportKind::LogosDelivery => {
|
||||||
@ -88,20 +90,15 @@ fn main() -> Result<()> {
|
|||||||
tcp_port: cli.port,
|
tcp_port: cli.port,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let (transport, inbound) =
|
let transport = Service::start(cfg).context("failed to start logos-delivery")?;
|
||||||
Service::start(cfg).context("failed to start logos-delivery")?;
|
|
||||||
|
|
||||||
println!("Node connected. Initializing chat client...");
|
println!("Node connected. Initializing chat client...");
|
||||||
run(transport, inbound, &cli)
|
run(transport, &cli)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run<D: DeliveryService + 'static>(
|
fn run<T: Transport>(transport: T, cli: &Cli) -> Result<()> {
|
||||||
transport: D,
|
|
||||||
inbound: mpsc::Receiver<Vec<u8>>,
|
|
||||||
cli: &Cli,
|
|
||||||
) -> Result<()> {
|
|
||||||
let db_path = cli
|
let db_path = cli
|
||||||
.db
|
.db
|
||||||
.clone()
|
.clone()
|
||||||
@ -118,31 +115,27 @@ fn run<D: DeliveryService + 'static>(
|
|||||||
match cli.registry_url.as_deref() {
|
match cli.registry_url.as_deref() {
|
||||||
Some(url) => {
|
Some(url) => {
|
||||||
let registry = HttpRegistry::new(url);
|
let registry = HttpRegistry::new(url);
|
||||||
let client =
|
let (client, events) =
|
||||||
ChatClient::open_with_registry(cli.name.clone(), storage, transport, registry)
|
ChatClient::open_with_registry(cli.name.clone(), storage, transport, registry)
|
||||||
.map_err(|e| anyhow::anyhow!("{e:?}"))
|
.map_err(|e| anyhow::anyhow!("{e:?}"))
|
||||||
.context("failed to open chat client with HTTP registry")?;
|
.context("failed to open chat client with HTTP registry")?;
|
||||||
launch_tui(client, inbound, cli)
|
launch_tui(client, events, cli)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let client = ChatClient::open(cli.name.clone(), storage, transport)
|
let (client, events) = ChatClient::open(cli.name.clone(), storage, transport)
|
||||||
.map_err(|e| anyhow::anyhow!("{e:?}"))
|
.map_err(|e| anyhow::anyhow!("{e:?}"))
|
||||||
.context("failed to open chat client")?;
|
.context("failed to open chat client")?;
|
||||||
launch_tui(client, inbound, cli)
|
launch_tui(client, events, cli)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn launch_tui<D, R>(
|
fn launch_tui<T, R>(client: ChatClient<T, R>, events: Receiver<Event>, cli: &Cli) -> Result<()>
|
||||||
client: ChatClient<D, R>,
|
|
||||||
inbound: mpsc::Receiver<Vec<u8>>,
|
|
||||||
cli: &Cli,
|
|
||||||
) -> Result<()>
|
|
||||||
where
|
where
|
||||||
D: DeliveryService + 'static,
|
T: DeliveryService + Send + 'static,
|
||||||
R: RegistrationService + 'static,
|
R: RegistrationService + Send + 'static,
|
||||||
{
|
{
|
||||||
let mut app = ChatApp::new(client, inbound, &cli.name, &cli.data)?;
|
let mut app = ChatApp::new(client, events, &cli.name, &cli.data)?;
|
||||||
|
|
||||||
if cli.smoketest {
|
if cli.smoketest {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@ -168,8 +161,7 @@ fn run_logos_delivery(cli: Cli) -> Result<()> {
|
|||||||
tcp_port: cli.port,
|
tcp_port: cli.port,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let (delivery, inbound) =
|
let delivery = Service::start(logos_cfg).context("failed to start logos-delivery")?;
|
||||||
Service::start(logos_cfg).context("failed to start logos-delivery")?;
|
|
||||||
|
|
||||||
eprintln!("Node connected. Initializing chat client...");
|
eprintln!("Node connected. Initializing chat client...");
|
||||||
|
|
||||||
@ -180,7 +172,7 @@ fn run_logos_delivery(cli: Cli) -> Result<()> {
|
|||||||
.map(|p| p.to_path_buf())
|
.map(|p| p.to_path_buf())
|
||||||
.unwrap_or_else(|| cli.data.clone());
|
.unwrap_or_else(|| cli.data.clone());
|
||||||
|
|
||||||
let client = match cli.db {
|
let (client, events) = match cli.db {
|
||||||
Some(ref path) => {
|
Some(ref path) => {
|
||||||
let db_str = path
|
let db_str = path
|
||||||
.to_str()
|
.to_str()
|
||||||
@ -200,7 +192,7 @@ fn run_logos_delivery(cli: Cli) -> Result<()> {
|
|||||||
None => logos_chat::ChatClient::new(cli.name.clone(), delivery),
|
None => logos_chat::ChatClient::new(cli.name.clone(), delivery),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut app = ChatApp::new(client, inbound, &cli.name, &data_dir)?;
|
let mut app = ChatApp::new(client, events, &cli.name, &data_dir)?;
|
||||||
|
|
||||||
if cli.smoketest {
|
if cli.smoketest {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@ -219,10 +211,10 @@ fn run_logos_delivery(cli: Cli) -> Result<()> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_app<D, R>(terminal: &mut ui::Tui, app: &mut ChatApp<D, R>) -> Result<()>
|
fn run_app<T, R>(terminal: &mut ui::Tui, app: &mut ChatApp<T, R>) -> Result<()>
|
||||||
where
|
where
|
||||||
D: DeliveryService + 'static,
|
T: DeliveryService + Send + 'static,
|
||||||
R: RegistrationService + 'static,
|
R: RegistrationService + Send + 'static,
|
||||||
{
|
{
|
||||||
loop {
|
loop {
|
||||||
app.process_incoming()?;
|
app.process_incoming()?;
|
||||||
|
|||||||
@ -2,11 +2,11 @@ use std::collections::BTreeMap;
|
|||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::{self, BufReader, Read, Seek, SeekFrom, Write};
|
use std::io::{self, BufReader, Read, Seek, SeekFrom, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use logos_chat::{AddressedEnvelope, DeliveryService};
|
use crossbeam_channel::{Receiver, Sender, bounded};
|
||||||
|
use logos_chat::{AddressedEnvelope, DeliveryService, Transport};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum FileTransportError {
|
pub enum FileTransportError {
|
||||||
@ -17,31 +17,31 @@ pub enum FileTransportError {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct FileTransport {
|
pub struct FileTransport {
|
||||||
transport_dir: PathBuf,
|
transport_dir: PathBuf,
|
||||||
|
inbound_rx: Option<Receiver<Vec<u8>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileTransport {
|
impl FileTransport {
|
||||||
/// All instances pointing at the same `transport_dir` share one broadcast bus.
|
/// All instances pointing at the same `transport_dir` share one broadcast bus.
|
||||||
///
|
///
|
||||||
/// Messages are written to `{transport_dir}/{delivery_address}/{hours_since_epoch}.bin`
|
/// Messages are written to `{transport_dir}/{delivery_address}/{hours_since_epoch}.bin`
|
||||||
/// as length-prefixed frames (`[u32 BE length][payload bytes]`). The background
|
/// as length-prefixed frames (`[u32 BE length][payload bytes]`). A background
|
||||||
/// thread reads all files under `transport_dir` and forwards every frame to
|
/// thread reads all files under `transport_dir` and forwards every frame to the
|
||||||
/// the returned channel; `client.receive()` discards frames it cannot decrypt.
|
/// inbound stream the client drains via [`Transport::inbound`] (discarding frames
|
||||||
pub fn new(transport_dir: &Path) -> io::Result<(Self, mpsc::Receiver<Vec<u8>>)> {
|
/// it cannot decrypt).
|
||||||
|
pub fn new(transport_dir: &Path) -> io::Result<Self> {
|
||||||
fs::create_dir_all(transport_dir)?;
|
fs::create_dir_all(transport_dir)?;
|
||||||
|
|
||||||
let (tx, rx) = mpsc::sync_channel(1024);
|
let (tx, rx) = bounded(1024);
|
||||||
let dir = transport_dir.to_path_buf();
|
let dir = transport_dir.to_path_buf();
|
||||||
|
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("file-transport".into())
|
.name("file-transport".into())
|
||||||
.spawn(move || poll_reader(dir, tx))?;
|
.spawn(move || poll_reader(dir, tx))?;
|
||||||
|
|
||||||
Ok((
|
Ok(Self {
|
||||||
Self {
|
transport_dir: transport_dir.to_path_buf(),
|
||||||
transport_dir: transport_dir.to_path_buf(),
|
inbound_rx: Some(rx),
|
||||||
},
|
})
|
||||||
rx,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +68,14 @@ impl DeliveryService for FileTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Transport for FileTransport {
|
||||||
|
fn inbound(&mut self) -> Receiver<Vec<u8>> {
|
||||||
|
self.inbound_rx
|
||||||
|
.take()
|
||||||
|
.expect("FileTransport::inbound called more than once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Hours since Unix epoch — used as the rolling filename.
|
/// Hours since Unix epoch — used as the rolling filename.
|
||||||
fn current_hour() -> u64 {
|
fn current_hour() -> u64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
@ -77,7 +85,7 @@ fn current_hour() -> u64 {
|
|||||||
/ 3600
|
/ 3600
|
||||||
}
|
}
|
||||||
|
|
||||||
fn poll_reader(transport_dir: PathBuf, tx: mpsc::SyncSender<Vec<u8>>) {
|
fn poll_reader(transport_dir: PathBuf, tx: Sender<Vec<u8>>) {
|
||||||
// Maps absolute file path → number of bytes already consumed.
|
// Maps absolute file path → number of bytes already consumed.
|
||||||
let mut offsets: BTreeMap<PathBuf, u64> = BTreeMap::new();
|
let mut offsets: BTreeMap<PathBuf, u64> = BTreeMap::new();
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use logos_chat::{AddressedEnvelope, DeliveryService};
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
use logos_chat::{AddressedEnvelope, DeliveryService, Transport};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use wrapper::LogosNodeCtx;
|
use wrapper::LogosNodeCtx;
|
||||||
@ -46,7 +47,7 @@ struct OutboundCmd {
|
|||||||
reply: mpsc::SyncSender<Result<(), DeliveryError>>,
|
reply: mpsc::SyncSender<Result<(), DeliveryError>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubscriberList = Arc<Mutex<Vec<mpsc::SyncSender<Vec<u8>>>>>;
|
type SubscriberList = Arc<Mutex<Vec<Sender<Vec<u8>>>>>;
|
||||||
|
|
||||||
// ── Config ───────────────────────────────────────────────────────────────────
|
// ── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -123,18 +124,19 @@ pub struct Service {
|
|||||||
outbound: mpsc::SyncSender<OutboundCmd>,
|
outbound: mpsc::SyncSender<OutboundCmd>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
subscribers: SubscriberList,
|
subscribers: SubscriberList,
|
||||||
|
inbound_rx: Option<Receiver<Vec<u8>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Service {
|
impl Service {
|
||||||
/// Start the embedded logos-delivery node. Returns the service and a
|
/// Start the embedded logos-delivery node. The client drains inbound
|
||||||
/// receiver for inbound raw payloads.
|
/// payloads via [`Transport::inbound`].
|
||||||
pub fn start(cfg: Config) -> Result<(Self, mpsc::Receiver<Vec<u8>>), DeliveryError> {
|
pub fn start(cfg: Config) -> Result<Self, DeliveryError> {
|
||||||
let (out_tx, out_rx) = mpsc::sync_channel::<OutboundCmd>(256);
|
let (out_tx, out_rx) = mpsc::sync_channel::<OutboundCmd>(256);
|
||||||
let subscribers: SubscriberList = Arc::new(Mutex::new(Vec::new()));
|
let subscribers: SubscriberList = Arc::new(Mutex::new(Vec::new()));
|
||||||
let (ready_tx, ready_rx) = mpsc::channel::<Result<(), DeliveryError>>();
|
let (ready_tx, ready_rx) = mpsc::channel::<Result<(), DeliveryError>>();
|
||||||
// Create the inbound channel before spawning so the receiver is
|
// Create the inbound channel before spawning so the receiver is
|
||||||
// registered inside the thread, before any event callback fires.
|
// registered inside the thread, before any event callback fires.
|
||||||
let (inbound_tx, inbound_rx) = mpsc::sync_channel::<Vec<u8>>(1024);
|
let (inbound_tx, inbound_rx) = crossbeam_channel::bounded::<Vec<u8>>(1024);
|
||||||
|
|
||||||
let subs_for_thread = subscribers.clone();
|
let subs_for_thread = subscribers.clone();
|
||||||
|
|
||||||
@ -167,20 +169,18 @@ impl Service {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((
|
Ok(Self {
|
||||||
Self {
|
outbound: out_tx,
|
||||||
outbound: out_tx,
|
subscribers,
|
||||||
subscribers,
|
inbound_rx: Some(inbound_rx),
|
||||||
},
|
})
|
||||||
inbound_rx,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn node_thread(
|
fn node_thread(
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
out_rx: mpsc::Receiver<OutboundCmd>,
|
out_rx: mpsc::Receiver<OutboundCmd>,
|
||||||
subscribers: SubscriberList,
|
subscribers: SubscriberList,
|
||||||
inbound_tx: mpsc::SyncSender<Vec<u8>>,
|
inbound_tx: Sender<Vec<u8>>,
|
||||||
ready_tx: mpsc::Sender<Result<(), DeliveryError>>,
|
ready_tx: mpsc::Sender<Result<(), DeliveryError>>,
|
||||||
) {
|
) {
|
||||||
// discv5UdpPort defaults to 9000 in libwaku, so a second instance with
|
// discv5UdpPort defaults to 9000 in libwaku, so a second instance with
|
||||||
@ -220,8 +220,8 @@ impl Service {
|
|||||||
};
|
};
|
||||||
guard.retain(|tx| match tx.try_send(payload.clone()) {
|
guard.retain(|tx| match tx.try_send(payload.clone()) {
|
||||||
Ok(()) => true,
|
Ok(()) => true,
|
||||||
Err(mpsc::TrySendError::Full(_)) => true,
|
Err(crossbeam_channel::TrySendError::Full(_)) => true,
|
||||||
Err(mpsc::TrySendError::Disconnected(_)) => false,
|
Err(crossbeam_channel::TrySendError::Disconnected(_)) => false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -306,3 +306,11 @@ impl DeliveryService for Service {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Transport for Service {
|
||||||
|
fn inbound(&mut self) -> Receiver<Vec<u8>> {
|
||||||
|
self.inbound_rx
|
||||||
|
.take()
|
||||||
|
.expect("Service::inbound called more than once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -38,7 +38,7 @@ pub fn restore() -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Draw the UI.
|
/// Draw the UI.
|
||||||
pub fn draw<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
pub fn draw<D: DeliveryService + Send + 'static, R: RegistrationService + Send + 'static>(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
app: &ChatApp<D, R>,
|
app: &ChatApp<D, R>,
|
||||||
) {
|
) {
|
||||||
@ -58,7 +58,7 @@ pub fn draw<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
|||||||
draw_status(frame, app, chunks[3]);
|
draw_status(frame, app, chunks[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_header<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
fn draw_header<D: DeliveryService + Send + 'static, R: RegistrationService + Send + 'static>(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
app: &ChatApp<D, R>,
|
app: &ChatApp<D, R>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@ -85,7 +85,7 @@ fn draw_header<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
|||||||
frame.render_widget(header, area);
|
frame.render_widget(header, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_messages<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
fn draw_messages<D: DeliveryService + Send + 'static, R: RegistrationService + Send + 'static>(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
app: &ChatApp<D, R>,
|
app: &ChatApp<D, R>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@ -175,7 +175,7 @@ fn draw_messages<D: DeliveryService + 'static, R: RegistrationService + 'static>
|
|||||||
frame.render_stateful_widget(messages_widget, area, &mut list_state);
|
frame.render_stateful_widget(messages_widget, area, &mut list_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_input<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
fn draw_input<D: DeliveryService + Send + 'static, R: RegistrationService + Send + 'static>(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
app: &ChatApp<D, R>,
|
app: &ChatApp<D, R>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@ -206,7 +206,7 @@ fn draw_input<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
|||||||
frame.set_cursor_position((cursor_x, area.y + 1));
|
frame.set_cursor_position((cursor_x, area.y + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_status<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
fn draw_status<D: DeliveryService + Send + 'static, R: RegistrationService + Send + 'static>(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
app: &ChatApp<D, R>,
|
app: &ChatApp<D, R>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@ -220,7 +220,10 @@ fn draw_status<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle keyboard events.
|
/// Handle keyboard events.
|
||||||
pub fn handle_events<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
pub fn handle_events<
|
||||||
|
D: DeliveryService + Send + 'static,
|
||||||
|
R: RegistrationService + Send + 'static,
|
||||||
|
>(
|
||||||
app: &mut ChatApp<D, R>,
|
app: &mut ChatApp<D, R>,
|
||||||
) -> io::Result<bool> {
|
) -> io::Result<bool> {
|
||||||
// Poll for events with a short timeout to allow checking incoming messages
|
// Poll for events with a short timeout to allow checking incoming messages
|
||||||
|
|||||||
@ -12,6 +12,7 @@ required-features = ["headers"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Workspace dependencies (sorted)
|
# Workspace dependencies (sorted)
|
||||||
|
crossbeam-channel = { workspace = true }
|
||||||
libchat = { workspace = true }
|
libchat = { workspace = true }
|
||||||
logos-chat = { workspace = true }
|
logos-chat = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@ -83,9 +83,9 @@ static int32_t deliver_cb(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
* Helper: pop one envelope from the bus and push it into receiver.
|
* Helper: pop one envelope from the bus, hand it to receiver's worker,
|
||||||
* Returns a heap-allocated event list; caller frees with
|
* then wait for the worker to produce events. Returns a heap-allocated
|
||||||
* event_list_free().
|
* event list; caller frees with event_list_free().
|
||||||
* ------------------------------------------------------------------ */
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
static EventList_t *route(ClientHandle_t *receiver)
|
static EventList_t *route(ClientHandle_t *receiver)
|
||||||
@ -94,8 +94,11 @@ static EventList_t *route(ClientHandle_t *receiver)
|
|||||||
size_t len;
|
size_t len;
|
||||||
int ok = queue_pop(&bus, &data, &len);
|
int ok = queue_pop(&bus, &data, &len);
|
||||||
assert(ok && "expected an envelope in the bus");
|
assert(ok && "expected an envelope in the bus");
|
||||||
EventList_t *evs = client_receive(receiver, SLICE(data, len));
|
client_push_inbound(receiver, SLICE(data, len));
|
||||||
assert(event_list_error_code(evs) == 0 && "client_receive failed");
|
|
||||||
|
/* Block until the worker decrypts the payload and produces events. */
|
||||||
|
EventList_t *evs = client_wait_events(receiver, 5000);
|
||||||
|
assert(event_list_len(evs) > 0 && "timed out waiting for events");
|
||||||
return evs;
|
return evs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
use safer_ffi::prelude::*;
|
use safer_ffi::prelude::*;
|
||||||
|
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
|
|
||||||
use crate::delivery::{CDelivery, DeliverFn};
|
use crate::delivery::{CDelivery, DeliverFn};
|
||||||
use libchat::ChatError;
|
use libchat::ChatError;
|
||||||
use logos_chat::{ChatClient, ClientError, ConversationClass, Event};
|
use logos_chat::{ChatClient, ClientError, ConversationClass, Event};
|
||||||
@ -10,7 +12,11 @@ use logos_chat::{ChatClient, ClientError, ConversationClass, Event};
|
|||||||
|
|
||||||
#[derive_ReprC]
|
#[derive_ReprC]
|
||||||
#[repr(opaque)]
|
#[repr(opaque)]
|
||||||
pub struct ClientHandle(pub(crate) ChatClient<CDelivery>);
|
pub struct ClientHandle {
|
||||||
|
client: ChatClient<CDelivery>,
|
||||||
|
events: Receiver<Event>,
|
||||||
|
inbound: Sender<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Error codes
|
// Error codes
|
||||||
@ -155,8 +161,17 @@ fn client_create(
|
|||||||
Err(_) => return None,
|
Err(_) => return None,
|
||||||
};
|
};
|
||||||
callback?;
|
callback?;
|
||||||
let delivery = CDelivery { callback };
|
let (inbound_tx, inbound_rx) = crossbeam_channel::unbounded();
|
||||||
Some(Box::new(ClientHandle(ChatClient::new(name_str, delivery))).into())
|
let delivery = CDelivery::new(callback, inbound_rx);
|
||||||
|
let (client, events) = ChatClient::new(name_str, delivery);
|
||||||
|
Some(
|
||||||
|
Box::new(ClientHandle {
|
||||||
|
client,
|
||||||
|
events,
|
||||||
|
inbound: inbound_tx,
|
||||||
|
})
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Free a client handle. Must not be used after this call.
|
/// Free a client handle. Must not be used after this call.
|
||||||
@ -174,7 +189,7 @@ fn client_destroy(handle: repr_c::Box<ClientHandle>) {
|
|||||||
#[ffi_export]
|
#[ffi_export]
|
||||||
fn client_installation_name(handle: &ClientHandle) -> c_slice::Box<u8> {
|
fn client_installation_name(handle: &ClientHandle) -> c_slice::Box<u8> {
|
||||||
handle
|
handle
|
||||||
.0
|
.client
|
||||||
.installation_name()
|
.installation_name()
|
||||||
.as_bytes()
|
.as_bytes()
|
||||||
.to_vec()
|
.to_vec()
|
||||||
@ -195,7 +210,7 @@ fn client_installation_name_free(name: c_slice::Box<u8>) {
|
|||||||
/// Free with `create_intro_result_free`.
|
/// Free with `create_intro_result_free`.
|
||||||
#[ffi_export]
|
#[ffi_export]
|
||||||
fn client_create_intro_bundle(handle: &mut ClientHandle) -> repr_c::Box<CreateIntroResult> {
|
fn client_create_intro_bundle(handle: &mut ClientHandle) -> repr_c::Box<CreateIntroResult> {
|
||||||
let result = match handle.0.create_intro_bundle() {
|
let result = match handle.client.create_intro_bundle() {
|
||||||
Ok(bytes) => CreateIntroResult {
|
Ok(bytes) => CreateIntroResult {
|
||||||
error_code: ErrorCode::None as i32,
|
error_code: ErrorCode::None as i32,
|
||||||
data: Some(bytes),
|
data: Some(bytes),
|
||||||
@ -239,7 +254,7 @@ fn client_create_conversation(
|
|||||||
content: c_slice::Ref<'_, u8>,
|
content: c_slice::Ref<'_, u8>,
|
||||||
) -> repr_c::Box<CreateConvoResult> {
|
) -> repr_c::Box<CreateConvoResult> {
|
||||||
let result = match handle
|
let result = match handle
|
||||||
.0
|
.client
|
||||||
.create_conversation(bundle.as_slice(), content.as_slice())
|
.create_conversation(bundle.as_slice(), content.as_slice())
|
||||||
{
|
{
|
||||||
Ok(convo_id) => CreateConvoResult {
|
Ok(convo_id) => CreateConvoResult {
|
||||||
@ -290,7 +305,7 @@ fn client_send_message(
|
|||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(_) => return ErrorCode::BadUtf8,
|
Err(_) => return ErrorCode::BadUtf8,
|
||||||
};
|
};
|
||||||
match handle.0.send_message(id_str, content.as_slice()) {
|
match handle.client.send_message(id_str, content.as_slice()) {
|
||||||
Ok(()) => ErrorCode::None,
|
Ok(()) => ErrorCode::None,
|
||||||
Err(ClientError::Chat(ChatError::Delivery(_))) => ErrorCode::DeliveryFail,
|
Err(ClientError::Chat(ChatError::Delivery(_))) => ErrorCode::DeliveryFail,
|
||||||
Err(_) => ErrorCode::UnknownError,
|
Err(_) => ErrorCode::UnknownError,
|
||||||
@ -298,31 +313,49 @@ fn client_send_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Receive (process inbound, get event list back)
|
// Inbound (push wire payloads in, drain events out)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Decrypt an inbound payload. Returns the events the payload produced;
|
/// Feed an inbound payload (read off the wire by the host) to the client's
|
||||||
/// the list may be empty for protocol-only frames. Free with
|
/// worker, which decrypts it and produces events for `client_poll_events`.
|
||||||
/// `event_list_free`.
|
|
||||||
#[ffi_export]
|
#[ffi_export]
|
||||||
fn client_receive(
|
fn client_push_inbound(handle: &ClientHandle, payload: c_slice::Ref<'_, u8>) {
|
||||||
handle: &mut ClientHandle,
|
// Disconnected only if the worker has stopped; nothing to do then.
|
||||||
payload: c_slice::Ref<'_, u8>,
|
let _ = handle.inbound.send(payload.as_slice().to_vec());
|
||||||
) -> repr_c::Box<EventList> {
|
}
|
||||||
let result = match handle.0.receive(payload.as_slice()) {
|
|
||||||
Ok(events) => EventList {
|
/// Drain every event the worker has produced since the last call. The list may
|
||||||
error_code: ErrorCode::None as i32,
|
/// be empty. Free with `event_list_free`.
|
||||||
events: events
|
#[ffi_export]
|
||||||
.into_iter()
|
fn client_poll_events(handle: &ClientHandle) -> repr_c::Box<EventList> {
|
||||||
.filter_map(EventRow::from_event)
|
let events = handle
|
||||||
.collect(),
|
.events
|
||||||
},
|
.try_iter()
|
||||||
Err(ClientError::Chat(_)) => EventList {
|
.filter_map(EventRow::from_event)
|
||||||
error_code: ErrorCode::BadPayload as i32,
|
.collect();
|
||||||
events: Vec::new(),
|
Box::new(EventList {
|
||||||
},
|
error_code: ErrorCode::None as i32,
|
||||||
};
|
events,
|
||||||
Box::new(result).into()
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block until the worker produces an event or `timeout_ms` elapses, then drain
|
||||||
|
/// everything available. Parks on the channel (no busy-wait); an empty list
|
||||||
|
/// means timeout or a stopped worker. Free with `event_list_free`.
|
||||||
|
#[ffi_export]
|
||||||
|
fn client_wait_events(handle: &ClientHandle, timeout_ms: u64) -> repr_c::Box<EventList> {
|
||||||
|
let timeout = std::time::Duration::from_millis(timeout_ms);
|
||||||
|
let mut events = Vec::new();
|
||||||
|
if let Ok(first) = handle.events.recv_timeout(timeout) {
|
||||||
|
events.extend(EventRow::from_event(first));
|
||||||
|
events.extend(handle.events.try_iter().filter_map(EventRow::from_event));
|
||||||
|
}
|
||||||
|
Box::new(EventList {
|
||||||
|
error_code: ErrorCode::None as i32,
|
||||||
|
events,
|
||||||
|
})
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ffi_export]
|
#[ffi_export]
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
|
use crossbeam_channel::Receiver;
|
||||||
use libchat::AddressedEnvelope;
|
use libchat::AddressedEnvelope;
|
||||||
use logos_chat::DeliveryService;
|
use logos_chat::{DeliveryService, Transport};
|
||||||
|
|
||||||
/// C callback invoked for each outbound envelope. Return 0 or positive on success, negative on
|
/// C callback invoked for each outbound envelope. Return 0 or positive on success, negative on
|
||||||
/// error. `addr_ptr/addr_len` is the delivery address; `data_ptr/data_len` is the encrypted
|
/// error. `addr_ptr/addr_len` is the delivery address; `data_ptr/data_len` is the encrypted
|
||||||
@ -15,9 +16,18 @@ pub type DeliverFn = Option<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
||||||
pub struct CDelivery {
|
pub struct CDelivery {
|
||||||
pub callback: DeliverFn,
|
pub callback: DeliverFn,
|
||||||
|
inbound_rx: Option<Receiver<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CDelivery {
|
||||||
|
pub fn new(callback: DeliverFn, inbound: Receiver<Vec<u8>>) -> Self {
|
||||||
|
Self {
|
||||||
|
callback,
|
||||||
|
inbound_rx: Some(inbound),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeliveryService for CDelivery {
|
impl DeliveryService for CDelivery {
|
||||||
@ -36,3 +46,11 @@ impl DeliveryService for CDelivery {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Transport for CDelivery {
|
||||||
|
fn inbound(&mut self) -> Receiver<Vec<u8>> {
|
||||||
|
self.inbound_rx
|
||||||
|
.take()
|
||||||
|
.expect("CDelivery::inbound called more than once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -9,12 +9,15 @@ crate-type = ["rlib"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
# Workspace dependencies (sorted)
|
# Workspace dependencies (sorted)
|
||||||
chat-sqlite = { workspace = true }
|
chat-sqlite = { workspace = true }
|
||||||
components = { workspace = true}
|
components = { workspace = true }
|
||||||
|
crossbeam-channel = { workspace = true }
|
||||||
libchat = { workspace = true }
|
libchat = { workspace = true }
|
||||||
logos-account = { workspace = true, features = ["dev"]}
|
logos-account = { workspace = true, features = ["dev"]}
|
||||||
|
|
||||||
# External dependencies (sorted)
|
# External dependencies (sorted)
|
||||||
|
parking_lot = "0.12"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# External dependencies (sorted)
|
# External dependencies (sorted)
|
||||||
|
|||||||
@ -1,39 +1,41 @@
|
|||||||
use logos_chat::{ChatClient, ConversationId, Event, InProcessDelivery};
|
use logos_chat::{ChatClient, Event, InProcessDelivery, MessageBus};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let delivery = InProcessDelivery::new(Default::default());
|
let bus = MessageBus::default();
|
||||||
let mut cursor = delivery.cursor_at_tail("delivery_address");
|
let saro_delivery = InProcessDelivery::new(bus.clone());
|
||||||
|
let raya_delivery = InProcessDelivery::new(bus);
|
||||||
|
|
||||||
let mut saro = ChatClient::new("saro", delivery.clone());
|
let (mut saro, saro_events) = ChatClient::new("saro", saro_delivery);
|
||||||
let mut raya = ChatClient::new("raya", delivery);
|
let (mut raya, raya_events) = ChatClient::new("raya", raya_delivery);
|
||||||
|
|
||||||
let raya_bundle = raya.create_intro_bundle().unwrap();
|
let raya_bundle = raya.create_intro_bundle().unwrap();
|
||||||
saro.create_conversation(&raya_bundle, b"hello raya")
|
saro.create_conversation(&raya_bundle, b"hello raya")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let raw = cursor.next().unwrap();
|
// Raya's worker delivers the new conversation, then its initial message.
|
||||||
let events = raya.receive(&raw).unwrap();
|
let raya_convo_id = match raya_events.recv_timeout(Duration::from_secs(5)).unwrap() {
|
||||||
let raya_convo_id: ConversationId = events
|
Event::ConversationStarted { convo_id, .. } => convo_id,
|
||||||
.iter()
|
other => panic!("expected ConversationStarted, got {other:?}"),
|
||||||
.find_map(|e| match e {
|
};
|
||||||
Event::ConversationStarted { convo_id, .. } => Some(convo_id.to_string()),
|
if let Event::MessageReceived { content, .. } =
|
||||||
_ => None,
|
raya_events.recv_timeout(Duration::from_secs(5)).unwrap()
|
||||||
})
|
{
|
||||||
.expect("expected ConversationStarted");
|
println!(
|
||||||
for event in &events {
|
"Raya received: {:?}",
|
||||||
if let Event::MessageReceived { content, .. } = event {
|
std::str::from_utf8(&content).unwrap()
|
||||||
println!("Raya received: {:?}", std::str::from_utf8(content).unwrap());
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
raya.send_message(&raya_convo_id, b"hi saro").unwrap();
|
raya.send_message(&raya_convo_id, b"hi saro").unwrap();
|
||||||
|
|
||||||
let raw = cursor.next().unwrap();
|
if let Event::MessageReceived { content, .. } =
|
||||||
let events = saro.receive(&raw).unwrap();
|
saro_events.recv_timeout(Duration::from_secs(5)).unwrap()
|
||||||
for event in &events {
|
{
|
||||||
if let Event::MessageReceived { content, .. } = event {
|
println!(
|
||||||
println!("Saro received: {:?}", std::str::from_utf8(content).unwrap());
|
"Saro received: {:?}",
|
||||||
}
|
std::str::from_utf8(&content).unwrap()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Message exchange complete.");
|
println!("Message exchange complete.");
|
||||||
|
|||||||
@ -1,31 +1,65 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
|
use components::EphemeralRegistry;
|
||||||
|
use crossbeam_channel::{Receiver, Sender, select};
|
||||||
use libchat::{
|
use libchat::{
|
||||||
ChatError, ChatStorage, ConversationId, ConvoOutcome, Core, DeliveryService, InboxOutcome,
|
ChatError, ChatStorage, ConversationId, ConvoOutcome, Core, DeliveryService, InboxOutcome,
|
||||||
Introduction, PayloadOutcome, RegistrationService, StorageConfig,
|
Introduction, PayloadOutcome, RegistrationService, StorageConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
use components::EphemeralRegistry;
|
|
||||||
use logos_account::TestLogosAccount;
|
use logos_account::TestLogosAccount;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
use crate::errors::ClientError;
|
use crate::errors::ClientError;
|
||||||
use crate::event::Event;
|
use crate::event::Event;
|
||||||
|
|
||||||
pub struct ChatClient<D: DeliveryService, R: RegistrationService = EphemeralRegistry> {
|
type ClientCore<T, R> = Core<(TestLogosAccount, T, R, ChatStorage)>;
|
||||||
core: Core<(TestLogosAccount, D, R, ChatStorage)>,
|
|
||||||
|
/// The transport as the client sees it: a [`DeliveryService`] for outbound
|
||||||
|
/// publishing plus the inbound payload stream the worker drains. One object owns
|
||||||
|
/// both directions of the boundary.
|
||||||
|
pub trait Transport: DeliveryService + Send + 'static {
|
||||||
|
/// Hand over the inbound payload stream. Called once, at client construction,
|
||||||
|
/// before the [`Core`] takes ownership of the service.
|
||||||
|
fn inbound(&mut self) -> Receiver<Vec<u8>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-level chat client.
|
||||||
|
///
|
||||||
|
/// Owns the synchronous [`Core`] behind an `Arc<Mutex<…>>` and a background
|
||||||
|
/// worker that consumes inbound payloads off the transport's channel, drives
|
||||||
|
/// the core, and forwards observations as [`Event`]s. Construction returns the
|
||||||
|
/// handle together with the `Receiver<Event>` the application drains on its own
|
||||||
|
/// schedule.
|
||||||
|
///
|
||||||
|
/// Outbound calls (`send_message`, `create_conversation`, …) run on the
|
||||||
|
/// caller's thread: they briefly lock the core, invoke it, and return — no
|
||||||
|
/// message-passing round-trip. The `Arc`/`Mutex`/threads live entirely here;
|
||||||
|
/// the core never mentions threads.
|
||||||
|
pub struct ChatClient<T: DeliveryService, R: RegistrationService = EphemeralRegistry> {
|
||||||
|
/// `parking_lot::Mutex` for its eventual fairness: an inbound burst can't
|
||||||
|
/// starve caller operations of the lock.
|
||||||
|
core: Arc<Mutex<ClientCore<T, R>>>,
|
||||||
|
/// Dropped on `Drop` to wake the worker's `select!` and shut it down.
|
||||||
|
shutdown: Option<Sender<()>>,
|
||||||
|
worker: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Default-registry constructors ────────────────────────────────────────────
|
// ── Default-registry constructors ────────────────────────────────────────────
|
||||||
|
|
||||||
impl<D: DeliveryService + 'static> ChatClient<D, EphemeralRegistry> {
|
impl<T: Transport> ChatClient<T, EphemeralRegistry> {
|
||||||
/// Create an in-memory, ephemeral client. Identity is lost on drop.
|
/// Create an in-memory, ephemeral client. Identity is lost on drop.
|
||||||
pub fn new(name: impl Into<String>, delivery: D) -> Self {
|
pub fn new(name: impl Into<String>, mut transport: T) -> (Self, Receiver<Event>) {
|
||||||
let registry = EphemeralRegistry::new();
|
let inbound = transport.inbound();
|
||||||
let store = ChatStorage::in_memory();
|
|
||||||
let ident = TestLogosAccount::new(name);
|
let ident = TestLogosAccount::new(name);
|
||||||
Self {
|
let core = Core::new_with_name(
|
||||||
core: Core::new_with_name(ident, delivery, registry, store).unwrap(),
|
ident,
|
||||||
}
|
transport,
|
||||||
|
EphemeralRegistry::new(),
|
||||||
|
ChatStorage::in_memory(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Self::spawn(core, inbound)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open or create a persistent client backed by `StorageConfig`.
|
/// Open or create a persistent client backed by `StorageConfig`.
|
||||||
@ -35,22 +69,22 @@ impl<D: DeliveryService + 'static> ChatClient<D, EphemeralRegistry> {
|
|||||||
pub fn open(
|
pub fn open(
|
||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
config: StorageConfig,
|
config: StorageConfig,
|
||||||
delivery: D,
|
mut transport: T,
|
||||||
) -> Result<Self, ClientError> {
|
) -> Result<(Self, Receiver<Event>), ClientError> {
|
||||||
let store = ChatStorage::new(config).map_err(ChatError::from)?;
|
let store = ChatStorage::new(config).map_err(ChatError::from)?;
|
||||||
let registry = EphemeralRegistry::new();
|
let inbound = transport.inbound();
|
||||||
let ident = TestLogosAccount::new(name);
|
let ident = TestLogosAccount::new(name);
|
||||||
let core = Core::new_from_store(ident, delivery, registry, store)?;
|
let core = Core::new_from_store(ident, transport, EphemeralRegistry::new(), store)?;
|
||||||
Ok(Self { core })
|
Ok(Self::spawn(core, inbound))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Caller-supplied registry + shared methods ────────────────────────────────
|
// ── Caller-supplied registry + shared methods ────────────────────────────────
|
||||||
|
|
||||||
impl<D, R> ChatClient<D, R>
|
impl<T, R> ChatClient<T, R>
|
||||||
where
|
where
|
||||||
D: DeliveryService + 'static,
|
T: DeliveryService + Send + 'static,
|
||||||
R: RegistrationService + 'static,
|
R: RegistrationService + Send + 'static,
|
||||||
{
|
{
|
||||||
/// Open or create a persistent client with a caller-supplied registration
|
/// Open or create a persistent client with a caller-supplied registration
|
||||||
/// service. Use this to swap in a network-backed registry (e.g. the
|
/// service. Use this to swap in a network-backed registry (e.g. the
|
||||||
@ -63,29 +97,52 @@ where
|
|||||||
pub fn open_with_registry(
|
pub fn open_with_registry(
|
||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
config: StorageConfig,
|
config: StorageConfig,
|
||||||
delivery: D,
|
mut transport: T,
|
||||||
registry: R,
|
registry: R,
|
||||||
) -> Result<Self, ClientError> {
|
) -> Result<(Self, Receiver<Event>), ClientError>
|
||||||
|
where
|
||||||
|
T: Transport,
|
||||||
|
{
|
||||||
let store = ChatStorage::new(config).map_err(ChatError::from)?;
|
let store = ChatStorage::new(config).map_err(ChatError::from)?;
|
||||||
|
let inbound = transport.inbound();
|
||||||
let ident = TestLogosAccount::new(name);
|
let ident = TestLogosAccount::new(name);
|
||||||
let mut core = Core::new_from_store(ident, delivery, registry, store)?;
|
let mut core = Core::new_from_store(ident, transport, registry, store)?;
|
||||||
core.register_keypackage()?;
|
core.register_keypackage()?;
|
||||||
Ok(Self { core })
|
Ok(Self::spawn(core, inbound))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn(core: ClientCore<T, R>, inbound: Receiver<Vec<u8>>) -> (Self, Receiver<Event>) {
|
||||||
|
let core = Arc::new(Mutex::new(core));
|
||||||
|
let (event_tx, event_rx) = crossbeam_channel::unbounded();
|
||||||
|
let (shutdown_tx, shutdown_rx) = crossbeam_channel::bounded::<()>(0);
|
||||||
|
|
||||||
|
let worker = thread::spawn({
|
||||||
|
let core = Arc::clone(&core);
|
||||||
|
move || worker_loop(core, inbound, shutdown_rx, event_tx)
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
core,
|
||||||
|
shutdown: Some(shutdown_tx),
|
||||||
|
worker: Some(worker),
|
||||||
|
},
|
||||||
|
event_rx,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the installation name (identity label) of this client.
|
/// Returns the installation name (identity label) of this client.
|
||||||
pub fn installation_name(&self) -> &str {
|
pub fn installation_name(&self) -> String {
|
||||||
self.core.installation_name()
|
self.core.lock().installation_name().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Produce a serialised introduction bundle for sharing out-of-band.
|
/// Produce a serialised introduction bundle for sharing out-of-band.
|
||||||
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ClientError> {
|
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ClientError> {
|
||||||
self.core.create_intro_bundle().map_err(Into::into)
|
self.core.lock().create_intro_bundle().map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse intro bundle bytes and initiate a private conversation. Returns
|
/// Parse intro bundle bytes and initiate a private conversation. Outbound
|
||||||
/// this side's conversation ID.
|
/// envelopes are published by the core. Returns this side's conversation ID.
|
||||||
pub fn create_conversation(
|
pub fn create_conversation(
|
||||||
&mut self,
|
&mut self,
|
||||||
intro_bundle: &[u8],
|
intro_bundle: &[u8],
|
||||||
@ -93,31 +150,79 @@ where
|
|||||||
) -> Result<ConversationId, ClientError> {
|
) -> Result<ConversationId, ClientError> {
|
||||||
let intro = Introduction::try_from(intro_bundle)?;
|
let intro = Introduction::try_from(intro_bundle)?;
|
||||||
self.core
|
self.core
|
||||||
|
.lock()
|
||||||
.create_private_convo(&intro, initial_content)
|
.create_private_convo(&intro, initial_content)
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all conversation IDs known to this client.
|
/// List all conversation IDs known to this client.
|
||||||
pub fn list_conversations(&self) -> Result<Vec<ConversationId>, ClientError> {
|
pub fn list_conversations(&self) -> Result<Vec<ConversationId>, ClientError> {
|
||||||
self.core.list_conversations().map_err(Into::into)
|
self.core.lock().list_conversations().map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypt and send `content` to an existing conversation.
|
/// Encrypt and send `content` to an existing conversation. The core
|
||||||
|
/// publishes the outbound envelope.
|
||||||
pub fn send_message(&mut self, convo_id: &str, content: &[u8]) -> Result<(), ClientError> {
|
pub fn send_message(&mut self, convo_id: &str, content: &[u8]) -> Result<(), ClientError> {
|
||||||
self.core
|
self.core
|
||||||
|
.lock()
|
||||||
.send_content(convo_id, content)
|
.send_content(convo_id, content)
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Decrypt an inbound payload. Returns the events the payload produced,
|
impl<T: DeliveryService, R: RegistrationService> Drop for ChatClient<T, R> {
|
||||||
/// in causal order. May be empty for protocol-only frames.
|
fn drop(&mut self) {
|
||||||
pub fn receive(&mut self, payload: &[u8]) -> Result<Vec<Event>, ClientError> {
|
// Dropping the sender disconnects the worker's shutdown channel, waking
|
||||||
let result = self.core.handle_payload(payload)?;
|
// its `select!` so it can exit; then we join it.
|
||||||
Ok(events_from_inbound(result))
|
self.shutdown.take();
|
||||||
|
if let Some(handle) = self.worker.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk an [`PayloadOutcome`] in causal order and emit one `Event` per
|
/// Background loop: block until an inbound payload or shutdown arrives, drive
|
||||||
|
/// the core on each payload, and forward events. No polling — `select!` parks
|
||||||
|
/// the thread until one of the channels is ready.
|
||||||
|
fn worker_loop<T, R>(
|
||||||
|
core: Arc<Mutex<ClientCore<T, R>>>,
|
||||||
|
inbound: Receiver<Vec<u8>>,
|
||||||
|
shutdown: Receiver<()>,
|
||||||
|
event_tx: Sender<Event>,
|
||||||
|
) where
|
||||||
|
T: DeliveryService + Send + 'static,
|
||||||
|
R: RegistrationService + Send + 'static,
|
||||||
|
{
|
||||||
|
loop {
|
||||||
|
select! {
|
||||||
|
recv(inbound) -> msg => {
|
||||||
|
let Ok(bytes) = msg else {
|
||||||
|
return; // transport's sender dropped
|
||||||
|
};
|
||||||
|
let events = {
|
||||||
|
let mut core = core.lock();
|
||||||
|
match core.handle_payload(&bytes) {
|
||||||
|
Ok(outcome) => events_from_inbound(outcome),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("inbound handle_payload failed: {e:?}");
|
||||||
|
vec![Event::InboundError {
|
||||||
|
message: e.to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for event in events {
|
||||||
|
if event_tx.send(event).is_err() {
|
||||||
|
return; // application dropped the receiver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recv(shutdown) -> _ => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk a [`PayloadOutcome`] in causal order and emit one `Event` per
|
||||||
/// observation. For an `Inbox` outcome, [`Event::ConversationStarted`]
|
/// observation. For an `Inbox` outcome, [`Event::ConversationStarted`]
|
||||||
/// precedes the message event. The convo id is wrapped into `Arc<str>` once
|
/// precedes the message event. The convo id is wrapped into `Arc<str>` once
|
||||||
/// per outcome and shared across the events it produces.
|
/// per outcome and shared across the events it produces.
|
||||||
|
|||||||
@ -1,103 +1,62 @@
|
|||||||
use crate::{AddressedEnvelope, DeliveryService};
|
use crate::{AddressedEnvelope, DeliveryService, Transport};
|
||||||
|
use crossbeam_channel::{Receiver, Sender, unbounded};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
type Message = Vec<u8>;
|
type Message = Vec<u8>;
|
||||||
|
|
||||||
/// Shared in-process message bus. Cheap to clone — all clones share the same log.
|
/// Shared in-process message bus. Cheap to clone — all clones share one routing
|
||||||
///
|
/// table. On `publish`, a message is fanned out to every endpoint subscribed to
|
||||||
/// Messages are stored in an append-only log per delivery address. Readers hold
|
/// its delivery address.
|
||||||
/// independent [`Cursor`]s and advance their position without consuming messages,
|
|
||||||
/// so multiple consumers on the same address each see every message.
|
|
||||||
#[derive(Clone, Default, Debug)]
|
#[derive(Clone, Default, Debug)]
|
||||||
pub struct MessageBus {
|
pub struct MessageBus {
|
||||||
log: Arc<RwLock<HashMap<String, Vec<Message>>>>,
|
routes: Arc<Mutex<HashMap<String, Vec<Sender<Message>>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageBus {
|
impl MessageBus {
|
||||||
/// Returns a cursor positioned at the beginning of `address`.
|
fn register(&self, address: &str, sender: Sender<Message>) {
|
||||||
/// The cursor will see all messages — past and future.
|
let mut routes = self.routes.lock().unwrap();
|
||||||
pub fn cursor(&self, address: &str) -> Cursor {
|
let senders = routes.entry(address.to_string()).or_default();
|
||||||
Cursor {
|
// Idempotent per endpoint: the core re-subscribes an address whenever it
|
||||||
bus: self.clone(),
|
// rebuilds a conversation, so skip senders already registered for it —
|
||||||
address: address.to_string(),
|
// otherwise each payload reaches that endpoint more than once.
|
||||||
pos: 0,
|
if senders.iter().any(|s| s.same_channel(&sender)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
senders.push(sender);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a cursor positioned at the current tail of `address`.
|
fn publish(&self, address: &str, data: Message) {
|
||||||
/// The cursor will only see messages delivered after this call.
|
if let Some(senders) = self.routes.lock().unwrap().get_mut(address) {
|
||||||
pub fn cursor_at_tail(&self, address: &str) -> Cursor {
|
// Prune endpoints whose receiver was dropped: a disconnected endpoint
|
||||||
let pos = self.log.read().unwrap().get(address).map_or(0, |v| v.len());
|
// is harmless, but keeping its sender would leak it in `routes`.
|
||||||
Cursor {
|
senders.retain(|tx| tx.send(data.clone()).is_ok());
|
||||||
bus: self.clone(),
|
|
||||||
address: address.to_string(),
|
|
||||||
pos,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get(&self, address: &str, pos: usize) -> Option<Message> {
|
|
||||||
// Unwrap produces a panic when the lock is poisoned.
|
|
||||||
// It would most likely indicate log corruption (e.g. incomplete write from another thread),
|
|
||||||
// so panic propagation seems appropriate.
|
|
||||||
self.log.read().unwrap().get(address)?.get(pos).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push(&self, address: String, data: Message) {
|
|
||||||
self.log
|
|
||||||
.write()
|
|
||||||
.unwrap()
|
|
||||||
.entry(address)
|
|
||||||
.or_default()
|
|
||||||
.push(data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-consumer read cursor into a [`MessageBus`] address slot.
|
/// One client's endpoint onto a shared [`MessageBus`].
|
||||||
///
|
///
|
||||||
/// Reads are non-destructive: the underlying log is never modified.
|
/// `publish` fans the message out through the bus; `subscribe` registers this
|
||||||
/// Multiple cursors on the same address each advance independently.
|
/// endpoint's inbound sender for an address, so subsequent publishes to it are
|
||||||
pub struct Cursor {
|
/// delivered. The client obtains the inbound stream via [`Transport::inbound`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct InProcessDelivery {
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
address: String,
|
inbound_tx: Sender<Message>,
|
||||||
pos: usize,
|
inbound_rx: Option<Receiver<Message>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Iterator for Cursor {
|
|
||||||
type Item = Message;
|
|
||||||
|
|
||||||
fn next(&mut self) -> Option<Message> {
|
|
||||||
let msg = self.bus.get(&self.address, self.pos)?;
|
|
||||||
self.pos += 1;
|
|
||||||
Some(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// In-process delivery service backed by a [`MessageBus`].
|
|
||||||
///
|
|
||||||
/// Cheap to clone — all clones share the same underlying bus, so multiple
|
|
||||||
/// clients can share one logical delivery service. Construct with a
|
|
||||||
/// [`MessageBus`] and use [`cursor`](InProcessDelivery::cursor) /
|
|
||||||
/// [`cursor_at_tail`](InProcessDelivery::cursor_at_tail) to read messages.
|
|
||||||
#[derive(Clone, Default, Debug)]
|
|
||||||
pub struct InProcessDelivery(MessageBus);
|
|
||||||
|
|
||||||
impl InProcessDelivery {
|
impl InProcessDelivery {
|
||||||
/// Create a delivery service backed by `bus`.
|
/// Create an endpoint on `bus`.
|
||||||
pub fn new(bus: MessageBus) -> Self {
|
pub fn new(bus: MessageBus) -> Self {
|
||||||
Self(bus)
|
let (tx, rx) = unbounded();
|
||||||
}
|
Self {
|
||||||
|
bus,
|
||||||
/// Returns a cursor positioned at the beginning of `address`.
|
inbound_tx: tx,
|
||||||
pub fn cursor(&self, address: &str) -> Cursor {
|
inbound_rx: Some(rx),
|
||||||
self.0.cursor(address)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a cursor positioned at the current tail of `address`.
|
|
||||||
/// The cursor will only see messages delivered after this call.
|
|
||||||
pub fn cursor_at_tail(&self, address: &str) -> Cursor {
|
|
||||||
self.0.cursor_at_tail(address)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,12 +64,20 @@ impl DeliveryService for InProcessDelivery {
|
|||||||
type Error = Infallible;
|
type Error = Infallible;
|
||||||
|
|
||||||
fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), Infallible> {
|
fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), Infallible> {
|
||||||
self.0.push(envelope.delivery_address, envelope.data);
|
self.bus.publish(&envelope.delivery_address, envelope.data);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn subscribe(&mut self, _delivery_address: &str) -> Result<(), Self::Error> {
|
fn subscribe(&mut self, delivery_address: &str) -> Result<(), Self::Error> {
|
||||||
// TODO: (P1) implement subscribe
|
self.bus.register(delivery_address, self.inbound_tx.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Transport for InProcessDelivery {
|
||||||
|
fn inbound(&mut self) -> Receiver<Vec<u8>> {
|
||||||
|
self.inbound_rx
|
||||||
|
.take()
|
||||||
|
.expect("InProcessDelivery::inbound called more than once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -24,4 +24,7 @@ pub enum Event {
|
|||||||
convo_id: Arc<str>,
|
convo_id: Arc<str>,
|
||||||
content: Vec<u8>,
|
content: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
InboundError {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,8 @@ mod delivery_in_process;
|
|||||||
mod errors;
|
mod errors;
|
||||||
mod event;
|
mod event;
|
||||||
|
|
||||||
pub use client::ChatClient;
|
pub use client::{ChatClient, Transport};
|
||||||
pub use delivery_in_process::{Cursor, InProcessDelivery, MessageBus};
|
pub use delivery_in_process::{InProcessDelivery, MessageBus};
|
||||||
pub use errors::ClientError;
|
pub use errors::ClientError;
|
||||||
pub use event::Event;
|
pub use event::Event;
|
||||||
|
|
||||||
|
|||||||
@ -1,95 +1,201 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
use logos_chat::{
|
use logos_chat::{
|
||||||
ChatClient, ConversationClass, ConversationId, Cursor, Event, InProcessDelivery, StorageConfig,
|
AddressedEnvelope, ChatClient, DeliveryService, Event, InProcessDelivery, MessageBus,
|
||||||
|
StorageConfig, Transport,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Pulls one envelope, decrypts, and returns the events emitted.
|
/// Block until the next event arrives and matches; panic on timeout/mismatch.
|
||||||
fn receive(receiver: &mut ChatClient<InProcessDelivery>, cursor: &mut Cursor) -> Vec<Event> {
|
fn expect_event<F, T>(events: &Receiver<Event>, label: &str, mut f: F) -> T
|
||||||
let raw = cursor.next().expect("expected envelope");
|
where
|
||||||
receiver.receive(&raw).expect("receive failed")
|
F: FnMut(Event) -> Result<T, Event>,
|
||||||
}
|
{
|
||||||
|
let event = events
|
||||||
fn expect_message(event: &Event) -> (&str, &[u8]) {
|
.recv_timeout(Duration::from_secs(5))
|
||||||
match event {
|
.unwrap_or_else(|_| panic!("timed out waiting for {label}"));
|
||||||
Event::MessageReceived {
|
f(event).unwrap_or_else(|other| panic!("expected {label}, got {other:?}"))
|
||||||
convo_id, content, ..
|
|
||||||
} => (convo_id.as_ref(), content.as_slice()),
|
|
||||||
other => panic!("expected MessageReceived, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expect_conversation_started(event: &Event) -> (&str, ConversationClass) {
|
|
||||||
match event {
|
|
||||||
Event::ConversationStarted {
|
|
||||||
convo_id, class, ..
|
|
||||||
} => (convo_id.as_ref(), *class),
|
|
||||||
other => panic!("expected ConversationStarted, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn saro_raya_message_exchange() {
|
fn saro_raya_message_exchange() {
|
||||||
let delivery = InProcessDelivery::new(Default::default());
|
let bus = MessageBus::default();
|
||||||
let mut cursor = delivery.cursor_at_tail("delivery_address");
|
let saro_delivery = InProcessDelivery::new(bus.clone());
|
||||||
|
let raya_delivery = InProcessDelivery::new(bus);
|
||||||
|
|
||||||
let mut saro = ChatClient::new("saro", delivery.clone());
|
let (mut saro, saro_events) = ChatClient::new("saro", saro_delivery);
|
||||||
let mut raya = ChatClient::new("raya", delivery);
|
let (mut raya, raya_events) = ChatClient::new("raya", raya_delivery);
|
||||||
|
|
||||||
let raya_bundle = raya.create_intro_bundle().unwrap();
|
let raya_bundle = raya.create_intro_bundle().unwrap();
|
||||||
let saro_convo_id = saro
|
let saro_convo_id = saro
|
||||||
.create_conversation(&raya_bundle, b"hello raya")
|
.create_conversation(&raya_bundle, b"hello raya")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let events = receive(&mut raya, &mut cursor);
|
// The invite payload yields ConversationStarted then MessageReceived.
|
||||||
assert_eq!(
|
let raya_convo_id = expect_event(&raya_events, "ConversationStarted", |e| match e {
|
||||||
events.len(),
|
Event::ConversationStarted { convo_id, .. } => Ok(convo_id),
|
||||||
2,
|
other => Err(other),
|
||||||
"expected ConversationStarted + MessageReceived"
|
});
|
||||||
);
|
expect_event(&raya_events, "MessageReceived", |e| match e {
|
||||||
let (started_id, class) = expect_conversation_started(&events[0]);
|
Event::MessageReceived { convo_id, content } => {
|
||||||
assert_eq!(class, ConversationClass::Private);
|
assert_eq!(convo_id, raya_convo_id);
|
||||||
let (msg_id, content) = expect_message(&events[1]);
|
assert_eq!(content.as_slice(), b"hello raya");
|
||||||
assert_eq!(content, b"hello raya");
|
Ok(())
|
||||||
assert_eq!(started_id, msg_id);
|
}
|
||||||
let raya_convo_id: ConversationId = started_id.to_owned();
|
other => Err(other),
|
||||||
|
});
|
||||||
|
|
||||||
raya.send_message(&raya_convo_id, b"hi saro").unwrap();
|
raya.send_message(&raya_convo_id, b"hi saro").unwrap();
|
||||||
let events = receive(&mut saro, &mut cursor);
|
expect_event(&saro_events, "MessageReceived", |e| match e {
|
||||||
assert_eq!(events.len(), 1);
|
Event::MessageReceived { content, .. } => {
|
||||||
let (_, content) = expect_message(&events[0]);
|
assert_eq!(content.as_slice(), b"hi saro");
|
||||||
assert_eq!(content, b"hi saro");
|
Ok(())
|
||||||
|
}
|
||||||
|
other => Err(other),
|
||||||
|
});
|
||||||
|
|
||||||
for i in 0u8..5 {
|
for i in 0u8..5 {
|
||||||
let msg = format!("msg {i}");
|
let msg = format!("msg {i}");
|
||||||
saro.send_message(&saro_convo_id, msg.as_bytes()).unwrap();
|
saro.send_message(&saro_convo_id, msg.as_bytes()).unwrap();
|
||||||
let events = receive(&mut raya, &mut cursor);
|
expect_event(
|
||||||
assert_eq!(events.len(), 1);
|
&raya_events,
|
||||||
let (_, content) = expect_message(&events[0]);
|
&format!("MessageReceived(msg {i})"),
|
||||||
assert_eq!(content, msg.as_bytes());
|
|e| match e {
|
||||||
|
Event::MessageReceived { content, .. } => {
|
||||||
|
assert_eq!(content.as_slice(), msg.as_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => Err(other),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let reply = format!("reply {i}");
|
let reply = format!("reply {i}");
|
||||||
raya.send_message(&raya_convo_id, reply.as_bytes()).unwrap();
|
raya.send_message(&raya_convo_id, reply.as_bytes()).unwrap();
|
||||||
let events = receive(&mut saro, &mut cursor);
|
expect_event(
|
||||||
assert_eq!(events.len(), 1);
|
&saro_events,
|
||||||
let (_, content) = expect_message(&events[0]);
|
&format!("MessageReceived(reply {i})"),
|
||||||
assert_eq!(content, reply.as_bytes());
|
|e| match e {
|
||||||
|
Event::MessageReceived { content, .. } => {
|
||||||
|
assert_eq!(content.as_slice(), reply.as_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => Err(other),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(saro.list_conversations().unwrap().len(), 1);
|
assert_eq!(saro.list_conversations().unwrap().len(), 1);
|
||||||
assert_eq!(raya.list_conversations().unwrap().len(), 1);
|
assert_eq!(raya.list_conversations().unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct FailingDelivery {
|
||||||
|
inbound_tx: Sender<Vec<u8>>,
|
||||||
|
inbound_rx: Option<Receiver<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FailingDelivery {
|
||||||
|
fn new() -> Self {
|
||||||
|
let (inbound_tx, inbound_rx) = crossbeam_channel::unbounded();
|
||||||
|
Self {
|
||||||
|
inbound_tx,
|
||||||
|
inbound_rx: Some(inbound_rx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sender into this transport's inbound stream — for tests to feed the
|
||||||
|
/// worker, or to hold open so it doesn't see a disconnect.
|
||||||
|
fn inbound_sender(&self) -> Sender<Vec<u8>> {
|
||||||
|
self.inbound_tx.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeliveryService for FailingDelivery {
|
||||||
|
type Error = &'static str;
|
||||||
|
|
||||||
|
fn publish(&mut self, _: AddressedEnvelope) -> Result<(), Self::Error> {
|
||||||
|
Err("simulated transport failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe(&mut self, _: &str) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transport for FailingDelivery {
|
||||||
|
fn inbound(&mut self) -> Receiver<Vec<u8>> {
|
||||||
|
self.inbound_rx
|
||||||
|
.take()
|
||||||
|
.expect("FailingDelivery::inbound called more than once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropping_client_shuts_down_worker() {
|
||||||
|
let delivery = InProcessDelivery::new(MessageBus::default());
|
||||||
|
let (client, events) = ChatClient::new("saro", delivery);
|
||||||
|
drop(client);
|
||||||
|
// Drop joins the worker; once joined its Sender<Event> is gone, so recv
|
||||||
|
// reports the channel as disconnected.
|
||||||
|
let res = events.recv_timeout(Duration::from_secs(5));
|
||||||
|
assert!(matches!(
|
||||||
|
res,
|
||||||
|
Err(crossbeam_channel::RecvTimeoutError::Disconnected)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_failure_surfaces_as_error() {
|
||||||
|
// A real raya just to mint a valid intro bundle.
|
||||||
|
let raya_delivery = InProcessDelivery::new(MessageBus::default());
|
||||||
|
let (mut raya, _raya_events) = ChatClient::new("raya", raya_delivery);
|
||||||
|
let bundle = raya.create_intro_bundle().unwrap();
|
||||||
|
|
||||||
|
// FailingDelivery never receives; keep the inbound sender alive so the
|
||||||
|
// worker doesn't exit early on a disconnected channel.
|
||||||
|
let delivery = FailingDelivery::new();
|
||||||
|
let _keep_inbound = delivery.inbound_sender();
|
||||||
|
let (mut saro, _saro_events) = ChatClient::new("saro", delivery);
|
||||||
|
let result = saro.create_conversation(&bundle, b"hello");
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"publish failure should surface as an error on the synchronous call"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_inbound_surfaces_as_error_event() {
|
||||||
|
// Feed the worker's inbound channel bytes that can't be decoded and assert
|
||||||
|
// it emits an InboundError instead of silently dropping the failure.
|
||||||
|
let delivery = FailingDelivery::new();
|
||||||
|
let inbound_tx = delivery.inbound_sender();
|
||||||
|
let (_saro, events) = ChatClient::new("saro", delivery);
|
||||||
|
|
||||||
|
inbound_tx.send(b"not a valid payload".to_vec()).unwrap();
|
||||||
|
|
||||||
|
expect_event(&events, "InboundError", |e| match e {
|
||||||
|
Event::InboundError { message } => {
|
||||||
|
assert!(!message.is_empty(), "error event should carry a message");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => Err(other),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_persistent_client() {
|
fn open_persistent_client() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let db_path = dir.path().join("test.db").to_string_lossy().to_string();
|
let db_path = dir.path().join("test.db").to_string_lossy().to_string();
|
||||||
let config = StorageConfig::File(db_path);
|
let config = StorageConfig::File(db_path);
|
||||||
|
|
||||||
let client1 = ChatClient::open("saro", config.clone(), InProcessDelivery::default()).unwrap();
|
let delivery1 = InProcessDelivery::new(MessageBus::default());
|
||||||
let name1 = client1.installation_name().to_string();
|
let (client1, _events1) = ChatClient::open("saro", config.clone(), delivery1).unwrap();
|
||||||
|
let name1 = client1.installation_name();
|
||||||
drop(client1);
|
drop(client1);
|
||||||
|
|
||||||
let client2 = ChatClient::open("saro", config, InProcessDelivery::default()).unwrap();
|
let delivery2 = InProcessDelivery::new(MessageBus::default());
|
||||||
let name2 = client2.installation_name().to_string();
|
let (client2, _events2) = ChatClient::open("saro", config, delivery2).unwrap();
|
||||||
|
let name2 = client2.installation_name();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
name1, name2,
|
name1, name2,
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
| Status | Accepted |
|
| Status | Accepted |
|
||||||
| Issue | https://github.com/logos-messaging/libchat/issues/97 |
|
| Issue | https://github.com/logos-messaging/libchat/issues/97 |
|
||||||
| Date | 2026-05-19 |
|
| Date | 2026-05-19 |
|
||||||
| Last revised | 2026-05-28 |
|
| Last revised | 2026-06-09 |
|
||||||
|
|
||||||
## Context and Problem
|
## Context and Problem
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ Three layers. Calls flow downward. Sync results return through method returns; e
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
A["<b>app</b><br/>drains Receiver<Event>"]
|
A["<b>app</b><br/>drains Receiver<Event>"]
|
||||||
B["<b>client</b><br/>owns transport poller + services<br/>translates PayloadOutcome → Event values<br/>pushes onto channel"]
|
B["<b>client</b><br/>owns worker thread + services<br/>translates PayloadOutcome → Event values<br/>pushes onto channel"]
|
||||||
C["<b>core</b><br/>strict sync, caller-driven<br/>returns PayloadOutcome"]
|
C["<b>core</b><br/>strict sync, caller-driven<br/>returns PayloadOutcome"]
|
||||||
|
|
||||||
A -- "method calls" --> B
|
A -- "method calls" --> B
|
||||||
@ -41,7 +41,7 @@ Crates: **app** — `bin/chat-cli`, future `logos-chat-module`; **client** — `
|
|||||||
|
|
||||||
1. **Core returns `PayloadOutcome`, a dispatcher-level enum.** Each inbound path inside the core yields its own concrete outcome type: `ConvoOutcome` (`Convo::handle_frame`) carries decrypted contents on an existing conversation; `InboxOutcome` (inbox / inbox_v2 handlers) carries a newly observed conversation plus an optional initial `ConvoOutcome`. `PayloadOutcome` is the dispatcher-level union (`Empty`, `Convo(ConvoOutcome)`, `Inbox(InboxOutcome)`) and is the single type `Context::handle_payload` returns; `From<ConvoOutcome>` / `From<InboxOutcome>` impls keep the per-path handlers free of `PayloadOutcome` in their signatures. The split encodes at the type level what each producer can populate — a `Convo` cannot manufacture a new conversation, so its signature precludes the possibility.
|
1. **Core returns `PayloadOutcome`, a dispatcher-level enum.** Each inbound path inside the core yields its own concrete outcome type: `ConvoOutcome` (`Convo::handle_frame`) carries decrypted contents on an existing conversation; `InboxOutcome` (inbox / inbox_v2 handlers) carries a newly observed conversation plus an optional initial `ConvoOutcome`. `PayloadOutcome` is the dispatcher-level union (`Empty`, `Convo(ConvoOutcome)`, `Inbox(InboxOutcome)`) and is the single type `Context::handle_payload` returns; `From<ConvoOutcome>` / `From<InboxOutcome>` impls keep the per-path handlers free of `PayloadOutcome` in their signatures. The split encodes at the type level what each producer can populate — a `Convo` cannot manufacture a new conversation, so its signature precludes the possibility.
|
||||||
|
|
||||||
2. **`Event` is an asynchronous notification.** The client's constructor returns a `Receiver<Event>` alongside the client handle. A background poller drives the transport, calls into the core for each inbound payload, translates the resulting `PayloadOutcome` into one event per observation, and pushes them onto the channel. Background work that has no synchronous trigger at all (delivery retry timeouts, future protocol timers) pushes onto the same channel.
|
2. **`Event` is an asynchronous notification.** The client's constructor returns a `Receiver<Event>` alongside the client handle. A background worker receives inbound payloads pushed from the transport (the Delivery Service's inbound side) — it is never polled — calls into the core for each, translates the resulting `PayloadOutcome` into one event per observation, and pushes them onto the channel. Background work that has no synchronous trigger at all (delivery retry timeouts, future protocol timers) pushes onto the same channel.
|
||||||
|
|
||||||
3. **Two enums, mapping at the client boundary.** `PayloadOutcome` is the dispatcher-level sum of observations from one payload; `Event` is a discrete app-facing notification. The two enums are allowed to diverge: a protocol-internal observation the app does not need lives only on a core outcome type; a client-only event like `DeliveryFailed { Timeout }` lives only on `Event`. Translation is an explicit per-variant `match` inside the client — not a blanket `From` impl — to preserve that divergence as both sides grow.
|
3. **Two enums, mapping at the client boundary.** `PayloadOutcome` is the dispatcher-level sum of observations from one payload; `Event` is a discrete app-facing notification. The two enums are allowed to diverge: a protocol-internal observation the app does not need lives only on a core outcome type; a client-only event like `DeliveryFailed { Timeout }` lives only on `Event`. Translation is an explicit per-variant `match` inside the client — not a blanket `From` impl — to preserve that divergence as both sides grow.
|
||||||
|
|
||||||
@ -55,13 +55,13 @@ Synchronous failures — publish, parse, store, MLS — stay on `Result<_, ChatE
|
|||||||
|
|
||||||
## Sequence
|
## Sequence
|
||||||
|
|
||||||
Two flows cover everything the application observes: a synchronous send initiated by the app, and inbound bytes carried by the client's transport poller.
|
Two flows cover everything the application observes: a synchronous send initiated by the app, and inbound bytes the transport pushes to the client's worker.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant App
|
participant App
|
||||||
participant Client
|
participant Client
|
||||||
participant Poller as Client poller (background)
|
participant Worker as Client worker (background)
|
||||||
participant Core
|
participant Core
|
||||||
participant Delivery as DeliveryService
|
participant Delivery as DeliveryService
|
||||||
|
|
||||||
@ -73,13 +73,12 @@ sequenceDiagram
|
|||||||
Core-->>Client: Ok(()) / Err
|
Core-->>Client: Ok(()) / Err
|
||||||
Client-->>App: Ok(()) / Err
|
Client-->>App: Ok(()) / Err
|
||||||
|
|
||||||
Note over Poller,Delivery: Inbound — background poller pushes events
|
Note over Worker,Delivery: Inbound — transport pushes, worker drives the core
|
||||||
Poller->>Delivery: poll
|
Delivery-)Worker: inbound payload (subscribed address)
|
||||||
Delivery-->>Poller: payload bytes
|
Worker->>Core: handle_payload(payload)
|
||||||
Poller->>Core: handle_payload(payload)
|
Core-->>Worker: Ok(PayloadOutcome)
|
||||||
Core-->>Poller: Ok(PayloadOutcome)
|
Worker->>Worker: translate fields → Event values
|
||||||
Poller->>Poller: translate fields → Event values
|
Worker-)App: events via Receiver<Event>
|
||||||
Poller-)App: events via Receiver<Event>
|
|
||||||
|
|
||||||
Note over App: App drains on its own schedule
|
Note over App: App drains on its own schedule
|
||||||
App->>App: for event in receiver.try_iter() { handle(event) }
|
App->>App: for event in receiver.try_iter() { handle(event) }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user