Merge 0a6e833b53d451665d1079bbea397d96cbf8b0d9 into 8cddd9ddcfb446deeff96fd5a68d6e4b14927d9f

This commit is contained in:
osmaczko 2026-03-30 19:24:38 +00:00 committed by GitHub
commit d344d0a4f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1009 additions and 8 deletions

View File

@ -56,3 +56,21 @@ jobs:
working-directory: nim-bindings
- run: nimble pingpong
working-directory: nim-bindings
c-ffi-smoketest:
name: C FFI Smoketest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: rustup update stable && rustup default stable
- name: Install valgrind
run: sudo apt-get install -y valgrind
- name: Build C FFI example
run: make
working-directory: examples/c-ffi
- name: Run C FFI smoketest
run: ./c-client
working-directory: examples/c-ffi
- name: Run C FFI smoketest under valgrind
run: make valgrind
working-directory: examples/c-ffi

6
.gitignore vendored
View File

@ -31,3 +31,9 @@ target
tmp
.DS_Store
# Generated C headers (produced by `make` in examples/c-ffi; do not commit)
crates/client-ffi/client_ffi.h
# Compiled C FFI example binary
examples/c-ffi/c-client

17
Cargo.lock generated
View File

@ -124,6 +124,16 @@ name = "client"
version = "0.1.0"
dependencies = [
"libchat",
"tempfile",
]
[[package]]
name = "client-ffi"
version = "0.1.0"
dependencies = [
"client",
"libchat",
"safer-ffi",
]
[[package]]
@ -452,6 +462,13 @@ dependencies = [
"digest",
]
[[package]]
name = "in-process"
version = "0.1.0"
dependencies = [
"client",
]
[[package]]
name = "indexmap"
version = "2.13.0"

View File

@ -8,6 +8,8 @@ members = [
"core/double-ratchets",
"core/storage",
"crates/client",
"crates/client-ffi",
"examples/in-process",
]
[workspace.dependencies]

View File

