0.15.0: simple builder API, v2.1 framing, stream lane, benches

- omnivox::server::<T>()/client::<T>() builders; plain closures, enum in,
  match in the callback — the original C++ UX with the ceremony removed
- serde + bincode replace the hand-rolled Cereal codec (vectors.rs still
  governs the crypto leaves via the C++ golden oracles)
- v2.1 frames: u32 len | type | ct | tag; nonce derived from the sequence
  counter (no wire nonce), type byte authenticated as AAD; any frame-level
  failure is fatal, authenticated-but-undecodable payloads are skipped
- stream lane: send_stream/on_stream, 1 MiB frames, shared sequence with
  messages (exact sender interleaving preserved); see dump-agent/viewer
- per-connection bounded queues + writer tasks; reply/reply_stream are
  non-blocking try_send lanes for callbacks
- benches: seal/open criterion group + transport harness (spray msg/s,
  stream GiB/s, raw loopback baseline)
- profile.dev.package scrypt/pbkdf2 opt-level 3: golden-vector scrypt at
  N=2^17 was unusable in debug builds
- PROTOCOL-V2.md and README rewritten for v2.1
This commit is contained in:
2026-09-30 08:07:38 -04:00
parent c614d8345f
commit fea8ea3ab1
15 changed files with 2827 additions and 1193 deletions
+43 -78
View File
@@ -1,15 +1,21 @@
//! Port of OmniVox-server.cpp: echoes each client payload back with the
//! client id, refusing no client that holds the key.
//! The minimal OmniVox server: one enum, one closure, one call.
//!
//! Run this, then run `cargo run --example omnivox-client` in another
//! terminal. The client sends `Hello`; we echo a `Note` back and finish
//! after three exchanges — the same callback shape as the original C++
//! demo, minus the boilerplate.
#[path = "common/mod.rs"]
mod common;
use omnivox::Decision;
use serde::{Deserialize, Serialize};
use common::{DatagramPayload, PASSWORD, SALT, SERVER_PORT};
use omnivox::{Config, ConnectionContext, Decision, OmniVox, derive_encrypt_key};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
/// The one message type of our tiny protocol. Because this is an enum,
/// `on_data`'s `match` is the switch-on-id pattern from the C++ days,
/// checked by the compiler instead of a runtime default case.
#[derive(Serialize, Deserialize, Debug)]
enum ChatMsg {
Hello,
Note(String),
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -18,78 +24,37 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.without_time()
.init();
tracing::info!("Starting server on port {SERVER_PORT}");
let server = Arc::new(OmniVox::<DatagramPayload>::new());
let key = derive_encrypt_key(PASSWORD.as_bytes(), SALT, 17, 8, 1)?;
server.set_encrypt_key(key);
// The C++ example's g_clientHalt map, kept for behavioral parity.
let client_halt: Arc<Mutex<HashMap<u32, bool>>> = Arc::new(Mutex::new(HashMap::new()));
let responder = Arc::clone(&server);
let received = Arc::new(move |data: &DatagramPayload, ctx: &ConnectionContext| {
tracing::info!(
"Received data from client {}: Payload size={}",
ctx.id,
data.payload.len()
);
if !data.payload.is_empty() {
tracing::info!(
"Payload content: {}",
String::from_utf8_lossy(&data.payload)
);
}
let response = DatagramPayload {
id: ctx.id as u16,
v1: 0,
payload: data.payload.clone(),
};
let server = Arc::clone(&responder);
let id = ctx.id;
// C++ echoed synchronously inside the callback; the Rust callback is
// sync, so the echo rides a task — per-connection socket order is
// still serialized by the client's write lock (contract B3).
tokio::spawn(async move {
if server.send_to_client(&response, id).await {
tracing::info!("Sent response to client {id}");
} else {
tracing::info!("Error sending data to client {id}");
let server = omnivox::server::<ChatMsg>()
// Both peers must derive the same key. scrypt runs once, at startup,
// on a blocking thread (~1 s, 128 MiB). Prefer a random 32-byte PSK
// via `.key([u8; 32])` in real deployments.
.password("SecurePassword123", b"static-demo-salt")
.on_data(|msg, ctx| match msg {
ChatMsg::Hello => {
tracing::info!("client {} says hello", ctx.id());
// Synchronous, non-blocking enqueue. A `false` return would
// mean the peer stopped reading — never a panic, never a stall.
let _ = ctx.reply(&ChatMsg::Note(format!("echo {}", ctx.id())));
}
});
});
let halt = Arc::clone(&client_halt);
let disconnect = Arc::new(move |ctx: &ConnectionContext| {
tracing::info!(
"Client {} {}:{} is disconnecting from the server cleaning up objects.",
ctx.id,
ctx.ip,
ctx.port
);
*halt.lock().unwrap().entry(ctx.id).or_default() = true;
});
let halt = Arc::clone(&client_halt);
let connect = Arc::new(move |ctx: &ConnectionContext| {
*halt.lock().unwrap().entry(ctx.id).or_default() = false;
tracing::info!("New client has connected - {}:{}", ctx.ip, ctx.port);
Decision::Allow
});
server
.start_server(
SERVER_PORT,
Config::default(),
received,
disconnect,
connect,
)
ChatMsg::Note(text) => {
tracing::info!("client {} wrote: {text}", ctx.id());
}
})
// Every authenticated client is welcome; return `Decision::Deny`
// to close specific peers (per-IP policy, allowlists, ...).
.on_connect(|ctx| {
tracing::info!("accepting {}:{}", ctx.ip(), ctx.port());
Decision::Allow
})
.on_disconnect(|ctx| tracing::info!("client {} left", ctx.id()))
.serve(8080)
.await?;
tracing::info!("Server started successfully");
tracing::info!("listening on :8080 (Ctrl-C to stop)");
// C++ slept u32::MAX hours; we serve until killed, like the reference.
// The handle can live forever; `server.stop().await` tears everything
// down cleanly when you want it to.
let _ = &server;
std::future::pending::<()>().await;
Ok(())
}