From 08e9b4f4e3a2ca0d9b9bedb6604ed48ecbd98805 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:58:14 -0700 Subject: [PATCH 1/5] Isolate logos-delivery --- Cargo.toml | 2 + extensions/embedded-logos-delivery/Cargo.toml | 2 +- extensions/embedded-logos-delivery/src/lib.rs | 322 +++------------- extensions/logos-delivery-rust/Cargo.toml | 18 + extensions/logos-delivery-rust/build.rs | 160 ++++++++ extensions/logos-delivery-rust/src/lib.rs | 7 + .../src/sys.rs | 0 .../logos-delivery-rust/src/threaded.rs | 360 ++++++++++++++++++ .../src/wrapper.rs | 34 ++ 9 files changed, 626 insertions(+), 279 deletions(-) create mode 100644 extensions/logos-delivery-rust/Cargo.toml create mode 100644 extensions/logos-delivery-rust/build.rs create mode 100644 extensions/logos-delivery-rust/src/lib.rs rename extensions/{embedded-logos-delivery => logos-delivery-rust}/src/sys.rs (100%) create mode 100644 extensions/logos-delivery-rust/src/threaded.rs rename extensions/{embedded-logos-delivery => logos-delivery-rust}/src/wrapper.rs (88%) diff --git a/Cargo.toml b/Cargo.toml index 598388bc..1efb2792 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/generic-chat", "extensions/components", "extensions/embedded-logos-delivery", + "extensions/logos-delivery-rust", ] default-members = [ @@ -39,6 +40,7 @@ crypto = { path = "core/crypto" } embedded-logos-delivery = { path = "extensions/embedded-logos-delivery" } libchat = { path = "core/conversations" } logos-chat = { path = "crates/logos-chat" } +logos-delivery = { path = "extensions/logos-delivery-rust"} logos-generic-chat = { path = "crates/generic-chat" } shared-traits = { path = "core/shared-traits" } storage = { path = "core/storage" } diff --git a/extensions/embedded-logos-delivery/Cargo.toml b/extensions/embedded-logos-delivery/Cargo.toml index 466f5fac..65a9c716 100644 --- a/extensions/embedded-logos-delivery/Cargo.toml +++ b/extensions/embedded-logos-delivery/Cargo.toml @@ -2,12 +2,12 @@ name = "embedded-logos-delivery" version = "0.1.0" edition = "2024" -links = "logosdelivery" [dependencies] # Workspace dependencies (sorted) crossbeam-channel = { workspace = true } libchat = { workspace = true } +logos-delivery = { workspace = true } logos-generic-chat = { workspace = true } # External dependencies (sorted) diff --git a/extensions/embedded-logos-delivery/src/lib.rs b/extensions/embedded-logos-delivery/src/lib.rs index 56cb8a5d..e21d6628 100644 --- a/extensions/embedded-logos-delivery/src/lib.rs +++ b/extensions/embedded-logos-delivery/src/lib.rs @@ -1,59 +1,27 @@ //! The embedded logos-delivery transport service. //! -//! [`EmbeddedLogosDelivery`] implements [`DeliveryService`] by wrapping an -//! embedded logos-delivery node running on a dedicated `std::thread`. All -//! interaction is via synchronous `std::sync::mpsc` channels. +//! [`EmbeddedLogosDelivery`] implements [`DeliveryService`] over the embedded +//! node owned by [`ThreadedDeliveryWrapper`]. The wrapper handles the node +//! thread and hands back raw [`WakuEvent`]s; this crate supplies the +//! delivery-specific mapping — content topics, `/logos-chat/1/…` filtering, and +//! payload decoding. //! -//! This crate links the native `liblogosdelivery` library, so it lives outside -//! the workspace's default members; depend on it (e.g. via the `logos-chat` -//! crate) only when shipping the embedded node. +//! The native node is linked transitively via the `logos-delivery-rust` crate, +//! so this crate lives outside the workspace's default members; depend on it +//! (e.g. via the `logos-chat` crate) only when shipping the embedded node. //! //! ## Content topic mapping //! //! `AddressedEnvelope::delivery_address` maps to logos-delivery content topic //! `/logos-chat/1/{delivery_address}/proto`. -pub(crate) mod sys; -pub(crate) mod wrapper; - -use std::sync::{Arc, Mutex, mpsc}; -use std::thread; -use std::time::Duration; - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64; -use crossbeam_channel::{Receiver, Sender}; +use crossbeam_channel::Receiver; use libchat::{AddressedEnvelope, DeliveryService}; -use tracing::{error, info, warn}; - -use wrapper::LogosNodeCtx; - -pub fn content_topic_for(delivery_address: &str) -> String { - format!("/logos-chat/1/{delivery_address}/proto") -} - -// ── Error ──────────────────────────────────────────────────────────────────── - -#[derive(Debug, thiserror::Error)] -pub enum DeliveryError { - #[error("node startup failed: {0}")] - StartupFailed(String), - #[error("publish failed: {0}")] - PublishFailed(String), - #[error("send channel closed")] - ChannelClosed, -} - -// ── Internals ──────────────────────────────────────────────────────────────── - -struct OutboundCmd { - message_json: String, - reply: mpsc::SyncSender>, -} -type SubscriberList = Arc>>>>; +use logos_delivery::{ThreadedDeliveryWrapper, WakuEvent}; -// ── P2pConfig ─────────────────────────────────────────────────────────────────── +pub use logos_delivery::{DeliveryError, P2pConfig}; +use tracing::debug; /// The logos-delivery network preset joined by default. pub const DEFAULT_NETWORK_PRESET: &str = "logos.dev"; @@ -61,69 +29,12 @@ pub const DEFAULT_NETWORK_PRESET: &str = "logos.dev"; /// Default TCP port for the embedded logos-delivery node. pub const DEFAULT_TCP_PORT: u16 = 60000; -#[derive(Debug, Clone)] -pub struct P2pConfig { - pub preset: String, - pub tcp_port: u16, - pub log_level: String, -} - -impl Default for P2pConfig { - fn default() -> Self { - Self { - preset: DEFAULT_NETWORK_PRESET.into(), - tcp_port: DEFAULT_TCP_PORT, - log_level: "ERROR".into(), - } - } -} - -// ── Wire types ────────────────────────────────────────────────────────────── - -/// Outbound message sent to the logos-delivery node. -#[derive(Debug, serde::Serialize, serde::Deserialize)] -struct WakuMessage { - #[serde(rename = "contentTopic")] - content_topic: String, - /// Base64-encoded payload. - payload: String, - ephemeral: bool, -} - -/// Top-level event envelope received from the logos-delivery node callback. -#[derive(Debug, serde::Deserialize)] -struct WakuEvent { - #[serde(rename = "eventType")] - event_type: String, - message: Option, -} - -/// Message payload from a `message_received` event. -#[derive(Debug, serde::Deserialize)] -struct ReceivedMessage { - #[serde(rename = "contentTopic")] - content_topic: String, - /// The node may deliver the payload as either a base64 string or a JSON - /// array of byte values. - payload: WakuPayload, -} - -/// Untagged union that handles both payload representations. -#[derive(Debug, serde::Deserialize)] -#[serde(untagged)] -enum WakuPayload { - Base64(String), - Bytes(Vec), +pub fn content_topic_for(delivery_address: &str) -> String { + format!("/logos-chat/1/{delivery_address}/proto") } -impl WakuPayload { - fn decode(self) -> Option> { - match self { - WakuPayload::Base64(s) => BASE64.decode(s).ok(), - WakuPayload::Bytes(b) => Some(b), - } - } -} +/// The content-topic prefix carrying logos-chat traffic. +const CHAT_TOPIC_PREFIX: &str = "/logos-chat/1/"; // ── EmbeddedLogosDelivery ────────────────────────────────────────────────── @@ -131,166 +42,28 @@ impl WakuPayload { /// the same background node. #[derive(Clone, Debug)] pub struct EmbeddedLogosDelivery { - outbound: mpsc::SyncSender, - #[allow(dead_code)] - subscribers: SubscriberList, - inbound_rx: Option>>, + inner: ThreadedDeliveryWrapper>, } impl EmbeddedLogosDelivery { - /// Start the embedded logos-delivery node. The client drains inbound - /// payloads via [`Self::inbound_queue`]. + /// Start the embedded logos-delivery node. Only chat payloads (on a + /// `/logos-chat/1/…` content topic) are kept on the inbound queue, decoded + /// to raw bytes. pub fn start(cfg: P2pConfig) -> Result { - let (out_tx, out_rx) = mpsc::sync_channel::(256); - let subscribers: SubscriberList = Arc::new(Mutex::new(Vec::new())); - let (ready_tx, ready_rx) = mpsc::channel::>(); - // Create the inbound channel before spawning so the receiver is - // registered inside the thread, before any event callback fires. - let (inbound_tx, inbound_rx) = crossbeam_channel::bounded::>(1024); - - let subs_for_thread = subscribers.clone(); - - let handle = thread::Builder::new() - .name("logos-node".into()) - .spawn(move || { - if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - Self::node_thread(cfg, out_rx, subs_for_thread, inbound_tx, ready_tx); - })) { - let msg = panic - .downcast_ref::<&str>() - .map(|s| s.to_string()) - .or_else(|| panic.downcast_ref::().cloned()) - .unwrap_or_else(|| "unknown panic".into()); - error!("logos-node thread panicked: {msg}"); - } - }) - .map_err(|e| DeliveryError::StartupFailed(e.to_string()))?; - - // On failure, the node thread drops LogosNodeCtx (stop+destroy against - // a half-initialized Nim node). Join it so the process doesn't begin - // teardown mid-destroy — that race SIGSEGVs inside the Nim async loop. - let ready = ready_rx.recv().unwrap_or_else(|_| { - Err(DeliveryError::StartupFailed( - "node thread exited before ready".into(), - )) - }); - if let Err(e) = ready { - let _ = handle.join(); - return Err(e); - } - - Ok(Self { - outbound: out_tx, - subscribers, - inbound_rx: Some(inbound_rx), - }) - } - - fn node_thread( - cfg: P2pConfig, - out_rx: mpsc::Receiver, - subscribers: SubscriberList, - inbound_tx: Sender>, - ready_tx: mpsc::Sender>, - ) { - // discv5UdpPort defaults to 9000 in libwaku, so a second instance with - // a distinct --port still collides on UDP. Bind it to tcp_port so a - // single --port knob keeps both ports distinct across instances. - let config_json = serde_json::json!({ - "logLevel": cfg.log_level, - "mode": "Core", - "preset": cfg.preset, - "tcpPort": cfg.tcp_port, - "discv5UdpPort": cfg.tcp_port, - }) - .to_string(); - - let mut node = match LogosNodeCtx::new(&config_json) { - Ok(n) => n, - Err(e) => { - let _ = ready_tx.send(Err(DeliveryError::StartupFailed(e))); - return; - } - }; - - // Register the inbound sender before installing the event callback so - // there is no window where the callback is live but the channel is not - // yet in the subscriber list. - subscribers.lock().unwrap().push(inbound_tx); - - let subs_for_cb = subscribers.clone(); - let event_closure = move |_ret: i32, data: &str| { - if let Some(payload) = Self::parse_message_received(data) { - let mut guard = match subs_for_cb.lock() { - Ok(g) => g, - Err(e) => { - error!("subscriber mutex poisoned: {e}"); - return; - } - }; - guard.retain(|tx| match tx.try_send(payload.clone()) { - Ok(()) => true, - Err(crossbeam_channel::TrySendError::Full(_)) => true, - Err(crossbeam_channel::TrySendError::Disconnected(_)) => false, - }); + let inner = ThreadedDeliveryWrapper::start(cfg, |event: WakuEvent| { + let msg = event.into_received()?; + if !msg.content_topic().starts_with(CHAT_TOPIC_PREFIX) { + return None; } - }; - node.set_event_callback(event_closure); + msg.into_payload() + })?; - if let Err(e) = node.start() { - let _ = ready_tx.send(Err(DeliveryError::StartupFailed(e))); - return; - } - info!("logos-delivery node started (preset={})", cfg.preset); - - // FIXME: This unconditional sleep is a stand-in for proper - // peer-connectivity detection. The right approach is to listen for a - // `peer_connected` (or equivalent status-change) event from the node - // callback and only proceed once at least one peer is reachable, - // falling back to a configurable timeout. logos-delivery would need to - // surface such an event via its callback mechanism for this to work. - thread::sleep(Duration::from_secs(3)); - - let default_topic = content_topic_for("delivery_address"); - if let Err(e) = node.subscribe(&default_topic) { - warn!("subscribe to {default_topic}: {e}"); - } else { - info!("subscribed to {default_topic}"); - } - - let _ = ready_tx.send(Ok(())); - - while let Ok(cmd) = out_rx.recv() { - let result = node - .send(&cmd.message_json) - .map(|_| ()) - .map_err(DeliveryError::PublishFailed); - let _ = cmd.reply.try_send(result); - } - - info!("logos-node outbound loop finished"); + Ok(Self { inner }) } - fn parse_message_received(data: &str) -> Option> { - let event: WakuEvent = serde_json::from_str(data).ok()?; - - if event.event_type != "message_received" { - return None; - } - - let msg = event.message?; - - if !msg.content_topic.starts_with("/logos-chat/1/") { - return None; - } - - msg.payload.decode() - } - - pub fn inbound_queue(&mut self) -> Receiver> { - self.inbound_rx - .take() - .expect("inbound_queue called more than once") + /// Stop delivering messages addressed to `delivery_address`. + pub fn unsubscribe(&self, delivery_address: &str) -> Result<(), DeliveryError> { + self.inner.unsubscribe(&content_topic_for(delivery_address)) } } @@ -298,28 +71,21 @@ impl DeliveryService for EmbeddedLogosDelivery { type Error = DeliveryError; fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), DeliveryError> { - let msg = WakuMessage { - content_topic: content_topic_for(&envelope.delivery_address), - payload: BASE64.encode(&envelope.data), - ephemeral: false, - }; - let message_json = - serde_json::to_string(&msg).map_err(|e| DeliveryError::PublishFailed(e.to_string()))?; - - let (reply_tx, reply_rx) = mpsc::sync_channel(1); - self.outbound - .send(OutboundCmd { - message_json, - reply: reply_tx, - }) - .map_err(|_| DeliveryError::ChannelClosed)?; - - reply_rx.recv().map_err(|_| DeliveryError::ChannelClosed)? + debug!( + topic = &content_topic_for(&envelope.delivery_address), + "Publish" + ); + self.inner.publish( + &content_topic_for(&envelope.delivery_address), + &envelope.data, + ) } - fn subscribe(&mut self, _: &str) -> Result<(), ::Error> { - // This Service does not support filtering - Ok(()) + fn subscribe( + &mut self, + delivery_address: &str, + ) -> Result<(), ::Error> { + self.inner.subscribe(&content_topic_for(delivery_address)) } } @@ -329,6 +95,6 @@ impl DeliveryService for EmbeddedLogosDelivery { // owns neither the trait nor the type. impl logos_generic_chat::Transport for EmbeddedLogosDelivery { fn inbound(&mut self) -> Receiver> { - self.inbound_queue() + self.inner.inbound_queue() } } diff --git a/extensions/logos-delivery-rust/Cargo.toml b/extensions/logos-delivery-rust/Cargo.toml new file mode 100644 index 00000000..e9dc5468 --- /dev/null +++ b/extensions/logos-delivery-rust/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "logos-delivery" +version = "0.1.0" +edition = "2024" +links = "logosdelivery" + +[dependencies] +# Workspace dependencies (sorted) +crossbeam-channel = { workspace = true } +libchat = { workspace = true } +logos-generic-chat = { workspace = true } + +# External dependencies (sorted) +base64 = "0.22" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2" +tracing = "0.1" diff --git a/extensions/logos-delivery-rust/build.rs b/extensions/logos-delivery-rust/build.rs new file mode 100644 index 00000000..2773451d --- /dev/null +++ b/extensions/logos-delivery-rust/build.rs @@ -0,0 +1,160 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-env-changed=LOGOS_DELIVERY_LIB_DIR"); + + let Some(lib_dir) = locate_lib_dir() else { + println!( + "cargo:warning=liblogosdelivery could not be located; `cargo check`/\ + `clippy` will pass, but building or testing will fail at link. Enter \ + the dev shell with `nix develop` or set LOGOS_DELIVERY_LIB_DIR to \ + the directory containing the library." + ); + return; + }; + + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set"); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + + // The shipped library carries a relocatable install name (@rpath on macOS, + // $ORIGIN soname on Linux), which would force every downstream BINARY to + // inject its own RPATH. Cargo propagates `rustc-link-search` and + // `rustc-link-lib` across crates, but NOT `rustc-link-arg` (the rpath) — so + // that relocatable name is exactly what makes consumers need their own + // build.rs. Instead, stamp a private copy with an ABSOLUTE install name; + // the propagating search + lib directives are then sufficient and consumers + // need zero build-script glue. + match target_os.as_str() { + "macos" => stamp_absolute_macos(&lib_dir, &out_dir), + "linux" => stamp_absolute_linux(&lib_dir, &out_dir), + other => panic!("unsupported OS for logos-delivery transport: {other}"), + } + + println!("cargo:rustc-link-search=native={out_dir}"); + println!("cargo:rustc-link-lib=dylib=logosdelivery"); +} + +/// Locate the native library directory as an ABSOLUTE, canonical path. Prefers +/// `LOGOS_DELIVERY_LIB_DIR`, then falls back to building it via nix. Returns +/// `None` when neither is available (e.g. `cargo check` without nix). +fn locate_lib_dir() -> Option { + if let Ok(dir) = std::env::var("LOGOS_DELIVERY_LIB_DIR") { + if let Some(resolved) = resolve_lib_dir(&dir) { + return Some(resolved); + } + println!( + "cargo:warning=LOGOS_DELIVERY_LIB_DIR='{dir}' could not be resolved; \ + falling back to `nix build`" + ); + } + resolve_lib_dir(&nix_build_logos_delivery()?) +} + +/// Resolve a lib dir to an absolute, canonical path. Cargo runs build scripts +/// with the cwd set to the crate dir, but a relative value (e.g. CI's +/// `./result/lib`) is anchored at the flake/workspace root where `nix build` +/// drops `result`. Canonicalizing also follows the `result` symlink to the +/// immutable store path, so the stamped install name / soname stays stable. +fn resolve_lib_dir(dir: &str) -> Option { + let path = Path::new(dir); + let anchored = if path.is_absolute() { + path.to_path_buf() + } else { + let manifest = std::env::var("CARGO_MANIFEST_DIR").ok()?; + Path::new(&find_flake_root(&manifest)?).join(path) + }; + anchored.canonicalize().ok() +} + +/// Copy `liblogosdelivery.dylib` into `OUT_DIR` and rewrite its install name to +/// the absolute store path. The consumer records that absolute path, so dyld +/// loads the original file directly — whose own `@loader_path` RPATH resolves +/// `librln.dylib` beside it — with no RPATH needed on the consumer. +fn stamp_absolute_macos(lib_dir: &Path, out_dir: &str) { + let src = lib_dir.join("liblogosdelivery.dylib"); + let dst = format!("{out_dir}/liblogosdelivery.dylib"); + copy_writable(&src, Path::new(&dst)); + run("install_name_tool", &["-id", path_str(&src), &dst]); + println!("cargo:rerun-if-changed={}", src.display()); +} + +/// Linux equivalent: an absolute `DT_SONAME` is recorded verbatim in the +/// consumer's `DT_NEEDED`, so `ld.so` loads it by path with no RPATH. Requires +/// `patchelf` at build time (provided by the nix devshell). +fn stamp_absolute_linux(lib_dir: &Path, out_dir: &str) { + let src = lib_dir.join("liblogosdelivery.so"); + let dst = format!("{out_dir}/liblogosdelivery.so"); + copy_writable(&src, Path::new(&dst)); + run("patchelf", &["--set-soname", path_str(&src), &dst]); + println!("cargo:rerun-if-changed={}", src.display()); +} + +fn path_str(p: &Path) -> &str { + p.to_str() + .unwrap_or_else(|| panic!("non-UTF-8 path: {}", p.display())) +} + +fn copy_writable(src: &Path, dst: &Path) { + use std::os::unix::fs::PermissionsExt; + + fs::copy(src, dst) + .unwrap_or_else(|e| panic!("copy {} -> {}: {e}", src.display(), dst.display())); + // Store-sourced files are read-only; restore owner write so the install + // name / soname can be rewritten. + fs::set_permissions(dst, fs::Permissions::from_mode(0o644)).unwrap(); +} + +fn run(cmd: &str, args: &[&str]) { + let status = Command::new(cmd) + .args(args) + .status() + .unwrap_or_else(|e| panic!("failed to run `{cmd}`: {e}")); + assert!(status.success(), "`{cmd} {args:?}` failed with {status}"); +} + +fn nix_build_logos_delivery() -> Option { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?; + let flake_root = find_flake_root(&manifest_dir)?; + + println!("cargo:rerun-if-changed={flake_root}/flake.lock"); + + let output = Command::new("nix") + .args([ + "build", + ".#logos-delivery", + "--no-link", + "--print-out-paths", + ]) + .current_dir(&flake_root) + .output() + .ok()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + println!("cargo:warning=nix build .#logos-delivery failed: {stderr}"); + return None; + } + + let store_path = String::from_utf8(output.stdout).ok()?; + let lib_dir = format!("{}/lib", store_path.trim()); + + if std::path::Path::new(&lib_dir).exists() { + Some(lib_dir) + } else { + None + } +} + +fn find_flake_root(start: &str) -> Option { + let mut path = std::path::PathBuf::from(start); + loop { + if path.join("flake.nix").exists() { + return Some(path.to_string_lossy().into_owned()); + } + if !path.pop() { + return None; + } + } +} diff --git a/extensions/logos-delivery-rust/src/lib.rs b/extensions/logos-delivery-rust/src/lib.rs new file mode 100644 index 00000000..d494ec66 --- /dev/null +++ b/extensions/logos-delivery-rust/src/lib.rs @@ -0,0 +1,7 @@ +mod sys; +mod threaded; +mod wrapper; + +pub use threaded::{ + DeliveryError, P2pConfig, ReceivedMessage, ThreadedDeliveryWrapper, WakuEvent, +}; diff --git a/extensions/embedded-logos-delivery/src/sys.rs b/extensions/logos-delivery-rust/src/sys.rs similarity index 100% rename from extensions/embedded-logos-delivery/src/sys.rs rename to extensions/logos-delivery-rust/src/sys.rs diff --git a/extensions/logos-delivery-rust/src/threaded.rs b/extensions/logos-delivery-rust/src/threaded.rs new file mode 100644 index 00000000..ce585195 --- /dev/null +++ b/extensions/logos-delivery-rust/src/threaded.rs @@ -0,0 +1,360 @@ +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; +use std::time::Duration; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use crossbeam_channel::{Receiver, Sender}; +use tracing::{error, info}; + +use crate::wrapper::LogosNodeCtx; + +// ── Error ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, thiserror::Error)] +pub enum DeliveryError { + #[error("node startup failed: {0}")] + StartupFailed(String), + #[error("publish failed: {0}")] + PublishFailed(String), + #[error("subscribe failed: {0}")] + SubscribeFailed(String), + #[error("unsubscribe failed: {0}")] + UnsubscribeFailed(String), + #[error("send channel closed")] + ChannelClosed, +} + +// ── Internals ──────────────────────────────────────────────────────────────── + +/// A node operation to run on the serialized node thread. +#[derive(Debug)] +enum NodeOp { + Publish(String), // message_json + Subscribe(String), // content_topic + Unsubscribe(String), // content_topic +} + +#[derive(Debug)] +struct NodeCmd { + op: NodeOp, + reply: mpsc::SyncSender>, +} + +type SubscriberList = Arc>>>; + +// ── P2pConfig ─────────────────────────────────────────────────────────────────── + +/// The logos-delivery network preset joined by default. +pub const DEFAULT_NETWORK_PRESET: &str = "logos.dev"; + +/// Default TCP port for the embedded logos-delivery node. +pub const DEFAULT_TCP_PORT: u16 = 60000; + +#[derive(Debug, Clone)] +pub struct P2pConfig { + pub preset: String, + pub tcp_port: u16, + pub log_level: String, +} + +impl Default for P2pConfig { + fn default() -> Self { + Self { + preset: DEFAULT_NETWORK_PRESET.into(), + tcp_port: DEFAULT_TCP_PORT, + log_level: "ERROR".into(), + } + } +} + +// ── Wire types ────────────────────────────────────────────────────────────── + +/// Outbound message sent to the logos-delivery node. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct WakuMessage { + #[serde(rename = "contentTopic")] + content_topic: String, + /// Base64-encoded payload. + payload: String, + ephemeral: bool, +} + +/// Top-level event envelope received from the logos-delivery node callback. +#[derive(Debug, serde::Deserialize, Clone)] +pub struct WakuEvent { + #[serde(rename = "eventType")] + event_type: String, + message: Option, +} + +impl WakuEvent { + /// The received message iff this is a `message_received` event. + pub fn into_received(self) -> Option { + (self.event_type == "message_received") + .then_some(self.message) + .flatten() + } +} + +/// Message payload from a `message_received` event. +#[derive(Debug, serde::Deserialize, Clone)] +pub struct ReceivedMessage { + #[serde(rename = "contentTopic")] + content_topic: String, + /// The node may deliver the payload as either a base64 string or a JSON + /// array of byte values. + payload: WakuPayload, +} + +impl ReceivedMessage { + pub fn content_topic(&self) -> &str { + &self.content_topic + } + + /// Decode the payload to raw bytes, whichever wire form the node used. + pub fn into_payload(self) -> Option> { + self.payload.decode() + } +} + +/// Untagged union that handles both payload representations. +#[derive(Debug, serde::Deserialize, Clone)] +#[serde(untagged)] +enum WakuPayload { + Base64(String), + Bytes(Vec), +} + +impl WakuPayload { + fn decode(self) -> Option> { + match self { + WakuPayload::Base64(s) => BASE64.decode(s).ok(), + WakuPayload::Bytes(b) => Some(b), + } + } +} + +// ── ThreadedDeliveryWrapper ───────────────────────────────────────────────── + +/// Owns the embedded node on a dedicated thread. Generic over the inbound item +/// type `T`: a caller-supplied mapper turns each raw [`WakuEvent`] into an +/// `Option` on the callback thread, so filtering and decoding happen inline +/// with no relay thread. Cheap to clone — all clones share the same node. +pub struct ThreadedDeliveryWrapper { + outbound: mpsc::SyncSender, + #[allow(dead_code)] + subscribers: SubscriberList, + inbound_rx: Option>, +} + +// Manual impls so `T` carries no `Clone`/`Debug` bound at the struct level — +// `Sender`/`Receiver` are `Clone` for every `T`. +impl Clone for ThreadedDeliveryWrapper { + fn clone(&self) -> Self { + Self { + outbound: self.outbound.clone(), + subscribers: self.subscribers.clone(), + inbound_rx: self.inbound_rx.clone(), + } + } +} + +impl std::fmt::Debug for ThreadedDeliveryWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ThreadedDeliveryWrapper") + .field("has_inbound", &self.inbound_rx.is_some()) + .finish_non_exhaustive() + } +} + +impl ThreadedDeliveryWrapper { + /// Start the embedded logos-delivery node. `map` runs on the node's event + /// callback for every received event; return `Some(item)` to enqueue it for + /// [`Self::inbound_queue`], or `None` to drop it. It must be non-blocking. + pub fn start(cfg: P2pConfig, map: F) -> Result + where + T: Clone + Send + 'static, + F: FnMut(WakuEvent) -> Option + Send + 'static, + { + let (out_tx, out_rx) = mpsc::sync_channel::(256); + let subscribers: SubscriberList = Arc::new(Mutex::new(Vec::new())); + let (ready_tx, ready_rx) = mpsc::channel::>(); + // Create the inbound channel before spawning so the receiver is + // registered inside the thread, before any event callback fires. + let (inbound_tx, inbound_rx) = crossbeam_channel::bounded::(1024); + + let subs_for_thread = subscribers.clone(); + + let handle = thread::Builder::new() + .name("logos-node".into()) + .spawn(move || { + if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + Self::node_thread(cfg, out_rx, subs_for_thread, inbound_tx, ready_tx, map); + })) { + let msg = panic + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| panic.downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic".into()); + error!("logos-node thread panicked: {msg}"); + } + }) + .map_err(|e| DeliveryError::StartupFailed(e.to_string()))?; + + // On failure, the node thread drops LogosNodeCtx (stop+destroy against + // a half-initialized Nim node). Join it so the process doesn't begin + // teardown mid-destroy — that race SIGSEGVs inside the Nim async loop. + let ready = ready_rx.recv().unwrap_or_else(|_| { + Err(DeliveryError::StartupFailed( + "node thread exited before ready".into(), + )) + }); + if let Err(e) = ready { + let _ = handle.join(); + return Err(e); + } + + Ok(Self { + outbound: out_tx, + subscribers, + inbound_rx: Some(inbound_rx), + }) + } + + /// Queue `op` on the node thread and block until it acknowledges. + fn send_cmd(&self, op: NodeOp) -> Result<(), DeliveryError> { + let (reply_tx, reply_rx) = mpsc::sync_channel(1); + self.outbound + .send(NodeCmd { + op, + reply: reply_tx, + }) + .map_err(|_| DeliveryError::ChannelClosed)?; + reply_rx.recv().map_err(|_| DeliveryError::ChannelClosed)? + } + + /// Start delivering messages on `content_topic`. Blocks until acknowledged. + pub fn subscribe(&self, content_topic: &str) -> Result<(), DeliveryError> { + self.send_cmd(NodeOp::Subscribe(content_topic.to_string())) + } + + /// Stop delivering messages on `content_topic`. Blocks until acknowledged. + pub fn unsubscribe(&self, content_topic: &str) -> Result<(), DeliveryError> { + self.send_cmd(NodeOp::Unsubscribe(content_topic.to_string())) + } + + /// Publish `payload` on `content_topic`. Blocks until the node acknowledges. + pub fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), DeliveryError> { + let msg = WakuMessage { + content_topic: content_topic.to_string(), + payload: BASE64.encode(payload), + ephemeral: false, + }; + let message_json = + serde_json::to_string(&msg).map_err(|e| DeliveryError::PublishFailed(e.to_string()))?; + self.send_cmd(NodeOp::Publish(message_json)) + } + + /// Take the inbound queue of mapped items. Callable once. + pub fn inbound_queue(&mut self) -> Receiver { + self.inbound_rx + .take() + .expect("inbound_queue called more than once") + } + + fn node_thread( + cfg: P2pConfig, + out_rx: mpsc::Receiver, + subscribers: SubscriberList, + inbound_tx: Sender, + ready_tx: mpsc::Sender>, + mut map: F, + ) where + T: Clone + Send + 'static, + F: FnMut(WakuEvent) -> Option + Send + 'static, + { + // discv5UdpPort defaults to 9000 in libwaku, so a second instance with + // a distinct --port still collides on UDP. Bind it to tcp_port so a + // single --port knob keeps both ports distinct across instances. + let config_json = serde_json::json!({ + "logLevel": cfg.log_level, + "mode": "Core", + "preset": cfg.preset, + "tcpPort": cfg.tcp_port, + "discv5UdpPort": cfg.tcp_port, + }) + .to_string(); + + let mut node = match LogosNodeCtx::new(&config_json) { + Ok(n) => n, + Err(e) => { + let _ = ready_tx.send(Err(DeliveryError::StartupFailed(e))); + return; + } + }; + + // Register the inbound sender before installing the event callback so + // there is no window where the callback is live but the channel is not + // yet in the subscriber list. + subscribers.lock().unwrap().push(inbound_tx); + + let subs_for_cb = subscribers.clone(); + let event_closure = move |_ret: i32, data: &str| { + let Ok(event) = serde_json::from_str::(data) else { + return; + }; + let Some(item) = map(event) else { + return; + }; + let mut guard = match subs_for_cb.lock() { + Ok(g) => g, + Err(e) => { + error!("subscriber mutex poisoned: {e}"); + return; + } + }; + guard.retain(|tx| match tx.try_send(item.clone()) { + Ok(()) => true, + Err(crossbeam_channel::TrySendError::Full(_)) => true, + Err(crossbeam_channel::TrySendError::Disconnected(_)) => false, + }); + }; + node.set_event_callback(event_closure); + + if let Err(e) = node.start() { + let _ = ready_tx.send(Err(DeliveryError::StartupFailed(e))); + return; + } + info!("logos-delivery node started (preset={})", cfg.preset); + + // FIXME: This unconditional sleep is a stand-in for proper + // peer-connectivity detection. The right approach is to listen for a + // `peer_connected` (or equivalent status-change) event from the node + // callback and only proceed once at least one peer is reachable, + // falling back to a configurable timeout. logos-delivery would need to + // surface such an event via its callback mechanism for this to work. + thread::sleep(Duration::from_secs(3)); + + let _ = ready_tx.send(Ok(())); + + while let Ok(cmd) = out_rx.recv() { + info!(">>>>> {:?} ", cmd); + let result = match cmd.op { + NodeOp::Publish(msg) => node + .send(&msg) + .map(|_| ()) + .map_err(DeliveryError::PublishFailed), + NodeOp::Subscribe(topic) => node + .subscribe(&topic) + .map_err(DeliveryError::SubscribeFailed), + NodeOp::Unsubscribe(topic) => node + .unsubscribe(&topic) + .map_err(DeliveryError::UnsubscribeFailed), + }; + let _ = cmd.reply.try_send(result); + } + + info!("logos-node command loop finished"); + } +} diff --git a/extensions/embedded-logos-delivery/src/wrapper.rs b/extensions/logos-delivery-rust/src/wrapper.rs similarity index 88% rename from extensions/embedded-logos-delivery/src/wrapper.rs rename to extensions/logos-delivery-rust/src/wrapper.rs index 04296a78..2af56ea4 100644 --- a/extensions/embedded-logos-delivery/src/wrapper.rs +++ b/extensions/logos-delivery-rust/src/wrapper.rs @@ -30,6 +30,8 @@ use std::ffi::CString; use std::os::raw::c_void; use std::sync::mpsc; +use tracing::info; + use super::sys::{self as ffi, RET_OK, get_trampoline}; /// Opaque handle to a logos-delivery node context. @@ -122,10 +124,42 @@ impl LogosNodeCtx { ffi::logosdelivery_subscribe(self.ctx, cb, raw as *const c_void, topic_cstr.as_ptr()) }; + info!("KLA 1"); if ret != RET_OK { drop(unsafe { Box::from_raw(raw) }); return Err(format!("logosdelivery_subscribe returned {ret}")); } + + info!("KLA 2"); + let result = rx + .recv() + .unwrap_or(Err("callback channel disconnected".into())); + drop(unsafe { Box::from_raw(raw) }); + result + } + + pub fn unsubscribe(&self, content_topic: &str) -> Result<(), String> { + let topic_cstr = CString::new(content_topic).map_err(|e| e.to_string())?; + + let (tx, rx) = mpsc::sync_channel::>(1); + let closure = move |ret: i32, data: &str| { + let _ = tx.send(if ret == RET_OK { + Ok(()) + } else { + Err(data.to_string()) + }); + }; + let raw = Box::into_raw(Box::new(closure)); + let cb = get_trampoline(unsafe { &*raw }); + + let ret = unsafe { + ffi::logosdelivery_unsubscribe(self.ctx, cb, raw as *const c_void, topic_cstr.as_ptr()) + }; + + if ret != RET_OK { + drop(unsafe { Box::from_raw(raw) }); + return Err(format!("logosdelivery_unsubscribe returned {ret}")); + } let result = rx .recv() .unwrap_or(Err("callback channel disconnected".into())); From fdad104129208569542aa7092570fbd14571f5be Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:05:03 -0700 Subject: [PATCH 2/5] Update cargo.toml + fixups --- Cargo.lock | 15 +++++++++++++++ extensions/logos-delivery-rust/src/lib.rs | 4 +--- extensions/logos-delivery-rust/src/wrapper.rs | 2 -- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c4f9e755..280e23cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2079,6 +2079,7 @@ dependencies = [ "base64", "crossbeam-channel", "libchat", + "logos-delivery", "logos-generic-chat", "serde", "serde_json", @@ -3622,6 +3623,20 @@ dependencies = [ "logos-generic-chat", ] +[[package]] +name = "logos-delivery" +version = "0.1.0" +dependencies = [ + "base64", + "crossbeam-channel", + "libchat", + "logos-generic-chat", + "serde", + "serde_json", + "thiserror", + "tracing", +] + [[package]] name = "logos-generic-chat" version = "0.1.0" diff --git a/extensions/logos-delivery-rust/src/lib.rs b/extensions/logos-delivery-rust/src/lib.rs index d494ec66..c38d7dcb 100644 --- a/extensions/logos-delivery-rust/src/lib.rs +++ b/extensions/logos-delivery-rust/src/lib.rs @@ -2,6 +2,4 @@ mod sys; mod threaded; mod wrapper; -pub use threaded::{ - DeliveryError, P2pConfig, ReceivedMessage, ThreadedDeliveryWrapper, WakuEvent, -}; +pub use threaded::{DeliveryError, P2pConfig, ReceivedMessage, ThreadedDeliveryWrapper, WakuEvent}; diff --git a/extensions/logos-delivery-rust/src/wrapper.rs b/extensions/logos-delivery-rust/src/wrapper.rs index 2af56ea4..4729d390 100644 --- a/extensions/logos-delivery-rust/src/wrapper.rs +++ b/extensions/logos-delivery-rust/src/wrapper.rs @@ -124,13 +124,11 @@ impl LogosNodeCtx { ffi::logosdelivery_subscribe(self.ctx, cb, raw as *const c_void, topic_cstr.as_ptr()) }; - info!("KLA 1"); if ret != RET_OK { drop(unsafe { Box::from_raw(raw) }); return Err(format!("logosdelivery_subscribe returned {ret}")); } - info!("KLA 2"); let result = rx .recv() .unwrap_or(Err("callback channel disconnected".into())); From a6051a57e595d14b2ed4f9c038185fb97c678f56 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:21:25 -0700 Subject: [PATCH 3/5] clippy fixes --- extensions/logos-delivery-rust/src/wrapper.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/extensions/logos-delivery-rust/src/wrapper.rs b/extensions/logos-delivery-rust/src/wrapper.rs index 4729d390..66a82f2f 100644 --- a/extensions/logos-delivery-rust/src/wrapper.rs +++ b/extensions/logos-delivery-rust/src/wrapper.rs @@ -30,8 +30,6 @@ use std::ffi::CString; use std::os::raw::c_void; use std::sync::mpsc; -use tracing::info; - use super::sys::{self as ffi, RET_OK, get_trampoline}; /// Opaque handle to a logos-delivery node context. From a584ba5180b94d675ba732ac65b9aba341db3915 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:19:41 -0700 Subject: [PATCH 4/5] fix: topic --- extensions/embedded-logos-delivery/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/embedded-logos-delivery/src/lib.rs b/extensions/embedded-logos-delivery/src/lib.rs index e21d6628..a9c83cb1 100644 --- a/extensions/embedded-logos-delivery/src/lib.rs +++ b/extensions/embedded-logos-delivery/src/lib.rs @@ -29,13 +29,13 @@ pub const DEFAULT_NETWORK_PRESET: &str = "logos.dev"; /// Default TCP port for the embedded logos-delivery node. pub const DEFAULT_TCP_PORT: u16 = 60000; -pub fn content_topic_for(delivery_address: &str) -> String { - format!("/logos-chat/1/{delivery_address}/proto") -} - /// The content-topic prefix carrying logos-chat traffic. const CHAT_TOPIC_PREFIX: &str = "/logos-chat/1/"; +pub fn content_topic_for(delivery_address: &str) -> String { + format!("{CHAT_TOPIC_PREFIX}{delivery_address}/proto") +} + // ── EmbeddedLogosDelivery ────────────────────────────────────────────────── /// logos-delivery backed delivery service. Cheap to clone — all clones share From 2a4f8482c5895ee08f0f8edc7ae21800129564d2 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:20:07 -0700 Subject: [PATCH 5/5] remove tcp prefix from port --- bin/chat-cli/src/main.rs | 2 +- extensions/logos-delivery-rust/src/threaded.rs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bin/chat-cli/src/main.rs b/bin/chat-cli/src/main.rs index cd069f96..4a51208a 100644 --- a/bin/chat-cli/src/main.rs +++ b/bin/chat-cli/src/main.rs @@ -79,7 +79,7 @@ fn main() -> Result<()> { TransportKind::LogosDelivery => { let mut p2p_config = P2pConfig::default(); if let Some(port) = cli.port { - p2p_config.tcp_port = port; + p2p_config.port = port; } if let Some(preset) = cli.preset.as_deref() { p2p_config.preset = preset.to_string(); diff --git a/extensions/logos-delivery-rust/src/threaded.rs b/extensions/logos-delivery-rust/src/threaded.rs index ce585195..80bc2e97 100644 --- a/extensions/logos-delivery-rust/src/threaded.rs +++ b/extensions/logos-delivery-rust/src/threaded.rs @@ -49,12 +49,12 @@ type SubscriberList = Arc>>>; pub const DEFAULT_NETWORK_PRESET: &str = "logos.dev"; /// Default TCP port for the embedded logos-delivery node. -pub const DEFAULT_TCP_PORT: u16 = 60000; +pub const DEFAULT_PORT: u16 = 60000; #[derive(Debug, Clone)] pub struct P2pConfig { pub preset: String, - pub tcp_port: u16, + pub port: u16, pub log_level: String, } @@ -62,7 +62,7 @@ impl Default for P2pConfig { fn default() -> Self { Self { preset: DEFAULT_NETWORK_PRESET.into(), - tcp_port: DEFAULT_TCP_PORT, + port: DEFAULT_PORT, log_level: "ERROR".into(), } } @@ -281,8 +281,8 @@ impl ThreadedDeliveryWrapper { "logLevel": cfg.log_level, "mode": "Core", "preset": cfg.preset, - "tcpPort": cfg.tcp_port, - "discv5UdpPort": cfg.tcp_port, + "tcpPort": cfg.port, + "discv5UdpPort": cfg.port, }) .to_string();