@ -10,9 +10,11 @@ mod storage;
mod types;
mod utils;
pub use ::storage::StorageConfig;
pub use api::*;
pub use context::{Context, Introduction};
pub use context::{Context, ConversationIdOwned, Introduction};
pub use errors::ChatError;
pub use types::{AddressedEnvelope, ContentData};
#[cfg(test)]
mod tests {

View File

@ -0,0 +1,19 @@
[package]
name = "client-ffi"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["staticlib", "rlib"]
[[bin]]
name = "generate-headers"
required-features = ["headers"]
[dependencies]
safer-ffi = "0.1.13"
client = { path = "../client" }
libchat = { workspace = true }
[features]
headers = ["safer-ffi/headers"]

View File

@ -0,0 +1,285 @@
use safer_ffi::prelude::*;
use std::sync::Arc;
use crate::delivery::{CDelivery, DeliverFn};
use client::{ChatClient, ClientError};
// ---------------------------------------------------------------------------
// Opaque client handle
// ---------------------------------------------------------------------------
#[derive_ReprC]
#[repr(opaque)]
pub struct ClientHandle(pub(crate) ChatClient<CDelivery>);
// ---------------------------------------------------------------------------
// Error codes
// ---------------------------------------------------------------------------
#[derive_ReprC]
#[repr(i32)]
pub enum ErrorCode {
None = 0,
BadUtf8 = -1,
BadIntro = -2,
DeliveryFail = -3,
UnknownError = -4,
}
// ---------------------------------------------------------------------------
// Result types (opaque, heap-allocated via repr_c::Box)
// ---------------------------------------------------------------------------
#[derive_ReprC]
#[repr(opaque)]
pub struct CreateIntroResult {
error_code: i32,
data: Option<Vec<u8>>,
}
#[derive_ReprC]
#[repr(opaque)]
pub struct CreateConvoResult {
error_code: i32,
convo_id: Option<String>,
}
#[derive_ReprC]
#[repr(opaque)]
pub struct PushInboundResult {
error_code: i32,
has_content: bool,
is_new_convo: bool,
convo_id: Option<String>,
content: Option<Vec<u8>>,
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
/// Create an ephemeral in-memory client. Returns NULL if `callback` is None or
/// `name` is not valid UTF-8. Free with `client_destroy`.
#[ffi_export]
fn client_create(
name: c_slice::Ref<'_, u8>,
callback: DeliverFn,
) -> Option<repr_c::Box<ClientHandle>> {
let name_str = match std::str::from_utf8(name.as_slice()) {
Ok(s) => s,
Err(_) => return None,
};
callback?;
let delivery = CDelivery { callback };
Some(Box::new(ClientHandle(ChatClient::new(name_str, delivery))).into())
}
/// Free a client handle. Must not be used after this call.
#[ffi_export]
fn client_destroy(handle: repr_c::Box<ClientHandle>) {
drop(handle)
}
// ---------------------------------------------------------------------------
// Identity
// ---------------------------------------------------------------------------
/// Return the installation name as an owned byte slice.
/// Free with `client_installation_name_free`.
#[ffi_export]
fn client_installation_name(handle: &ClientHandle) -> c_slice::Box<u8> {
handle
.0
.installation_name()
.as_bytes()
.to_vec()
.into_boxed_slice()
.into()
}
#[ffi_export]
fn client_installation_name_free(name: c_slice::Box<u8>) {
drop(name)
}
// ---------------------------------------------------------------------------
// Intro bundle
// ---------------------------------------------------------------------------
/// Produce a serialised introduction bundle for out-of-band sharing.
/// Free with `create_intro_result_free`.
#[ffi_export]
fn client_create_intro_bundle(handle: &mut ClientHandle) -> repr_c::Box<CreateIntroResult> {
let result = match handle.0.create_intro_bundle() {
Ok(bytes) => CreateIntroResult {
error_code: ErrorCode::None as i32,
data: Some(bytes),
},
Err(_) => CreateIntroResult {
error_code: ErrorCode::UnknownError as i32,
data: None,
},
};
Box::new(result).into()
}
#[ffi_export]
fn create_intro_result_error_code(r: &CreateIntroResult) -> i32 {
r.error_code
}
/// Returns an empty slice when error_code != 0.
/// The slice is valid only while `r` is alive.
#[ffi_export]
fn create_intro_result_bytes(r: &CreateIntroResult) -> c_slice::Ref<'_, u8> {
r.data.as_deref().unwrap_or(&[]).into()
}
#[ffi_export]
fn create_intro_result_free(r: repr_c::Box<CreateIntroResult>) {
drop(r)
}
// ---------------------------------------------------------------------------
// Create conversation
// ---------------------------------------------------------------------------
/// Parse an intro bundle and initiate a private conversation.
/// Outbound envelopes are dispatched through the delivery callback.
/// Free with `create_convo_result_free`.
#[ffi_export]
fn client_create_conversation(
handle: &mut ClientHandle,
bundle: c_slice::Ref<'_, u8>,
content: c_slice::Ref<'_, u8>,
) -> repr_c::Box<CreateConvoResult> {
let result = match handle
.0
.create_conversation(bundle.as_slice(), content.as_slice())
{
Ok(convo_id) => CreateConvoResult {
error_code: ErrorCode::None as i32,
convo_id: Some(convo_id.to_string()),
},
Err(ClientError::Chat(_)) => CreateConvoResult {
error_code: ErrorCode::BadIntro as i32,
convo_id: None,
},
Err(ClientError::Delivery(_)) => CreateConvoResult {
error_code: ErrorCode::DeliveryFail as i32,
convo_id: None,
},
};
Box::new(result).into()
}
#[ffi_export]
fn create_convo_result_error_code(r: &CreateConvoResult) -> i32 {
r.error_code
}
/// Returns an empty slice when error_code != 0.
/// The slice is valid only while `r` is alive.
#[ffi_export]
fn create_convo_result_id(r: &CreateConvoResult) -> c_slice::Ref<'_, u8> {
r.convo_id.as_deref().unwrap_or("").as_bytes().into()
}
#[ffi_export]
fn create_convo_result_free(r: repr_c::Box<CreateConvoResult>) {
drop(r)
}
// ---------------------------------------------------------------------------
// Send message
// ---------------------------------------------------------------------------
/// Encrypt `content` and dispatch outbound envelopes. Returns an `ErrorCode`.
#[ffi_export]
fn client_send_message(
handle: &mut ClientHandle,
convo_id: c_slice::Ref<'_, u8>,
content: c_slice::Ref<'_, u8>,
) -> ErrorCode {
let id_str = match std::str::from_utf8(convo_id.as_slice()) {
Ok(s) => s,
Err(_) => return ErrorCode::BadUtf8,
};
let convo_id_owned: client::ConversationIdOwned = Arc::from(id_str);
match handle.0.send_message(&convo_id_owned, content.as_slice()) {
Ok(()) => ErrorCode::None,
Err(ClientError::Delivery(_)) => ErrorCode::DeliveryFail,
Err(_) => ErrorCode::UnknownError,
}
}
// ---------------------------------------------------------------------------
// Push inbound
// ---------------------------------------------------------------------------
/// Decrypt an inbound payload. `has_content` is false for protocol frames.
/// Free with `push_inbound_result_free`.
#[ffi_export]
fn client_receive(
handle: &mut ClientHandle,
payload: c_slice::Ref<'_, u8>,
) -> repr_c::Box<PushInboundResult> {
let result = match handle.0.receive(payload.as_slice()) {
Ok(Some(cd)) => PushInboundResult {
error_code: ErrorCode::None as i32,
has_content: true,
is_new_convo: cd.is_new_convo,
convo_id: Some(cd.conversation_id),
content: Some(cd.data),
},
Ok(None) => PushInboundResult {
error_code: ErrorCode::None as i32,
has_content: false,
is_new_convo: false,
convo_id: None,
content: None,
},
Err(_) => PushInboundResult {
error_code: ErrorCode::UnknownError as i32,
has_content: false,
is_new_convo: false,
convo_id: None,
content: None,
},
};
Box::new(result).into()
}
#[ffi_export]
fn push_inbound_result_error_code(r: &PushInboundResult) -> i32 {
r.error_code
}
#[ffi_export]
fn push_inbound_result_has_content(r: &PushInboundResult) -> bool {
r.has_content
}
#[ffi_export]
fn push_inbound_result_is_new_convo(r: &PushInboundResult) -> bool {
r.is_new_convo
}
/// Returns an empty slice when has_content is false.
/// The slice is valid only while `r` is alive.
#[ffi_export]
fn push_inbound_result_convo_id(r: &PushInboundResult) -> c_slice::Ref<'_, u8> {
r.convo_id.as_deref().unwrap_or("").as_bytes().into()
}
/// Returns an empty slice when has_content is false.
/// The slice is valid only while `r` is alive.
#[ffi_export]
fn push_inbound_result_content(r: &PushInboundResult) -> c_slice::Ref<'_, u8> {
r.content.as_deref().unwrap_or(&[]).into()
}
#[ffi_export]
fn push_inbound_result_free(r: repr_c::Box<PushInboundResult>) {
drop(r)
}

