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:
osmaczko
2026-06-11 10:08:07 +02:00
committed by GitHub
parent a610117e81
commit 7838d43b30
20 changed files with 601 additions and 338 deletions
+20 -23
View File
@@ -1,10 +1,10 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use anyhow::Result;
use arboard::Clipboard;
use crossbeam_channel::Receiver;
use logos_chat::{ChatClient, DeliveryService, EphemeralRegistry, Event, RegistrationService};
use serde::{Deserialize, Serialize};
@@ -41,9 +41,9 @@ pub struct AppState {
pub active_chat: Option<String>,
}
pub struct ChatApp<D: DeliveryService, R: RegistrationService = EphemeralRegistry> {
pub client: ChatClient<D, R>,
inbound: mpsc::Receiver<Vec<u8>>,
pub struct ChatApp<T: DeliveryService, R: RegistrationService = EphemeralRegistry> {
pub client: ChatClient<T, R>,
events: Receiver<Event>,
pub state: AppState,
/// Ephemeral command output — not persisted, cleared on chat switch.
command_output: Vec<DisplayMessage>,
@@ -53,14 +53,14 @@ pub struct ChatApp<D: DeliveryService, R: RegistrationService = EphemeralRegistr
state_path: PathBuf,
}
impl<D, R> ChatApp<D, R>
impl<T, R> ChatApp<T, R>
where
D: DeliveryService + 'static,
R: RegistrationService + 'static,
T: DeliveryService + Send + 'static,
R: RegistrationService + Send + 'static,
{
pub fn new(
client: ChatClient<D, R>,
inbound: mpsc::Receiver<Vec<u8>>,
client: ChatClient<T, R>,
events: Receiver<Event>,
user_name: &str,
data_dir: &Path,
) -> Result<Self> {
@@ -80,7 +80,7 @@ where
Ok(Self {
client,
inbound,
events,
state,
command_output: Vec::new(),
input: String::new(),
@@ -146,19 +146,13 @@ where
}
pub fn process_incoming(&mut self) -> Result<()> {
while let Ok(payload) = self.inbound.try_recv() {
match self.client.receive(&payload) {
Ok(events) => {
for event in events {
self.handle_event(event);
}
self.save_state()?;
}
Err(e) => {
tracing::warn!("receive error: {e:?}");
self.status = format!("Could not decrypt incoming message: {e}");
}
}
let mut received = false;
while let Ok(event) = self.events.try_recv() {
self.handle_event(event);
received = true;
}
if received {
self.save_state()?;
}
Ok(())
}
@@ -195,6 +189,9 @@ where
timestamp: now(),
});
}
Event::InboundError { message } => {
self.status = format!("Could not process incoming message: {message}");
}
_ => {}
}
}
+23 -31
View File
@@ -4,11 +4,13 @@ mod ui;
mod utils;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use anyhow::{Context, Result};
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;
@@ -72,9 +74,9 @@ fn main() -> Result<()> {
match cli.transport {
TransportKind::File => {
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")?;
run(transport, inbound, &cli)
run(transport, &cli)
}
#[cfg(logos_delivery)]
TransportKind::LogosDelivery => {
@@ -88,20 +90,15 @@ fn main() -> Result<()> {
tcp_port: cli.port,
..Default::default()
};
let (transport, inbound) =
Service::start(cfg).context("failed to start logos-delivery")?;
let transport = Service::start(cfg).context("failed to start logos-delivery")?;
println!("Node connected. Initializing chat client...");
run(transport, inbound, &cli)
run(transport, &cli)
}
}
}
fn run<D: DeliveryService + 'static>(
transport: D,
inbound: mpsc::Receiver<Vec<u8>>,
cli: &Cli,
) -> Result<()> {
fn run<T: Transport>(transport: T, cli: &Cli) -> Result<()> {
let db_path = cli
.db
.clone()
@@ -118,31 +115,27 @@ fn run<D: DeliveryService + 'static>(
match cli.registry_url.as_deref() {
Some(url) => {
let registry = HttpRegistry::new(url);
let client =
let (client, events) =
ChatClient::open_with_registry(cli.name.clone(), storage, transport, registry)
.map_err(|e| anyhow::anyhow!("{e:?}"))
.context("failed to open chat client with HTTP registry")?;
launch_tui(client, inbound, cli)
launch_tui(client, events, cli)
}
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:?}"))
.context("failed to open chat client")?;
launch_tui(client, inbound, cli)
launch_tui(client, events, cli)
}
}
}
fn launch_tui<D, R>(
client: ChatClient<D, R>,
inbound: mpsc::Receiver<Vec<u8>>,
cli: &Cli,
) -> Result<()>
fn launch_tui<T, R>(client: ChatClient<T, R>, events: Receiver<Event>, cli: &Cli) -> Result<()>
where
D: DeliveryService + 'static,
R: RegistrationService + 'static,
T: DeliveryService + Send + '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 {
return Ok(());
@@ -168,8 +161,7 @@ fn run_logos_delivery(cli: Cli) -> Result<()> {
tcp_port: cli.port,
..Default::default()
};
let (delivery, inbound) =
Service::start(logos_cfg).context("failed to start logos-delivery")?;
let delivery = Service::start(logos_cfg).context("failed to start logos-delivery")?;
eprintln!("Node connected. Initializing chat client...");
@@ -180,7 +172,7 @@ fn run_logos_delivery(cli: Cli) -> Result<()> {
.map(|p| p.to_path_buf())
.unwrap_or_else(|| cli.data.clone());
let client = match cli.db {
let (client, events) = match cli.db {
Some(ref path) => {
let db_str = path
.to_str()
@@ -200,7 +192,7 @@ fn run_logos_delivery(cli: Cli) -> Result<()> {
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 {
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
D: DeliveryService + 'static,
R: RegistrationService + 'static,
T: DeliveryService + Send + 'static,
R: RegistrationService + Send + 'static,
{
loop {
app.process_incoming()?;
+22 -14
View File
@@ -2,11 +2,11 @@ use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::{self, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
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)]
pub enum FileTransportError {
@@ -17,31 +17,31 @@ pub enum FileTransportError {
#[derive(Debug)]
pub struct FileTransport {
transport_dir: PathBuf,
inbound_rx: Option<Receiver<Vec<u8>>>,
}
impl FileTransport {
/// All instances pointing at the same `transport_dir` share one broadcast bus.
///
/// Messages are written to `{transport_dir}/{delivery_address}/{hours_since_epoch}.bin`
/// as length-prefixed frames (`[u32 BE length][payload bytes]`). The background
/// thread reads all files under `transport_dir` and forwards every frame to
/// the returned channel; `client.receive()` discards frames it cannot decrypt.
pub fn new(transport_dir: &Path) -> io::Result<(Self, mpsc::Receiver<Vec<u8>>)> {
/// as length-prefixed frames (`[u32 BE length][payload bytes]`). A background
/// thread reads all files under `transport_dir` and forwards every frame to the
/// inbound stream the client drains via [`Transport::inbound`] (discarding frames
/// it cannot decrypt).
pub fn new(transport_dir: &Path) -> io::Result<Self> {
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();
thread::Builder::new()
.name("file-transport".into())
.spawn(move || poll_reader(dir, tx))?;
Ok((
Self {
transport_dir: transport_dir.to_path_buf(),
},
rx,
))
Ok(Self {
transport_dir: transport_dir.to_path_buf(),
inbound_rx: Some(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.
fn current_hour() -> u64 {
SystemTime::now()
@@ -77,7 +85,7 @@ fn current_hour() -> u64 {
/ 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.
let mut offsets: BTreeMap<PathBuf, u64> = BTreeMap::new();
+24 -16
View File
@@ -18,7 +18,8 @@ use std::time::Duration;
use base64::Engine;
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 wrapper::LogosNodeCtx;
@@ -46,7 +47,7 @@ struct OutboundCmd {
reply: mpsc::SyncSender<Result<(), DeliveryError>>,
}
type SubscriberList = Arc<Mutex<Vec<mpsc::SyncSender<Vec<u8>>>>>;
type SubscriberList = Arc<Mutex<Vec<Sender<Vec<u8>>>>>;
// ── Config ───────────────────────────────────────────────────────────────────
@@ -123,18 +124,19 @@ pub struct Service {
outbound: mpsc::SyncSender<OutboundCmd>,
#[allow(dead_code)]
subscribers: SubscriberList,
inbound_rx: Option<Receiver<Vec<u8>>>,
}
impl Service {
/// Start the embedded logos-delivery node. Returns the service and a
/// receiver for inbound raw payloads.
pub fn start(cfg: Config) -> Result<(Self, mpsc::Receiver<Vec<u8>>), DeliveryError> {
/// Start the embedded logos-delivery node. The client drains inbound
/// payloads via [`Transport::inbound`].
pub fn start(cfg: Config) -> Result<Self, DeliveryError> {
let (out_tx, out_rx) = mpsc::sync_channel::<OutboundCmd>(256);
let subscribers: SubscriberList = Arc::new(Mutex::new(Vec::new()));
let (ready_tx, ready_rx) = mpsc::channel::<Result<(), DeliveryError>>();
// Create the inbound channel before spawning so the receiver is
// 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();
@@ -167,20 +169,18 @@ impl Service {
return Err(e);
}
Ok((
Self {
outbound: out_tx,
subscribers,
},
inbound_rx,
))
Ok(Self {
outbound: out_tx,
subscribers,
inbound_rx: Some(inbound_rx),
})
}
fn node_thread(
cfg: Config,
out_rx: mpsc::Receiver<OutboundCmd>,
subscribers: SubscriberList,
inbound_tx: mpsc::SyncSender<Vec<u8>>,
inbound_tx: Sender<Vec<u8>>,
ready_tx: mpsc::Sender<Result<(), DeliveryError>>,
) {
// 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()) {
Ok(()) => true,
Err(mpsc::TrySendError::Full(_)) => true,
Err(mpsc::TrySendError::Disconnected(_)) => false,
Err(crossbeam_channel::TrySendError::Full(_)) => true,
Err(crossbeam_channel::TrySendError::Disconnected(_)) => false,
});
}
};
@@ -306,3 +306,11 @@ impl DeliveryService for Service {
Ok(())
}
}
impl Transport for Service {
fn inbound(&mut self) -> Receiver<Vec<u8>> {
self.inbound_rx
.take()
.expect("Service::inbound called more than once")
}
}
+9 -6
View File
@@ -38,7 +38,7 @@ pub fn restore() -> io::Result<()> {
}
/// 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,
app: &ChatApp<D, R>,
) {
@@ -58,7 +58,7 @@ pub fn draw<D: DeliveryService + 'static, R: RegistrationService + 'static>(
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,
app: &ChatApp<D, R>,
area: Rect,
@@ -85,7 +85,7 @@ fn draw_header<D: DeliveryService + 'static, R: RegistrationService + 'static>(
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,
app: &ChatApp<D, R>,
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);
}
fn draw_input<D: DeliveryService + 'static, R: RegistrationService + 'static>(
fn draw_input<D: DeliveryService + Send + 'static, R: RegistrationService + Send + 'static>(
frame: &mut Frame,
app: &ChatApp<D, R>,
area: Rect,
@@ -206,7 +206,7 @@ fn draw_input<D: DeliveryService + 'static, R: RegistrationService + 'static>(
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,
app: &ChatApp<D, R>,
area: Rect,
@@ -220,7 +220,10 @@ fn draw_status<D: DeliveryService + 'static, R: RegistrationService + 'static>(
}
/// 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>,
) -> io::Result<bool> {
// Poll for events with a short timeout to allow checking incoming messages