View File

@ -0,0 +1,6 @@
fn main() -> std::io::Result<()> {
let path = std::env::args()
.nth(1)
.unwrap_or_else(|| "client_ffi.h".into());
client_ffi::generate_headers(&path)
}

View File

@ -0,0 +1,31 @@
use client::DeliveryService;
use libchat::AddressedEnvelope;
/// 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
/// payload. Both pointers are borrowed for the duration of the call only; the callee must not
/// retain or free them.
pub type DeliverFn = Option<
unsafe extern "C" fn(
addr_ptr: *const u8,
addr_len: usize,
data_ptr: *const u8,
data_len: usize,
) -> i32,
>;
pub struct CDelivery {
pub callback: DeliverFn,
}
impl DeliveryService for CDelivery {
type Error = i32;
fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), i32> {
let cb = self.callback.expect("callback must be non-null");
let addr = envelope.delivery_address.as_bytes();
let data = envelope.data.as_slice();
let rc = unsafe { cb(addr.as_ptr(), addr.len(), data.as_ptr(), data.len()) };
if rc < 0 { Err(rc) } else { Ok(()) }
}
}

View File

@ -0,0 +1,7 @@
mod api;
mod delivery;
#[cfg(feature = "headers")]
pub fn generate_headers(path: &str) -> std::io::Result<()> {
safer_ffi::headers::builder().to_file(path)?.generate()
}

View File

@ -8,3 +8,6 @@ crate-type = ["rlib"]
[dependencies]
libchat = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@ -1,18 +1,90 @@
use libchat::ChatError;
use libchat::Context;
use libchat::{
AddressedEnvelope, ContentData, Context, ConversationIdOwned, Introduction, StorageConfig,
};
pub struct ChatClient {
use crate::{delivery::DeliveryService, errors::ClientError};
pub struct ChatClient<D: DeliveryService> {
ctx: Context,
delivery: D,
}
impl ChatClient {
pub fn new(name: impl Into<String>) -> Self {
impl<D: DeliveryService> ChatClient<D> {
/// Create an in-memory, ephemeral client. Identity is lost on drop.
pub fn new(name: impl Into<String>, delivery: D) -> Self {
Self {
ctx: Context::new_with_name(name),
delivery,
}
}
pub fn create_bundle(&mut self) -> Result<Vec<u8>, ChatError> {
self.ctx.create_intro_bundle()
/// Open or create a persistent client backed by `StorageConfig`.
///
/// If an identity already exists in storage it is loaded; otherwise a new
/// one is created and saved.
pub fn open(
name: impl Into<String>,
config: StorageConfig,
delivery: D,
) -> Result<Self, ClientError<D::Error>> {
let ctx = Context::open(name, config)?;
Ok(Self { ctx, delivery })
}
/// Returns the installation name (identity label) of this client.
pub fn installation_name(&self) -> &str {
self.ctx.installation_name()
}
/// Produce a serialised introduction bundle for sharing out-of-band.
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ClientError<D::Error>> {
self.ctx.create_intro_bundle().map_err(Into::into)
}
/// Parse intro bundle bytes, initiate a private conversation, and deliver
/// all outbound envelopes. Returns this side's conversation ID.
pub fn create_conversation(
&mut self,
intro_bundle: &[u8],
initial_content: &[u8],
) -> Result<ConversationIdOwned, ClientError<D::Error>> {
let intro = Introduction::try_from(intro_bundle)?;
let (convo_id, envelopes) = self.ctx.create_private_convo(&intro, initial_content);
self.dispatch_all(envelopes)?;
Ok(convo_id)
}
/// List all conversation IDs known to this client.
pub fn list_conversations(&self) -> Result<Vec<ConversationIdOwned>, ClientError<D::Error>> {
self.ctx.list_conversations().map_err(Into::into)
}
/// Encrypt `content` and dispatch all outbound envelopes.
pub fn send_message(
&mut self,
convo_id: &ConversationIdOwned,
content: &[u8],
) -> Result<(), ClientError<D::Error>> {
let envelopes = self.ctx.send_content(convo_id.as_ref(), content)?;
self.dispatch_all(envelopes)
}
/// Decrypt an inbound payload. Returns `Some(ContentData)` for user
/// content, `None` for protocol frames.
pub fn receive(
&mut self,
payload: &[u8],
) -> Result<Option<ContentData>, ClientError<D::Error>> {
self.ctx.handle_payload(payload).map_err(Into::into)
}
fn dispatch_all(
&mut self,
envelopes: Vec<AddressedEnvelope>,
) -> Result<(), ClientError<D::Error>> {
for env in envelopes {
self.delivery.deliver(env).map_err(ClientError::Delivery)?;
}
Ok(())
}
}

View File

@ -0,0 +1,6 @@
use libchat::AddressedEnvelope;
pub trait DeliveryService {
type Error: std::fmt::Debug;
fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), Self::Error>;
}

View File

@ -0,0 +1,109 @@
use crate::{AddressedEnvelope, delivery::DeliveryService};
use std::collections::HashMap;
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
type Message = Vec<u8>;
/// Shared in-process message bus. Cheap to clone — all clones share the same log.
///
/// Messages are stored in an append-only log per delivery address. Readers hold
/// independent [`Cursor`]s and advance their position without consuming messages,
/// so multiple consumers on the same address each see every message.
#[derive(Clone, Default)]
pub struct MessageBus {
log: Arc<Mutex<HashMap<String, Vec<Message>>>>,
}
impl MessageBus {
/// Returns a cursor positioned at the beginning of `address`.
/// The cursor will see all messages — past and future.
pub fn subscribe(&self, address: &str) -> Cursor {
Cursor {
bus: self.clone(),
address: address.to_string(),
pos: 0,
}
}
/// Returns a cursor positioned at the current tail of `address`.
/// The cursor will only see messages delivered after this call.
pub fn subscribe_tail(&self, address: &str) -> Cursor {
let pos = self.log.lock().unwrap().get(address).map_or(0, |v| v.len());
Cursor {
bus: self.clone(),
address: address.to_string(),
pos,
}
}
fn push(&self, address: String, data: Message) {
self.log
.lock()
.unwrap()
.entry(address)
.or_default()
.push(data);
}
}
/// Per-consumer read cursor into a [`MessageBus`] address slot.
///
/// Reads are non-destructive: the underlying log is never modified.
/// Multiple cursors on the same address each advance independently.
pub struct Cursor {
bus: MessageBus,
address: String,
pos: usize,
}
impl Iterator for Cursor {
type Item = Message;
fn next(&mut self) -> Option<Message> {
let guard = self.bus.log.lock().unwrap();
let msgs = guard.get(&self.address)?;
if self.pos < msgs.len() {
let msg = msgs[self.pos].clone();
self.pos += 1;
Some(msg)
} else {
None
}
}
}
/// 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. Use [`InProcessDelivery::new`]
/// to get both the service and a bus handle for subscribing [`Cursor`]s.
#[derive(Clone)]
pub struct InProcessDelivery(MessageBus);
impl InProcessDelivery {
/// Create a new delivery service with its own private bus.
/// Returns both the service and a handle to the bus so callers can
/// subscribe [`Cursor`]s to read delivered messages.
pub fn new() -> (Self, MessageBus) {
let bus = MessageBus::default();
(Self(bus.clone()), bus)
}
}
impl Default for InProcessDelivery {
/// Create a standalone delivery service with no externally-held bus handle.
/// Useful when routing is not needed (e.g. persistent-client tests).
fn default() -> Self {
Self(MessageBus::default())
}
}
impl DeliveryService for InProcessDelivery {
type Error = Infallible;
fn deliver(&mut self, envelope: AddressedEnvelope) -> Result<(), Infallible> {
self.0.push(envelope.delivery_address, envelope.data);
Ok(())
}
}

View File

@ -0,0 +1,15 @@
use libchat::ChatError;
#[derive(Debug)]
pub enum ClientError<D> {
Chat(ChatError),
/// Crypto state advanced but at least one envelope failed delivery.
/// Caller decides whether to retry.
Delivery(D),
}
impl<D> From<ChatError> for ClientError<D> {
fn from(e: ChatError) -> Self {
Self::Chat(e)
}
}

View File

@ -1,3 +1,12 @@
mod client;
mod delivery;
mod delivery_in_process;
mod errors;
pub use client::ChatClient;
pub use delivery::DeliveryService;
pub use delivery_in_process::{Cursor, InProcessDelivery, MessageBus};
pub use errors::ClientError;
// Re-export types callers need to interact with ChatClient
pub use libchat::{AddressedEnvelope, ContentData, ConversationIdOwned, StorageConfig};

View File

@ -0,0 +1,71 @@
use client::{
ChatClient, ContentData, ConversationIdOwned, Cursor, InProcessDelivery, StorageConfig,
};
use std::sync::Arc;
fn receive(receiver: &mut ChatClient<InProcessDelivery>, cursor: &mut Cursor) -> ContentData {
let raw = cursor.next().expect("expected envelope");
receiver
.receive(&raw)
.expect("receive failed")
.expect("expected content")
}
#[test]
fn alice_bob_message_exchange() {
let (delivery, bus) = InProcessDelivery::new();
let mut cursor = bus.subscribe_tail("delivery_address");
let mut alice = ChatClient::new("alice", delivery.clone());
let mut bob = ChatClient::new("bob", delivery);
let bob_bundle = bob.create_intro_bundle().unwrap();
let alice_convo_id = alice
.create_conversation(&bob_bundle, b"hello bob")
.unwrap();
let content = receive(&mut bob, &mut cursor);
assert_eq!(content.data, b"hello bob");
assert!(content.is_new_convo);
let bob_convo_id: ConversationIdOwned = Arc::from(content.conversation_id.as_str());
bob.send_message(&bob_convo_id, b"hi alice").unwrap();
let content = receive(&mut alice, &mut cursor);
assert_eq!(content.data, b"hi alice");
assert!(!content.is_new_convo);
for i in 0u8..5 {
let msg = format!("msg {i}");
alice.send_message(&alice_convo_id, msg.as_bytes()).unwrap();
let content = receive(&mut bob, &mut cursor);
assert_eq!(content.data, msg.as_bytes());
let reply = format!("reply {i}");
bob.send_message(&bob_convo_id, reply.as_bytes()).unwrap();
let content = receive(&mut alice, &mut cursor);
assert_eq!(content.data, reply.as_bytes());
}
assert_eq!(alice.list_conversations().unwrap().len(), 1);
assert_eq!(bob.list_conversations().unwrap().len(), 1);
}
#[test]
fn open_persistent_client() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db").to_string_lossy().to_string();
let config = StorageConfig::File(db_path);
let client1 = ChatClient::open("alice", config.clone(), InProcessDelivery::default()).unwrap();
let name1 = client1.installation_name().to_string();
drop(client1);
let client2 = ChatClient::open("alice", config, InProcessDelivery::default()).unwrap();
let name2 = client2.installation_name().to_string();
assert_eq!(
name1, name2,
"installation name should persist across restarts"
);
}

39
examples/c-ffi/Makefile Normal file
View File

@ -0,0 +1,39 @@
REPO_ROOT := $(shell cd ../.. && pwd)
CARGO_PROFILE ?= debug
LIB_DIR := $(REPO_ROOT)/target/$(CARGO_PROFILE)
INCLUDE_DIR := $(REPO_ROOT)/crates/client-ffi
HEADER := $(INCLUDE_DIR)/client_ffi.h
CC ?= cc
CFLAGS := -Wall -Wextra -std=c11 -I$(INCLUDE_DIR)
LIBS := -L$(LIB_DIR) -lclient_ffi -lpthread -ldl -lm
.PHONY: all run valgrind clean generate-headers _cargo
all: c-client
generate-headers:
cargo run --manifest-path $(REPO_ROOT)/Cargo.toml \
-p client-ffi --bin generate-headers --features headers \
-- $(HEADER)
_cargo:
cargo build --manifest-path $(REPO_ROOT)/Cargo.toml -p client-ffi \
$(if $(filter release,$(CARGO_PROFILE)),--release,)
c-client: src/main.c generate-headers _cargo
$(CC) $(CFLAGS) src/main.c $(LIBS) -o c-client
run: c-client
./c-client
valgrind: c-client
valgrind \
--error-exitcode=1 \
--leak-check=full \
--errors-for-leak-kinds=definite,indirect \
--track-origins=yes \
./c-client
clean:
rm -f c-client $(HEADER)

21
examples/c-ffi/README.md Normal file
View File

@ -0,0 +1,21 @@
# c-client
An example C application built on top of [`crates/client-ffi`](../../crates/client-ffi).
It demonstrates that the C ABI exposed by `crates/client-ffi` is straightforward to
consume from plain C — or from any language that can call into a C ABI. No Rust code,
no Cargo project: just a C source file linked against the pre-built static library.
## Building and running
```sh
make # builds client-ffi with Cargo, then compiles src/main.c
make run # build + execute
make clean # remove the compiled binary
```
For a release build:
```sh
make CARGO_PROFILE=release
```

202
examples/c-ffi/src/main.c Normal file
View File

@ -0,0 +1,202 @@
/*
* c-client: Alice-Bob message exchange written entirely in C.
*
* Demonstrates that the client-ffi C API is straightforward to consume
* directly no Rust glue required. Build with the provided Makefile.
*/
#include "client_ffi.h"
#include <assert.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* ------------------------------------------------------------------
* Convenience macros for building slice_ref_uint8_t values.
* SLICE(p, n) arbitrary pointer + length.
* STR(s) string literal (length computed at compile time).
* ------------------------------------------------------------------ */
#define SLICE(p, n) ((slice_ref_uint8_t){ .ptr = (const uint8_t *)(p), .len = (n) })
#define STR(s) SLICE(s, sizeof(s) - 1)
/* ------------------------------------------------------------------
* In-memory delivery bus (shared by all clients, like InProcessDelivery)
* ------------------------------------------------------------------ */
#define MAX_ENVELOPES 32
#define MAX_ENVELOPE_SZ 2048
typedef struct {
uint8_t data[MAX_ENVELOPE_SZ];
size_t len;
} Envelope;
typedef struct {
Envelope items[MAX_ENVELOPES];
int head;
int tail;
int count;
} Queue;
static Queue bus;
static void queue_init(Queue *q)
{
memset(q, 0, sizeof(*q));
}
static void queue_push(Queue *q, const uint8_t *data, size_t len)
{
assert(q->count < MAX_ENVELOPES && "delivery queue overflow");
assert(len <= MAX_ENVELOPE_SZ && "envelope too large");
memcpy(q->items[q->tail].data, data, len);
q->items[q->tail].len = len;
q->tail = (q->tail + 1) % MAX_ENVELOPES;
q->count++;
}
static int queue_pop(Queue *q, const uint8_t **data_out, size_t *len_out)
{
if (q->count == 0) return 0;
*data_out = q->items[q->head].data;
*len_out = q->items[q->head].len;
q->head = (q->head + 1) % MAX_ENVELOPES;
q->count--;
return 1;
}
/* ------------------------------------------------------------------
* Delivery callback: all clients share one bus.
* ------------------------------------------------------------------ */
static int32_t deliver_cb(
const uint8_t *addr_ptr, size_t addr_len,
const uint8_t *data_ptr, size_t data_len)
{
(void)addr_ptr; (void)addr_len;
queue_push(&bus, data_ptr, data_len);
return 0;
}
/* ------------------------------------------------------------------
* Helper: pop one envelope from the bus and push it into receiver.
* Returns a heap-allocated result; caller frees with
* push_inbound_result_free().
* ------------------------------------------------------------------ */
static PushInboundResult_t *route(ClientHandle_t *receiver)
{
const uint8_t *data;
size_t len;
int ok = queue_pop(&bus, &data, &len);
assert(ok && "expected an envelope in the bus");
PushInboundResult_t *r = client_receive(receiver, SLICE(data, len));
assert(push_inbound_result_error_code(r) == 0 && "push_inbound failed");
return r;
}
/* ------------------------------------------------------------------
* Main
* ------------------------------------------------------------------ */
int main(void)
{
queue_init(&bus);
/* Create clients — both share the same delivery bus */
ClientHandle_t *alice = client_create(STR("alice"), deliver_cb);
ClientHandle_t *bob = client_create(STR("bob"), deliver_cb);
assert(alice && "client_create returned NULL for alice");
assert(bob && "client_create returned NULL for bob");
/* Bob generates an intro bundle */
CreateIntroResult_t *bob_intro = client_create_intro_bundle(bob);
assert(create_intro_result_error_code(bob_intro) == 0);
slice_ref_uint8_t intro_bytes = create_intro_result_bytes(bob_intro);
/* Alice initiates a conversation with Bob */
CreateConvoResult_t *alice_convo = client_create_conversation(
alice, intro_bytes, STR("hello bob"));
assert(create_convo_result_error_code(alice_convo) == 0);
create_intro_result_free(bob_intro);
/* Route alice -> bob */
PushInboundResult_t *recv = route(bob);
assert(push_inbound_result_has_content(recv) && "expected content from alice");
assert(push_inbound_result_is_new_convo(recv) && "expected new-conversation flag");
slice_ref_uint8_t content = push_inbound_result_content(recv);
assert(content.len == 9);
assert(memcmp(content.ptr, "hello bob", 9) == 0);
printf("Bob received: \"%.*s\"\n", (int)content.len, content.ptr);
/* Copy Bob's convo_id before freeing recv */
slice_ref_uint8_t cid_ref = push_inbound_result_convo_id(recv);
uint8_t bob_cid[256];
size_t bob_cid_len = cid_ref.len;
if (bob_cid_len >= sizeof(bob_cid)) {
fprintf(stderr, "conversation id too long (%zu bytes)\n", bob_cid_len);
return 1;
}
memcpy(bob_cid, cid_ref.ptr, bob_cid_len);
push_inbound_result_free(recv);
/* Bob replies */
ErrorCode_t rc = client_send_message(
bob, SLICE(bob_cid, bob_cid_len), STR("hi alice"));
assert(rc == ERROR_CODE_NONE);
recv = route(alice);
assert(push_inbound_result_has_content(recv) && "expected content from bob");
assert(!push_inbound_result_is_new_convo(recv) && "unexpected new-convo flag");
content = push_inbound_result_content(recv);
assert(content.len == 8);
assert(memcmp(content.ptr, "hi alice", 8) == 0);
printf("Alice received: \"%.*s\"\n", (int)content.len, content.ptr);
push_inbound_result_free(recv);
/* Multiple back-and-forth rounds */
slice_ref_uint8_t alice_cid = create_convo_result_id(alice_convo);
for (int i = 0; i < 3; i++) {
char msg[32];
int mlen = snprintf(msg, sizeof(msg), "msg %d", i);
rc = client_send_message(alice, alice_cid, SLICE(msg, (size_t)mlen));
assert(rc == ERROR_CODE_NONE);
recv = route(bob);
assert(push_inbound_result_has_content(recv));
content = push_inbound_result_content(recv);
assert((int)content.len == mlen);
assert(memcmp(content.ptr, msg, (size_t)mlen) == 0);
push_inbound_result_free(recv);
char reply[32];
int rlen = snprintf(reply, sizeof(reply), "reply %d", i);
rc = client_send_message(
bob, SLICE(bob_cid, bob_cid_len), SLICE(reply, (size_t)rlen));
assert(rc == ERROR_CODE_NONE);
recv = route(alice);
assert(push_inbound_result_has_content(recv));
content = push_inbound_result_content(recv);
assert((int)content.len == rlen);
assert(memcmp(content.ptr, reply, (size_t)rlen) == 0);
push_inbound_result_free(recv);
}
/* Cleanup */
create_convo_result_free(alice_convo);
client_destroy(alice);
client_destroy(bob);
printf("Message exchange complete.\n");
return 0;
}

View File

@ -0,0 +1,11 @@
[package]
name = "in-process"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "in-process"
path = "src/main.rs"
[dependencies]
client = { path = "../../crates/client" }

View File

@ -0,0 +1,16 @@
# in-process
An example Rust application built on top of [`crates/client`](../../crates/client).
It demonstrates that creating a working chat client in pure Rust is trivial: depend on
`crates/client`, pick a `DeliveryService` implementation (here the in-memory
`InProcessDelivery` shipped with the crate), and wire up `ChatClient`. No boilerplate, no FFI.
## Running
```
cargo run -p in-process
```
The binary performs an Alice-Bob message exchange entirely in-process and prints
the exchanged messages to stdout.

View File

@ -0,0 +1,34 @@
use client::{ChatClient, ConversationIdOwned, InProcessDelivery};
use std::sync::Arc;
fn main() {
let (delivery, bus) = InProcessDelivery::new();
let mut cursor = bus.subscribe_tail("delivery_address");
let mut alice = ChatClient::new("alice", delivery.clone());
let mut bob = ChatClient::new("bob", delivery);
let bob_bundle = bob.create_intro_bundle().unwrap();
alice
.create_conversation(&bob_bundle, b"hello bob")
.unwrap();
let raw = cursor.next().unwrap();
let content = bob.receive(&raw).unwrap().unwrap();
println!(
"Bob received: {:?}",
std::str::from_utf8(&content.data).unwrap()
);
let bob_convo_id: ConversationIdOwned = Arc::from(content.conversation_id.as_str());
bob.send_message(&bob_convo_id, b"hi alice").unwrap();
let raw = cursor.next().unwrap();
let content = alice.receive(&raw).unwrap().unwrap();
println!(
"Alice received: {:?}",
std::str::from_utf8(&content.data).unwrap()
);
println!("Message exchange complete.");
}