From 022f3de8e1ede7b21d1071519b925acd40eaebd8 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Mon, 3 Aug 2026 17:21:11 -0600 Subject: [PATCH 01/43] saikuro-random --- .cargo/config.toml | 30 +++ Build/Cargo.lock | 110 ++++++++-- Build/Cargo.toml | 19 +- Build/adapters/rust/Cargo.toml | 4 +- Build/crates/saikuro-core/Cargo.toml | 1 + Build/crates/saikuro-core/src/invocation.rs | 4 +- Build/crates/saikuro-random/Cargo.toml | 28 +++ Build/crates/saikuro-random/src/drbg.rs | 191 ++++++++++++++++++ Build/crates/saikuro-random/src/lib.rs | 172 ++++++++++++++++ Build/crates/saikuro-random/tests/drbg.rs | 109 ++++++++++ .../saikuro-random/tests/drbg_unseeded.rs | 14 ++ .../crates/saikuro-random/tests/os_backend.rs | 39 ++++ Build/crates/saikuro-router/Cargo.toml | 1 - Build/crates/saikuro-runtime/Cargo.toml | 4 +- Build/crates/saikuro-transport/Cargo.toml | 1 - Build/deny.toml | 6 +- Build/tests/Cargo.toml | 3 + 17 files changed, 711 insertions(+), 25 deletions(-) create mode 100644 Build/crates/saikuro-random/Cargo.toml create mode 100644 Build/crates/saikuro-random/src/drbg.rs create mode 100644 Build/crates/saikuro-random/src/lib.rs create mode 100644 Build/crates/saikuro-random/tests/drbg.rs create mode 100644 Build/crates/saikuro-random/tests/drbg_unseeded.rs create mode 100644 Build/crates/saikuro-random/tests/os_backend.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 0e66c8aa..23b4fbbd 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,8 +4,38 @@ # Run with: cargo test -p saikuro-tests --target wasm32-unknown-unknown # Or: wasm-pack test --headless --chrome Build/tests +# getrandom 0.3 selects its backend at compile time via the +# `getrandom_backend` cfg. +# +# - wasm32-unknown-unknown has no OS entropy source, so every build for that +# target pins the `wasm_js` backend; the matching cargo feature is enabled +# by saikuro-random's `wasm` feature (wired through adapters/rust, +# saikuro-runtime, and saikuro-tests' wasm32 deps). +# - bare-metal MCU targets have no OS and no practical wasm host, so they use +# the `custom` backend. The final binary must define `__getrandom_v03_custom` +# (a no-op here would link a broken RNG, so `fill` fails loudly instead). The +# matching cargo feature is enabled via saikuro-random's `custom` feature. +# +# Host targets leave the cfg unset and getrandom uses its per-target default +# (the OS backends). [target.wasm32-unknown-unknown] runner = "wasm-bindgen-test-runner" +rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""] + +# Bare-metal targets (verification + intended chips): +# riscv32imc-unknown-none-elf ESP32-C3 +# thumbv6m-none-eabi RP2040 +# thumbv8m.main-none-eabihf RP2350 +[target.aarch64-unknown-none] +rustflags = ["--cfg", "getrandom_backend=\"custom\""] +[target.riscv32imac-unknown-none-elf] +rustflags = ["--cfg", "getrandom_backend=\"custom\""] +[target.riscv32imc-unknown-none-elf] +rustflags = ["--cfg", "getrandom_backend=\"custom\""] +[target.thumbv6m-none-eabi] +rustflags = ["--cfg", "getrandom_backend=\"custom\""] +[target.thumbv8m.main-none-eabihf] +rustflags = ["--cfg", "getrandom_backend=\"custom\""] # [build] # rustflags = ["--cfg=web_sys_unstable_apis"] diff --git a/Build/Cargo.lock b/Build/Cargo.lock index f94b1a0f..c7c7713d 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -178,6 +178,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -203,6 +214,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.6.3" @@ -288,6 +309,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -596,6 +623,20 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -603,11 +644,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", - "wasm-bindgen", ] [[package]] @@ -739,6 +778,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -964,6 +1012,15 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +dependencies = [ + "critical-section", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -997,6 +1054,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1020,7 +1083,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -1191,6 +1254,7 @@ dependencies = [ "rmp-serde", "saikuro-core", "saikuro-exec", + "saikuro-random", "saikuro-storage", "saikuro-transport", "serde", @@ -1198,7 +1262,6 @@ dependencies = [ "syn", "thiserror 2.0.18", "tracing", - "uuid", ] [[package]] @@ -1238,6 +1301,7 @@ dependencies = [ "chrono", "rmp-serde", "rmpv", + "saikuro-random", "serde", "serde_bytes", "serde_json", @@ -1258,6 +1322,16 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "saikuro-random" +version = "0.1.0" +dependencies = [ + "chacha20 0.9.1", + "getrandom 0.3.4", + "portable-atomic", + "uuid", +] + [[package]] name = "saikuro-router" version = "0.1.0" @@ -1274,7 +1348,6 @@ dependencies = [ "thiserror 2.0.18", "tracing", "tracing-subscriber", - "uuid", ] [[package]] @@ -1291,6 +1364,7 @@ dependencies = [ "rmp-serde", "saikuro-core", "saikuro-exec", + "saikuro-random", "saikuro-router", "saikuro-schema", "saikuro-transport", @@ -1302,7 +1376,6 @@ dependencies = [ "tracing", "tracing-subscriber", "tungstenite 0.30.0", - "uuid", ] [[package]] @@ -1355,6 +1428,7 @@ dependencies = [ "saikuro-codegen", "saikuro-core", "saikuro-exec", + "saikuro-random", "saikuro-router", "saikuro-runtime", "saikuro-schema", @@ -1375,7 +1449,6 @@ dependencies = [ "async-trait", "bytes", "futures", - "getrandom 0.4.3", "js-sys", "pin-project-lite", "rmp-serde", @@ -1943,12 +2016,6 @@ name = "uuid" version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "wasm-bindgen", -] [[package]] name = "valuable" @@ -1984,6 +2051,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.122" @@ -2187,6 +2263,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.8.40" diff --git a/Build/Cargo.toml b/Build/Cargo.toml index a408cee0..1cb534b3 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/saikuro-router", "crates/saikuro-runtime", "crates/saikuro-exec", + "crates/saikuro-random", "crates/saikuro-codegen", "adapters/c", "adapters/rust", @@ -34,8 +35,21 @@ futures = "0.3" async-trait = "0.1" pin-project-lite = "0.2" -# UUID -uuid = { version = "1.23.2", features = ["v4", "serde", "js"] } +# UUID: default-features off so the crate stays no_std +uuid = { version = "1.23.2", default-features = false } + +# Randomness: saikuro-random selects the backend via its own features +getrandom = { version = "0.3.1", default-features = false } + +# Deterministic DRBG backend for saikuro-random +# Kept no_std (no default features). +chacha20 = { version = "0.9", default-features = false } + +# Atomics that work on MCU targets without native 64-bit (or any) atomics +# (riscv32imc, thumbv6m). `fallback` provides the 64-bit ops on 32-bit-CAS +# targets (riscv32imac); `critical-section` covers targets with no atomics at +# all. Native instructions are still used where available. +portable-atomic = { version = "1", default-features = false, features = ["fallback", "critical-section"] } # Duration / utility serde helpers serde_with = "3.0" @@ -68,4 +82,5 @@ saikuro-router = { path = "crates/saikuro-router", default-features = false } saikuro-runtime = { path = "crates/saikuro-runtime", default-features = false } saikuro-codegen = { path = "crates/saikuro-codegen" } saikuro-exec = { path = "crates/saikuro-exec", default-features = false } +saikuro-random = { path = "crates/saikuro-random" } saikuro = { path = "adapters/rust", default-features = false } diff --git a/Build/adapters/rust/Cargo.toml b/Build/adapters/rust/Cargo.toml index 3932835d..34544667 100644 --- a/Build/adapters/rust/Cargo.toml +++ b/Build/adapters/rust/Cargo.toml @@ -19,7 +19,7 @@ default = ["tcp", "unix", "ws", "storage", "saikuro-exec/tokio-runtime"] tcp = ["saikuro-transport/native-transport"] unix = ["saikuro-transport/native-transport"] ws = ["saikuro-transport/native-ws"] -wasm = ["saikuro-transport/wasm-runtime", "saikuro-transport/ws-transport"] +wasm = ["saikuro-transport/wasm-runtime", "saikuro-transport/ws-transport", "saikuro-random/wasm"] # Storage backends: platform-agnostic factory in storage module storage = ["saikuro-storage/native-storage"] @@ -32,6 +32,7 @@ wasm-storage = ["saikuro-storage/wasm-storage"] saikuro-core = { path = "../../crates/saikuro-core" } saikuro-storage = { path = "../../crates/saikuro-storage", default-features = false } saikuro-transport = { path = "../../crates/saikuro-transport", default-features = false } +saikuro-random = { path = "../../crates/saikuro-random" } anyhow = { workspace = true } serde = { version = "1.0", features = ["derive"] } @@ -44,6 +45,5 @@ async-trait = "0.1" tracing = "0.1" thiserror = "2.0" dashmap = "6.2.1" -uuid = { workspace = true } clap = { version = "4.5", features = ["derive"] } syn = { version = "2.0", features = ["full"] } diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index c79b2829..5b02e7e5 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -16,6 +16,7 @@ rmp-serde = { workspace = true } rmpv = { workspace = true } bytes = { workspace = true } uuid = { workspace = true } +saikuro-random = { workspace = true } thiserror = { workspace = true } chrono = { workspace = true } serde_with = { workspace = true } diff --git a/Build/crates/saikuro-core/src/invocation.rs b/Build/crates/saikuro-core/src/invocation.rs index ab53c224..4f500f61 100644 --- a/Build/crates/saikuro-core/src/invocation.rs +++ b/Build/crates/saikuro-core/src/invocation.rs @@ -72,7 +72,9 @@ impl InvocationId { /// Generate a fresh, globally-unique invocation identifier. #[inline] pub fn new() -> Self { - Self(Uuid::new_v4()) + // Entropy failure is a platform-level fault: without it no invocation + // id can ever be minted, so aborting is the only sane response. + Self(saikuro_random::uuid_v4().expect("entropy backend unavailable")) } /// Construct from an existing UUID. diff --git a/Build/crates/saikuro-random/Cargo.toml b/Build/crates/saikuro-random/Cargo.toml new file mode 100644 index 00000000..43fa3a14 --- /dev/null +++ b/Build/crates/saikuro-random/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "saikuro-random" +description = "Randomness and entropy facade for Saikuro" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords = ["ipc", "cross-language", "saikuro", "random", "rng"] + +# The active randomness source is selected by exactly one of the `os`, `wasm`, +# or `custom` features (plus the `drbg` deterministic override) +[features] +default = ["os"] +os = ["dep:getrandom", "getrandom/std", "std"] +wasm = ["dep:getrandom", "getrandom/wasm_js", "std"] +custom = ["dep:getrandom"] +drbg = ["dep:chacha20", "dep:portable-atomic"] +std = [] + +[dependencies] +getrandom = { workspace = true, optional = true } +chacha20 = { workspace = true, optional = true } +portable-atomic = { workspace = true, optional = true } +uuid = { workspace = true } + +[dev-dependencies] +chacha20 = { workspace = true } diff --git a/Build/crates/saikuro-random/src/drbg.rs b/Build/crates/saikuro-random/src/drbg.rs new file mode 100644 index 00000000..2e78fdfa --- /dev/null +++ b/Build/crates/saikuro-random/src/drbg.rs @@ -0,0 +1,191 @@ +//! Deterministic ChaCha20 DRBG backend. +//! +//! A counter-mode DRBG built from the RFC 8439 ChaCha20 stream cipher. The +//! keystream for block `n` is `ChaCha20(key, nonce)` seeked to byte offset +//! `n * 64`, so the entire stream is a pure function of the 56-byte seed +//! (32-byte key, 24-byte XChaCha20 nonce). Identical seeds produce identical +//! output, which is what makes this backend usable for deterministic tests. +//! +//! On MCUs with no entropy source (e.g. RP2040) the binary seeds the global +//! state from whatever weak entropy the hardware can provide (ROSC jitter) and +//! all [`crate::fill`] calls draw from it. + +// portable-atomic instead of core::sync::atomic: the MCU targets (riscv32imc, +// thumbv6m) have no native atomics and riscv32imac has no 64-bit ones. +// portable-atomic maps to native instructions where they exist and to the +// critical-section fallback elsewhere, which keeps this module compiling for +// every supported target. +use portable_atomic::{AtomicBool, AtomicU64, Ordering}; + +use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; +use chacha20::XChaCha20; + +/// ChaCha20 operates on 64-byte blocks. +const BLOCK_LEN: usize = 64; +/// ChaCha20 key length in bytes. +const KEY_LEN: usize = 32; +/// XChaCha20 extended nonce length in bytes. +const NONCE_LEN: usize = 24; +/// Total seed length in bytes. +const SEED_LEN: usize = KEY_LEN + NONCE_LEN; +/// Global seed stored as `SEED_LEN / 8` independent `u64` words. +const SEED_WORDS: usize = SEED_LEN / 8; + +/// Generate keystream block `index` for the given key and nonce. +/// +/// Errors if the block index overruns the cipher's u32 block counter (2^32 +/// blocks, i.e. 256 GiB of stream), which is how exhaustion is surfaced. +fn keystream_block( + key: &[u8; KEY_LEN], + nonce: &[u8; NONCE_LEN], + index: u64, +) -> Result<[u8; BLOCK_LEN], crate::Error> { + let mut cipher = + XChaCha20::new_from_slices(key, nonce).map_err(|_| crate::Error::InvalidSeed)?; + // chacha20's seek positions are byte offsets, not block indices. + let pos = index + .checked_mul(BLOCK_LEN as u64) + .ok_or(crate::Error::DrbgExhausted)?; + cipher + .try_seek(pos) + .map_err(|_| crate::Error::DrbgExhausted)?; + let mut block = [0u8; BLOCK_LEN]; + cipher.apply_keystream(&mut block); + Ok(block) +} + +/// A seedable, deterministic counter-mode ChaCha20 DRBG. +/// +/// Local instances are the unit-testable form of the backend; the process-wide +/// seeded state ([`seed_from_slice`]) is a thin wrapper over the same +/// keystream construction. +#[derive(Debug, PartialEq, Eq)] +pub struct Drbg { + key: [u8; KEY_LEN], + nonce: [u8; NONCE_LEN], + counter: u64, +} + +impl Drbg { + /// Construct a DRBG from a seed of at least [`SEED_LEN`] bytes. + /// + /// The first 32 bytes form the key, the next 24 the nonce; extra bytes are + /// ignored. + pub fn from_seed(seed: &[u8]) -> Result { + if seed.len() < SEED_LEN { + return Err(crate::Error::InvalidSeed); + } + let mut key = [0u8; KEY_LEN]; + let mut nonce = [0u8; NONCE_LEN]; + key.copy_from_slice(&seed[..KEY_LEN]); + nonce.copy_from_slice(&seed[KEY_LEN..SEED_LEN]); + Ok(Self { + key, + nonce, + counter: 0, + }) + } + + /// Fill `dest` with the next bytes of the keystream. + pub fn fill(&mut self, dest: &mut [u8]) -> Result<(), crate::Error> { + let blocks = dest.len().div_ceil(BLOCK_LEN); + let start = self.counter; + self.counter = self.counter.saturating_add(blocks as u64); + for i in 0..blocks { + let block = keystream_block(&self.key, &self.nonce, start + i as u64)?; + let from = i * BLOCK_LEN; + let to = core::cmp::min(from + BLOCK_LEN, dest.len()); + dest[from..to].copy_from_slice(&block[..to - from]); + } + Ok(()) + } + + /// Fill potentially uninitialized `dest` with keystream bytes. + pub fn fill_uninit( + &mut self, + dest: &mut [core::mem::MaybeUninit], + ) -> Result<(), crate::Error> { + // SAFETY: `MaybeUninit` carries no validity constraints, so writing + // initialized bytes through an `&mut [u8]` view is always sound. + let bytes = + unsafe { core::slice::from_raw_parts_mut(dest.as_mut_ptr() as *mut u8, dest.len()) }; + self.fill(bytes) + } +} + +static SEEDED: AtomicBool = AtomicBool::new(false); +static COUNTER: AtomicU64 = AtomicU64::new(0); +// Explicit literal: an array-repeat of a non-Copy type needs inline const +// blocks, which require rustc >= 1.79 and the workspace floor is 1.75. +static SEED: [AtomicU64; SEED_WORDS] = [ + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), +]; + +/// Seed the process-wide DRBG from `seed`. +/// +/// Call once at startup, before any concurrent [`crate::fill`]. The seed words +/// are stored with release ordering and each word is individually atomic, so +/// readers that observe `SEEDED` never see a partially-written seed. +pub fn seed_from_slice(seed: &[u8]) -> Result<(), crate::Error> { + if seed.len() < SEED_LEN { + return Err(crate::Error::InvalidSeed); + } + for (i, word) in SEED.iter().enumerate() { + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&seed[i * 8..i * 8 + 8]); + word.store(u64::from_ne_bytes(bytes), Ordering::Release); + } + COUNTER.store(0, Ordering::Relaxed); + SEEDED.store(true, Ordering::Release); + Ok(()) +} + +/// Whether the process-wide DRBG has been seeded. +pub fn is_seeded() -> bool { + SEEDED.load(Ordering::Acquire) +} + +/// Read the process-wide seed as `(key, nonce)`. +fn read_seed() -> ([u8; KEY_LEN], [u8; NONCE_LEN]) { + let mut seed = [0u8; SEED_LEN]; + for (i, word) in SEED.iter().enumerate() { + seed[i * 8..i * 8 + 8].copy_from_slice(&word.load(Ordering::Acquire).to_ne_bytes()); + } + let mut key = [0u8; KEY_LEN]; + let mut nonce = [0u8; NONCE_LEN]; + key.copy_from_slice(&seed[..KEY_LEN]); + nonce.copy_from_slice(&seed[KEY_LEN..SEED_LEN]); + (key, nonce) +} + +/// Fill `dest` from the process-wide DRBG. +pub fn fill(dest: &mut [u8]) -> Result<(), crate::Error> { + if !is_seeded() { + return Err(crate::Error::DrbgNotSeeded); + } + let (key, nonce) = read_seed(); + let blocks = dest.len().div_ceil(BLOCK_LEN); + let start = COUNTER.fetch_add(blocks as u64, Ordering::Relaxed); + for i in 0..blocks { + let block = keystream_block(&key, &nonce, start + i as u64)?; + let from = i * BLOCK_LEN; + let to = core::cmp::min(from + BLOCK_LEN, dest.len()); + dest[from..to].copy_from_slice(&block[..to - from]); + } + Ok(()) +} + +/// Fill potentially uninitialized `dest` from the process-wide DRBG. +pub fn fill_uninit(dest: &mut [core::mem::MaybeUninit]) -> Result<(), crate::Error> { + // SAFETY: `MaybeUninit` carries no validity constraints, so writing + // initialized bytes through an `&mut [u8]` view is always sound. + let bytes = + unsafe { core::slice::from_raw_parts_mut(dest.as_mut_ptr() as *mut u8, dest.len()) }; + fill(bytes) +} diff --git a/Build/crates/saikuro-random/src/lib.rs b/Build/crates/saikuro-random/src/lib.rs new file mode 100644 index 00000000..17f5950d --- /dev/null +++ b/Build/crates/saikuro-random/src/lib.rs @@ -0,0 +1,172 @@ +//! Randomness and entropy facade for Saikuro. +//! +//! Hides the platform entropy source behind a small `no_std` API so that +//! protocol types do not depend on a specific RNG crate. +//! +//! Backend selection mirrors saikuro-exec: a binary selects its +//! entropy source with cargo features +//! +//! # Determinism +//! +//! Enabling `drbg` makes every call reproducible for a given seed, which is +//! the only way to write deterministic tests over code that issues UUIDs. + +#![no_std] + +#[cfg(feature = "std")] +extern crate std; + +use core::mem::MaybeUninit; + +#[cfg(feature = "drbg")] +mod drbg; + +pub use uuid::Uuid; + +/// Deterministic, seedable ChaCha20 DRBG. +/// +/// Available with the `drbg` feature. Local instances are fully deterministic: +/// identical seeds produce identical streams, which makes them usable in +/// reproducible tests and as the entropy core for MCUs without a hardware RNG. +#[cfg(feature = "drbg")] +pub use drbg::Drbg; + +/// Errors produced by the entropy facade. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Error { + /// The getrandom-based backend failed to produce entropy. + #[cfg(any(feature = "os", feature = "wasm", feature = "custom"))] + Backend(getrandom::Error), + /// The DRBG backend was used before being seeded. + #[cfg(feature = "drbg")] + DrbgNotSeeded, + /// The seed passed to the DRBG was too short. + #[cfg(feature = "drbg")] + InvalidSeed, + /// The DRBG keystream for the current seed has been exhausted. + #[cfg(feature = "drbg")] + DrbgExhausted, +} + +impl core::fmt::Display for Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + #[cfg(any(feature = "os", feature = "wasm", feature = "custom"))] + Error::Backend(e) => write!(f, "entropy backend failed: {e}"), + #[cfg(feature = "drbg")] + Error::DrbgNotSeeded => write!(f, "DRBG used before being seeded"), + #[cfg(feature = "drbg")] + Error::InvalidSeed => write!(f, "DRBG seed must be at least 56 bytes"), + #[cfg(feature = "drbg")] + Error::DrbgExhausted => write!(f, "DRBG keystream exhausted; reseed required"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +#[cfg(any(feature = "os", feature = "wasm", feature = "custom"))] +impl From for Error { + fn from(err: getrandom::Error) -> Self { + Error::Backend(err) + } +} + +/// Fill `dest` with cryptographically secure random bytes. +/// +/// With the `drbg` feature the DRBG backend is used instead; it must be +/// seeded first via [`seed_from_slice`]. +pub fn fill(dest: &mut [u8]) -> Result<(), Error> { + fill_impl(dest) +} + +/// Fill potentially uninitialized `dest` with random bytes. +/// +/// Semantics match [`getrandom::fill_uninit`]: every byte is initialized on +/// success, even in error paths the buffer may be partially written. +pub fn fill_uninit(dest: &mut [MaybeUninit]) -> Result<(), Error> { + fill_uninit_impl(dest) +} + +/// Draw a random `u32` from the active backend. +pub fn u32() -> Result { + let mut bytes = [0u8; 4]; + fill(&mut bytes)?; + Ok(u32::from_ne_bytes(bytes)) +} + +/// Draw a random `u64` from the active backend. +pub fn u64() -> Result { + let mut bytes = [0u8; 8]; + fill(&mut bytes)?; + Ok(u64::from_ne_bytes(bytes)) +} + +/// Generate a random RFC 4122 version 4 UUID. +/// +/// The 16 random bytes come from the active backend; version and variant bits +/// are set per RFC 9562 section 5.8. +pub fn uuid_v4() -> Result { + let mut bytes = [0u8; 16]; + fill(&mut bytes)?; + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Ok(Uuid::from_bytes(bytes)) +} + +/// Seed the DRBG backend from `seed`. +/// +/// The seed must be at least 56 bytes; the first 32 bytes form the ChaCha20 +/// key and the next 24 the XChaCha20 nonce. Call once at startup, before any +/// concurrent `fill` call (the seed bytes are written before tasks spawn, so +/// readers never observe a partially-written seed). Only available with the +/// `drbg` feature. +#[cfg(feature = "drbg")] +pub fn seed_from_slice(seed: &[u8]) -> Result<(), Error> { + drbg::seed_from_slice(seed) +} + +/// Report whether the DRBG backend has been seeded. +#[cfg(feature = "drbg")] +pub fn is_seeded() -> bool { + drbg::is_seeded() +} + +#[cfg(feature = "drbg")] +fn fill_impl(dest: &mut [u8]) -> Result<(), Error> { + drbg::fill(dest) +} + +#[cfg(feature = "drbg")] +fn fill_uninit_impl(dest: &mut [MaybeUninit]) -> Result<(), Error> { + drbg::fill_uninit(dest) +} + +#[cfg(all( + not(feature = "drbg"), + any(feature = "os", feature = "wasm", feature = "custom") +))] +fn fill_impl(dest: &mut [u8]) -> Result<(), Error> { + getrandom::fill(dest).map_err(Error::from) +} + +#[cfg(all( + not(feature = "drbg"), + any(feature = "os", feature = "wasm", feature = "custom") +))] +fn fill_uninit_impl(dest: &mut [MaybeUninit]) -> Result<(), Error> { + getrandom::fill_uninit(dest) + .map_err(Error::from) + .map(|_| ()) +} + +#[cfg(all( + not(feature = "os"), + not(feature = "wasm"), + not(feature = "custom"), + not(feature = "drbg") +))] +compile_error!( + "saikuro-random requires exactly one entropy backend: enable `os`, `wasm`, `custom`, or `drbg`" +); diff --git a/Build/crates/saikuro-random/tests/drbg.rs b/Build/crates/saikuro-random/tests/drbg.rs new file mode 100644 index 00000000..ea6fa335 --- /dev/null +++ b/Build/crates/saikuro-random/tests/drbg.rs @@ -0,0 +1,109 @@ +#![cfg(feature = "drbg")] + +use chacha20::cipher::{KeyIvInit, StreamCipher}; +use chacha20::XChaCha20; +use saikuro_random::{fill, is_seeded, seed_from_slice, Drbg, Error}; + +const SEED_LEN: usize = 56; +const KEY_LEN: usize = 32; +const NONCE_LEN: usize = 24; +const BLOCK_LEN: usize = 64; + +const SEED_ONE: [u8; SEED_LEN] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, +]; + +#[test] +fn same_seed_is_reproducible() { + let mut a = Drbg::from_seed(&SEED_ONE).expect("valid seed"); + let mut b = Drbg::from_seed(&SEED_ONE).expect("valid seed"); + let mut buf_a = [0u8; 128]; + let mut buf_b = [0u8; 128]; + a.fill(&mut buf_a).expect("fill ok"); + b.fill(&mut buf_b).expect("fill ok"); + assert_eq!(buf_a, buf_b); +} + +#[test] +fn different_seeds_diverge() { + let mut a = Drbg::from_seed(&SEED_ONE).expect("valid seed"); + let mut b = Drbg::from_seed(&[0xee; SEED_LEN]).expect("valid seed"); + let mut buf_a = [0u8; 64]; + let mut buf_b = [0u8; 64]; + a.fill(&mut buf_a).expect("fill ok"); + b.fill(&mut buf_b).expect("fill ok"); + assert_ne!(buf_a, buf_b); +} + +#[test] +fn drbg_matches_reference_stream() { + let mut drbg = Drbg::from_seed(&SEED_ONE).expect("valid seed"); + let mut buf = [0u8; 128]; + drbg.fill(&mut buf).expect("fill ok"); + + let key = &SEED_ONE[..KEY_LEN]; + let nonce = &SEED_ONE[KEY_LEN..SEED_LEN]; + let mut cipher = XChaCha20::new_from_slices(key, nonce).expect("valid lengths"); + let mut block_zero = [0u8; BLOCK_LEN]; + cipher.apply_keystream(&mut block_zero); + assert_eq!( + buf[..BLOCK_LEN], + block_zero, + "seek(0) must equal the first keystream block" + ); + let mut block_one = [0u8; BLOCK_LEN]; + cipher.apply_keystream(&mut block_one); + assert_eq!( + buf[BLOCK_LEN..], + block_one, + "sequential blocks must be contiguous" + ); +} + +#[test] +fn short_seed_is_rejected() { + assert_eq!(Drbg::from_seed(&[0u8; 8]), Err(Error::InvalidSeed)); +} + +#[test] +fn global_stream_matches_a_seeded_local_drbg_and_advances() { + seed_from_slice(&SEED_ONE).expect("valid seed"); + assert!(is_seeded()); + + let mut first = [0u8; 32]; + fill(&mut first).expect("seeded fill ok"); + let mut second = [0u8; 32]; + fill(&mut second).expect("seeded fill ok"); + + let mut drbg = Drbg::from_seed(&SEED_ONE).expect("valid seed"); + let mut expected = [0u8; 128]; + drbg.fill(&mut expected).expect("fill ok"); + assert_eq!( + &first[..], + &expected[..32], + "first draw must match the head of the stream" + ); + assert_eq!( + &second[..], + &expected[64..96], + "second draw must continue the stream at the next block" + ); +} + +#[test] +fn fill_uninit_initializes_every_byte() { + let mut drbg = Drbg::from_seed(&SEED_ONE).expect("valid seed"); + let mut buf = [core::mem::MaybeUninit::::uninit(); 16]; + drbg.fill_uninit(&mut buf).expect("fill ok"); + let buf = buf.map(|slot| { + // SAFETY: fill_uninit initialized every slot on Ok. + unsafe { slot.assume_init() } + }); + let mut expected = [0u8; 16]; + let mut probe = Drbg::from_seed(&SEED_ONE).expect("valid seed"); + probe.fill(&mut expected).expect("fill ok"); + assert_eq!(&buf[..], &expected[..]); +} diff --git a/Build/crates/saikuro-random/tests/drbg_unseeded.rs b/Build/crates/saikuro-random/tests/drbg_unseeded.rs new file mode 100644 index 00000000..e3a9828b --- /dev/null +++ b/Build/crates/saikuro-random/tests/drbg_unseeded.rs @@ -0,0 +1,14 @@ +//! The process-wide DRBG must refuse to draw before seeding. +//! +//! This lives in its own test binary so its statics start unseeded btw + +#![cfg(feature = "drbg")] + +use saikuro_random::{fill, is_seeded, Error}; + +#[test] +fn unseeded_fill_errors() { + assert!(!is_seeded()); + let mut buf = [0u8; 16]; + assert_eq!(fill(&mut buf), Err(Error::DrbgNotSeeded)); +} diff --git a/Build/crates/saikuro-random/tests/os_backend.rs b/Build/crates/saikuro-random/tests/os_backend.rs new file mode 100644 index 00000000..2de2fd4a --- /dev/null +++ b/Build/crates/saikuro-random/tests/os_backend.rs @@ -0,0 +1,39 @@ +#![cfg(all( + not(feature = "drbg"), + any(feature = "os", feature = "wasm", feature = "custom") +))] + +use saikuro_random::{u32, u64, uuid_v4}; + +#[test] +fn uuid_v4_sets_version_and_variant_bits() { + let uuid = uuid_v4().expect("entropy available"); + assert_eq!(uuid.get_version_num(), 4); + let bytes = uuid.as_bytes(); + assert_eq!(bytes[6] >> 4, 4); + assert_eq!(bytes[8] & 0xc0, 0x80); +} + +#[test] +fn generated_uuids_are_unique() { + let mut seen = std::collections::BTreeSet::new(); + for _ in 0..64 { + let uuid = uuid_v4().expect("entropy available"); + assert!(seen.insert(uuid), "duplicate uuid {uuid}"); + } +} + +#[test] +fn u32_and_u64_draws_are_sane() { + let mut words = std::collections::BTreeSet::new(); + for _ in 0..4 { + words.insert(u32().expect("entropy available")); + } + assert!( + words.len() >= 2, + "four draws all colliding is statistically impossible" + ); + + let x = u64().expect("entropy available"); + assert!(x != 0 || u64().expect("entropy available") != 0); +} diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index e3e50de0..28a1d281 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -21,7 +21,6 @@ thiserror = { workspace = true } tracing = { workspace = true } dashmap = { workspace = true } saikuro-exec = { workspace = true, default-features = false } -uuid = { workspace = true } [dev-dependencies] tracing-subscriber = { workspace = true } diff --git a/Build/crates/saikuro-runtime/Cargo.toml b/Build/crates/saikuro-runtime/Cargo.toml index e08737b5..e1d27acb 100644 --- a/Build/crates/saikuro-runtime/Cargo.toml +++ b/Build/crates/saikuro-runtime/Cargo.toml @@ -17,7 +17,7 @@ required-features = ["native-transport"] default = ["native-transport"] native-transport = ["saikuro-transport/native-transport", "saikuro-exec/tokio-runtime"] ws-transport = ["saikuro-transport/ws-transport", "dep:tokio-tungstenite", "dep:tungstenite"] -wasm-runtime = ["saikuro-exec/wasm-runtime"] +wasm-runtime = ["saikuro-exec/wasm-runtime", "saikuro-random/wasm"] [dependencies] saikuro-core = { workspace = true } @@ -25,6 +25,7 @@ saikuro-schema = { workspace = true } saikuro-transport = { workspace = true } saikuro-router = { workspace = true } saikuro-exec = { workspace = true, default-features = false } +saikuro-random = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -37,7 +38,6 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } dashmap = { workspace = true } parking_lot = { workspace = true } -uuid = { workspace = true } serde_with = { workspace = true } # CLI and error handling for binary diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index 54bc32ab..929ded7c 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -45,7 +45,6 @@ tungstenite = { version = "0.30", optional = true } # WASM host transport + WASM WebSocket [target.'cfg(target_arch = "wasm32")'.dependencies] -getrandom = { version = "0.4.2", features = ["wasm_js"] } send_wrapper = "0.6" wasm-bindgen = "0.2" js-sys = "0.3" diff --git a/Build/deny.toml b/Build/deny.toml index 8dd79acc..e4d1303e 100644 --- a/Build/deny.toml +++ b/Build/deny.toml @@ -58,8 +58,10 @@ skip = [ { name = "thiserror-impl" }, { name = "windows-sys" }, # tungstenite -> rand 0.8 -> rand_core -> getrandom 0.2 - # uuid -> getrandom 0.4 - # Both chains are external; nothing in the workspace controls either pin. + # tungstenite -> rand 0.10 -> getrandom 0.4 + # saikuro-random -> getrandom 0.3 + # All pins except 0.3 are external (from the tungstenite/rand WebSocket + # stack); nothing in the workspace controls them. { name = "getrandom" }, ] diff --git a/Build/tests/Cargo.toml b/Build/tests/Cargo.toml index 761283d2..58211a01 100644 --- a/Build/tests/Cargo.toml +++ b/Build/tests/Cargo.toml @@ -33,6 +33,9 @@ saikuro-runtime = { workspace = true, features = ["native-transport"] } [target.'cfg(target_arch = "wasm32")'.dependencies] saikuro-transport = { workspace = true, features = ["wasm-runtime"] } saikuro-runtime = { workspace = true, default-features = false } +# WASM has no OS entropy; saikuro-core draws randomness via saikuro-random, +# so the whole graph must use the wasm_js backend on this target. +saikuro-random = { workspace = true, features = ["wasm"] } wasm-bindgen = "0.2" wasm-bindgen-test = "0.3" wasm-bindgen-futures = "0.4" From 00b41c09afd7a3da65735715de9de80c13e26f88 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Tue, 4 Aug 2026 02:59:07 -0600 Subject: [PATCH 02/43] saikuro-exec backend for embedded --- Build/Cargo.lock | 176 +++- Build/Cargo.toml | 21 +- Build/adapters/c/tests/c_api_runtime.rs | 53 +- Build/adapters/c/tests/cpp_wrapper_runtime.rs | 106 +- Build/adapters/rust/src/schema.rs | 65 +- Build/crates/saikuro-codegen/src/generator.rs | 6 +- .../saikuro-codegen/tests/c_cpp_codegen.rs | 28 +- Build/crates/saikuro-core/Cargo.toml | 25 +- Build/crates/saikuro-core/src/capability.rs | 45 +- Build/crates/saikuro-core/src/envelope.rs | 19 +- Build/crates/saikuro-core/src/error.rs | 60 +- Build/crates/saikuro-core/src/invocation.rs | 9 +- Build/crates/saikuro-core/src/lib.rs | 17 +- Build/crates/saikuro-core/src/log.rs | 52 +- Build/crates/saikuro-core/src/resource.rs | 23 +- Build/crates/saikuro-core/src/schema.rs | 38 +- Build/crates/saikuro-core/src/value.rs | 144 ++- Build/crates/saikuro-exec/Cargo.toml | 14 +- .../saikuro-exec/src/embassy_backend.rs | 948 +++++++++++++++++- Build/crates/saikuro-exec/src/lib.rs | 23 + Build/crates/saikuro-random/src/drbg.rs | 3 + .../crates/saikuro-runtime/src/connection.rs | 31 +- Build/crates/saikuro-runtime/src/lib.rs | 34 +- Build/crates/saikuro-schema/src/registry.rs | 14 +- Build/tests/tests/capability_enforcement.rs | 19 +- Build/tests/tests/codegen_output.rs | 365 ++++--- Build/tests/tests/common/mod.rs | 54 +- Build/tests/tests/cross_language_wire.rs | 54 +- Build/tests/tests/envelope_roundtrip.rs | 14 +- Build/tests/tests/error_propagation.rs | 7 +- Build/tests/tests/resource_dispatch.rs | 8 +- Build/tests/tests/sandbox_dispatch.rs | 128 +-- Build/tests/tests/schema_validation.rs | 97 +- 33 files changed, 2062 insertions(+), 638 deletions(-) diff --git a/Build/Cargo.lock b/Build/Cargo.lock index c7c7713d..6cc6c88f 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -207,10 +207,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", - "js-sys", "num-traits", "serde", - "wasm-bindgen", "windows-link", ] @@ -434,12 +432,114 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-sync" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d2c8cdff05a7a51ba0087489ea44b0b1d97a296ca6b1d6d1a33ea7423d34049" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async", + "futures-sink", + "futures-util", + "heapless", +] + +[[package]] +name = "embassy-time" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158080d48f824fad101d7b2fae2d83ac39e3f7a6fa01811034f7ab8ffc6e7309" +dependencies = [ + "cfg-if", + "critical-section", + "document-features", + "embassy-time-driver", + "embassy-time-queue-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-util", + "heapless", +] + +[[package]] +name = "embassy-time-driver" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0c214077aaa9206958b16411c157961fb7990d4ea628120a78d1a5a28aed24" +dependencies = [ + "document-features", +] + +[[package]] +name = "embassy-time-queue-driver" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1177859559ebf42cd24ae7ba8fe6ee707489b01d0bf471f8827b7b12dcb0bc0" + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -649,6 +749,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -688,6 +797,17 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "serde", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -852,6 +972,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -903,6 +1029,21 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1199,17 +1340,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rmpv" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" -dependencies = [ - "rmp", - "serde", - "serde_bytes", -] - [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -1297,15 +1427,12 @@ dependencies = [ name = "saikuro-core" version = "0.1.0" dependencies = [ - "bytes", - "chrono", + "heapless", "rmp-serde", - "rmpv", "saikuro-random", "serde", "serde_bytes", "serde_json", - "serde_with", "strum", "thiserror 2.0.18", "uuid", @@ -1315,6 +1442,9 @@ dependencies = [ name = "saikuro-exec" version = "0.1.0" dependencies = [ + "embassy-futures", + "embassy-sync", + "embassy-time", "fluvio-wasm-timer", "futures", "tokio", @@ -1693,6 +1823,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -2035,6 +2171,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Build/Cargo.toml b/Build/Cargo.toml index 1cb534b3..b5e63367 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -26,12 +26,12 @@ rust-version = "1.75" [workspace.dependencies] # Serialization serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde_json = { version = "1.0", default-features = false } serde_bytes = "0.11" rmp-serde = "1.3" rmpv = { version = "1.3", features = ["with-serde"] } bytes = "1.7" -futures = "0.3" +futures = { version = "0.3", default-features = false, features = ["alloc"] } async-trait = "0.1" pin-project-lite = "0.2" @@ -51,6 +51,14 @@ chacha20 = { version = "0.9", default-features = false } # all. Native instructions are still used where available. portable-atomic = { version = "1", default-features = false, features = ["fallback", "critical-section"] } +# Fixed-capacity collections for no_std builds. `BTreeMap` does not exist in +# heapless. IndexMap/IndexSet give deterministic (insertion-ordered) maps with a +# compile-time capacity and a serde impl that errors on overflow. +# heapless 0.7 is unusable here: its FnvIndexMap keys must implement the +# hash32::Hash trait, which `String` does not. 0.8 keys use the standard +# `Hash` trait (foldhash backend, no_std-safe). +heapless = { version = "0.8", default-features = false, features = ["serde"] } + # Duration / utility serde helpers serde_with = "3.0" @@ -62,8 +70,13 @@ strum = { version = "0.28.0", features = ["derive"] } tracing = "0.1" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt", "json"] } +# Embedded async runtime (embassy) for no_std MCU targets +embassy-sync = { version = "0.6", default-features = false } +embassy-time = { version = "0.3", default-features = false } +embassy-futures = { version = "0.1", default-features = false } + # Error handling: both are wasm-safe -thiserror = "2.0" +thiserror = { version = "2.0", default-features = false } anyhow = "1.0" # Concurrency: dashmap and parking_lot are wasm-safe for single-threaded WASM i think @@ -82,5 +95,5 @@ saikuro-router = { path = "crates/saikuro-router", default-features = false } saikuro-runtime = { path = "crates/saikuro-runtime", default-features = false } saikuro-codegen = { path = "crates/saikuro-codegen" } saikuro-exec = { path = "crates/saikuro-exec", default-features = false } -saikuro-random = { path = "crates/saikuro-random" } +saikuro-random = { path = "crates/saikuro-random", default-features = false } saikuro = { path = "adapters/rust", default-features = false } diff --git a/Build/adapters/c/tests/c_api_runtime.rs b/Build/adapters/c/tests/c_api_runtime.rs index f549276d..ce806f64 100644 --- a/Build/adapters/c/tests/c_api_runtime.rs +++ b/Build/adapters/c/tests/c_api_runtime.rs @@ -11,7 +11,10 @@ use saikuro_c::{ use saikuro_core::{ capability::CapabilitySet, envelope::{Envelope, InvocationType}, - schema::{FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility}, + schema::{ + FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, + TypeDescriptor, TypeMap, Visibility, + }, value::Value, ResponseEnvelope, }; @@ -34,32 +37,36 @@ fn make_schema(namespace: &str, function: &str, n_args: usize) -> Schema { }) .collect(); - let mut functions = std::collections::HashMap::new(); - functions.insert( - function.to_owned(), - FunctionSchema { - args, - returns: TypeDescriptor::primitive(PrimitiveType::Any), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ); + let mut functions = FunctionMap::new(); + functions + .insert( + function.to_owned(), + FunctionSchema { + args, + returns: TypeDescriptor::primitive(PrimitiveType::Any), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .ok(); - let mut namespaces = std::collections::HashMap::new(); - namespaces.insert( - namespace.to_owned(), - NamespaceSchema { - functions, - doc: None, - }, - ); + let mut namespaces = NamespaceMap::new(); + namespaces + .insert( + namespace.to_owned(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .ok(); Schema { version: 1, - namespaces, - types: std::collections::HashMap::new(), + namespaces: Box::new(namespaces), + types: Box::new(TypeMap::new()), } } diff --git a/Build/adapters/c/tests/cpp_wrapper_runtime.rs b/Build/adapters/c/tests/cpp_wrapper_runtime.rs index 024550d9..4f161fca 100644 --- a/Build/adapters/c/tests/cpp_wrapper_runtime.rs +++ b/Build/adapters/c/tests/cpp_wrapper_runtime.rs @@ -12,8 +12,8 @@ use saikuro_core::{ capability::CapabilitySet, envelope::{Envelope, InvocationType}, schema::{ - ArgumentDescriptor, FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, - Visibility, + ArgumentDescriptor, FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, + PrimitiveType, Schema, TypeDescriptor, TypeMap, Visibility, }, value::Value, ResponseEnvelope, @@ -192,58 +192,64 @@ fn spawn_runtime_for_cpp_client() -> (String, thread::JoinHandle<()>) { let done_tx = Arc::new(Mutex::new(Some(done_tx))); let call_count = Arc::new(AtomicUsize::new(0)); - let mut functions = std::collections::HashMap::new(); - functions.insert( - "add".to_owned(), - FunctionSchema { - args: vec![ - ArgumentDescriptor { - name: "a".to_owned(), - r#type: TypeDescriptor::primitive(PrimitiveType::I64), - optional: false, - default: None, - doc: None, - }, - ArgumentDescriptor { - name: "b".to_owned(), - r#type: TypeDescriptor::primitive(PrimitiveType::I64), - optional: false, - default: None, - doc: None, + let mut functions = FunctionMap::new(); + functions + .insert( + "add".to_owned(), + FunctionSchema { + args: vec![ + ArgumentDescriptor { + name: "a".to_owned(), + r#type: TypeDescriptor::primitive(PrimitiveType::I64), + optional: false, + default: None, + doc: None, + }, + ArgumentDescriptor { + name: "b".to_owned(), + r#type: TypeDescriptor::primitive(PrimitiveType::I64), + optional: false, + default: None, + doc: None, + }, + ], + returns: TypeDescriptor::primitive(PrimitiveType::Any), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .ok(); + functions + .insert( + "watch".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::Stream { + item: Box::new(TypeDescriptor::primitive(PrimitiveType::Any)), }, - ], - returns: TypeDescriptor::primitive(PrimitiveType::Any), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ); - functions.insert( - "watch".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::Stream { - item: Box::new(TypeDescriptor::primitive(PrimitiveType::Any)), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, }, - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ); - let mut namespaces = std::collections::HashMap::new(); - namespaces.insert( - "math".to_owned(), - NamespaceSchema { - functions, - doc: None, - }, - ); + ) + .ok(); + let mut namespaces = NamespaceMap::new(); + namespaces + .insert( + "math".to_owned(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .ok(); let schema = Schema { version: 1, - namespaces, - types: std::collections::HashMap::new(), + namespaces: Box::new(namespaces), + types: Box::new(TypeMap::new()), }; handle .register_schema(schema, "cpp-runtime-provider") diff --git a/Build/adapters/rust/src/schema.rs b/Build/adapters/rust/src/schema.rs index 610462e5..c0dfa1dc 100644 --- a/Build/adapters/rust/src/schema.rs +++ b/Build/adapters/rust/src/schema.rs @@ -56,42 +56,39 @@ impl NamespaceSchema { /// Convert to the core `NamespaceSchema` for announcement. pub fn to_core(&self) -> CoreNamespaceSchema { - let functions: HashMap = self - .functions - .iter() - .map(|(name, fs)| { - let args: Vec = fs - .args - .iter() - .map(|a| ArgumentDescriptor { - name: a.name.clone(), - r#type: a.r#type.clone(), - optional: a.optional, - doc: a.doc.clone(), - default: None, - }) - .collect(); + let mut functions = saikuro_core::schema::FunctionMap::new(); + for (name, fs) in &self.functions { + let args: Vec = fs + .args + .iter() + .map(|a| ArgumentDescriptor { + name: a.name.clone(), + r#type: a.r#type.clone(), + optional: a.optional, + doc: a.doc.clone(), + default: None, + }) + .collect(); - let core_fn = CoreFunctionSchema { - args, - returns: fs.returns.clone().unwrap_or(TypeDescriptor::Primitive { - r#type: PrimitiveType::Any, - }), - visibility: fs.visibility, - capabilities: fs - .capabilities - .iter() - .map(|s| saikuro_core::capability::CapabilityToken::from(s.as_str())) - .collect(), - idempotent: fs.idempotent, - doc: fs.doc.clone(), - }; - (name.clone(), core_fn) - }) - .collect(); + let core_fn = CoreFunctionSchema { + args, + returns: fs.returns.clone().unwrap_or(TypeDescriptor::Primitive { + r#type: PrimitiveType::Any, + }), + visibility: fs.visibility, + capabilities: fs + .capabilities + .iter() + .map(|s| saikuro_core::capability::CapabilityToken::from(s.as_str())) + .collect(), + idempotent: fs.idempotent, + doc: fs.doc.clone(), + }; + functions.insert(name.clone(), core_fn).ok(); + } CoreNamespaceSchema { - functions, + functions: Box::new(functions), doc: self.doc.clone(), } } @@ -101,7 +98,7 @@ impl NamespaceSchema { pub(crate) fn build_schema(namespaces: &HashMap) -> Schema { let mut schema = Schema::new(); for (ns_name, ns) in namespaces { - schema.namespaces.insert(ns_name.clone(), ns.to_core()); + schema.namespaces.insert(ns_name.clone(), ns.to_core()).ok(); } schema } diff --git a/Build/crates/saikuro-codegen/src/generator.rs b/Build/crates/saikuro-codegen/src/generator.rs index 8d21d1e9..5da03efb 100644 --- a/Build/crates/saikuro-codegen/src/generator.rs +++ b/Build/crates/saikuro-codegen/src/generator.rs @@ -1,9 +1,7 @@ //! Common generator traits and output types. -use std::collections::BTreeMap; - use saikuro_core::schema::{ - FieldDescriptor, FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDefinition, + FieldMap, FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDefinition, TypeDescriptor, Visibility, }; @@ -148,7 +146,7 @@ pub fn convert_type(desc: &TypeDescriptor, conv: &impl TypeConverter) -> String pub fn generate_types_from_schema( schema: &Schema, header: Vec, - mut on_record: impl FnMut(&str, &BTreeMap) -> Result>, + mut on_record: impl FnMut(&str, &FieldMap) -> Result>, mut on_enum: impl FnMut(&str, &[String]) -> Result>, mut on_alias: impl FnMut(&str, &TypeDescriptor) -> Result>, ) -> Result { diff --git a/Build/crates/saikuro-codegen/tests/c_cpp_codegen.rs b/Build/crates/saikuro-codegen/tests/c_cpp_codegen.rs index 5786cffd..58372bdb 100644 --- a/Build/crates/saikuro-codegen/tests/c_cpp_codegen.rs +++ b/Build/crates/saikuro-codegen/tests/c_cpp_codegen.rs @@ -9,23 +9,25 @@ fn sample_schema() -> Schema { let mut schema = Schema::new(); let mut ns = NamespaceSchema { - functions: Default::default(), + functions: Box::default(), doc: Some("Math functions".to_owned()), }; - ns.functions.insert( - "add".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::I64), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: true, - doc: Some("Add two values".to_owned()), - }, - ); + ns.functions + .insert( + "add".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::I64), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: true, + doc: Some("Add two values".to_owned()), + }, + ) + .ok(); - schema.namespaces.insert("math".to_owned(), ns); + schema.namespaces.insert("math".to_owned(), ns).ok(); schema } diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index 5b02e7e5..628a4598 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -8,16 +8,25 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "rpc", "msgpack"] +# The crate is always `no_std` + `alloc`. The default `std` feature adds the +# std-only conveniences (msgpack codec helpers, `std::io::Error` variant) and +# selects the OS entropy backend; `custom` selects the caller-provided getrandom +# backend for bare-metal targets (see saikuro-random). +[features] +default = ["std"] +std = ["dep:rmp-serde", "saikuro-random/os"] +custom = ["saikuro-random/custom"] + [dependencies] serde = { workspace = true } -serde_json = { workspace = true } +# serde_json is used for schema tooling and the stderr log sink; `alloc` +# keeps it no_std-compatible. +serde_json = { workspace = true, default-features = false, features = ["alloc"] } serde_bytes = { workspace = true } -rmp-serde = { workspace = true } -rmpv = { workspace = true } -bytes = { workspace = true } uuid = { workspace = true } -saikuro-random = { workspace = true } -thiserror = { workspace = true } -chrono = { workspace = true } -serde_with = { workspace = true } +saikuro-random = { workspace = true, default-features = false } +thiserror = { workspace = true, default-features = false } strum = { workspace = true } +heapless = { workspace = true } +# msgpack helpers on Envelope/ResponseEnvelope; rmp-serde is std-only. +rmp-serde = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-core/src/capability.rs b/Build/crates/saikuro-core/src/capability.rs index 0e531f14..2b24fb2b 100644 --- a/Build/crates/saikuro-core/src/capability.rs +++ b/Build/crates/saikuro-core/src/capability.rs @@ -9,14 +9,19 @@ //! A [`CapabilitySet`] is the collection of tokens held by a connected peer, //! issued during the handshake phase. +use alloc::string::String; +use core::fmt; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use std::fmt; -use std::sync::OnceLock; /// Sentinel token value that grants access to all capabilities. pub const WILDCARD_TOKEN: &str = "*"; +/// Maximum number of distinct capability tokens a peer can hold. +pub const CAPABILITY_SET_CAPACITY: usize = 256; + +/// Fixed-capacity set of capability tokens held by a peer. +pub type TokenSet = heapless::FnvIndexSet; + /// A single capability token : a namespaced, human-readable permission string. /// /// By convention tokens are dot-separated: `"."`. @@ -63,7 +68,7 @@ impl From for CapabilityToken { /// on every invocation. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CapabilitySet { - tokens: HashSet, + tokens: TokenSet, } impl CapabilitySet { @@ -73,10 +78,19 @@ impl CapabilitySet { } /// Construct a set from an iterator of tokens. - pub fn from_tokens(iter: impl IntoIterator) -> Self { - Self { - tokens: iter.into_iter().collect(), + /// + /// Fails if the iterator yields more than [`CAPABILITY_SET_CAPACITY`] + /// distinct tokens. + pub fn from_tokens( + iter: impl IntoIterator, + ) -> Result { + let mut tokens = TokenSet::new(); + for token in iter { + tokens + .insert(token) + .map_err(|_| "capability set capacity exceeded")?; } + Ok(Self { tokens }) } /// Construct an unrestricted set that passes all capability checks. @@ -85,13 +99,15 @@ impl CapabilitySet { // Sentinel: we use a special token that the capability engine // recognises as granting everything. Self::from_tokens([CapabilityToken::new(WILDCARD_TOKEN)]) + .expect("wildcard token always fits in CAPABILITY_SET_CAPACITY") } /// Return `true` if this set grants the given capability. /// /// The wildcard token `"*"` grants every capability. pub fn grants(&self, required: &CapabilityToken) -> bool { - self.tokens.contains(wildcard_token()) || self.tokens.contains(required) + self.tokens.contains(&CapabilityToken::new(WILDCARD_TOKEN)) + || self.tokens.contains(required) } /// Return `true` if this set satisfies *all* of the required capabilities. @@ -100,8 +116,11 @@ impl CapabilitySet { } /// Add a token to the set. - pub fn insert(&mut self, token: CapabilityToken) { - self.tokens.insert(token); + /// + /// Fails (returning the token) if the set is already at + /// [`CAPABILITY_SET_CAPACITY`] distinct tokens. + pub fn insert(&mut self, token: CapabilityToken) -> Result { + self.tokens.insert(token) } /// Return an iterator over all tokens in the set. @@ -119,9 +138,3 @@ impl CapabilitySet { self.tokens.is_empty() } } - -static WILDCARD: OnceLock = OnceLock::new(); - -fn wildcard_token() -> &'static CapabilityToken { - WILDCARD.get_or_init(|| CapabilityToken::new(WILDCARD_TOKEN)) -} diff --git a/Build/crates/saikuro-core/src/envelope.rs b/Build/crates/saikuro-core/src/envelope.rs index e1c9abd7..fbc853c8 100644 --- a/Build/crates/saikuro-core/src/envelope.rs +++ b/Build/crates/saikuro-core/src/envelope.rs @@ -5,13 +5,19 @@ //! serialised to binary using MessagePack (via `rmp-serde`) before transit; //! the types here are the canonical in-memory representation. +use alloc::{borrow::ToOwned, string::String, vec::Vec}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; use crate::{ capability::CapabilityToken, invocation::InvocationId, value::Value, PROTOCOL_VERSION, }; +/// Maximum number of key/value metadata entries an [`Envelope`] can carry. +pub const ENVELOPE_META_CAPACITY: usize = 16; + +/// Fixed-capacity map of metadata entries on an [`Envelope`]. +pub type MetaMap = heapless::FnvIndexMap; + /// The type of an outgoing invocation. /// /// This is the primary discriminator that tells the runtime and the @@ -90,8 +96,8 @@ pub struct Envelope { pub args: Vec, /// Optional key/value metadata bag (trace IDs, deadlines, …). - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub meta: BTreeMap, + #[serde(default, skip_serializing_if = "MetaMap::is_empty")] + pub meta: MetaMap, /// Capability token presented by the caller. Required when the target /// function declares one or more `capabilities`. @@ -113,7 +119,8 @@ pub struct Envelope { pub seq: Option, } -// Shared MessagePack serialization for wire types. +// Shared MessagePack serialization for wire types. rmp-serde is std-only. +#[cfg(feature = "std")] macro_rules! impl_msgpack { ($ty:ty) => { impl $ty { @@ -130,7 +137,9 @@ macro_rules! impl_msgpack { }; } +#[cfg(feature = "std")] impl_msgpack!(Envelope); +#[cfg(feature = "std")] impl_msgpack!(ResponseEnvelope); impl Envelope { @@ -142,7 +151,7 @@ impl Envelope { id: InvocationId::new(), target: target.into(), args, - meta: BTreeMap::new(), + meta: MetaMap::new(), capability: None, batch_items: None, stream_control: None, diff --git a/Build/crates/saikuro-core/src/error.rs b/Build/crates/saikuro-core/src/error.rs index e10d0713..efd3b413 100644 --- a/Build/crates/saikuro-core/src/error.rs +++ b/Build/crates/saikuro-core/src/error.rs @@ -2,18 +2,25 @@ //! //! Errors are modelled at two levels: //! -//! 1. **[`SaikuroError`]** : the Rust `std::error::Error`-implementing type +//! 1. **[`SaikuroError`]** : the Rust `Error`-implementing type //! used throughout the runtime for fallible operations. //! 2. **[`ErrorDetail`]** : the wire representation serialised into //! [`ResponseEnvelope`] when an invocation fails. This is what remote //! adapters receive and surface to their callers. +use alloc::string::{String, ToString}; +use core::fmt; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; use thiserror::Error; use crate::value::Value; +/// Maximum number of structured context entries an [`ErrorDetail`] can carry. +pub const ERROR_DETAIL_CAPACITY: usize = 16; + +/// Fixed-capacity map of structured context entries on [`ErrorDetail`]. +pub type DetailMap = heapless::FnvIndexMap; + /// Machine-readable error codes transmitted on the wire. /// /// Each variant maps to a distinct failure category so that adapters can @@ -76,8 +83,8 @@ pub enum ErrorCode { Internal, } -impl std::fmt::Display for ErrorCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for ErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // Delegate to the derived Debug output which matches the serde names. write!(f, "{self:?}") } @@ -93,8 +100,8 @@ pub struct ErrorDetail { pub message: String, /// Optional structured context (stack traces, field paths, …). - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub details: BTreeMap, + #[serde(default, skip_serializing_if = "DetailMap::is_empty")] + pub details: DetailMap, } impl ErrorDetail { @@ -103,19 +110,31 @@ impl ErrorDetail { Self { code, message: message.into(), - details: BTreeMap::new(), + details: DetailMap::new(), } } /// Add a detail entry and return `self` for chaining. - pub fn with_detail(mut self, key: impl Into, value: impl Into) -> Self { - self.details.insert(key.into(), value.into()); - self + /// + /// Fails with [`SaikuroError::CapacityExceeded`] if the detail bag is + /// already at [`ERROR_DETAIL_CAPACITY`] entries. + pub fn with_detail( + mut self, + key: impl Into, + value: impl Into, + ) -> core::result::Result { + let key = key.into(); + self.details + .insert(key.clone(), value.into()) + .map_err(|_| { + SaikuroError::CapacityExceeded(format!("error detail bag full at key '{key}'")) + })?; + Ok(self) } } -impl std::fmt::Display for ErrorDetail { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for ErrorDetail { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "[{}] {}", self.code, self.message) } } @@ -190,17 +209,25 @@ pub enum SaikuroError { #[error("out-of-order sequence: expected {expected}, got {received}")] OutOfOrder { expected: u64, received: u64 }, - // Serialisation + // Serialisation (rmp-serde is std-only) + #[cfg(feature = "std")] #[error("msgpack encode error: {0}")] MsgpackEncode(#[from] rmp_serde::encode::Error), + #[cfg(feature = "std")] #[error("msgpack decode error: {0}")] MsgpackDecode(#[from] rmp_serde::decode::Error), // I/O + #[cfg(feature = "std")] #[error("I/O error: {0}")] Io(#[from] std::io::Error), + /// A fixed-capacity map reached its compile-time limit + /// (e.g. [`crate::value::VALUE_MAP_CAPACITY`]). + #[error("capacity exceeded: {0}")] + CapacityExceeded(String), + // Catch-all #[error("internal error: {0}")] Internal(String), @@ -228,10 +255,11 @@ impl From for ErrorDetail { SaikuroError::StreamClosed => ErrorCode::StreamClosed, SaikuroError::ChannelClosed => ErrorCode::ChannelClosed, SaikuroError::OutOfOrder { .. } => ErrorCode::OutOfOrder, + #[cfg(feature = "std")] SaikuroError::MsgpackEncode(_) | SaikuroError::MsgpackDecode(_) - | SaikuroError::Io(_) - | SaikuroError::Internal(_) => ErrorCode::Internal, + | SaikuroError::Io(_) => ErrorCode::Internal, + SaikuroError::CapacityExceeded(_) | SaikuroError::Internal(_) => ErrorCode::Internal, }; ErrorDetail::new(code, err.to_string()) @@ -239,4 +267,4 @@ impl From for ErrorDetail { } /// Convenience alias for `Result`. -pub type Result = std::result::Result; +pub type Result = core::result::Result; diff --git a/Build/crates/saikuro-core/src/invocation.rs b/Build/crates/saikuro-core/src/invocation.rs index 4f500f61..26e8e1a3 100644 --- a/Build/crates/saikuro-core/src/invocation.rs +++ b/Build/crates/saikuro-core/src/invocation.rs @@ -5,9 +5,13 @@ //! originating invocation using this identifier. UUIDs v4 are used to ensure //! global uniqueness without coordination. +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; +use core::fmt; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde_bytes::ByteBuf; -use std::fmt; use uuid::Uuid; /// A globally-unique identifier for a single invocation. @@ -126,7 +130,7 @@ impl From for Uuid { } } -impl std::str::FromStr for InvocationId { +impl core::str::FromStr for InvocationId { type Err = uuid::Error; fn from_str(s: &str) -> Result { @@ -137,6 +141,7 @@ impl std::str::FromStr for InvocationId { #[cfg(test)] mod tests { use super::InvocationId; + use alloc::string::ToString; #[test] fn msgpack_roundtrip_uses_binary_uuid() { diff --git a/Build/crates/saikuro-core/src/lib.rs b/Build/crates/saikuro-core/src/lib.rs index 766b407c..75f1a5dc 100644 --- a/Build/crates/saikuro-core/src/lib.rs +++ b/Build/crates/saikuro-core/src/lib.rs @@ -4,6 +4,19 @@ //! for the Saikuro cross-language invocation fabric. Every other crate //! in the workspace depends on this one; it purposely has minimal dependencies //! and zero async code so it can be embedded anywhere. +//! +//! The crate is always `#![no_std]` + `alloc`: strings and vectors come from +//! `alloc`, and all maps/sets are fixed-capacity `heapless` collections. The +//! default `std` feature adds std-only conveniences (the msgpack codec helpers +//! on envelopes and the `Io` error variant). + +#![no_std] + +#[macro_use] +extern crate alloc; + +#[cfg(feature = "std")] +extern crate std; pub mod capability; pub mod envelope; @@ -18,7 +31,9 @@ pub use capability::{CapabilitySet, CapabilityToken}; pub use envelope::{split_target, Envelope, InvocationType, ResponseEnvelope}; pub use error::{ErrorCode, ErrorDetail, SaikuroError}; pub use invocation::InvocationId; -pub use log::{stderr_log_sink, LogLevel, LogRecord, LogSink}; +#[cfg(feature = "std")] +pub use log::stderr_log_sink; +pub use log::{LogLevel, LogRecord, LogSink}; pub use resource::ResourceHandle; pub use value::Value; diff --git a/Build/crates/saikuro-core/src/log.rs b/Build/crates/saikuro-core/src/log.rs index 0fb35e91..f12d0016 100644 --- a/Build/crates/saikuro-core/src/log.rs +++ b/Build/crates/saikuro-core/src/log.rs @@ -9,11 +9,18 @@ //! The runtime's router intercepts `Log` envelopes before they reach a //! provider and dispatches them to the configured [`LogSink`]. +use alloc::{boxed::Box, string::String}; +use core::fmt; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; use crate::value::Value; +/// Maximum number of structured context fields a [`LogRecord`] can carry. +pub const LOG_FIELDS_CAPACITY: usize = 16; + +/// Fixed-capacity map of structured context fields on [`LogRecord`]. +pub type LogFieldMap = heapless::FnvIndexMap; + // Log level /// Severity level of a log record, ordered from least to most severe. @@ -62,8 +69,8 @@ pub struct LogRecord { pub msg: String, /// Additional structured context fields. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub fields: BTreeMap, + #[serde(default, skip_serializing_if = "LogFieldMap::is_empty")] + pub fields: LogFieldMap, } impl LogRecord { @@ -79,19 +86,31 @@ impl LogRecord { level, name: name.into(), msg: msg.into(), - fields: BTreeMap::new(), + fields: LogFieldMap::new(), } } /// Add a structured field and return `self` for chaining. - pub fn with_field(mut self, key: impl Into, value: impl Into) -> Self { - self.fields.insert(key.into(), value.into()); - self + /// + /// Fails with [`crate::error::SaikuroError::CapacityExceeded`] if the + /// record is already at [`LOG_FIELDS_CAPACITY`] fields. + pub fn with_field( + mut self, + key: impl Into, + value: impl Into, + ) -> Result { + let key = key.into(); + self.fields.insert(key.clone(), value.into()).map_err(|_| { + crate::error::SaikuroError::CapacityExceeded(format!( + "log field bag full at key '{key}'" + )) + })?; + Ok(self) } } -/// Helper: extract a `Value::String` from a map by key. -fn take_string(map: &mut BTreeMap, key: &str) -> Option { +/// Helper: extract a `Value::String` from a [`ValueMap`](crate::value::ValueMap) by key. +fn take_string(map: &mut crate::value::ValueMap, key: &str) -> Option { match map.remove(key) { Some(Value::String(s)) => Some(s), _ => None, @@ -114,12 +133,18 @@ impl TryFrom for LogRecord { .unwrap_or(LogLevel::Info); let name = take_string(&mut map, "name").unwrap_or_default(); let msg = take_string(&mut map, "msg").unwrap_or_default(); + let mut fields = LogFieldMap::new(); + for (k, v) in map.into_iter() { + fields + .insert(k, v) + .map_err(|_| "log record has too many fields")?; + } Ok(LogRecord { ts, level, name, msg, - fields: map, + fields, }) } _ => Err("expected a Map"), @@ -127,8 +152,8 @@ impl TryFrom for LogRecord { } } -impl std::fmt::Display for LogRecord { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for LogRecord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "[{}] {} {} : {}", @@ -149,10 +174,11 @@ pub type LogSink = Box; /// A simple log sink that serialises each [`LogRecord`] as a JSON line and /// writes it to stderr. Used when no richer sink is configured. +#[cfg(feature = "std")] pub fn stderr_log_sink() -> LogSink { Box::new(|record: LogRecord| { if let Ok(json) = serde_json::to_string(&record) { - eprintln!("{}", json); + std::eprintln!("{}", json); } }) } diff --git a/Build/crates/saikuro-core/src/resource.rs b/Build/crates/saikuro-core/src/resource.rs index ecf005ad..354761e7 100644 --- a/Build/crates/saikuro-core/src/resource.rs +++ b/Build/crates/saikuro-core/src/resource.rs @@ -28,11 +28,11 @@ //! } //! ``` +use alloc::{borrow::ToOwned, boxed::Box, string::String}; +use core::fmt; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::fmt; -use crate::value::Value; +use crate::value::{Value, ValueMap}; // ResourceHandle @@ -104,18 +104,23 @@ impl ResourceHandle { /// Convert this handle into a [`Value`] map suitable for embedding in an /// envelope `result` field. pub fn to_value(&self) -> Value { - let mut map = BTreeMap::new(); - map.insert("id".to_owned(), Value::String(self.id.clone())); + // A handle serialises to at most 4 fields, well under VALUE_MAP_CAPACITY. + let mut map = ValueMap::new(); + map.insert("id".to_owned(), Value::String(self.id.clone())) + .expect("resource handle map fits in VALUE_MAP_CAPACITY"); if let Some(mime) = &self.mime_type { - map.insert("mime_type".to_owned(), Value::String(mime.clone())); + map.insert("mime_type".to_owned(), Value::String(mime.clone())) + .expect("resource handle map fits in VALUE_MAP_CAPACITY"); } if let Some(size) = self.size { - map.insert("size".to_owned(), Value::UInt(size)); + map.insert("size".to_owned(), Value::UInt(size)) + .expect("resource handle map fits in VALUE_MAP_CAPACITY"); } if let Some(uri) = &self.uri { - map.insert("uri".to_owned(), Value::String(uri.clone())); + map.insert("uri".to_owned(), Value::String(uri.clone())) + .expect("resource handle map fits in VALUE_MAP_CAPACITY"); } - Value::Map(map) + Value::Map(Box::new(map)) } /// Attempt to deserialise a [`ResourceHandle`] from a [`Value`]. diff --git a/Build/crates/saikuro-core/src/schema.rs b/Build/crates/saikuro-core/src/schema.rs index f113ed84..2ad96601 100644 --- a/Build/crates/saikuro-core/src/schema.rs +++ b/Build/crates/saikuro-core/src/schema.rs @@ -5,14 +5,32 @@ //! workspace can read schemas without depending on the heavier validation //! and registry machinery in `saikuro-schema`. +use alloc::{boxed::Box, string::String, vec::Vec}; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; use crate::capability::CapabilityToken; /// The protocol version this schema was compiled against. pub const SCHEMA_VERSION: u32 = 1; +/// Maximum number of functions a single namespace can declare. +pub const SCHEMA_FUNCTIONS_CAPACITY: usize = 256; +/// Maximum number of namespaces a root schema can declare. +pub const SCHEMA_NAMESPACES_CAPACITY: usize = 256; +/// Maximum number of user-defined types a root schema can declare. +pub const SCHEMA_TYPES_CAPACITY: usize = 256; +/// Maximum number of fields a record type can declare. +pub const RECORD_FIELDS_CAPACITY: usize = 64; + +/// Fixed-capacity, insertion-ordered map of function schemas. +pub type FunctionMap = heapless::FnvIndexMap; +/// Fixed-capacity, insertion-ordered map of namespace schemas. +pub type NamespaceMap = heapless::FnvIndexMap; +/// Fixed-capacity, insertion-ordered map of user-defined types. +pub type TypeMap = heapless::FnvIndexMap; +/// Fixed-capacity, insertion-ordered map of record fields. +pub type FieldMap = heapless::FnvIndexMap; + // Primitive types /// A scalar type name used in function argument and return-type declarations. @@ -41,8 +59,8 @@ pub enum PrimitiveType { Unit, } -impl std::fmt::Display for PrimitiveType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Display for PrimitiveType { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let s = match self { Self::Bool => "bool", Self::I8 => "i8", @@ -200,9 +218,7 @@ pub struct FieldDescriptor { #[serde(tag = "kind", rename_all = "snake_case")] pub enum TypeDefinition { /// A product type (named fields). - Record { - fields: BTreeMap, - }, + Record { fields: Box }, /// A sum type (tagged union of named variants). Enum { variants: Vec }, /// A newtype wrapper around another type. @@ -215,7 +231,7 @@ pub enum TypeDefinition { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NamespaceSchema { /// All functions exposed by this namespace. - pub functions: HashMap, + pub functions: Box, /// Human-readable description. #[serde(skip_serializing_if = "Option::is_none")] pub doc: Option, @@ -231,11 +247,11 @@ pub struct Schema { pub version: u32, /// All registered namespaces, keyed by namespace name. - pub namespaces: HashMap, + pub namespaces: Box, /// User-defined types, keyed by type name. #[serde(default)] - pub types: HashMap, + pub types: Box, } impl Schema { @@ -243,8 +259,8 @@ impl Schema { pub fn new() -> Self { Self { version: SCHEMA_VERSION, - namespaces: HashMap::new(), - types: HashMap::new(), + namespaces: Box::new(NamespaceMap::new()), + types: Box::new(TypeMap::new()), } } diff --git a/Build/crates/saikuro-core/src/value.rs b/Build/crates/saikuro-core/src/value.rs index 67420ea3..492502ea 100644 --- a/Build/crates/saikuro-core/src/value.rs +++ b/Build/crates/saikuro-core/src/value.rs @@ -3,11 +3,28 @@ //! Saikuro carries typed arguments on the wire, but the runtime must be able //! to handle values whose exact Rust type is not known at compile time. //! [`Value`] is the universal representation that can model every type in the -//! Saikuro type system, round-trip through MessagePack without loss, and be -//! validated against a schema field descriptor. +//! Saikuro type system, round-trip through MessagePack without loss (bounded +//! by [`VALUE_MAP_CAPACITY`] for map values), and be validated against a +//! schema field descriptor. +use alloc::{borrow::ToOwned, boxed::Box, string::String, vec::Vec}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; + +/// Maximum number of entries a [`Value::Map`] can hold. +/// +/// Saikuro's wire format is schema-driven: argument lists, error detail bags, +/// and log fields are all small by construction. This bound keeps `Value` +/// embeddable without a heap-based map. Deserialising a map larger than this +/// fails cleanly with a serde error rather than truncating. +pub const VALUE_MAP_CAPACITY: usize = 64; + +/// Fixed-capacity, insertion-ordered map backing [`Value::Map`]. +/// +/// Insertion order is deterministic for a given construction sequence, which +/// keeps serialisation order stable for content-addressed hashing. Entries are +/// serialised in insertion order, so two semantically-equal maps built in +/// different orders are not byte-identical (and are not `PartialEq`-equal). +pub type ValueMap = heapless::FnvIndexMap; /// A dynamically-typed value that can appear in an invocation argument list, /// a return value, an error detail bag, or a schema default. @@ -15,7 +32,7 @@ use std::collections::BTreeMap; /// The set of variants is deliberately minimal: it mirrors the MessagePack /// type system so serialisation is lossless: while still providing the /// richness needed to express the full Saikuro type system. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(untagged)] pub enum Value { /// Explicit absence of a value. @@ -55,9 +72,41 @@ pub enum Value { #[serde(with = "serde_bytes")] Bytes(Vec), - /// String-keyed mapping of values. `BTreeMap` is used for deterministic - /// serialisation order, which makes content-addressed hashing predictable. - Map(BTreeMap), + /// String-keyed mapping of values. A `Box` breaks the recursive + /// `Value -> ValueMap -> Value` cycle: heapless maps are stored inline, so + /// without indirection `Value` would have infinite size. The `ValueMap` is + /// an insertion-ordered fixed-capacity map, so serialisation order is + /// deterministic, which makes content-addressed hashing predictable. + Map(Box), +} + +/// Equality for [`Value`]. +/// +/// Implemented manually because the fixed-capacity map backing `Map` only +/// implements `PartialEq` when the value type is `Eq`, which `Value` cannot be +/// (it contains `f64`). Map equality is order-sensitive: two maps with the same +/// entries inserted in different orders are *not* equal, matching the byte-level +/// serialisation behaviour (see [`ValueMap`]). +impl PartialEq for Value { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Null, Self::Null) => true, + (Self::Bool(a), Self::Bool(b)) => a == b, + (Self::Int(a), Self::Int(b)) => a == b, + (Self::UInt(a), Self::UInt(b)) => a == b, + (Self::Float(a), Self::Float(b)) => a == b, + (Self::String(a), Self::String(b)) => a == b, + (Self::Bytes(a), Self::Bytes(b)) => a == b, + (Self::Array(a), Self::Array(b)) => a == b, + (Self::Map(a), Self::Map(b)) => { + a.len() == b.len() + && a.iter() + .zip(b.iter()) + .all(|((ka, va), (kb, vb))| ka == kb && va == vb) + } + _ => false, + } + } } impl Value { @@ -138,7 +187,7 @@ impl Value { /// Attempt to borrow the inner map. Returns `None` for other variants. #[inline] - pub fn as_map(&self) -> Option<&BTreeMap> { + pub fn as_map(&self) -> Option<&ValueMap> { match self { Self::Map(m) => Some(m), _ => None, @@ -227,12 +276,6 @@ impl From> for Value { } } -impl From> for Value { - fn from(v: BTreeMap) -> Self { - Self::Map(v) - } -} - impl> From> for Value { fn from(v: Option) -> Self { match v { @@ -246,37 +289,41 @@ impl> From> for Value { mod tests { use super::*; use crate::schema::{ - FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility, + FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, + TypeDescriptor, Visibility, }; - use std::collections::HashMap; /// Regression: Schema -> msgpack bytes -> Value -> msgpack bytes -> Schema must round-trip. #[test] fn schema_round_trip_via_value() { - let mut functions = HashMap::new(); - functions.insert( - "hello".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ); - let mut namespaces = HashMap::new(); - namespaces.insert( - "svc".to_owned(), - NamespaceSchema { - functions, - doc: None, - }, - ); + let mut functions = FunctionMap::new(); + functions + .insert( + "hello".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .expect("schema fits in FunctionMap capacity"); + let mut namespaces = NamespaceMap::new(); + namespaces + .insert( + "svc".to_owned(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .expect("schema fits in NamespaceMap capacity"); let schema = Schema { version: 1, - namespaces, - types: HashMap::new(), + namespaces: Box::new(namespaces), + types: Box::new(crate::schema::TypeMap::new()), }; let bytes1 = rmp_serde::to_vec_named(&schema).expect("schema to msgpack"); @@ -314,4 +361,25 @@ mod tests { "Expected Bytes, got: {decoded:?}" ); } + + #[test] + fn check_sizes() { + std::eprintln!("Value: {} bytes", std::mem::size_of::()); + std::eprintln!("ValueMap: {} bytes", std::mem::size_of::()); + } + + /// Value::Map with a nested map must round-trip. + #[test] + fn simple_map_round_trip() { + let mut inner = ValueMap::new(); + inner.insert("b".to_owned(), Value::Int(2)).expect("fits"); + let mut outer = ValueMap::new(); + outer + .insert("a".to_owned(), Value::Map(Box::new(inner))) + .expect("fits"); + let original = Value::Map(Box::new(outer)); + let bytes = rmp_serde::to_vec_named(&original).expect("serialize"); + let decoded: Value = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(original, decoded); + } } diff --git a/Build/crates/saikuro-exec/Cargo.toml b/Build/crates/saikuro-exec/Cargo.toml index 41d333e6..57c1ed92 100644 --- a/Build/crates/saikuro-exec/Cargo.toml +++ b/Build/crates/saikuro-exec/Cargo.toml @@ -9,14 +9,18 @@ repository.workspace = true [features] default = ["tokio-runtime"] -tokio-runtime = ["tokio/full", "tokio-util"] -wasm-runtime = ["wasm-bindgen-futures", "fluvio-wasm-timer"] -embassy-runtime = [] +tokio-runtime = ["dep:tokio", "tokio/full", "dep:tokio-util", "futures/std"] +wasm-runtime = ["dep:tokio", "wasm-bindgen-futures", "fluvio-wasm-timer", "futures/std"] +embassy-runtime = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"] [dependencies] -tokio = { version = "1.52.3", default-features = false, features = ["macros"] } +tokio = { version = "1.52.3", default-features = false, features = ["macros"], optional = true } tokio-util = { version = "0.7.18", features = ["codec"], optional = true } futures = { workspace = true } wasm-bindgen-futures = { version = "0.4.71", optional = true } -fluvio-wasm-timer = { version = "0.2.5", optional = true } \ No newline at end of file +fluvio-wasm-timer = { version = "0.2.5", optional = true } + +embassy-sync = { workspace = true, optional = true } +embassy-time = { workspace = true, optional = true } +embassy-futures = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-exec/src/embassy_backend.rs b/Build/crates/saikuro-exec/src/embassy_backend.rs index 711bda1c..db6a13b4 100644 --- a/Build/crates/saikuro-exec/src/embassy_backend.rs +++ b/Build/crates/saikuro-exec/src/embassy_backend.rs @@ -1,2 +1,946 @@ -// Embassy backend placeholder. Implement when I am ready to add an embedded async runtime. -compile_error!("embassy-runtime backend is not implemented yet"); +//! Embassy backend for `saikuro-exec` (`no_std`). +//! +//! Provides embassy-backed implementations of the saikuro-exec API surface. +//! The actual executor is provided by the application via `embassy-executor`; +//! this crate only supplies the concurrency facade. +//! +//! # Channels +//! +//! `mpsc`, `oneshot`, and `watch` are real, owned wrappers over embassy-sync +//! primitives. The channel state is shared between the sender and receiver +//! through `alloc::sync::Arc`, so the handles are `'static` (matching the +//! tokio facade) and the backing storage is freed once every handle is +//! dropped. Facade channels are created once and live for the lifetime of the +//! process, which is how the router uses them. +//! +//! Channel state is guarded by +//! `embassy_sync::blocking_mutex::CriticalSectionRawMutex`. On single-core +//! MCUs the `critical-section` backend comes from the HAL +//! (`critical-section-single-core`, `cortex-m`, and so on); multicore targets +//! must provide a critical-section implementation that covers the whole core. +//! +//! # Task lifecycle +//! +//! `spawn` and `block_on` are not provided. The embassy executor +//! owns task scheduling: the application creates a static +//! `embassy_executor::Executor` and hands out `Spawner`s. A facade cannot +//! invent a global executor without conflicting with the application's own. +//! The stubs exist so host-only crates that select `tokio-runtime` resolve +//! unchanged; they panic with a pointer to the embassy equivalent. +//! `net`, `signal`, and `runtime` are likewise absent from the embassy model. + +use alloc::sync::Arc; +use core::cell::RefCell; +use core::future::{poll_fn, Future}; +use core::pin::Pin; +use core::task::{Context, Poll, Waker}; +use core::time::Duration; + +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::blocking_mutex::CriticalSectionMutex; +use embassy_sync::channel::Channel as EmbChannel; +use embassy_sync::channel::TrySendError as EmbTrySendError; +use embassy_sync::waitqueue::MultiWakerRegistration; +use embassy_time::{Duration as EmbDuration, Timer}; + +// Sleep / Timeout / Yield + +pub async fn sleep(dur: Duration) { + Timer::after(EmbDuration::from_millis(dur.as_millis() as u64)).await; +} + +pub async fn timeout(dur: Duration, fut: F) -> Result +where + F: Future, +{ + match embassy_futures::select::select( + fut, + Timer::after(EmbDuration::from_millis(dur.as_millis() as u64)), + ) + .await + { + embassy_futures::select::Either::First(res) => Ok(res), + embassy_futures::select::Either::Second(_) => Err(()), + } +} + +pub async fn yield_now() { + embassy_futures::yield_now().await; +} + +// Spawn / Block-on +// See the module documentation: the application owns the executor and its +// Spawner, so the facade cannot provide a global spawn or block_on. +pub fn spawn(_fut: F) -> JoinHandle +where + F: Future + Send + 'static, + T: Send + 'static, +{ + panic!( + "saikuro-exec: embassy spawn requires a Spawner; \ + use embassy_executor::Spawner::spawn() directly" + ) +} + +pub fn block_on(_future: F) -> F::Output +where + F: Future, +{ + panic!( + "saikuro-exec: block_on is not available on embassy-runtime; \ + use embassy_executor::Executor instead" + ) +} + +// JoinHandle / Runtime stubs +pub struct JoinHandle { + _marker: core::marker::PhantomData, +} + +impl Future for JoinHandle { + type Output = Result; + + fn poll( + self: core::pin::Pin<&mut Self>, + _cx: &mut core::task::Context<'_>, + ) -> core::task::Poll { + unreachable!("saikuro-exec: JoinHandle::poll on embassy-runtime (spawn is not provided)") + } +} + +#[derive(Debug)] +pub struct JoinError; + +impl core::fmt::Display for JoinError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("task was cancelled") + } +} + +pub struct Runtime { + _private: (), +} + +pub struct RuntimeBuilder { + _private: (), +} + +impl RuntimeBuilder { + pub fn enable_all(self) -> Self { + self + } + + pub fn build(self) -> Result { + Ok(Runtime { _private: () }) + } +} + +#[derive(Debug)] +pub struct RuntimeBuildError; + +impl core::fmt::Display for RuntimeBuildError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("embassy runtime does not support tokio-style Builder") + } +} + +pub fn new_runtime() -> RuntimeBuilder { + RuntimeBuilder { _private: () } +} + +impl Runtime { + pub fn block_on(&self, _future: F) -> F::Output { + panic!("saikuro-exec: Runtime::block_on is not available on embassy-runtime") + } +} + +// mpsc +/// Bounded multi-producer, single-consumer channel. +pub mod mpsc { + use super::*; + + /// Fixed backing capacity of an embassy mpsc channel. + /// + /// `saikuro-exec::mpsc::channel` takes a runtime capacity to match tokio's + /// API, but embassy-sync's `Channel` needs the capacity as a const generic. + /// The facade allocates a queue of this size and asserts that the requested + /// capacity fits within it. The router's default channel capacity is 128; + /// 256 leaves headroom for runtime configuration. + pub const CHANNEL_CAPACITY: usize = 256; + + /// Waker slots for senders blocked on a full channel. + /// + /// `MultiWakerRegistration` falls back to waking every registered waker + /// when this fills up, so this is a performance knob, not a hard limit. + const MAX_WAITING_SENDERS: usize = 16; + + /// Error returned by [`Sender::send`] when the receiver has been dropped. + #[derive(Debug)] + pub struct SendError(pub T); + + impl SendError { + pub fn into_inner(self) -> T { + self.0 + } + } + + impl core::fmt::Display for SendError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("send failed: channel is disconnected") + } + } + + /// Error returned by [`Sender::try_send`]. + #[derive(Debug)] + pub enum TrySendError { + Full(T), + Disconnected(T), + } + + impl TrySendError { + pub fn into_inner(self) -> T { + match self { + TrySendError::Full(v) => v, + TrySendError::Disconnected(v) => v, + } + } + + pub fn is_full(&self) -> bool { + matches!(self, TrySendError::Full(_)) + } + + pub fn is_disconnected(&self) -> bool { + matches!(self, TrySendError::Disconnected(_)) + } + } + + impl core::fmt::Display for TrySendError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + TrySendError::Full(_) => f.write_str("send failed: channel is full"), + TrySendError::Disconnected(_) => { + f.write_str("send failed: channel is disconnected") + } + } + } + } + + struct ChannelState { + senders: usize, + receivers: usize, + senders_waiting: MultiWakerRegistration, + receivers_waiting: MultiWakerRegistration<1>, + } + + impl ChannelState { + const fn new() -> Self { + ChannelState { + senders: 0, + receivers: 0, + senders_waiting: MultiWakerRegistration::new(), + receivers_waiting: MultiWakerRegistration::new(), + } + } + } + + struct ChannelInner { + state: CriticalSectionMutex>, + channel: EmbChannel, + } + + /// Sending half of a bounded mpsc channel. Cloneable; each clone can send + /// independently, and the channel closes for receivers once every sender is + /// dropped. + pub struct Sender { + inner: Arc>, + } + + impl Clone for Sender { + fn clone(&self) -> Self { + self.inner.state.lock(|s| s.borrow_mut().senders += 1); + Sender { + inner: self.inner.clone(), + } + } + } + + impl Drop for Sender { + fn drop(&mut self) { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.senders -= 1; + if state.senders == 0 { + // A receiver parked in recv() must observe the closure. + state.receivers_waiting.wake(); + } + }); + } + } + + impl Sender { + /// Returns true once the receiver has been dropped. + pub fn is_closed(&self) -> bool { + self.inner.state.lock(|s| s.borrow().receivers == 0) + } + + /// Attempt to enqueue `value` without waiting. + pub fn try_send(&self, value: T) -> Result<(), TrySendError> { + if self.is_closed() { + return Err(TrySendError::Disconnected(value)); + } + match self.inner.channel.try_send(value) { + Ok(()) => Ok(()), + Err(EmbTrySendError::Full(value)) => Err(TrySendError::Full(value)), + } + } + + /// Send `value`, waiting for capacity when the channel is full. + /// + /// Returns `Err(SendError(value))` once the receiver has been dropped. + pub async fn send(&self, value: T) -> Result<(), SendError> { + // The message lives in an Option so the FnMut poll closure can take + // and restore it without moving out of the captured binding. + let mut pending = Some(value); + poll_fn(move |cx| { + loop { + if self.is_closed() { + // The Full arm always restores the message before the + // loop continues, so `pending` is Some here. + let message = pending + .take() + .expect("mpsc send message is restored on the Full path"); + return Poll::Ready(Err(SendError(message))); + } + let message = pending + .take() + .expect("mpsc send message is restored on the Full path"); + match self.inner.channel.try_send(message) { + Ok(()) => return Poll::Ready(Ok(())), + Err(EmbTrySendError::Full(message)) => { + pending = Some(message); + self.inner + .state + .lock(|s| s.borrow_mut().senders_waiting.register(cx.waker())); + // Re-check after registering so a wake that fired + // between try_send and register is not missed. + if self.is_closed() { + let message = pending + .take() + .expect("mpsc send message is restored on the Full path"); + return Poll::Ready(Err(SendError(message))); + } + if !self.inner.channel.is_full() { + continue; + } + return Poll::Pending; + } + } + } + }) + .await + } + } + + /// Receiving half of a bounded mpsc channel. Not cloneable. + pub struct Receiver { + inner: Arc>, + } + + impl Drop for Receiver { + fn drop(&mut self) { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.receivers -= 1; + if state.receivers == 0 { + // Senders blocked on a full channel must observe the + // receiver disappearing, otherwise they wait forever. + state.senders_waiting.wake(); + } + }); + } + } + + impl Receiver { + /// Receive the next value, or `None` once every sender has been dropped + /// and the buffered values have been drained. + pub async fn recv(&mut self) -> Option { + poll_fn(|cx| self.poll_recv(cx)).await + } + + fn poll_recv(&self, cx: &mut Context<'_>) -> Poll> { + // Register the closure waker under the same lock as the closure + // check so a sender drop racing with registration is observed. + let all_senders_gone = self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.receivers_waiting.register(cx.waker()); + state.senders == 0 + }); + + if let Ok(value) = self.inner.channel.try_receive() { + self.inner + .state + .lock(|s| s.borrow_mut().senders_waiting.wake()); + return Poll::Ready(Some(value)); + } + + if all_senders_gone { + return Poll::Ready(None); + } + + match self.inner.channel.poll_receive(cx) { + Poll::Ready(value) => { + self.inner + .state + .lock(|s| s.borrow_mut().senders_waiting.wake()); + Poll::Ready(Some(value)) + } + Poll::Pending => { + // A sender may have enqueued and dropped between the checks + // above; drain instead of parking on an empty closed queue. + if self.inner.state.lock(|s| s.borrow().senders) == 0 { + match self.inner.channel.try_receive() { + Ok(value) => { + self.inner + .state + .lock(|s| s.borrow_mut().senders_waiting.wake()); + Poll::Ready(Some(value)) + } + Err(_) => Poll::Ready(None), + } + } else { + Poll::Pending + } + } + } + } + } + + /// Create a bounded channel with the given capacity. + /// + /// The embassy backend stores the queue in a fixed `CHANNEL_CAPACITY` + /// buffer, so `capacity` must not exceed it. The channel state is + /// reference-counted and freed once all handles are dropped. + pub fn channel(capacity: usize) -> (Sender, Receiver) { + assert!( + capacity <= CHANNEL_CAPACITY, + "saikuro-exec: mpsc capacity {capacity} exceeds the fixed \ + embassy capacity {CHANNEL_CAPACITY}" + ); + let inner = Arc::new(ChannelInner { + state: CriticalSectionMutex::new(RefCell::new(ChannelState::new())), + channel: EmbChannel::new(), + }); + inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.senders = 1; + state.receivers = 1; + }); + ( + Sender { + inner: inner.clone(), + }, + Receiver { inner }, + ) + } +} + +// oneshot + +/// Single-value channel used to return one response to one caller. +pub mod oneshot { + use super::*; + + enum State { + Empty, + Waiting(Waker), + Ready(T), + Closed, + } + + struct InnerData { + channel: State, + receiver_alive: bool, + } + + struct Inner { + state: CriticalSectionMutex>>, + } + + /// Sending half of a one-shot channel. Not cloneable; `send` consumes it. + pub struct Sender { + inner: Arc>, + } + + impl Sender { + /// Deliver `value`, returning it if the receiver was already dropped. + pub fn send(self, value: T) -> Result<(), T> { + self.inner.state.lock(|s| { + let mut data = s.borrow_mut(); + if !data.receiver_alive { + return Err(value); + } + match core::mem::replace(&mut data.channel, State::Empty) { + State::Empty => data.channel = State::Ready(value), + State::Waiting(waker) => { + data.channel = State::Ready(value); + waker.wake(); + } + State::Ready(v) => { + data.channel = State::Ready(v); + core::unreachable!("oneshot sender cannot send twice"); + } + State::Closed => { + data.channel = State::Closed; + core::unreachable!("oneshot sender cannot send on a closed channel"); + } + } + Ok(()) + }) + } + } + + impl Drop for Sender { + fn drop(&mut self) { + self.inner.state.lock(|s| { + let mut data = s.borrow_mut(); + if matches!(data.channel, State::Ready(_)) { + // The value was delivered; keep it available to the receiver. + return; + } + let old = core::mem::replace(&mut data.channel, State::Closed); + if let State::Waiting(waker) = old { + waker.wake(); + } + }); + } + } + + /// Error returned by the receiver when the sender is dropped without + /// sending a value. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct RecvError; + + impl core::fmt::Display for RecvError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("oneshot receiver closed") + } + } + + /// Receiving half of a one-shot channel. Awaits the single value. + pub struct Receiver { + inner: Arc>, + } + + impl Drop for Receiver { + fn drop(&mut self) { + self.inner + .state + .lock(|s| s.borrow_mut().receiver_alive = false); + } + } + + impl Future for Receiver { + type Output = Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.get_mut().inner.state.lock(|s| { + let mut data = s.borrow_mut(); + match core::mem::replace(&mut data.channel, State::Empty) { + State::Ready(value) => Poll::Ready(Ok(value)), + State::Closed => Poll::Ready(Err(RecvError)), + State::Empty => { + data.channel = State::Waiting(cx.waker().clone()); + Poll::Pending + } + State::Waiting(w) => { + if w.will_wake(cx.waker()) { + data.channel = State::Waiting(w); + } else { + data.channel = State::Waiting(cx.waker().clone()); + w.wake(); + } + Poll::Pending + } + } + }) + } + } + + /// Create a one-shot channel. The channel state is reference-counted and + /// freed once both handles are dropped. + pub fn channel() -> (Sender, Receiver) { + let inner = Arc::new(Inner { + state: CriticalSectionMutex::new(RefCell::new(InnerData { + channel: State::Empty, + receiver_alive: true, + })), + }); + ( + Sender { + inner: inner.clone(), + }, + Receiver { inner }, + ) + } +} + +// sync +pub mod sync { + use super::*; + + /// Async mutual-exclusion lock. + pub use embassy_sync::mutex::Mutex; + + /// Read/write lock. + /// + /// Backed by a single async `embassy_sync::mutex::Mutex`. Readers are + /// serialized with writers rather than running concurrently; this is a safe + /// subset of the tokio semantics. Guards deref to the guarded value. + pub struct RwLock { + inner: embassy_sync::mutex::Mutex, + } + + impl RwLock { + pub const fn new(value: T) -> Self { + RwLock { + inner: embassy_sync::mutex::Mutex::new(value), + } + } + + /// Acquire a shared read guard. + pub async fn read(&self) -> RwLockReadGuard<'_, T> { + RwLockReadGuard { + guard: self.inner.lock().await, + } + } + + /// Acquire an exclusive write guard. + pub async fn write(&self) -> RwLockWriteGuard<'_, T> { + RwLockWriteGuard { + guard: self.inner.lock().await, + } + } + } + + pub struct RwLockReadGuard<'a, T> { + guard: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, + } + + impl core::ops::Deref for RwLockReadGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.guard + } + } + + pub struct RwLockWriteGuard<'a, T> { + guard: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, + } + + impl core::ops::Deref for RwLockWriteGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.guard + } + } + + impl core::ops::DerefMut for RwLockWriteGuard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.guard + } + } + + /// Waker slots for tasks parked at a barrier. + const MAX_BARRIER_WAITERS: usize = 16; + + /// Synchronization barrier that releases `n` tasks together. + pub struct Barrier { + inner: Arc, + } + + struct BarrierInner { + state: CriticalSectionMutex>, + } + + struct BarrierState { + count: usize, + arrived: usize, + generation: u64, + waiting: MultiWakerRegistration, + } + + impl Barrier { + pub fn new(n: usize) -> Self { + assert!(n > 0, "saikuro-exec: Barrier::new requires n > 0"); + let inner = Arc::new(BarrierInner { + state: CriticalSectionMutex::new(RefCell::new(BarrierState { + count: n, + arrived: 0, + generation: 0, + waiting: MultiWakerRegistration::new(), + })), + }); + Barrier { inner } + } + + /// Wait until all `n` tasks have called `wait`. Returns immediately for + /// the task that releases the barrier. + pub async fn wait(&self) { + let released = self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.arrived += 1; + if state.arrived == state.count { + state.arrived = 0; + state.generation += 1; + state.waiting.wake(); + true + } else { + false + } + }); + if released { + return; + } + let mut gen = self.inner.state.lock(|s| s.borrow().generation); + poll_fn(move |cx| { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + if state.generation != gen { + gen = state.generation; + Poll::Ready(()) + } else { + state.waiting.register(cx.waker()); + if state.generation != gen { + gen = state.generation; + Poll::Ready(()) + } else { + Poll::Pending + } + } + }) + }) + .await + } + } +} + +// signal / watch / net / runtime +pub mod signal { + pub async fn ctrl_c() -> Result<(), core::convert::Infallible> { + core::future::pending().await + } +} + +/// Watch channel: a shared value with change notification. +pub mod watch { + use super::*; + + /// Waker slots for receivers blocked in [`Receiver::changed`]. + /// + /// `MultiWakerRegistration` falls back to waking every registered waker + /// when this fills up, so this is a performance knob, not a hard limit. + const MAX_WAITING_RECEIVERS: usize = 16; + + /// Error returned by [`Sender::send`] when there are no receivers left. + #[derive(Debug)] + pub struct SendError(pub T); + + impl SendError { + pub fn into_inner(self) -> T { + self.0 + } + } + + impl core::fmt::Display for SendError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("watch channel has no receivers") + } + } + + /// Error returned by `changed` once all senders have been dropped. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct RecvError; + + impl core::fmt::Display for RecvError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("watch channel closed") + } + } + + struct WatchState { + value: T, + version: u64, + senders: usize, + receivers: usize, + waiting: MultiWakerRegistration, + } + + struct WatchInner { + state: CriticalSectionMutex>>, + } + + /// Sending half of a watch channel. Cloneable; the channel closes for + /// receivers when the last sender is dropped. + pub struct Sender { + inner: Arc>, + } + + impl Sender { + /// Publish `value`, returning it if there are no receivers left. + pub fn send(&self, value: T) -> Result<(), SendError> { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + if state.receivers == 0 { + return Err(SendError(value)); + } + state.value = value; + state.version += 1; + state.waiting.wake(); + Ok(()) + }) + } + } + + impl Clone for Sender { + fn clone(&self) -> Self { + self.inner.state.lock(|s| s.borrow_mut().senders += 1); + Sender { + inner: self.inner.clone(), + } + } + } + + impl Drop for Sender { + fn drop(&mut self) { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.senders -= 1; + if state.senders == 0 { + // Receivers parked in changed() observe the closure. + state.waiting.wake(); + } + }); + } + } + + /// Receiving half of a watch channel. Cloneable; each clone tracks its own + /// observed version. + pub struct Receiver { + inner: Arc>, + version: u64, + } + + impl Receiver { + /// Snapshot of the latest value. + /// + /// The value is cloned rather than borrowed, matching the wasm backend; + /// this avoids holding a critical section across the returned borrow. + pub fn borrow(&self) -> T { + self.inner.state.lock(|s| s.borrow().value.clone()) + } + + /// Future that completes when a new value is sent, or with `Err` once + /// all senders are dropped. + pub fn changed(&mut self) -> ChangedFuture<'_, T> { + ChangedFuture { receiver: self } + } + } + + impl Clone for Receiver { + fn clone(&self) -> Self { + self.inner.state.lock(|s| s.borrow_mut().receivers += 1); + Receiver { + inner: self.inner.clone(), + version: self.version, + } + } + } + + impl Drop for Receiver { + fn drop(&mut self) { + self.inner.state.lock(|s| s.borrow_mut().receivers -= 1); + } + } + + /// Future returned by [`Receiver::changed`]. + pub struct ChangedFuture<'a, T> { + receiver: &'a mut Receiver, + } + + impl Future for ChangedFuture<'_, T> { + type Output = Result<(), RecvError>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + this.receiver.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + // Register before checking so a send that races with the + // registration is not missed. + state.waiting.register(cx.waker()); + if state.senders == 0 { + return Poll::Ready(Err(RecvError)); + } + let version = state.version; + if this.receiver.version != version { + this.receiver.version = version; + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + }) + } + } + + /// Create a watch channel seeded with `initial`. The channel state is + /// reference-counted and freed once all handles are dropped. + pub fn channel(initial: T) -> (Sender, Receiver) { + let inner = Arc::new(WatchInner { + state: CriticalSectionMutex::new(RefCell::new(WatchState { + value: initial, + version: 0, + senders: 1, + receivers: 1, + waiting: MultiWakerRegistration::new(), + })), + }); + let receiver = Receiver { + inner: inner.clone(), + version: 0, + }; + (Sender { inner }, receiver) + } +} + +pub mod net { + // Empty. networking on embedded uses embassy-net, not tokio::net. +} + +pub mod runtime { + pub struct Builder { + _private: (), + } + + pub struct Runtime { + _private: (), + } + + impl Builder { + pub fn new_current_thread() -> Self { + Builder { _private: () } + } + + pub fn enable_all(self) -> Self { + self + } + + pub fn build(self) -> Result { + Ok(Runtime { _private: () }) + } + } + + impl Runtime { + pub fn block_on(&self, _future: F) -> F::Output { + panic!("saikuro-exec: runtime::Runtime::block_on is not available on embassy-runtime") + } + } +} diff --git a/Build/crates/saikuro-exec/src/lib.rs b/Build/crates/saikuro-exec/src/lib.rs index 9e41dd62..b1ff998f 100644 --- a/Build/crates/saikuro-exec/src/lib.rs +++ b/Build/crates/saikuro-exec/src/lib.rs @@ -3,6 +3,11 @@ //! Re-exports one backend implementation depending on enabled cargo features. //! Supported backends: `tokio-runtime` (default), `wasm-runtime`, `embassy-runtime`. +#![cfg_attr(feature = "embassy-runtime", no_std)] + +#[cfg(feature = "embassy-runtime")] +extern crate alloc; + #[cfg(all(feature = "tokio-runtime", feature = "wasm-runtime"))] compile_error!("Features `tokio-runtime` and `wasm-runtime` are mutually exclusive."); @@ -22,6 +27,7 @@ compile_error!( Enable one of `tokio-runtime`, `wasm-runtime`, or `embassy-runtime`." ); +#[cfg(any(feature = "tokio-runtime", feature = "wasm-runtime"))] pub use tokio as _tokio; #[cfg(feature = "tokio-runtime")] @@ -39,6 +45,9 @@ mod embassy_backend; #[cfg(feature = "embassy-runtime")] pub use embassy_backend::*; +#[cfg(feature = "embassy-runtime")] +pub use futures as _futures; + #[macro_export] macro_rules! select { ($($tt:tt)*) => { @@ -47,9 +56,23 @@ macro_rules! select { } #[doc(hidden)] +#[cfg(any(feature = "tokio-runtime", feature = "wasm-runtime"))] #[macro_export] macro_rules! select_impl { ($($tt:tt)*) => { $crate::_tokio::select! { $($tt)* } }; } + +/// Embassy-compatible `select!` for two branches. +/// +/// Each branch future must be fused (`.fuse()`) because `futures::select!` +/// requires `FusedFuture` +#[doc(hidden)] +#[cfg(feature = "embassy-runtime")] +#[macro_export] +macro_rules! select_impl { + ($($tt:tt)*) => { + $crate::_futures::select! { $($tt)* } + }; +} diff --git a/Build/crates/saikuro-random/src/drbg.rs b/Build/crates/saikuro-random/src/drbg.rs index 2e78fdfa..b27ecc2d 100644 --- a/Build/crates/saikuro-random/src/drbg.rs +++ b/Build/crates/saikuro-random/src/drbg.rs @@ -160,6 +160,9 @@ fn read_seed() -> ([u8; KEY_LEN], [u8; NONCE_LEN]) { let mut key = [0u8; KEY_LEN]; let mut nonce = [0u8; NONCE_LEN]; key.copy_from_slice(&seed[..KEY_LEN]); + // SEED is zero-initialized only because it is a static; seed_from_slice() + // writes external entropy into it before fill(), which is guarded by + // is_seeded(), can read it, so the zero initializer is never observable. nonce.copy_from_slice(&seed[KEY_LEN..SEED_LEN]); (key, nonce) } diff --git a/Build/crates/saikuro-runtime/src/connection.rs b/Build/crates/saikuro-runtime/src/connection.rs index 5da4759f..c84451df 100644 --- a/Build/crates/saikuro-runtime/src/connection.rs +++ b/Build/crates/saikuro-runtime/src/connection.rs @@ -473,7 +473,7 @@ where // Copy types: they are passive descriptors and always included. filtered.types = full.types.clone(); - for (ns_name, ns_schema) in &full.namespaces { + for (ns_name, ns_schema) in full.namespaces.iter() { let accessible = self.capability_engine.filter_accessible_functions( ns_schema.functions.iter().map(|(n, s)| (n.as_str(), s)), &self.peer_capabilities, @@ -481,19 +481,24 @@ where if accessible.is_empty() { continue; } - let functions = ns_schema - .functions - .iter() - .filter(|(name, _)| accessible.contains(name)) - .map(|(name, schema)| (name.clone(), schema.clone())) - .collect(); - filtered.namespaces.insert( - ns_name.clone(), - saikuro_core::schema::NamespaceSchema { - functions, - doc: ns_schema.doc.clone(), - }, + let functions = Box::new( + ns_schema + .functions + .iter() + .filter(|(name, _)| accessible.contains(name)) + .map(|(name, schema)| (name.clone(), schema.clone())) + .collect(), ); + filtered + .namespaces + .insert( + ns_name.clone(), + saikuro_core::schema::NamespaceSchema { + functions, + doc: ns_schema.doc.clone(), + }, + ) + .ok(); } filtered diff --git a/Build/crates/saikuro-runtime/src/lib.rs b/Build/crates/saikuro-runtime/src/lib.rs index b5ac2680..82a0a306 100644 --- a/Build/crates/saikuro-runtime/src/lib.rs +++ b/Build/crates/saikuro-runtime/src/lib.rs @@ -18,35 +18,37 @@ pub use runtime::SaikuroRuntime; mod tests { use crate::runtime::SaikuroRuntime; use saikuro_core::schema::{ - FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility, + FunctionMap, FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, + Visibility, }; - use std::collections::HashMap; /// Smoke test: build a runtime, register a schema, verify lookup works. #[test] fn schema_registration_roundtrip() { let rt = SaikuroRuntime::builder().build(); - let mut functions = HashMap::new(); - functions.insert( - "ping".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::String), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: true, - doc: Some("Returns 'pong'".to_owned()), - }, - ); + let mut functions = FunctionMap::new(); + functions + .insert( + "ping".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::String), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: true, + doc: Some("Returns 'pong'".to_owned()), + }, + ) + .ok(); let ns = NamespaceSchema { - functions, + functions: Box::new(functions), doc: None, }; let mut schema = Schema::new(); - schema.namespaces.insert("health".to_owned(), ns); + schema.namespaces.insert("health".to_owned(), ns).ok(); rt.schema_registry() .merge_schema(schema, "test-provider") diff --git a/Build/crates/saikuro-schema/src/registry.rs b/Build/crates/saikuro-schema/src/registry.rs index 20e08cd1..f419049d 100644 --- a/Build/crates/saikuro-schema/src/registry.rs +++ b/Build/crates/saikuro-schema/src/registry.rs @@ -78,7 +78,7 @@ impl SchemaRegistry { /// immediately frozen into production mode. pub fn from_frozen_schema(schema: Schema) -> Self { let registry = Self::new(); - for (ns_name, ns_schema) in schema.namespaces { + for (ns_name, ns_schema) in (*schema.namespaces).into_iter() { registry.namespaces.insert( ns_name.clone(), NamespaceEntry { @@ -87,7 +87,7 @@ impl SchemaRegistry { }, ); } - for (type_name, type_def) in schema.types { + for (type_name, type_def) in (*schema.types).into_iter() { registry.types.insert(type_name, type_def); } *registry.mode.write() = RegistryMode::Production; @@ -134,10 +134,10 @@ impl SchemaRegistry { ) -> Result<(), RegistryError> { let provider_id = provider_id.into(); // Merge types first (functions may reference them). - for (name, typedef) in schema.types { + for (name, typedef) in (*schema.types).into_iter() { self.types.insert(name, typedef); } - for (ns_name, ns_schema) in schema.namespaces { + for (ns_name, ns_schema) in (*schema.namespaces).into_iter() { self.register(NamespaceRegistration { namespace: ns_name, schema: ns_schema, @@ -209,12 +209,14 @@ impl SchemaRegistry { for entry in self.namespaces.iter() { schema .namespaces - .insert(entry.key().clone(), entry.value().schema.clone()); + .insert(entry.key().clone(), entry.value().schema.clone()) + .ok(); } for entry in self.types.iter() { schema .types - .insert(entry.key().clone(), entry.value().clone()); + .insert(entry.key().clone(), entry.value().clone()) + .ok(); } schema } diff --git a/Build/tests/tests/capability_enforcement.rs b/Build/tests/tests/capability_enforcement.rs index e399e4fa..1a841d64 100644 --- a/Build/tests/tests/capability_enforcement.rs +++ b/Build/tests/tests/capability_enforcement.rs @@ -33,7 +33,7 @@ fn empty_set_denies_required_cap() { #[test] fn set_with_exact_token_grants() { - let set = CapabilitySet::from_tokens([CapabilityToken::new("math.basic")]); + let set = CapabilitySet::from_tokens([CapabilityToken::new("math.basic")]).unwrap(); assert!(set.grants(&CapabilityToken::new("math.basic"))); assert!(!set.grants(&CapabilityToken::new("math.advanced"))); } @@ -49,7 +49,8 @@ fn wildcard_set_grants_everything() { #[test] fn grants_all_requires_every_token() { let set = - CapabilitySet::from_tokens([CapabilityToken::new("read"), CapabilityToken::new("write")]); + CapabilitySet::from_tokens([CapabilityToken::new("read"), CapabilityToken::new("write")]) + .unwrap(); let required = [CapabilityToken::new("read"), CapabilityToken::new("write")]; assert!(set.grants_all(required.iter())); @@ -89,7 +90,7 @@ fn engine_grants_function_with_no_required_caps() { fn engine_grants_when_caller_holds_required_cap() { let engine = CapabilityEngine::new(); let schema = fn_requiring(&["data.read"]); - let caps = CapabilitySet::from_tokens([CapabilityToken::new("data.read")]); + let caps = CapabilitySet::from_tokens([CapabilityToken::new("data.read")]).unwrap(); assert!(matches!( engine.check(&caps, &schema), CapabilityOutcome::Granted @@ -100,7 +101,7 @@ fn engine_grants_when_caller_holds_required_cap() { fn engine_denies_when_caller_missing_cap() { let engine = CapabilityEngine::new(); let schema = fn_requiring(&["data.write"]); - let caps = CapabilitySet::from_tokens([CapabilityToken::new("data.read")]); + let caps = CapabilitySet::from_tokens([CapabilityToken::new("data.read")]).unwrap(); let result = engine.check(&caps, &schema); match result { CapabilityOutcome::Denied { missing } => { @@ -115,7 +116,7 @@ fn engine_denies_on_first_missing_cap() { // Function requires both A and B; caller has only A. let engine = CapabilityEngine::new(); let schema = fn_requiring(&["cap.a", "cap.b"]); - let caps = CapabilitySet::from_tokens([CapabilityToken::new("cap.a")]); + let caps = CapabilitySet::from_tokens([CapabilityToken::new("cap.a")]).unwrap(); assert!(matches!( engine.check(&caps, &schema), CapabilityOutcome::Denied { .. } @@ -187,13 +188,13 @@ fn capability_set_insert_and_len() { assert_eq!(set.len(), 0); assert!(set.is_empty()); - set.insert(CapabilityToken::new("a")); - set.insert(CapabilityToken::new("b")); + set.insert(CapabilityToken::new("a")).ok(); + set.insert(CapabilityToken::new("b")).ok(); assert_eq!(set.len(), 2); assert!(!set.is_empty()); // Duplicate insert should not grow the set. - set.insert(CapabilityToken::new("a")); + set.insert(CapabilityToken::new("a")).ok(); assert_eq!(set.len(), 2); } @@ -204,7 +205,7 @@ fn capability_set_iter_contains_all_tokens() { CapabilityToken::new("y"), CapabilityToken::new("z"), ]; - let set = CapabilitySet::from_tokens(tokens.clone()); + let set = CapabilitySet::from_tokens(tokens.clone()).unwrap(); let collected: std::collections::HashSet<_> = set.iter().cloned().collect(); for t in &tokens { assert!(collected.contains(t)); diff --git a/Build/tests/tests/codegen_output.rs b/Build/tests/tests/codegen_output.rs index 5d893c17..bbf75c60 100644 --- a/Build/tests/tests/codegen_output.rs +++ b/Build/tests/tests/codegen_output.rs @@ -5,10 +5,9 @@ use saikuro_codegen::{ rust::RustGenerator, typescript::TypeScriptGenerator, }; use saikuro_core::schema::{ - ArgumentDescriptor, FieldDescriptor, FunctionSchema, NamespaceSchema, PrimitiveType, Schema, - TypeDefinition, TypeDescriptor, Visibility, + ArgumentDescriptor, FieldDescriptor, FieldMap, FunctionMap, FunctionSchema, NamespaceSchema, + PrimitiveType, Schema, TypeDefinition, TypeDescriptor, Visibility, }; -use std::collections::{BTreeMap, HashMap}; // Schema builders @@ -31,21 +30,30 @@ fn simple_fn(vis: Visibility) -> FunctionSchema { fn make_schema_with_math() -> Schema { let mut schema = Schema::new(); - let mut functions = HashMap::new(); - functions.insert("add".into(), simple_fn(Visibility::Public)); - functions.insert("sub".into(), { - let mut f = simple_fn(Visibility::Internal); - f.doc = None; - f - }); - functions.insert("secret".into(), simple_fn(Visibility::Private)); - schema.namespaces.insert( - "math".into(), - NamespaceSchema { - functions, - doc: Some("Math namespace".into()), - }, - ); + let mut functions = FunctionMap::new(); + functions + .insert("add".into(), simple_fn(Visibility::Public)) + .ok(); + functions + .insert("sub".into(), { + let mut f = simple_fn(Visibility::Internal); + f.doc = None; + f + }) + .ok(); + functions + .insert("secret".into(), simple_fn(Visibility::Private)) + .ok(); + schema + .namespaces + .insert( + "math".into(), + NamespaceSchema { + functions: Box::new(functions), + doc: Some("Math namespace".into()), + }, + ) + .ok(); schema } @@ -53,42 +61,58 @@ fn make_schema_with_types() -> Schema { let mut schema = Schema::new(); // Record type. - let mut fields = BTreeMap::new(); - fields.insert( - "name".into(), - FieldDescriptor { - r#type: TypeDescriptor::primitive(PrimitiveType::String), - optional: false, - doc: None, - }, - ); - fields.insert( - "age".into(), - FieldDescriptor { - r#type: TypeDescriptor::primitive(PrimitiveType::I64), - optional: true, - doc: None, - }, - ); + let mut fields = FieldMap::new(); + fields + .insert( + "name".into(), + FieldDescriptor { + r#type: TypeDescriptor::primitive(PrimitiveType::String), + optional: false, + doc: None, + }, + ) + .ok(); + fields + .insert( + "age".into(), + FieldDescriptor { + r#type: TypeDescriptor::primitive(PrimitiveType::I64), + optional: true, + doc: None, + }, + ) + .ok(); schema .types - .insert("Person".into(), TypeDefinition::Record { fields }); + .insert( + "Person".into(), + TypeDefinition::Record { + fields: Box::new(fields), + }, + ) + .ok(); // Enum type. - schema.types.insert( - "Color".into(), - TypeDefinition::Enum { - variants: vec!["Red".into(), "Green".into(), "Blue".into()], - }, - ); + schema + .types + .insert( + "Color".into(), + TypeDefinition::Enum { + variants: vec!["Red".into(), "Green".into(), "Blue".into()], + }, + ) + .ok(); // Alias type. - schema.types.insert( - "UserId".into(), - TypeDefinition::Alias { - inner: TypeDescriptor::primitive(PrimitiveType::String), - }, - ); + schema + .types + .insert( + "UserId".into(), + TypeDefinition::Alias { + inner: TypeDescriptor::primitive(PrimitiveType::String), + }, + ) + .ok(); schema } @@ -236,7 +260,7 @@ fn python_all_primitive_types_map_correctly() { ]; let mut schema = Schema::new(); - let mut functions = HashMap::new(); + let mut functions = FunctionMap::new(); for (name, prim, _) in primitive_cases { let f = FunctionSchema { args: vec![], @@ -246,15 +270,18 @@ fn python_all_primitive_types_map_correctly() { idempotent: false, doc: None, }; - functions.insert((*name).to_owned(), f); + functions.insert((*name).to_owned(), f).ok(); } - schema.namespaces.insert( - "types_ns".into(), - NamespaceSchema { - functions, - doc: None, - }, - ); + schema + .namespaces + .insert( + "types_ns".into(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .ok(); let gen = PythonGenerator; let output = gen.generate(&schema).expect("generate"); @@ -398,7 +425,7 @@ fn typescript_all_primitive_types_map_correctly() { ]; let mut schema = Schema::new(); - let mut functions = HashMap::new(); + let mut functions = FunctionMap::new(); for (name, prim, _) in primitive_cases { let f = FunctionSchema { args: vec![], @@ -408,15 +435,18 @@ fn typescript_all_primitive_types_map_correctly() { idempotent: false, doc: None, }; - functions.insert((*name).to_owned(), f); + functions.insert((*name).to_owned(), f).ok(); } - schema.namespaces.insert( - "types_ns".into(), - NamespaceSchema { - functions, - doc: None, - }, - ); + schema + .namespaces + .insert( + "types_ns".into(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .ok(); let gen = TypeScriptGenerator; let output = gen.generate(&schema).expect("generate"); @@ -440,40 +470,45 @@ fn typescript_all_primitive_types_map_correctly() { #[test] fn typescript_optional_arg_has_question_mark() { let mut schema = Schema::new(); - let mut functions = HashMap::new(); - functions.insert( - "greet".into(), - FunctionSchema { - args: vec![ - ArgumentDescriptor { - name: "name".into(), - r#type: TypeDescriptor::primitive(PrimitiveType::String), - optional: false, - default: None, - doc: None, - }, - ArgumentDescriptor { - name: "greeting".into(), - r#type: TypeDescriptor::primitive(PrimitiveType::String), - optional: true, - default: None, - doc: None, - }, - ], - returns: TypeDescriptor::primitive(PrimitiveType::String), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ); - schema.namespaces.insert( - "greeter".into(), - NamespaceSchema { - functions, - doc: None, - }, - ); + let mut functions = FunctionMap::new(); + functions + .insert( + "greet".into(), + FunctionSchema { + args: vec![ + ArgumentDescriptor { + name: "name".into(), + r#type: TypeDescriptor::primitive(PrimitiveType::String), + optional: false, + default: None, + doc: None, + }, + ArgumentDescriptor { + name: "greeting".into(), + r#type: TypeDescriptor::primitive(PrimitiveType::String), + optional: true, + default: None, + doc: None, + }, + ], + returns: TypeDescriptor::primitive(PrimitiveType::String), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .ok(); + schema + .namespaces + .insert( + "greeter".into(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .ok(); let gen = TypeScriptGenerator; let output = gen.generate(&schema).expect("generate"); @@ -493,65 +528,74 @@ fn typescript_optional_arg_has_question_mark() { fn make_schema_with_stream_and_channel() -> Schema { let mut schema = Schema::new(); - let mut functions = HashMap::new(); + let mut functions = FunctionMap::new(); // Stream-returning function. - functions.insert( - "subscribe".into(), - FunctionSchema { - args: vec![ArgumentDescriptor { - name: "topic".into(), - r#type: TypeDescriptor::primitive(PrimitiveType::String), - optional: false, - default: None, - doc: None, - }], - returns: TypeDescriptor::Stream { - item: Box::new(TypeDescriptor::primitive(PrimitiveType::String)), + functions + .insert( + "subscribe".into(), + FunctionSchema { + args: vec![ArgumentDescriptor { + name: "topic".into(), + r#type: TypeDescriptor::primitive(PrimitiveType::String), + optional: false, + default: None, + doc: None, + }], + returns: TypeDescriptor::Stream { + item: Box::new(TypeDescriptor::primitive(PrimitiveType::String)), + }, + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: Some("Subscribe to a topic and receive a stream of messages.".into()), }, - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: Some("Subscribe to a topic and receive a stream of messages.".into()), - }, - ); + ) + .ok(); // Channel-returning function. - functions.insert( - "chat".into(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::Channel { - inbound: Box::new(TypeDescriptor::primitive(PrimitiveType::String)), - outbound: Box::new(TypeDescriptor::primitive(PrimitiveType::String)), + functions + .insert( + "chat".into(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::Channel { + inbound: Box::new(TypeDescriptor::primitive(PrimitiveType::String)), + outbound: Box::new(TypeDescriptor::primitive(PrimitiveType::String)), + }, + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: Some("Open a bidirectional chat channel.".into()), }, - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: Some("Open a bidirectional chat channel.".into()), - }, - ); + ) + .ok(); // Regular call-returning function for contrast. - functions.insert( - "ping".into(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::String), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ); + functions + .insert( + "ping".into(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::String), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .ok(); - schema.namespaces.insert( - "events".into(), - NamespaceSchema { - functions, - doc: None, - }, - ); + schema + .namespaces + .insert( + "events".into(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .ok(); schema } @@ -836,7 +880,7 @@ fn csharp_all_primitive_types_map_correctly() { ]; let mut schema = Schema::new(); - let mut functions = HashMap::new(); + let mut functions = FunctionMap::new(); for (name, prim, _) in primitive_cases { let f = FunctionSchema { args: vec![], @@ -846,15 +890,18 @@ fn csharp_all_primitive_types_map_correctly() { idempotent: false, doc: None, }; - functions.insert((*name).to_owned(), f); + functions.insert((*name).to_owned(), f).ok(); } - schema.namespaces.insert( - "types_ns".into(), - NamespaceSchema { - functions, - doc: None, - }, - ); + schema + .namespaces + .insert( + "types_ns".into(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .ok(); let gen = CSharpGenerator; let output = gen.generate(&schema).expect("generate"); diff --git a/Build/tests/tests/common/mod.rs b/Build/tests/tests/common/mod.rs index ccfe58a3..5e6f678c 100644 --- a/Build/tests/tests/common/mod.rs +++ b/Build/tests/tests/common/mod.rs @@ -2,7 +2,10 @@ use bytes::Bytes; use saikuro_core::{ capability::CapabilitySet, envelope::Envelope, - schema::{FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility}, + schema::{ + FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, + TypeDescriptor, TypeMap, Visibility, + }, value::Value, ResponseEnvelope, }; @@ -19,7 +22,6 @@ use saikuro_transport::{ memory::MemoryTransport, traits::{Transport, TransportReceiver, TransportSender}, }; -use std::collections::HashMap; pub fn make_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver) { let (work_tx, work_rx) = mpsc::channel::(64); @@ -34,30 +36,34 @@ pub fn make_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver Schema { - let mut functions = HashMap::new(); - functions.insert( - function.to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ); - let mut namespaces = HashMap::new(); - namespaces.insert( - namespace.to_owned(), - NamespaceSchema { - functions, - doc: None, - }, - ); + let mut functions = FunctionMap::new(); + functions + .insert( + function.to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .ok(); + let mut namespaces = NamespaceMap::new(); + namespaces + .insert( + namespace.to_owned(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .ok(); Schema { version: 1, - namespaces, - types: HashMap::new(), + namespaces: Box::new(namespaces), + types: Box::new(TypeMap::new()), } } diff --git a/Build/tests/tests/cross_language_wire.rs b/Build/tests/tests/cross_language_wire.rs index 5f14b2c8..cb089800 100644 --- a/Build/tests/tests/cross_language_wire.rs +++ b/Build/tests/tests/cross_language_wire.rs @@ -5,7 +5,10 @@ use saikuro_core::{ capability::CapabilitySet, envelope::{Envelope, InvocationType}, error::ErrorCode, - schema::{FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility}, + schema::{ + FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, + TypeDescriptor, TypeMap, Visibility, + }, value::Value, InvocationId, ResponseEnvelope, PROTOCOL_VERSION, }; @@ -14,7 +17,6 @@ use saikuro_transport::{ memory::MemoryTransport, traits::{Transport, TransportReceiver, TransportSender}, }; -use std::collections::HashMap; // Shared helpers @@ -50,30 +52,34 @@ fn make_schema_with_args(namespace: &str, function: &str, n_args: usize) -> Sche doc: None, }) .collect(); - let mut functions = HashMap::new(); - functions.insert( - function.to_owned(), - FunctionSchema { - args, - returns: TypeDescriptor::primitive(PrimitiveType::Any), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ); - let mut namespaces = HashMap::new(); - namespaces.insert( - namespace.to_owned(), - NamespaceSchema { - functions, - doc: None, - }, - ); + let mut functions = FunctionMap::new(); + functions + .insert( + function.to_owned(), + FunctionSchema { + args, + returns: TypeDescriptor::primitive(PrimitiveType::Any), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .ok(); + let mut namespaces = NamespaceMap::new(); + namespaces + .insert( + namespace.to_owned(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .ok(); Schema { version: 1, - namespaces, - types: HashMap::new(), + namespaces: Box::new(namespaces), + types: Box::new(TypeMap::new()), } } diff --git a/Build/tests/tests/envelope_roundtrip.rs b/Build/tests/tests/envelope_roundtrip.rs index 5df9fd1f..2d897c76 100644 --- a/Build/tests/tests/envelope_roundtrip.rs +++ b/Build/tests/tests/envelope_roundtrip.rs @@ -5,10 +5,9 @@ use saikuro_core::{ envelope::{Envelope, InvocationType, ResponseEnvelope, StreamControl}, error::{ErrorCode, ErrorDetail}, invocation::InvocationId, - value::Value, + value::{Value, ValueMap}, PROTOCOL_VERSION, }; -use std::collections::BTreeMap; // Helpers @@ -77,8 +76,9 @@ fn envelope_with_capability_roundtrip() { fn envelope_with_meta_roundtrip() { let mut env = Envelope::call("trace.op", vec![]); env.meta - .insert("trace-id".into(), Value::String("abc-123".into())); - env.meta.insert("deadline-ms".into(), Value::Int(5000)); + .insert("trace-id".into(), Value::String("abc-123".into())) + .ok(); + env.meta.insert("deadline-ms".into(), Value::Int(5000)).ok(); let decoded = roundtrip_envelope(&env); assert_eq!(decoded.meta["trace-id"], Value::String("abc-123".into())); assert_eq!(decoded.meta["deadline-ms"], Value::Int(5000)); @@ -147,9 +147,9 @@ fn value_all_variants_roundtrip() { Value::Bytes(vec![0x00, 0xff, 0x7e]), Value::Array(vec![Value::Int(1), Value::String("two".into())]), { - let mut m = BTreeMap::new(); - m.insert("key".into(), Value::Bool(false)); - Value::Map(m) + let mut m = ValueMap::new(); + m.insert("key".into(), Value::Bool(false)).ok(); + Value::Map(Box::new(m)) }, ]; diff --git a/Build/tests/tests/error_propagation.rs b/Build/tests/tests/error_propagation.rs index 959a740e..e5560ec7 100644 --- a/Build/tests/tests/error_propagation.rs +++ b/Build/tests/tests/error_propagation.rs @@ -157,7 +157,9 @@ fn internal_error_maps_correctly() { fn error_detail_with_detail_accumulates_entries() { let detail = ErrorDetail::new(ErrorCode::ProviderError, "something went wrong") .with_detail("field", Value::String("arg_a".into())) - .with_detail("line", Value::Int(42)); + .unwrap() + .with_detail("line", Value::Int(42)) + .unwrap(); assert_eq!(detail.details["field"], Value::String("arg_a".into())); assert_eq!(detail.details["line"], Value::Int(42)); @@ -177,7 +179,8 @@ fn error_detail_display_includes_code_and_message() { fn error_response_survives_msgpack_roundtrip() { let id = InvocationId::new(); let detail = ErrorDetail::new(ErrorCode::InvalidArguments, "bad types") - .with_detail("arg", Value::String("x".into())); + .with_detail("arg", Value::String("x".into())) + .unwrap(); let resp = ResponseEnvelope::err(id, detail.clone()); let bytes = resp.to_msgpack().expect("serialize"); diff --git a/Build/tests/tests/resource_dispatch.rs b/Build/tests/tests/resource_dispatch.rs index 5d0e3ab5..a84ce3e0 100644 --- a/Build/tests/tests/resource_dispatch.rs +++ b/Build/tests/tests/resource_dispatch.rs @@ -332,10 +332,10 @@ fn resource_handle_from_value_rejects_non_map() { /// `ResourceHandle::from_value` returns `None` for a map that has no `id` field. #[test] fn resource_handle_from_value_rejects_missing_id() { - use std::collections::BTreeMap; - let mut map: BTreeMap = BTreeMap::new(); - map.insert("size".to_owned(), Value::Int(100)); - let v = Value::Map(map); + use saikuro_core::value::ValueMap; + let mut map = ValueMap::new(); + map.insert("size".to_owned(), Value::Int(100)).ok(); + let v = Value::Map(Box::new(map)); assert!( ResourceHandle::from_value(&v).is_none(), "from_value must return None when 'id' is absent" diff --git a/Build/tests/tests/sandbox_dispatch.rs b/Build/tests/tests/sandbox_dispatch.rs index 1fc855d5..3d369646 100644 --- a/Build/tests/tests/sandbox_dispatch.rs +++ b/Build/tests/tests/sandbox_dispatch.rs @@ -4,7 +4,10 @@ use bytes::Bytes; use saikuro_core::{ capability::{CapabilitySet, CapabilityToken}, envelope::{Envelope, InvocationType}, - schema::{FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility}, + schema::{ + FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, + TypeDescriptor, TypeMap, Visibility, + }, value::Value, InvocationId, ResponseEnvelope, PROTOCOL_VERSION, }; @@ -20,68 +23,77 @@ use saikuro_transport::{ memory::MemoryTransport, traits::{Transport, TransportReceiver, TransportSender}, }; -use std::collections::HashMap; // Helpers fn build_schema() -> Schema { - let mut functions = HashMap::new(); - functions.insert( - "public_fn".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ); - functions.insert( - "internal_fn".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Internal, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ); - functions.insert( - "private_fn".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Private, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ); - functions.insert( - "guarded_fn".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Public, - capabilities: vec![CapabilityToken::new("special.cap")], - idempotent: false, - doc: None, - }, - ); - let mut namespaces = HashMap::new(); - namespaces.insert( - "svc".to_owned(), - NamespaceSchema { - functions, - doc: None, - }, - ); + let mut functions = FunctionMap::new(); + functions + .insert( + "public_fn".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .ok(); + functions + .insert( + "internal_fn".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Internal, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .ok(); + functions + .insert( + "private_fn".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Private, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .ok(); + functions + .insert( + "guarded_fn".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Public, + capabilities: vec![CapabilityToken::new("special.cap")], + idempotent: false, + doc: None, + }, + ) + .ok(); + let mut namespaces = NamespaceMap::new(); + namespaces + .insert( + "svc".to_owned(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .ok(); Schema { version: 1, - namespaces, - types: HashMap::new(), + namespaces: Box::new(namespaces), + types: Box::new(TypeMap::new()), } } @@ -286,7 +298,7 @@ fn sandbox_filtered_schema_includes_functions_peer_has_caps_for() { let schema = build_schema(); let env = make_announce(&schema); - let caps = CapabilitySet::from_tokens([CapabilityToken::new("special.cap")]); + let caps = CapabilitySet::from_tokens([CapabilityToken::new("special.cap")]).unwrap(); let frames = run_and_collect(registry, caps, true, env).await; assert_eq!(frames.len(), 2); diff --git a/Build/tests/tests/schema_validation.rs b/Build/tests/tests/schema_validation.rs index 7fcefce1..8ecb090b 100644 --- a/Build/tests/tests/schema_validation.rs +++ b/Build/tests/tests/schema_validation.rs @@ -4,8 +4,8 @@ use saikuro_core::{ envelope::{Envelope, InvocationType}, error::ErrorCode, schema::{ - ArgumentDescriptor, FunctionSchema, NamespaceSchema, PrimitiveType, TypeDescriptor, - Visibility, + ArgumentDescriptor, FunctionMap, FunctionSchema, NamespaceSchema, PrimitiveType, + TypeDescriptor, Visibility, }, value::Value, }; @@ -13,7 +13,6 @@ use saikuro_schema::{ registry::{NamespaceRegistration, SchemaRegistry}, validator::{InvocationValidator, ValidationError}, }; -use std::collections::HashMap; // Helpers @@ -56,25 +55,31 @@ fn unit_fn() -> FunctionSchema { fn make_registry_with_math() -> SchemaRegistry { let registry = SchemaRegistry::new(); - let mut functions = HashMap::new(); - functions.insert("add".into(), two_arg_fn(Visibility::Public)); - functions.insert("noop".into(), unit_fn()); - functions.insert("internal_op".into(), { - let mut f = unit_fn(); - f.visibility = Visibility::Internal; - f - }); - functions.insert("secret".into(), { - let mut f = unit_fn(); - f.visibility = Visibility::Private; - f - }); + let mut functions = FunctionMap::new(); + functions + .insert("add".into(), two_arg_fn(Visibility::Public)) + .ok(); + functions.insert("noop".into(), unit_fn()).ok(); + functions + .insert("internal_op".into(), { + let mut f = unit_fn(); + f.visibility = Visibility::Internal; + f + }) + .ok(); + functions + .insert("secret".into(), { + let mut f = unit_fn(); + f.visibility = Visibility::Private; + f + }) + .ok(); registry .register(NamespaceRegistration { namespace: "math".into(), schema: NamespaceSchema { - functions, + functions: Box::new(functions), doc: None, }, provider_id: "provider-1".into(), @@ -235,38 +240,40 @@ fn malformed_target_without_dot_fails() { fn optional_argument_may_be_omitted() { // Register a function with one required and one optional argument. let registry = SchemaRegistry::new(); - let mut functions = HashMap::new(); - functions.insert( - "greet".into(), - FunctionSchema { - args: vec![ - ArgumentDescriptor { - name: "name".into(), - r#type: TypeDescriptor::primitive(PrimitiveType::String), - optional: false, - default: None, - doc: None, - }, - ArgumentDescriptor { - name: "greeting".into(), - r#type: TypeDescriptor::primitive(PrimitiveType::String), - optional: true, - default: Some(Value::String("Hello".into())), - doc: None, - }, - ], - returns: TypeDescriptor::primitive(PrimitiveType::String), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ); + let mut functions = FunctionMap::new(); + functions + .insert( + "greet".into(), + FunctionSchema { + args: vec![ + ArgumentDescriptor { + name: "name".into(), + r#type: TypeDescriptor::primitive(PrimitiveType::String), + optional: false, + default: None, + doc: None, + }, + ArgumentDescriptor { + name: "greeting".into(), + r#type: TypeDescriptor::primitive(PrimitiveType::String), + optional: true, + default: Some(Value::String("Hello".into())), + doc: None, + }, + ], + returns: TypeDescriptor::primitive(PrimitiveType::String), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .ok(); registry .register(NamespaceRegistration { namespace: "greet".into(), schema: NamespaceSchema { - functions, + functions: Box::new(functions), doc: None, }, provider_id: "p".into(), From 7f04c7d3e4a9d1feffeb2badd425a234c7d0a970 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Tue, 4 Aug 2026 10:39:43 -0600 Subject: [PATCH 03/43] saikuro-router and saikuro-schema no_std --- Build/Cargo.lock | 20 +- Build/Cargo.toml | 5 + Build/crates/saikuro-core/Cargo.toml | 20 +- Build/crates/saikuro-core/src/lib.rs | 1 + Build/crates/saikuro-core/src/sync.rs | 213 ++++++++++++++++++ Build/crates/saikuro-router/Cargo.toml | 21 +- Build/crates/saikuro-router/src/error.rs | 3 +- Build/crates/saikuro-router/src/lib.rs | 7 + Build/crates/saikuro-router/src/provider.rs | 41 ++-- Build/crates/saikuro-router/src/router.rs | 3 +- .../crates/saikuro-router/src/stream_state.rs | 59 +++-- Build/crates/saikuro-schema/Cargo.toml | 13 +- .../saikuro-schema/src/capability_engine.rs | 1 + Build/crates/saikuro-schema/src/lib.rs | 7 + Build/crates/saikuro-schema/src/registry.rs | 137 +++++++---- Build/crates/saikuro-schema/src/validator.rs | 5 + 16 files changed, 437 insertions(+), 119 deletions(-) create mode 100644 Build/crates/saikuro-core/src/sync.rs diff --git a/Build/Cargo.lock b/Build/Cargo.lock index 6cc6c88f..def2ef54 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -1433,6 +1433,7 @@ dependencies = [ "serde", "serde_bytes", "serde_json", + "spin", "strum", "thiserror 2.0.18", "uuid", @@ -1467,14 +1468,10 @@ name = "saikuro-router" version = "0.1.0" dependencies = [ "async-trait", - "bytes", - "dashmap", - "futures", - "rmp-serde", + "portable-atomic", "saikuro-core", "saikuro-exec", "saikuro-schema", - "serde", "thiserror 2.0.18", "tracing", "tracing-subscriber", @@ -1512,11 +1509,7 @@ dependencies = [ name = "saikuro-schema" version = "0.1.0" dependencies = [ - "dashmap", - "parking_lot 0.12.5", "saikuro-core", - "serde", - "serde_json", "thiserror 2.0.18", "tracing", ] @@ -1811,6 +1804,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spin" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b" +dependencies = [ + "portable-atomic", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" diff --git a/Build/Cargo.toml b/Build/Cargo.toml index b5e63367..205f0485 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -83,6 +83,11 @@ anyhow = "1.0" dashmap = "6.1" parking_lot = "0.12" +# Spinlocks backing the saikuro-core::sync facade on no_std (MCU) builds. +# `portable-atomic` makes them work on targets without native atomics +# (thumbv6m); native instructions are still used where available. +spin = { version = "0.12", default-features = false, features = ["mutex", "spin_mutex", "rwlock", "portable-atomic"] } + # Time: chrono is wasm-safe when 'wasmbind' feature is enabled chrono = { version = "0.4", features = ["serde", "wasmbind"] } diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index 628a4598..3ab7f54b 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -11,22 +11,32 @@ keywords = ["ipc", "cross-language", "saikuro", "rpc", "msgpack"] # The crate is always `no_std` + `alloc`. The default `std` feature adds the # std-only conveniences (msgpack codec helpers, `std::io::Error` variant) and # selects the OS entropy backend; `custom` selects the caller-provided getrandom -# backend for bare-metal targets (see saikuro-random). +# backend and `drbg` the deterministic chacha20 DRBG for bare-metal targets +# (see saikuro-random). `std` is mutually exclusive with `custom`/`drbg`. [features] default = ["std"] std = ["dep:rmp-serde", "saikuro-random/os"] custom = ["saikuro-random/custom"] +drbg = ["saikuro-random/drbg"] [dependencies] -serde = { workspace = true } +# serde/serde_bytes/strum are declared directly (not workspace-inherited) so we +# can force `default-features = false`; the workspace entries keep `std` for the +# host-only crates. Feature unification re-enables `std` on host builds. +serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } # serde_json is used for schema tooling and the stderr log sink; `alloc` -# keeps it no_std-compatible. +# keeps it no_std-compatible (workspace entry already disables `std`). serde_json = { workspace = true, default-features = false, features = ["alloc"] } -serde_bytes = { workspace = true } +# msgpack `bin` wire-type support for the `Bytes` value variant. +serde_bytes = { version = "0.11", default-features = false, features = ["alloc"] } uuid = { workspace = true } saikuro-random = { workspace = true, default-features = false } thiserror = { workspace = true, default-features = false } -strum = { workspace = true } +# strum derive macros only; `std` stays off so core remains no_std. +strum = { version = "0.28", default-features = false, features = ["derive"] } heapless = { workspace = true } +# Blocking locks for the no_std sync facade (`crate::sync`); unused on `std` +# builds where `std::sync` is selected instead. +spin = { workspace = true } # msgpack helpers on Envelope/ResponseEnvelope; rmp-serde is std-only. rmp-serde = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-core/src/lib.rs b/Build/crates/saikuro-core/src/lib.rs index 75f1a5dc..bbfa923a 100644 --- a/Build/crates/saikuro-core/src/lib.rs +++ b/Build/crates/saikuro-core/src/lib.rs @@ -25,6 +25,7 @@ pub mod invocation; pub mod log; pub mod resource; pub mod schema; +pub mod sync; pub mod value; pub use capability::{CapabilitySet, CapabilityToken}; diff --git a/Build/crates/saikuro-core/src/sync.rs b/Build/crates/saikuro-core/src/sync.rs new file mode 100644 index 00000000..ee73fc93 --- /dev/null +++ b/Build/crates/saikuro-core/src/sync.rs @@ -0,0 +1,213 @@ +//! Blocking synchronization primitives for the no_std tiers. +//! +//! `RwLock` and `Mutex` are thin wrappers over two backends: +//! +//! - `std` builds use `std::sync` locks, which park the OS thread while +//! contended; +//! - `no_std` builds use spinlocks from the `spin` crate. +//! +//! Both backends expose the same guard-based API, so downstream crates +//! (`saikuro-schema`, `saikuro-router`) can share one code path between host +//! and MCU targets. The guards are only ever held for short map mutations; +//! they are never held across an `await`. +//! +//! Lock poisoning is deliberately ignored: a panic while one of these guards +//! is held is a bug that should surface immediately, not be silently recovered +//! from. + +use core::fmt; +use core::ops::{Deref, DerefMut}; + +#[cfg(feature = "std")] +use std::sync as imp; + +#[cfg(not(feature = "std"))] +use spin as imp; + +/// A reader-writer lock. `read`/`write` return guards that deref to the +/// protected value. +pub struct RwLock { + inner: imp::RwLock, +} + +/// Guard acquired by [`RwLock::read`]. +pub struct RwLockReadGuard<'a, T: ?Sized> { + inner: imp::RwLockReadGuard<'a, T>, +} + +/// Guard acquired by [`RwLock::write`]. +pub struct RwLockWriteGuard<'a, T: ?Sized> { + inner: imp::RwLockWriteGuard<'a, T>, +} + +/// A mutual-exclusion lock. `lock` returns a guard that derefs to the +/// protected value. +pub struct Mutex { + inner: imp::Mutex, +} + +/// Guard acquired by [`Mutex::lock`]. +pub struct MutexGuard<'a, T: ?Sized> { + inner: imp::MutexGuard<'a, T>, +} + +// The std and spin backends disagree on whether lock acquisition can fail +// (std returns `LockResult`, spin returns a guard directly). These traits +// normalize the two behind a single guard-returning API. + +trait RwLockAccess { + fn read_guard(&self) -> imp::RwLockReadGuard<'_, T>; + fn write_guard(&self) -> imp::RwLockWriteGuard<'_, T>; +} + +trait MutexAccess { + fn lock_guard(&self) -> imp::MutexGuard<'_, T>; +} + +#[cfg(feature = "std")] +impl RwLockAccess for imp::RwLock { + fn read_guard(&self) -> imp::RwLockReadGuard<'_, T> { + self.read().unwrap_or_else(|poison| poison.into_inner()) + } + + fn write_guard(&self) -> imp::RwLockWriteGuard<'_, T> { + self.write().unwrap_or_else(|poison| poison.into_inner()) + } +} + +#[cfg(feature = "std")] +impl MutexAccess for imp::Mutex { + fn lock_guard(&self) -> imp::MutexGuard<'_, T> { + self.lock().unwrap_or_else(|poison| poison.into_inner()) + } +} + +#[cfg(not(feature = "std"))] +impl RwLockAccess for imp::RwLock { + fn read_guard(&self) -> imp::RwLockReadGuard<'_, T> { + self.read() + } + + fn write_guard(&self) -> imp::RwLockWriteGuard<'_, T> { + self.write() + } +} + +#[cfg(not(feature = "std"))] +impl MutexAccess for imp::Mutex { + fn lock_guard(&self) -> imp::MutexGuard<'_, T> { + self.lock() + } +} + +impl RwLock { + /// Create a new lock guarding `value`. + pub const fn new(value: T) -> Self { + Self { + inner: imp::RwLock::new(value), + } + } +} + +impl Default for RwLock { + fn default() -> Self { + Self::new(T::default()) + } +} + +impl RwLock { + /// Acquire the read guard. + pub fn read(&self) -> RwLockReadGuard<'_, T> { + RwLockReadGuard { + inner: self.inner.read_guard(), + } + } + + /// Acquire the write guard. + pub fn write(&self) -> RwLockWriteGuard<'_, T> { + RwLockWriteGuard { + inner: self.inner.write_guard(), + } + } +} + +impl Deref for RwLockReadGuard<'_, T> { + type Target = T; + + fn deref(&self) -> &T { + &self.inner + } +} + +impl Deref for RwLockWriteGuard<'_, T> { + type Target = T; + + fn deref(&self) -> &T { + &self.inner + } +} + +impl DerefMut for RwLockWriteGuard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.inner + } +} + +impl Mutex { + /// Create a new mutex guarding `value`. + pub const fn new(value: T) -> Self { + Self { + inner: imp::Mutex::new(value), + } + } +} + +impl Default for Mutex { + fn default() -> Self { + Self::new(T::default()) + } +} + +impl Mutex { + /// Acquire the guard. + pub fn lock(&self) -> MutexGuard<'_, T> { + MutexGuard { + inner: self.inner.lock_guard(), + } + } +} + +impl Deref for MutexGuard<'_, T> { + type Target = T; + + fn deref(&self) -> &T { + &self.inner + } +} + +impl DerefMut for MutexGuard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.inner + } +} + +// Debug + +impl fmt::Debug for RwLock { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RwLock") + .field("inner", &&self.inner) + .finish() + } +} + +impl fmt::Debug for Mutex { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Mutex") + .field("inner", &&self.inner) + .finish() + } +} + +// The wrappers inherit `Send`/`Sync` from their inner locks: std locks are +// `Send + Sync` when `T: Send`, spin locks likewise. No manual impls needed. diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index 28a1d281..726420a9 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -8,19 +8,24 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "router", "rpc"] +[features] +default = ["std"] +std = ["saikuro-core/std", "saikuro-exec/tokio-runtime"] +embassy = ["saikuro-exec/embassy-runtime"] + [dependencies] -saikuro-core = { workspace = true } +saikuro-core = { path = "../saikuro-core", default-features = false } saikuro-schema = { workspace = true } +saikuro-exec = { workspace = true, default-features = false } -serde = { workspace = true } -bytes = { workspace = true } -rmp-serde = { workspace = true } async-trait = { workspace = true } -futures = { workspace = true } thiserror = { workspace = true } -tracing = { workspace = true } -dashmap = { workspace = true } -saikuro-exec = { workspace = true, default-features = false } +# Atomics for sequence tracking that also work on MCU targets without native +# 64-bit CAS (thumbv7m); native instructions are used where available. +portable-atomic = { workspace = true } +# tracing is declared directly so the `std` feature stays off on MCU; the +# `attributes` feature is required by `#[instrument]` on `dispatch`. +tracing = { version = "0.1", default-features = false, features = ["attributes"] } [dev-dependencies] tracing-subscriber = { workspace = true } diff --git a/Build/crates/saikuro-router/src/error.rs b/Build/crates/saikuro-router/src/error.rs index 7e125028..ec852fa5 100644 --- a/Build/crates/saikuro-router/src/error.rs +++ b/Build/crates/saikuro-router/src/error.rs @@ -1,5 +1,6 @@ //! Router error type. +use alloc::string::String; use thiserror::Error; #[derive(Debug, Error)] @@ -32,4 +33,4 @@ pub enum RouterError { SendError(String), } -pub type Result = std::result::Result; +pub type Result = core::result::Result; diff --git a/Build/crates/saikuro-router/src/lib.rs b/Build/crates/saikuro-router/src/lib.rs index d0c30017..6c732597 100644 --- a/Build/crates/saikuro-router/src/lib.rs +++ b/Build/crates/saikuro-router/src/lib.rs @@ -2,6 +2,13 @@ //! //! This crate owns the invocation router and provider registry. It maps //! namespace names to provider handles and dispatches incoming envelopes. +//! +//! The crate is `no_std` + `alloc` + +#![no_std] + +#[macro_use] +extern crate alloc; pub mod error; pub mod provider; diff --git a/Build/crates/saikuro-router/src/provider.rs b/Build/crates/saikuro-router/src/provider.rs index af703617..23880ccb 100644 --- a/Build/crates/saikuro-router/src/provider.rs +++ b/Build/crates/saikuro-router/src/provider.rs @@ -8,11 +8,12 @@ //! Each handle wraps a MPSC sender so the router can dispatch work //! without blocking. +use alloc::{ + borrow::ToOwned, boxed::Box, collections::BTreeMap, string::String, sync::Arc, vec::Vec, +}; use async_trait::async_trait; -use dashmap::DashMap; -use saikuro_core::{envelope::Envelope, ResponseEnvelope}; +use saikuro_core::{envelope::Envelope, sync::RwLock, ResponseEnvelope}; use saikuro_exec::{mpsc, oneshot}; -use std::sync::Arc; use tracing::{debug, warn}; use crate::error::{Result, RouterError}; @@ -118,12 +119,16 @@ impl Provider for ProviderHandle { // ProviderRegistry /// Thread-safe registry mapping namespace names to provider handles. +/// +/// The two maps are guarded by separate [`RwLock`]s. `register` and +/// `deregister` never hold both locks simultaneously (each map operation uses +/// a single-statement guard), so there is no lock-order inversion. #[derive(Clone, Default)] pub struct ProviderRegistry { /// namespace -> provider handle - by_namespace: Arc>, + by_namespace: Arc>>, /// provider_id -> list of namespaces (for cleanup on disconnect) - by_provider: Arc>>, + by_provider: Arc>>>, } impl ProviderRegistry { @@ -139,23 +144,28 @@ impl ProviderRegistry { let provider_id = handle.id().to_owned(); let namespaces = handle.namespaces().to_vec(); - for ns in &namespaces { - if self.by_namespace.contains_key(ns.as_str()) { - warn!(namespace = %ns, provider = %provider_id, "replacing existing namespace provider"); - } else { - debug!(namespace = %ns, provider = %provider_id, "registering provider for namespace"); + { + let mut ns_guard = self.by_namespace.write(); + for ns in &namespaces { + if ns_guard.contains_key(ns.as_str()) { + warn!(namespace = %ns, provider = %provider_id, "replacing existing namespace provider"); + } else { + debug!(namespace = %ns, provider = %provider_id, "registering provider for namespace"); + } + ns_guard.insert(ns.clone(), handle.clone()); } - self.by_namespace.insert(ns.clone(), handle.clone()); } - self.by_provider.insert(provider_id, namespaces); + self.by_provider.write().insert(provider_id, namespaces); } /// Remove all namespace registrations for the given provider ID. pub fn deregister(&self, provider_id: &str) { - if let Some((_, namespaces)) = self.by_provider.remove(provider_id) { + // Take the provider record first; the namespace removals each use a + // fresh guard so the two locks are never nested. + if let Some(namespaces) = self.by_provider.write().remove(provider_id) { for ns in namespaces { - self.by_namespace.remove(&ns); + self.by_namespace.write().remove(&ns); debug!(namespace = %ns, provider = %provider_id, "deregistered namespace provider"); } } @@ -163,12 +173,13 @@ impl ProviderRegistry { /// Look up the provider for a namespace. pub fn get(&self, namespace: &str) -> Option { - self.by_namespace.get(namespace).map(|r| r.clone()) + self.by_namespace.read().get(namespace).cloned() } /// Return `true` if a live provider exists for the namespace. pub fn has_live_provider(&self, namespace: &str) -> bool { self.by_namespace + .read() .get(namespace) .map(|h| h.is_alive()) .unwrap_or(false) diff --git a/Build/crates/saikuro-router/src/router.rs b/Build/crates/saikuro-router/src/router.rs index cf2ad86c..c4e46dee 100644 --- a/Build/crates/saikuro-router/src/router.rs +++ b/Build/crates/saikuro-router/src/router.rs @@ -13,6 +13,8 @@ //! 6. For `Log`: extracts a [`LogRecord`] from `args[0]` and forwards it to //! the configured log sink without routing to any provider. +use alloc::{borrow::ToOwned, boxed::Box, string::ToString, sync::Arc, vec::Vec}; +use core::time::Duration; use saikuro_core::{ envelope::{Envelope, InvocationType, StreamControl}, error::{ErrorDetail, SaikuroError}, @@ -21,7 +23,6 @@ use saikuro_core::{ ResponseEnvelope, }; use saikuro_exec::{mpsc, oneshot, timeout}; -use std::{sync::Arc, time::Duration}; use tracing::{debug, instrument, warn}; use crate::{ diff --git a/Build/crates/saikuro-router/src/stream_state.rs b/Build/crates/saikuro-router/src/stream_state.rs index 8aba16f5..3dd17dc9 100644 --- a/Build/crates/saikuro-router/src/stream_state.rs +++ b/Build/crates/saikuro-router/src/stream_state.rs @@ -5,14 +5,13 @@ //! same invocation ID are correlated back to that entry for sequence checking //! and backpressure enforcement. -use dashmap::DashMap; +use alloc::{collections::BTreeMap, sync::Arc}; +use core::sync::atomic::Ordering; +use portable_atomic::{AtomicBool, AtomicU64}; use saikuro_core::invocation::InvocationId; +use saikuro_core::sync::RwLock; use saikuro_core::ResponseEnvelope; use saikuro_exec::mpsc; -use std::sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, -}; /// Extension trait for atomic sequence-number advancement. /// @@ -130,18 +129,24 @@ impl ChannelState { // Store /// Thread-safe store for all open stream and channel states. +/// +/// Each map has its own [`RwLock`]; every access is a single-statement guard +/// so no two locks are ever held simultaneously. `InvocationId` is +/// `Ord`, so `BTreeMap` keys keep iteration deterministic. #[derive(Clone, Default)] pub struct StreamStateStore { - streams: Arc>>, + streams: Arc>>>, /// Receivers for stream item channels. Stored here so the channel stays /// live (i.e. `item_tx.send()` does not fail with "channel closed") until /// a caller explicitly takes and consumes the receiver. - stream_receivers: Arc>>, - channels: Arc>>, + stream_receivers: Arc>>>, + channels: Arc>>>, /// Receivers for channel inbound messages. - channel_inbound_receivers: Arc>>, + channel_inbound_receivers: + Arc>>>, /// Receivers for channel outbound messages. - channel_outbound_receivers: Arc>>, + channel_outbound_receivers: + Arc>>>, } impl StreamStateStore { @@ -161,17 +166,17 @@ impl StreamStateStore { state: Arc, receiver: mpsc::Receiver, ) { - self.streams.insert(id, state); - self.stream_receivers.insert(id, receiver); + self.streams.write().insert(id, state); + self.stream_receivers.write().insert(id, receiver); } pub fn get_stream(&self, id: &InvocationId) -> Option> { - self.streams.get(id).map(|r| r.clone()) + self.streams.read().get(id).cloned() } pub fn remove_stream(&self, id: &InvocationId) -> Option> { - self.stream_receivers.remove(id); - self.streams.remove(id).map(|(_, v)| v) + self.stream_receivers.write().remove(id); + self.streams.write().remove(id) } /// Take the receiver half of the stream item channel. @@ -183,7 +188,7 @@ impl StreamStateStore { &self, id: &InvocationId, ) -> Option> { - self.stream_receivers.remove(id).map(|(_, v)| v) + self.stream_receivers.write().remove(id) } // Channel @@ -195,19 +200,23 @@ impl StreamStateStore { inbound_rx: mpsc::Receiver, outbound_rx: mpsc::Receiver, ) { - self.channels.insert(id, state); - self.channel_inbound_receivers.insert(id, inbound_rx); - self.channel_outbound_receivers.insert(id, outbound_rx); + self.channels.write().insert(id, state); + self.channel_inbound_receivers + .write() + .insert(id, inbound_rx); + self.channel_outbound_receivers + .write() + .insert(id, outbound_rx); } pub fn get_channel(&self, id: &InvocationId) -> Option> { - self.channels.get(id).map(|r| r.clone()) + self.channels.read().get(id).cloned() } pub fn remove_channel(&self, id: &InvocationId) -> Option> { - self.channel_inbound_receivers.remove(id); - self.channel_outbound_receivers.remove(id); - self.channels.remove(id).map(|(_, v)| v) + self.channel_inbound_receivers.write().remove(id); + self.channel_outbound_receivers.write().remove(id); + self.channels.write().remove(id) } /// Take the inbound receiver (client -> provider) for a channel. @@ -215,7 +224,7 @@ impl StreamStateStore { &self, id: &InvocationId, ) -> Option> { - self.channel_inbound_receivers.remove(id).map(|(_, v)| v) + self.channel_inbound_receivers.write().remove(id) } /// Take the outbound receiver (provider -> client) for a channel. @@ -223,6 +232,6 @@ impl StreamStateStore { &self, id: &InvocationId, ) -> Option> { - self.channel_outbound_receivers.remove(id).map(|(_, v)| v) + self.channel_outbound_receivers.write().remove(id) } } diff --git a/Build/crates/saikuro-schema/Cargo.toml b/Build/crates/saikuro-schema/Cargo.toml index cbaeadd7..a8b1a34b 100644 --- a/Build/crates/saikuro-schema/Cargo.toml +++ b/Build/crates/saikuro-schema/Cargo.toml @@ -8,11 +8,12 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "schema", "validation"] +# This crate is always `no_std` + `alloc` [dependencies] -saikuro-core = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } +saikuro-core = { path = "../saikuro-core", default-features = false } + +# thiserror is already default-features-off at the workspace level. thiserror = { workspace = true } -dashmap = { workspace = true } -parking_lot = { workspace = true } -tracing = { workspace = true } +# tracing is declared directly so the `std` feature stays off; only the +# event macros are used (no `#[instrument]`). +tracing = { version = "0.1", default-features = false } diff --git a/Build/crates/saikuro-schema/src/capability_engine.rs b/Build/crates/saikuro-schema/src/capability_engine.rs index cad70fda..18038315 100644 --- a/Build/crates/saikuro-schema/src/capability_engine.rs +++ b/Build/crates/saikuro-schema/src/capability_engine.rs @@ -14,6 +14,7 @@ //! [`Visibility::Internal`] visibility are treated as inaccessible: only //! `Public` functions are reachable by sandboxed peers. +use alloc::{borrow::ToOwned, string::String, vec::Vec}; use saikuro_core::{ capability::{CapabilitySet, CapabilityToken}, schema::{FunctionSchema, Visibility}, diff --git a/Build/crates/saikuro-schema/src/lib.rs b/Build/crates/saikuro-schema/src/lib.rs index b2b6630d..50d10964 100644 --- a/Build/crates/saikuro-schema/src/lib.rs +++ b/Build/crates/saikuro-schema/src/lib.rs @@ -3,6 +3,13 @@ //! This crate owns the runtime schema registry, invocation validator, and //! capability enforcement engine. It is the source of truth for "is this //! invocation well-formed and permitted?". +//! +//! The crate is always `no_std` + `alloc` + +#![no_std] + +#[macro_use] +extern crate alloc; pub mod capability_engine; pub mod registry; diff --git a/Build/crates/saikuro-schema/src/registry.rs b/Build/crates/saikuro-schema/src/registry.rs index f419049d..7607503a 100644 --- a/Build/crates/saikuro-schema/src/registry.rs +++ b/Build/crates/saikuro-schema/src/registry.rs @@ -8,11 +8,16 @@ //! In **development mode** providers announce their schemas at connection time //! and the registry merges them in. In **production mode** schemas are loaded //! from a frozen file at startup and providers cannot alter them. - -use dashmap::DashMap; -use parking_lot::RwLock; -use saikuro_core::schema::{FunctionSchema, NamespaceSchema, Schema}; -use std::sync::Arc; +//! +//! All state lives in a single `RwLock` from `saikuro-core::sync` so that the +//! mode check and the mutations it guards are atomic (a registered namespace +//! can never be half-applied against a changing mode). The lock is held only +//! for short map operations and never across an `await`. Keys are ordered +//! `BTreeMap`s for deterministic iteration on both host and MCU targets. + +use alloc::{borrow::ToOwned, collections::BTreeMap, string::String, sync::Arc, vec::Vec}; +use saikuro_core::schema::{FunctionSchema, NamespaceSchema, Schema, TypeDefinition}; +use saikuro_core::sync::RwLock; use tracing::{debug, info, warn}; use crate::validator::ValidationError; @@ -43,19 +48,14 @@ pub struct NamespaceRegistration { // Registry -/// The live schema registry. -/// -/// All lookups are lock-free reads via `DashMap`. Writes (registrations, -/// merges) are infrequent and go through a coarser `RwLock` that guards the -/// mode and global schema snapshot. -#[derive(Clone)] -pub struct SchemaRegistry { +/// All registry state, guarded as a unit by [`SchemaRegistry`]'s lock. +struct Schemata { /// Per-namespace schemas and their owning provider ID. - namespaces: Arc>, + namespaces: BTreeMap, /// Shared type library merged from all registered schemas. - types: Arc>, + types: BTreeMap, /// Mode controlling whether dynamic updates are allowed. - mode: Arc>, + mode: RegistryMode, } #[derive(Debug, Clone)] @@ -64,23 +64,38 @@ struct NamespaceEntry { provider_id: String, } +/// The live schema registry. +/// +/// Reads are shared-lock `BTreeMap` lookups; writes (registrations, merges) +/// go through the exclusive lock and are infrequent. +#[derive(Clone)] +pub struct SchemaRegistry { + inner: Arc>, +} + impl SchemaRegistry { /// Create a new registry in development mode. pub fn new() -> Self { Self { - namespaces: Arc::new(DashMap::new()), - types: Arc::new(DashMap::new()), - mode: Arc::new(RwLock::new(RegistryMode::Development)), + inner: Arc::new(RwLock::new(Schemata { + namespaces: BTreeMap::new(), + types: BTreeMap::new(), + mode: RegistryMode::Development, + })), } } /// Create a registry pre-loaded from a full [`Schema`] document and /// immediately frozen into production mode. pub fn from_frozen_schema(schema: Schema) -> Self { - let registry = Self::new(); + let mut schemata = Schemata { + namespaces: BTreeMap::new(), + types: BTreeMap::new(), + mode: RegistryMode::Production, + }; for (ns_name, ns_schema) in (*schema.namespaces).into_iter() { - registry.namespaces.insert( - ns_name.clone(), + schemata.namespaces.insert( + ns_name, NamespaceEntry { schema: ns_schema, provider_id: "frozen".to_owned(), @@ -88,32 +103,34 @@ impl SchemaRegistry { ); } for (type_name, type_def) in (*schema.types).into_iter() { - registry.types.insert(type_name, type_def); + schemata.types.insert(type_name, type_def); } - *registry.mode.write() = RegistryMode::Production; info!( "schema registry frozen with {} namespace(s)", - registry.namespaces.len() + schemata.namespaces.len() ); - registry + Self { + inner: Arc::new(RwLock::new(schemata)), + } } /// Register (or replace) a namespace. /// /// In production mode this returns an error rather than mutating state. pub fn register(&self, registration: NamespaceRegistration) -> Result<(), RegistryError> { - if *self.mode.read() == RegistryMode::Production { + let mut schemata = self.inner.write(); + if schemata.mode == RegistryMode::Production { return Err(RegistryError::FrozenSchema(registration.namespace)); } let ns = registration.namespace.clone(); - if self.namespaces.contains_key(&ns) { + if schemata.namespaces.contains_key(&ns) { warn!(namespace = %ns, "overwriting existing namespace schema"); } else { debug!(namespace = %ns, provider = %registration.provider_id, "registering namespace"); } - self.namespaces.insert( + schemata.namespaces.insert( ns, NamespaceEntry { schema: registration.schema, @@ -133,16 +150,36 @@ impl SchemaRegistry { provider_id: impl Into, ) -> Result<(), RegistryError> { let provider_id = provider_id.into(); + + // The whole merge happens under one write guard so a concurrent + // `freeze()` cannot interleave between the type and namespace phases. + let mut schemata = self.inner.write(); + + // In production mode only namespace registration is forbidden; an empty + // namespace list is therefore a no-op merge (types alone are permitted). + if schemata.mode == RegistryMode::Production && !schema.namespaces.is_empty() { + let ns = schema.namespaces.keys().next().cloned().unwrap_or_default(); + return Err(RegistryError::FrozenSchema(ns)); + } + // Merge types first (functions may reference them). for (name, typedef) in (*schema.types).into_iter() { - self.types.insert(name, typedef); + schemata.types.insert(name, typedef); } for (ns_name, ns_schema) in (*schema.namespaces).into_iter() { - self.register(NamespaceRegistration { - namespace: ns_name, - schema: ns_schema, - provider_id: provider_id.clone(), - })?; + let ns = ns_name.clone(); + if schemata.namespaces.contains_key(&ns) { + warn!(namespace = %ns, "overwriting existing namespace schema"); + } else { + debug!(namespace = %ns, provider = %provider_id, "registering namespace"); + } + schemata.namespaces.insert( + ns, + NamespaceEntry { + schema: ns_schema, + provider_id: provider_id.clone(), + }, + ); } Ok(()) } @@ -151,7 +188,8 @@ impl SchemaRegistry { /// /// Called when a provider disconnects. pub fn deregister_provider(&self, provider_id: &str) { - self.namespaces.retain(|_ns, entry| { + let mut schemata = self.inner.write(); + schemata.namespaces.retain(|_ns, entry| { let keep = entry.provider_id != provider_id; if !keep { debug!(provider = %provider_id, "deregistered namespace on disconnect"); @@ -166,7 +204,8 @@ impl SchemaRegistry { pub fn lookup_function(&self, target: &str) -> Result { let (ns_name, fn_name) = split_target(target)?; - let entry = self + let schemata = self.inner.read(); + let entry = schemata .namespaces .get(ns_name) .ok_or_else(|| RegistryError::NamespaceNotFound(ns_name.to_owned()))?; @@ -188,48 +227,48 @@ impl SchemaRegistry { /// Return the provider ID for the given namespace. pub fn provider_for_namespace(&self, namespace: &str) -> Option { - self.namespaces + self.inner + .read() + .namespaces .get(namespace) .map(|e| e.provider_id.clone()) } /// Return `true` if the given namespace is registered. pub fn has_namespace(&self, namespace: &str) -> bool { - self.namespaces.contains_key(namespace) + self.inner.read().namespaces.contains_key(namespace) } - /// Return all registered namespace names. + /// Return all registered namespace names (in key order). pub fn namespace_names(&self) -> Vec { - self.namespaces.iter().map(|e| e.key().clone()).collect() + self.inner.read().namespaces.keys().cloned().collect() } /// Export a snapshot of the full schema at this instant. pub fn snapshot(&self) -> Schema { let mut schema = Schema::new(); - for entry in self.namespaces.iter() { + let schemata = self.inner.read(); + for (name, entry) in schemata.namespaces.iter() { schema .namespaces - .insert(entry.key().clone(), entry.value().schema.clone()) + .insert(name.clone(), entry.schema.clone()) .ok(); } - for entry in self.types.iter() { - schema - .types - .insert(entry.key().clone(), entry.value().clone()) - .ok(); + for (name, type_def) in schemata.types.iter() { + schema.types.insert(name.clone(), type_def.clone()).ok(); } schema } /// Freeze the registry, preventing any further schema changes. pub fn freeze(&self) { - *self.mode.write() = RegistryMode::Production; + self.inner.write().mode = RegistryMode::Production; info!("schema registry frozen"); } /// Return the current operating mode. pub fn mode(&self) -> RegistryMode { - *self.mode.read() + self.inner.read().mode } } diff --git a/Build/crates/saikuro-schema/src/validator.rs b/Build/crates/saikuro-schema/src/validator.rs index 6a21f940..2df5b657 100644 --- a/Build/crates/saikuro-schema/src/validator.rs +++ b/Build/crates/saikuro-schema/src/validator.rs @@ -15,6 +15,11 @@ //! All errors are returned as typed [`ValidationError`] values so the //! runtime can produce the right [`ErrorCode`] on the wire. +use alloc::{ + borrow::ToOwned, + boxed::Box, + string::{String, ToString}, +}; use saikuro_core::{ envelope::{Envelope, InvocationType}, error::ErrorCode, From f328ca0829f5ec01bdf7d8eabc953821210c3921 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Tue, 4 Aug 2026 11:55:48 -0600 Subject: [PATCH 04/43] Switch to no_std messagepack library --- .cargo/config.toml | 35 ++--- Build/Cargo.lock | 26 +++- Build/Cargo.toml | 78 +++++------ Build/crates/saikuro-core/Cargo.toml | 31 ++--- Build/crates/saikuro-core/src/envelope.rs | 16 +-- Build/crates/saikuro-core/src/error.rs | 13 +- Build/crates/saikuro-core/src/invocation.rs | 9 +- Build/crates/saikuro-core/src/lib.rs | 1 + Build/crates/saikuro-core/src/msgpack.rs | 128 ++++++++++++++++++ Build/crates/saikuro-core/src/value.rs | 20 +-- .../saikuro-exec/src/embassy_backend.rs | 38 +++--- Build/crates/saikuro-random/src/drbg.rs | 62 +++++---- Build/crates/saikuro-random/src/lib.rs | 47 +++---- Build/crates/saikuro-runtime/Cargo.toml | 1 - .../crates/saikuro-runtime/src/connection.rs | 10 +- Build/crates/saikuro-storage/Cargo.toml | 1 - Build/crates/saikuro-storage/src/error.rs | 8 +- Build/crates/saikuro-storage/src/traits.rs | 4 +- Build/crates/saikuro-transport/Cargo.toml | 1 - Build/crates/saikuro-transport/src/error.rs | 4 +- Build/tests/Cargo.toml | 1 + 21 files changed, 334 insertions(+), 200 deletions(-) create mode 100644 Build/crates/saikuro-core/src/msgpack.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 23b4fbbd..ea1162cc 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,28 +1,29 @@ -# Saikuro Cargo configuration +# Saikuro Cargo config # -# WASM test runner: requires `cargo install wasm-bindgen-cli` -# Run with: cargo test -p saikuro-tests --target wasm32-unknown-unknown -# Or: wasm-pack test --headless --chrome Build/tests +# WASM tests need wasm-bindgen-cli (cargo install wasm-bindgen-cli). +# cargo test -p saikuro-tests --target wasm32-unknown-unknown +# wasm-pack test --headless --chrome Build/tests -# getrandom 0.3 selects its backend at compile time via the -# `getrandom_backend` cfg. +# getrandom 0.3 picks its backend at compile time with the `getrandom_backend` +# cfg, so we set it per-target here. # -# - wasm32-unknown-unknown has no OS entropy source, so every build for that -# target pins the `wasm_js` backend; the matching cargo feature is enabled -# by saikuro-random's `wasm` feature (wired through adapters/rust, -# saikuro-runtime, and saikuro-tests' wasm32 deps). -# - bare-metal MCU targets have no OS and no practical wasm host, so they use -# the `custom` backend. The final binary must define `__getrandom_v03_custom` -# (a no-op here would link a broken RNG, so `fill` fails loudly instead). The -# matching cargo feature is enabled via saikuro-random's `custom` feature. +# wasm32-unknown-unknown has no OS entropy, so we pin the `wasm_js` backend on +# every build for it. saikuro-random's `wasm` feature turns on the matching +# cargo feature (threaded through adapters/rust, saikuro-runtime, and +# saikuro-tests' wasm32 deps). # -# Host targets leave the cfg unset and getrandom uses its per-target default -# (the OS backends). +# Bare-metal MCU targets have no OS and no real wasm host, so they get the +# `custom` backend. Whatever we link has to define `__getrandom_v03_custom` -- +# we deliberately don't stub it, because a no-op would be a silently broken RNG. +# Better to have `fill` blow up than hand back fake randomness. saikuro-random's +# `custom` feature wires up the cargo feature. +# +# Host targets: leave the cfg alone and let getrandom use its normal OS backend. [target.wasm32-unknown-unknown] runner = "wasm-bindgen-test-runner" rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""] -# Bare-metal targets (verification + intended chips): +# Bare-metal targets: # riscv32imc-unknown-none-elf ESP32-C3 # thumbv6m-none-eabi RP2040 # thumbv8m.main-none-eabihf RP2350 diff --git a/Build/Cargo.lock b/Build/Cargo.lock index def2ef54..6de5f6c1 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -1008,6 +1008,26 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "messagepack-core" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95fe590f7b7d58bfefd83d9995c0b09eaecb8607e4bb66a658c95c0bd691f876" +dependencies = [ + "num-traits", +] + +[[package]] +name = "messagepack-serde" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a630d3ff4e4c893267925baffa3f8e75f0cdb20d212170ee7d8576a45ac1869" +dependencies = [ + "messagepack-core", + "num-traits", + "serde", +] + [[package]] name = "minicov" version = "0.3.8" @@ -1428,7 +1448,7 @@ name = "saikuro-core" version = "0.1.0" dependencies = [ "heapless", - "rmp-serde", + "messagepack-serde", "saikuro-random", "serde", "serde_bytes", @@ -1488,7 +1508,6 @@ dependencies = [ "dashmap", "futures", "parking_lot 0.12.5", - "rmp-serde", "saikuro-core", "saikuro-exec", "saikuro-random", @@ -1524,7 +1543,6 @@ dependencies = [ "futures", "js-sys", "parking_lot 0.12.5", - "rmp-serde", "rusqlite", "saikuro-core", "saikuro-exec", @@ -1556,6 +1574,7 @@ dependencies = [ "saikuro-runtime", "saikuro-schema", "saikuro-transport", + "serde", "serde_json", "tracing", "tracing-subscriber", @@ -1574,7 +1593,6 @@ dependencies = [ "futures", "js-sys", "pin-project-lite", - "rmp-serde", "saikuro-core", "saikuro-exec", "send_wrapper", diff --git a/Build/Cargo.toml b/Build/Cargo.toml index 205f0485..fa0760d6 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -28,35 +28,27 @@ rust-version = "1.75" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", default-features = false } serde_bytes = "0.11" +messagepack-serde = { version = "0.2.4", default-features = false, features = [ + "alloc", +] } +# Reference wire-format implementation used only by the adapter simulators +# (tests/tests/common, cross_language_wire, sandbox_dispatch, log_dispatch) and +# the adapters/rust reference adapter rmp-serde = "1.3" -rmpv = { version = "1.3", features = ["with-serde"] } bytes = "1.7" futures = { version = "0.3", default-features = false, features = ["alloc"] } async-trait = "0.1" pin-project-lite = "0.2" -# UUID: default-features off so the crate stays no_std uuid = { version = "1.23.2", default-features = false } -# Randomness: saikuro-random selects the backend via its own features getrandom = { version = "0.3.1", default-features = false } - -# Deterministic DRBG backend for saikuro-random -# Kept no_std (no default features). chacha20 = { version = "0.9", default-features = false } +portable-atomic = { version = "1", default-features = false, features = [ + "fallback", + "critical-section", +] } -# Atomics that work on MCU targets without native 64-bit (or any) atomics -# (riscv32imc, thumbv6m). `fallback` provides the 64-bit ops on 32-bit-CAS -# targets (riscv32imac); `critical-section` covers targets with no atomics at -# all. Native instructions are still used where available. -portable-atomic = { version = "1", default-features = false, features = ["fallback", "critical-section"] } - -# Fixed-capacity collections for no_std builds. `BTreeMap` does not exist in -# heapless. IndexMap/IndexSet give deterministic (insertion-ordered) maps with a -# compile-time capacity and a serde impl that errors on overflow. -# heapless 0.7 is unusable here: its FnvIndexMap keys must implement the -# hash32::Hash trait, which `String` does not. 0.8 keys use the standard -# `Hash` trait (foldhash backend, no_std-safe). heapless = { version = "0.8", default-features = false, features = ["serde"] } # Duration / utility serde helpers @@ -65,40 +57,44 @@ serde_with = "3.0" # Enum string conversion strum = { version = "0.28.0", features = ["derive"] } -# Logging / tracing: tracing itself is wasm-safe; the subscriber is not -# used directly from WASM so it only appears in crates that explicitly need it. +# Logging / tracing tracing = "0.1" -tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt", "json"] } +tracing-subscriber = { version = "0.3.23", features = [ + "env-filter", + "fmt", + "json", +] } # Embedded async runtime (embassy) for no_std MCU targets -embassy-sync = { version = "0.6", default-features = false } -embassy-time = { version = "0.3", default-features = false } +embassy-sync = { version = "0.6", default-features = false } +embassy-time = { version = "0.3", default-features = false } embassy-futures = { version = "0.1", default-features = false } -# Error handling: both are wasm-safe +# Error handling thiserror = { version = "2.0", default-features = false } anyhow = "1.0" -# Concurrency: dashmap and parking_lot are wasm-safe for single-threaded WASM i think +# Concurrency dashmap = "6.1" parking_lot = "0.12" - -# Spinlocks backing the saikuro-core::sync facade on no_std (MCU) builds. -# `portable-atomic` makes them work on targets without native atomics -# (thumbv6m); native instructions are still used where available. -spin = { version = "0.12", default-features = false, features = ["mutex", "spin_mutex", "rwlock", "portable-atomic"] } - -# Time: chrono is wasm-safe when 'wasmbind' feature is enabled +spin = { version = "0.12", default-features = false, features = [ + "mutex", + "spin_mutex", + "rwlock", + "portable-atomic", +] } + +# Time chrono = { version = "0.4", features = ["serde", "wasmbind"] } # Internal crates -saikuro-core = { path = "crates/saikuro-core" } -saikuro-schema = { path = "crates/saikuro-schema" } -saikuro-storage = { path = "crates/saikuro-storage", default-features = false } +saikuro-core = { path = "crates/saikuro-core" } +saikuro-schema = { path = "crates/saikuro-schema" } +saikuro-storage = { path = "crates/saikuro-storage", default-features = false } saikuro-transport = { path = "crates/saikuro-transport", default-features = false } -saikuro-router = { path = "crates/saikuro-router", default-features = false } -saikuro-runtime = { path = "crates/saikuro-runtime", default-features = false } -saikuro-codegen = { path = "crates/saikuro-codegen" } -saikuro-exec = { path = "crates/saikuro-exec", default-features = false } -saikuro-random = { path = "crates/saikuro-random", default-features = false } -saikuro = { path = "adapters/rust", default-features = false } +saikuro-router = { path = "crates/saikuro-router", default-features = false } +saikuro-runtime = { path = "crates/saikuro-runtime", default-features = false } +saikuro-codegen = { path = "crates/saikuro-codegen" } +saikuro-exec = { path = "crates/saikuro-exec", default-features = false } +saikuro-random = { path = "crates/saikuro-random", default-features = false } +saikuro = { path = "adapters/rust", default-features = false } diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index 3ab7f54b..bda652c2 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -9,34 +9,31 @@ repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "rpc", "msgpack"] # The crate is always `no_std` + `alloc`. The default `std` feature adds the -# std-only conveniences (msgpack codec helpers, `std::io::Error` variant) and -# selects the OS entropy backend; `custom` selects the caller-provided getrandom +# std-only conveniences (`std::io::Error` variant, stderr log sink) and selects +# the OS entropy backend; `custom` selects the caller-provided getrandom # backend and `drbg` the deterministic chacha20 DRBG for bare-metal targets # (see saikuro-random). `std` is mutually exclusive with `custom`/`drbg`. [features] default = ["std"] -std = ["dep:rmp-serde", "saikuro-random/os"] +std = ["saikuro-random/os"] custom = ["saikuro-random/custom"] drbg = ["saikuro-random/drbg"] [dependencies] -# serde/serde_bytes/strum are declared directly (not workspace-inherited) so we -# can force `default-features = false`; the workspace entries keep `std` for the -# host-only crates. Feature unification re-enables `std` on host builds. -serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } -# serde_json is used for schema tooling and the stderr log sink; `alloc` -# keeps it no_std-compatible (workspace entry already disables `std`). -serde_json = { workspace = true, default-features = false, features = ["alloc"] } -# msgpack `bin` wire-type support for the `Bytes` value variant. -serde_bytes = { version = "0.11", default-features = false, features = ["alloc"] } +serde = { version = "1.0", default-features = false, features = [ + "derive", + "alloc", +] } +serde_json = { workspace = true, default-features = false, features = [ + "alloc", +] } +serde_bytes = { version = "0.11", default-features = false, features = [ + "alloc", +] } uuid = { workspace = true } saikuro-random = { workspace = true, default-features = false } thiserror = { workspace = true, default-features = false } -# strum derive macros only; `std` stays off so core remains no_std. strum = { version = "0.28", default-features = false, features = ["derive"] } heapless = { workspace = true } -# Blocking locks for the no_std sync facade (`crate::sync`); unused on `std` -# builds where `std::sync` is selected instead. spin = { workspace = true } -# msgpack helpers on Envelope/ResponseEnvelope; rmp-serde is std-only. -rmp-serde = { workspace = true, optional = true } +messagepack-serde = { workspace = true } diff --git a/Build/crates/saikuro-core/src/envelope.rs b/Build/crates/saikuro-core/src/envelope.rs index fbc853c8..bed07d49 100644 --- a/Build/crates/saikuro-core/src/envelope.rs +++ b/Build/crates/saikuro-core/src/envelope.rs @@ -2,7 +2,7 @@ //! //! Every message exchanged between a language adapter and the Saikuro runtime //! is wrapped in an [`Envelope`] or [`ResponseEnvelope`]. Envelopes are -//! serialised to binary using MessagePack (via `rmp-serde`) before transit; +//! serialised to binary using MessagePack via `crate::msgpack` before transit; //! the types here are the canonical in-memory representation. use alloc::{borrow::ToOwned, string::String, vec::Vec}; @@ -119,27 +119,25 @@ pub struct Envelope { pub seq: Option, } -// Shared MessagePack serialization for wire types. rmp-serde is std-only. -#[cfg(feature = "std")] +// Shared MessagePack serialization for wire types. The codec is no_std + alloc, +// so these helpers exist on every build target. macro_rules! impl_msgpack { ($ty:ty) => { impl $ty { /// Serialise this envelope to MessagePack bytes. - pub fn to_msgpack(&self) -> Result, rmp_serde::encode::Error> { - rmp_serde::to_vec_named(self) + pub fn to_msgpack(&self) -> Result, crate::msgpack::EncodeError> { + crate::msgpack::to_vec(self) } /// Deserialise from MessagePack bytes. - pub fn from_msgpack(bytes: &[u8]) -> Result { - rmp_serde::from_slice(bytes) + pub fn from_msgpack(bytes: &[u8]) -> Result { + crate::msgpack::from_slice(bytes) } } }; } -#[cfg(feature = "std")] impl_msgpack!(Envelope); -#[cfg(feature = "std")] impl_msgpack!(ResponseEnvelope); impl Envelope { diff --git a/Build/crates/saikuro-core/src/error.rs b/Build/crates/saikuro-core/src/error.rs index efd3b413..00f62e46 100644 --- a/Build/crates/saikuro-core/src/error.rs +++ b/Build/crates/saikuro-core/src/error.rs @@ -209,14 +209,12 @@ pub enum SaikuroError { #[error("out-of-order sequence: expected {expected}, got {received}")] OutOfOrder { expected: u64, received: u64 }, - // Serialisation (rmp-serde is std-only) - #[cfg(feature = "std")] + // Serialisation #[error("msgpack encode error: {0}")] - MsgpackEncode(#[from] rmp_serde::encode::Error), + MsgpackEncode(#[from] crate::msgpack::EncodeError), - #[cfg(feature = "std")] #[error("msgpack decode error: {0}")] - MsgpackDecode(#[from] rmp_serde::decode::Error), + MsgpackDecode(#[from] crate::msgpack::DecodeError), // I/O #[cfg(feature = "std")] @@ -255,10 +253,9 @@ impl From for ErrorDetail { SaikuroError::StreamClosed => ErrorCode::StreamClosed, SaikuroError::ChannelClosed => ErrorCode::ChannelClosed, SaikuroError::OutOfOrder { .. } => ErrorCode::OutOfOrder, + SaikuroError::MsgpackEncode(_) | SaikuroError::MsgpackDecode(_) => ErrorCode::Internal, #[cfg(feature = "std")] - SaikuroError::MsgpackEncode(_) - | SaikuroError::MsgpackDecode(_) - | SaikuroError::Io(_) => ErrorCode::Internal, + SaikuroError::Io(_) => ErrorCode::Internal, SaikuroError::CapacityExceeded(_) | SaikuroError::Internal(_) => ErrorCode::Internal, }; diff --git a/Build/crates/saikuro-core/src/invocation.rs b/Build/crates/saikuro-core/src/invocation.rs index 26e8e1a3..0ed5730b 100644 --- a/Build/crates/saikuro-core/src/invocation.rs +++ b/Build/crates/saikuro-core/src/invocation.rs @@ -146,17 +146,18 @@ mod tests { #[test] fn msgpack_roundtrip_uses_binary_uuid() { let id = InvocationId::new(); - let encoded = rmp_serde::to_vec_named(&id).expect("encode invocation id"); - let decoded: InvocationId = rmp_serde::from_slice(&encoded).expect("decode invocation id"); + let encoded = crate::msgpack::to_vec(&id).expect("encode invocation id"); + let decoded: InvocationId = + crate::msgpack::from_slice(&encoded).expect("decode invocation id"); assert_eq!(id, decoded); } #[test] fn msgpack_accepts_uuid_string_for_compatibility() { let uuid_text = "6f9619ff-8b86-d011-b42d-00cf4fc964ff"; - let encoded = rmp_serde::to_vec_named(&uuid_text).expect("encode uuid string payload"); + let encoded = crate::msgpack::to_vec(&uuid_text).expect("encode uuid string payload"); let decoded: InvocationId = - rmp_serde::from_slice(&encoded).expect("decode uuid string payload"); + crate::msgpack::from_slice(&encoded).expect("decode uuid string payload"); let text = decoded.to_string(); assert_eq!(text, uuid_text); diff --git a/Build/crates/saikuro-core/src/lib.rs b/Build/crates/saikuro-core/src/lib.rs index bbfa923a..e1d08d96 100644 --- a/Build/crates/saikuro-core/src/lib.rs +++ b/Build/crates/saikuro-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod envelope; pub mod error; pub mod invocation; pub mod log; +pub mod msgpack; pub mod resource; pub mod schema; pub mod sync; diff --git a/Build/crates/saikuro-core/src/msgpack.rs b/Build/crates/saikuro-core/src/msgpack.rs new file mode 100644 index 00000000..b4f5f2d4 --- /dev/null +++ b/Build/crates/saikuro-core/src/msgpack.rs @@ -0,0 +1,128 @@ +//! MessagePack codec +//! +//! All Saikuro wire encoding goes through this module so host, wasm, and MCU +//! targets emit identical bytes. The underlying encoder is `messagepack-serde`, +//! a `no_std` + alloc MessagePack serializer, so these helpers are available on +//! every build target (the previous rmp-serde codec was std-only). +//! +//! Encoding always uses [`RmpCompatible`], which reproduces the reference +//! rmp-serde byte format exactly: integers are minimized to the smallest +//! representation that holds them and floats keep their native width. +//! `messagepack-serde`'s default `LosslessMinimize` config downcasts `f64` +//! values that fit exactly in `f32`, which would silently change the wire +//! format for `Value::Float`; `RmpCompatible` restores rmp-serde's behavior. +//! Tests in the workspace use rmp-serde as a reference implementation + +use alloc::vec::Vec; +use core::convert::Infallible; +use messagepack_serde::{ + messagepack_core::{encode::int::EncodeMinimizeInt, io::IoWrite, io::RError, Encode}, + ser::NumEncoder, +}; +use serde::{Deserialize, Serialize}; + +/// Encodes numbers exactly like rmp-serde +struct RmpCompatible; + +impl NumEncoder for RmpCompatible { + fn encode_i8( + v: i8, + writer: &mut W, + ) -> Result> { + EncodeMinimizeInt(v).encode(writer) + } + + fn encode_i16( + v: i16, + writer: &mut W, + ) -> Result> { + EncodeMinimizeInt(v).encode(writer) + } + + fn encode_i32( + v: i32, + writer: &mut W, + ) -> Result> { + EncodeMinimizeInt(v).encode(writer) + } + + fn encode_i64( + v: i64, + writer: &mut W, + ) -> Result> { + EncodeMinimizeInt(v).encode(writer) + } + + fn encode_i128( + v: i128, + writer: &mut W, + ) -> Result> { + EncodeMinimizeInt(v).encode(writer) + } + + fn encode_u8( + v: u8, + writer: &mut W, + ) -> Result> { + EncodeMinimizeInt(v).encode(writer) + } + + fn encode_u16( + v: u16, + writer: &mut W, + ) -> Result> { + EncodeMinimizeInt(v).encode(writer) + } + + fn encode_u32( + v: u32, + writer: &mut W, + ) -> Result> { + EncodeMinimizeInt(v).encode(writer) + } + + fn encode_u64( + v: u64, + writer: &mut W, + ) -> Result> { + EncodeMinimizeInt(v).encode(writer) + } + + fn encode_u128( + v: u128, + writer: &mut W, + ) -> Result> { + EncodeMinimizeInt(v).encode(writer) + } + + fn encode_f32( + v: f32, + writer: &mut W, + ) -> Result> { + v.encode(writer) + } + + fn encode_f64( + v: f64, + writer: &mut W, + ) -> Result> { + v.encode(writer) + } +} + +/// Encoding error produced by [`to_vec`]. +pub type EncodeError = messagepack_serde::ser::Error; + +/// Decoding error produced by [`from_slice`]. +pub type DecodeError = messagepack_serde::de::Error; + +/// Serialize a value to MessagePack bytes using the rmp-serde-compatible +/// encoding. +pub fn to_vec(value: &T) -> Result, EncodeError> { + messagepack_serde::ser::to_vec_with_config(value, RmpCompatible) +} + +/// Deserialize a value from MessagePack bytes. +pub fn from_slice<'de, T: Deserialize<'de>>(bytes: &'de [u8]) -> Result { + messagepack_serde::de::from_slice(bytes) +} diff --git a/Build/crates/saikuro-core/src/value.rs b/Build/crates/saikuro-core/src/value.rs index 492502ea..62d2e87b 100644 --- a/Build/crates/saikuro-core/src/value.rs +++ b/Build/crates/saikuro-core/src/value.rs @@ -326,10 +326,10 @@ mod tests { types: Box::new(crate::schema::TypeMap::new()), }; - let bytes1 = rmp_serde::to_vec_named(&schema).expect("schema to msgpack"); - let value: Value = rmp_serde::from_slice(&bytes1).expect("msgpack to Value"); - let bytes2 = rmp_serde::to_vec_named(&value).expect("Value to msgpack"); - let schema2: Schema = rmp_serde::from_slice(&bytes2).expect("msgpack to Schema"); + let bytes1 = crate::msgpack::to_vec(&schema).expect("schema to msgpack"); + let value: Value = crate::msgpack::from_slice(&bytes1).expect("msgpack to Value"); + let bytes2 = crate::msgpack::to_vec(&value).expect("Value to msgpack"); + let schema2: Schema = crate::msgpack::from_slice(&bytes2).expect("msgpack to Schema"); assert_eq!(schema2.version, 1); assert!( @@ -342,8 +342,8 @@ mod tests { #[test] fn array_not_confused_with_bytes() { let original = Value::Array(vec![Value::Int(1), Value::Int(2)]); - let bytes = rmp_serde::to_vec_named(&original).expect("serialize"); - let decoded: Value = rmp_serde::from_slice(&bytes).expect("deserialize"); + let bytes = crate::msgpack::to_vec(&original).expect("serialize"); + let decoded: Value = crate::msgpack::from_slice(&bytes).expect("deserialize"); assert!( matches!(decoded, Value::Array(_)), "Expected Array, got: {decoded:?}" @@ -354,8 +354,8 @@ mod tests { #[test] fn bytes_round_trip() { let original = Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef]); - let bytes = rmp_serde::to_vec_named(&original).expect("serialize"); - let decoded: Value = rmp_serde::from_slice(&bytes).expect("deserialize"); + let bytes = crate::msgpack::to_vec(&original).expect("serialize"); + let decoded: Value = crate::msgpack::from_slice(&bytes).expect("deserialize"); assert!( matches!(decoded, Value::Bytes(_)), "Expected Bytes, got: {decoded:?}" @@ -378,8 +378,8 @@ mod tests { .insert("a".to_owned(), Value::Map(Box::new(inner))) .expect("fits"); let original = Value::Map(Box::new(outer)); - let bytes = rmp_serde::to_vec_named(&original).expect("serialize"); - let decoded: Value = rmp_serde::from_slice(&bytes).expect("deserialize"); + let bytes = crate::msgpack::to_vec(&original).expect("serialize"); + let decoded: Value = crate::msgpack::from_slice(&bytes).expect("deserialize"); assert_eq!(original, decoded); } } diff --git a/Build/crates/saikuro-exec/src/embassy_backend.rs b/Build/crates/saikuro-exec/src/embassy_backend.rs index db6a13b4..1f146d59 100644 --- a/Build/crates/saikuro-exec/src/embassy_backend.rs +++ b/Build/crates/saikuro-exec/src/embassy_backend.rs @@ -1,33 +1,33 @@ //! Embassy backend for `saikuro-exec` (`no_std`). //! -//! Provides embassy-backed implementations of the saikuro-exec API surface. -//! The actual executor is provided by the application via `embassy-executor`; -//! this crate only supplies the concurrency facade. +//! Embassy-backed implementations of the saikuro-exec API surface. The actual +//! executor comes from the application via `embassy-executor`; all this crate +//! provides is the concurrency facade. //! //! # Channels //! //! `mpsc`, `oneshot`, and `watch` are real, owned wrappers over embassy-sync -//! primitives. The channel state is shared between the sender and receiver -//! through `alloc::sync::Arc`, so the handles are `'static` (matching the -//! tokio facade) and the backing storage is freed once every handle is -//! dropped. Facade channels are created once and live for the lifetime of the -//! process, which is how the router uses them. +//! primitives. The channel state is shared between sender and receiver through +//! `alloc::sync::Arc`, so the handles are `'static` (same as the tokio facade) +//! and the backing storage is freed once the last handle is dropped. In +//! practice the router creates its facade channels once and keeps them around +//! for the whole life of the process. //! //! Channel state is guarded by -//! `embassy_sync::blocking_mutex::CriticalSectionRawMutex`. On single-core -//! MCUs the `critical-section` backend comes from the HAL -//! (`critical-section-single-core`, `cortex-m`, and so on); multicore targets -//! must provide a critical-section implementation that covers the whole core. +//! `embassy_sync::blocking_mutex::CriticalSectionRawMutex`. On single-core MCUs +//! the `critical-section` backend comes from the HAL +//! (`critical-section-single-core`, `cortex-m`, etc.); multicore targets have +//! to supply a critical-section impl that covers the whole core. //! //! # Task lifecycle //! -//! `spawn` and `block_on` are not provided. The embassy executor -//! owns task scheduling: the application creates a static -//! `embassy_executor::Executor` and hands out `Spawner`s. A facade cannot -//! invent a global executor without conflicting with the application's own. -//! The stubs exist so host-only crates that select `tokio-runtime` resolve -//! unchanged; they panic with a pointer to the embassy equivalent. -//! `net`, `signal`, and `runtime` are likewise absent from the embassy model. +//! There's no `spawn` or `block_on` here. The embassy executor owns task +//! scheduling: the application stands up a static `embassy_executor::Executor` +//! and hands out `Spawner`s. A facade can't conjure its own global executor +//! without clashing with the application's. The stubs are only here so that +//! host-only crates selecting `tokio-runtime` still resolve, call one and it +//! panics, pointing you at the embassy equivalent. `net`, `signal`, and +//! `runtime` are missing from the embassy model for the same reason. use alloc::sync::Arc; use core::cell::RefCell; diff --git a/Build/crates/saikuro-random/src/drbg.rs b/Build/crates/saikuro-random/src/drbg.rs index b27ecc2d..70403e60 100644 --- a/Build/crates/saikuro-random/src/drbg.rs +++ b/Build/crates/saikuro-random/src/drbg.rs @@ -1,20 +1,20 @@ //! Deterministic ChaCha20 DRBG backend. //! -//! A counter-mode DRBG built from the RFC 8439 ChaCha20 stream cipher. The -//! keystream for block `n` is `ChaCha20(key, nonce)` seeked to byte offset -//! `n * 64`, so the entire stream is a pure function of the 56-byte seed -//! (32-byte key, 24-byte XChaCha20 nonce). Identical seeds produce identical -//! output, which is what makes this backend usable for deterministic tests. +//! This is a counter-mode DRBG over the RFC 8439 ChaCha20 stream cipher. Block +//! `n` of the keystream is `ChaCha20(key, nonce)` seeked to byte `n * 64`, so +//! the whole stream is just a function of the 56-byte seed (32-byte key + +//! 24-byte XChaCha20 nonce). Same seed in, same bytes out: that's the whole +//! point, since it lets tests be deterministic. //! -//! On MCUs with no entropy source (e.g. RP2040) the binary seeds the global -//! state from whatever weak entropy the hardware can provide (ROSC jitter) and -//! all [`crate::fill`] calls draw from it. +//! On MCUs with no entropy source (RP2040, say) the binary seeds the global +//! state from whatever weak entropy the hardware has lying around (ROSC jitter) +//! and every [`crate::fill`] call pulls from that. -// portable-atomic instead of core::sync::atomic: the MCU targets (riscv32imc, -// thumbv6m) have no native atomics and riscv32imac has no 64-bit ones. -// portable-atomic maps to native instructions where they exist and to the -// critical-section fallback elsewhere, which keeps this module compiling for -// every supported target. +// We use portable-atomic rather than core::sync::atomic because some of the MCU +// targets don't have the atomics we need: riscv32imc and thumbv6m have no +// native atomics at all, and riscv32imac has no 64-bit ones. portable-atomic +// uses native instructions when they're there and falls back to +// critical-section otherwise, so this module keeps compiling everywhere. use portable_atomic::{AtomicBool, AtomicU64, Ordering}; use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; @@ -33,8 +33,8 @@ const SEED_WORDS: usize = SEED_LEN / 8; /// Generate keystream block `index` for the given key and nonce. /// -/// Errors if the block index overruns the cipher's u32 block counter (2^32 -/// blocks, i.e. 256 GiB of stream), which is how exhaustion is surfaced. +/// Returns an error if `index` runs past the cipher's u32 block counter (2^32 +/// blocks, ~256 GiB of stream) -- that's how we signal the DRBG is exhausted. fn keystream_block( key: &[u8; KEY_LEN], nonce: &[u8; NONCE_LEN], @@ -42,7 +42,7 @@ fn keystream_block( ) -> Result<[u8; BLOCK_LEN], crate::Error> { let mut cipher = XChaCha20::new_from_slices(key, nonce).map_err(|_| crate::Error::InvalidSeed)?; - // chacha20's seek positions are byte offsets, not block indices. + // chacha20 seeks by byte offset, not by block index. let pos = index .checked_mul(BLOCK_LEN as u64) .ok_or(crate::Error::DrbgExhausted)?; @@ -56,9 +56,9 @@ fn keystream_block( /// A seedable, deterministic counter-mode ChaCha20 DRBG. /// -/// Local instances are the unit-testable form of the backend; the process-wide -/// seeded state ([`seed_from_slice`]) is a thin wrapper over the same -/// keystream construction. +/// Local instances are the easy-to-unit-test form of this backend. The +/// process-wide seeded state ([`seed_from_slice`]) is just a thin wrapper around +/// the same keystream construction. #[derive(Debug, PartialEq, Eq)] pub struct Drbg { key: [u8; KEY_LEN], @@ -69,7 +69,7 @@ pub struct Drbg { impl Drbg { /// Construct a DRBG from a seed of at least [`SEED_LEN`] bytes. /// - /// The first 32 bytes form the key, the next 24 the nonce; extra bytes are + /// First 32 bytes are the key, next 24 are the nonce. Anything past that is /// ignored. pub fn from_seed(seed: &[u8]) -> Result { if seed.len() < SEED_LEN { @@ -105,7 +105,7 @@ impl Drbg { &mut self, dest: &mut [core::mem::MaybeUninit], ) -> Result<(), crate::Error> { - // SAFETY: `MaybeUninit` carries no validity constraints, so writing + // SAFETY: `MaybeUninit` has no validity constraints, so writing // initialized bytes through an `&mut [u8]` view is always sound. let bytes = unsafe { core::slice::from_raw_parts_mut(dest.as_mut_ptr() as *mut u8, dest.len()) }; @@ -115,8 +115,10 @@ impl Drbg { static SEEDED: AtomicBool = AtomicBool::new(false); static COUNTER: AtomicU64 = AtomicU64::new(0); -// Explicit literal: an array-repeat of a non-Copy type needs inline const -// blocks, which require rustc >= 1.79 and the workspace floor is 1.75. +// Written out longhand on purpose: array-repeat of a non-Copy type wants inline +// const blocks, and those need rustc >= 1.79 while our workspace floor is 1.75. +// I'm keeping it that low because I don't want Saikuro to be not compatible +// with older toolchains, and 1.75 is the oldest that is reasonable. static SEED: [AtomicU64; SEED_WORDS] = [ AtomicU64::new(0), AtomicU64::new(0), @@ -129,9 +131,9 @@ static SEED: [AtomicU64; SEED_WORDS] = [ /// Seed the process-wide DRBG from `seed`. /// -/// Call once at startup, before any concurrent [`crate::fill`]. The seed words -/// are stored with release ordering and each word is individually atomic, so -/// readers that observe `SEEDED` never see a partially-written seed. +/// Call this once at startup, before any concurrent [`crate::fill`]. Each seed +/// word is stored individually with release ordering, so a reader that sees +/// `SEEDED` will never catch a half-written seed. pub fn seed_from_slice(seed: &[u8]) -> Result<(), crate::Error> { if seed.len() < SEED_LEN { return Err(crate::Error::InvalidSeed); @@ -160,9 +162,9 @@ fn read_seed() -> ([u8; KEY_LEN], [u8; NONCE_LEN]) { let mut key = [0u8; KEY_LEN]; let mut nonce = [0u8; NONCE_LEN]; key.copy_from_slice(&seed[..KEY_LEN]); - // SEED is zero-initialized only because it is a static; seed_from_slice() - // writes external entropy into it before fill(), which is guarded by - // is_seeded(), can read it, so the zero initializer is never observable. + // SEED only starts out zeroed because it's a static. seed_from_slice() writes + // real entropy into it before anyone calls fill(), and fill() is gated on + // is_seeded(), so nobody ever actually reads the zero initializer. nonce.copy_from_slice(&seed[KEY_LEN..SEED_LEN]); (key, nonce) } @@ -186,7 +188,7 @@ pub fn fill(dest: &mut [u8]) -> Result<(), crate::Error> { /// Fill potentially uninitialized `dest` from the process-wide DRBG. pub fn fill_uninit(dest: &mut [core::mem::MaybeUninit]) -> Result<(), crate::Error> { - // SAFETY: `MaybeUninit` carries no validity constraints, so writing + // SAFETY: `MaybeUninit` has no validity constraints, so writing // initialized bytes through an `&mut [u8]` view is always sound. let bytes = unsafe { core::slice::from_raw_parts_mut(dest.as_mut_ptr() as *mut u8, dest.len()) }; diff --git a/Build/crates/saikuro-random/src/lib.rs b/Build/crates/saikuro-random/src/lib.rs index 17f5950d..8b354965 100644 --- a/Build/crates/saikuro-random/src/lib.rs +++ b/Build/crates/saikuro-random/src/lib.rs @@ -1,15 +1,13 @@ //! Randomness and entropy facade for Saikuro. //! -//! Hides the platform entropy source behind a small `no_std` API so that -//! protocol types do not depend on a specific RNG crate. +//! Wraps the platform entropy source in a small `no_std` API so protocol types +//! don't have to depend on one specific RNG crate. //! -//! Backend selection mirrors saikuro-exec: a binary selects its -//! entropy source with cargo features +//! Backend selection works the same way as saikuro-exec: the binary picks its +//! entropy source through cargo features. //! //! # Determinism -//! -//! Enabling `drbg` makes every call reproducible for a given seed, which is -//! the only way to write deterministic tests over code that issues UUIDs. +//! Turn on `drbg` and every call becomes reproducible for a given seed #![no_std] @@ -25,25 +23,25 @@ pub use uuid::Uuid; /// Deterministic, seedable ChaCha20 DRBG. /// -/// Available with the `drbg` feature. Local instances are fully deterministic: -/// identical seeds produce identical streams, which makes them usable in -/// reproducible tests and as the entropy core for MCUs without a hardware RNG. +/// Comes with the `drbg` feature. Local instances are fully deterministic: +/// the same seed always gives the same stream, which is what makes them handy +/// for reproducible tests and as the entropy core on MCUs with no hardware RNG. #[cfg(feature = "drbg")] pub use drbg::Drbg; /// Errors produced by the entropy facade. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Error { - /// The getrandom-based backend failed to produce entropy. + /// The getrandom-based backend couldn't produce entropy. #[cfg(any(feature = "os", feature = "wasm", feature = "custom"))] Backend(getrandom::Error), - /// The DRBG backend was used before being seeded. + /// The DRBG was used before anyone seeded it. #[cfg(feature = "drbg")] DrbgNotSeeded, - /// The seed passed to the DRBG was too short. + /// The seed handed to the DRBG was too short. #[cfg(feature = "drbg")] InvalidSeed, - /// The DRBG keystream for the current seed has been exhausted. + /// The DRBG keystream for the current seed ran out. #[cfg(feature = "drbg")] DrbgExhausted, } @@ -75,16 +73,16 @@ impl From for Error { /// Fill `dest` with cryptographically secure random bytes. /// -/// With the `drbg` feature the DRBG backend is used instead; it must be -/// seeded first via [`seed_from_slice`]. +/// With the `drbg` feature on you get the DRBG backend instead, and you have to +/// seed it first via [`seed_from_slice`]. pub fn fill(dest: &mut [u8]) -> Result<(), Error> { fill_impl(dest) } /// Fill potentially uninitialized `dest` with random bytes. /// -/// Semantics match [`getrandom::fill_uninit`]: every byte is initialized on -/// success, even in error paths the buffer may be partially written. +/// Same semantics as [`getrandom::fill_uninit`]: on success every byte is +/// initialized, and even on the error path the buffer may be partly written. pub fn fill_uninit(dest: &mut [MaybeUninit]) -> Result<(), Error> { fill_uninit_impl(dest) } @@ -105,8 +103,8 @@ pub fn u64() -> Result { /// Generate a random RFC 4122 version 4 UUID. /// -/// The 16 random bytes come from the active backend; version and variant bits -/// are set per RFC 9562 section 5.8. +/// The 16 random bytes come from the active backend; the version and variant +/// bits get set per RFC 9562 section 5.8. pub fn uuid_v4() -> Result { let mut bytes = [0u8; 16]; fill(&mut bytes)?; @@ -117,11 +115,10 @@ pub fn uuid_v4() -> Result { /// Seed the DRBG backend from `seed`. /// -/// The seed must be at least 56 bytes; the first 32 bytes form the ChaCha20 -/// key and the next 24 the XChaCha20 nonce. Call once at startup, before any -/// concurrent `fill` call (the seed bytes are written before tasks spawn, so -/// readers never observe a partially-written seed). Only available with the -/// `drbg` feature. +/// The seed needs at least 56 bytes: the first 32 are the ChaCha20 key and the +/// next 24 are the XChaCha20 nonce. Call it once at startup, before any +/// concurrent `fill` calls, since the seed is written before tasks spawn, readers +/// never catch a half-written seed. Only there with the `drbg` feature. #[cfg(feature = "drbg")] pub fn seed_from_slice(seed: &[u8]) -> Result<(), Error> { drbg::seed_from_slice(seed) diff --git a/Build/crates/saikuro-runtime/Cargo.toml b/Build/crates/saikuro-runtime/Cargo.toml index e1d27acb..82c5e870 100644 --- a/Build/crates/saikuro-runtime/Cargo.toml +++ b/Build/crates/saikuro-runtime/Cargo.toml @@ -29,7 +29,6 @@ saikuro-random = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -rmp-serde = { workspace = true } bytes = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } diff --git a/Build/crates/saikuro-runtime/src/connection.rs b/Build/crates/saikuro-runtime/src/connection.rs index c84451df..b84190ea 100644 --- a/Build/crates/saikuro-runtime/src/connection.rs +++ b/Build/crates/saikuro-runtime/src/connection.rs @@ -71,7 +71,7 @@ type PendingCalls = Arc> /// Encode a serializable value as MessagePack `Bytes`. fn encode_bytes(value: &T) -> Result { - rmp_serde::to_vec_named(value) + saikuro_core::msgpack::to_vec(value) .map(Bytes::from) .map_err(|e| e.to_string()) } @@ -276,7 +276,7 @@ where /// Decode a MessagePack frame into an [`Envelope`], or return an error /// response on failure. fn decode_envelope(&self, frame: &[u8]) -> Result> { - match rmp_serde::from_slice(frame) { + match saikuro_core::msgpack::from_slice(frame) { Ok(env) => Ok(env), Err(e) => { warn!(peer = %self.peer_id, "envelope decode failed: {e}"); @@ -318,7 +318,7 @@ where // Envelope has `type` (the discriminant) as a required field. // We can tell them apart by attempting ResponseEnvelope decode and // checking if the resulting `id` matches any pending call. - if let Ok(resp) = rmp_serde::from_slice::(&frame) { + if let Ok(resp) = saikuro_core::msgpack::from_slice::(&frame) { if let Some((_, sender)) = pending.remove(&resp.id) { let _ = sender.send(resp); return true; @@ -358,7 +358,7 @@ where let schema: Option = envelope.args.into_iter().next().and_then(|v| { let bytes = encode_bytes(&v).ok()?; - rmp_serde::from_slice(&bytes).ok() + saikuro_core::msgpack::from_slice(&bytes).ok() }); match schema { @@ -511,7 +511,7 @@ where let schema_value: Value = { let bytes = encode_bytes(&filtered).map_err(|e| format!("sandbox schema encode error: {e}"))?; - rmp_serde::from_slice::(&bytes) + saikuro_core::msgpack::from_slice::(&bytes) .map_err(|e| format!("sandbox schema value decode error: {e}"))? }; let announce = Envelope::announce(schema_value); diff --git a/Build/crates/saikuro-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index 231fa726..e927e9ae 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -40,7 +40,6 @@ saikuro-exec = { workspace = true, default-features = false } serde = { workspace = true } serde_json = { workspace = true } -rmp-serde = { workspace = true } bytes = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } diff --git a/Build/crates/saikuro-storage/src/error.rs b/Build/crates/saikuro-storage/src/error.rs index 3a6c7853..5c6a8b69 100644 --- a/Build/crates/saikuro-storage/src/error.rs +++ b/Build/crates/saikuro-storage/src/error.rs @@ -91,14 +91,14 @@ impl StorageError { } } -impl From for StorageError { - fn from(e: rmp_serde::encode::Error) -> Self { +impl From for StorageError { + fn from(e: saikuro_core::msgpack::EncodeError) -> Self { StorageError::Serialization(e.to_string()) } } -impl From for StorageError { - fn from(e: rmp_serde::decode::Error) -> Self { +impl From for StorageError { + fn from(e: saikuro_core::msgpack::DecodeError) -> Self { StorageError::Deserialization(e.to_string()) } } diff --git a/Build/crates/saikuro-storage/src/traits.rs b/Build/crates/saikuro-storage/src/traits.rs index 6de6eb06..260a8a19 100644 --- a/Build/crates/saikuro-storage/src/traits.rs +++ b/Build/crates/saikuro-storage/src/traits.rs @@ -125,7 +125,7 @@ pub trait KeyValueBackendExt: KeyValueBackend { ) -> Result> { match self.get(namespace, key).await? { Some(bytes) => { - let value = rmp_serde::from_slice(&bytes) + let value = saikuro_core::msgpack::from_slice(&bytes) .map_err(|e| super::error::StorageError::deserialization(e.to_string()))?; Ok(Some(value)) } @@ -140,7 +140,7 @@ pub trait KeyValueBackendExt: KeyValueBackend { key: &str, value: &T, ) -> Result<()> { - let bytes = rmp_serde::to_vec_named(value) + let bytes = saikuro_core::msgpack::to_vec(value) .map_err(|e| super::error::StorageError::serialization(e.to_string()))?; self.put(namespace, key, Bytes::from(bytes)).await } diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index 929ded7c..22f009e4 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -30,7 +30,6 @@ saikuro-core = { workspace = true } serde = { workspace = true } bytes = { workspace = true } -rmp-serde = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } pin-project-lite = { workspace = true } diff --git a/Build/crates/saikuro-transport/src/error.rs b/Build/crates/saikuro-transport/src/error.rs index a421132c..8e54ab4d 100644 --- a/Build/crates/saikuro-transport/src/error.rs +++ b/Build/crates/saikuro-transport/src/error.rs @@ -29,10 +29,10 @@ pub enum TransportError { Io(#[from] std::io::Error), #[error("msgpack encode error: {0}")] - MsgpackEncode(#[from] rmp_serde::encode::Error), + MsgpackEncode(#[from] saikuro_core::msgpack::EncodeError), #[error("msgpack decode error: {0}")] - MsgpackDecode(#[from] rmp_serde::decode::Error), + MsgpackDecode(#[from] saikuro_core::msgpack::DecodeError), #[error("channel closed")] ChannelClosed, diff --git a/Build/tests/Cargo.toml b/Build/tests/Cargo.toml index 58211a01..21b9a8b6 100644 --- a/Build/tests/Cargo.toml +++ b/Build/tests/Cargo.toml @@ -20,6 +20,7 @@ saikuro-exec = { workspace = true } bytes = { workspace = true } rmp-serde = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } From 601c82123db2d98520d92130d5d22528493b89c2 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Tue, 4 Aug 2026 13:14:02 -0600 Subject: [PATCH 05/43] Tests in the tests --- Build/Cargo.toml | 11 +- Build/adapters/rust/src/error.rs | 4 + Build/adapters/rust/src/provider.rs | 12 +- Build/adapters/rust/src/schema.rs | 29 ++- Build/adapters/rust/tests/integration.rs | 4 +- Build/adapters/rust/tests/schema_capacity.rs | 26 +++ Build/crates/saikuro-core/Cargo.toml | 9 +- Build/crates/saikuro-core/src/invocation.rs | 26 --- Build/crates/saikuro-core/src/resource.rs | 30 --- Build/crates/saikuro-core/src/value.rs | 99 ---------- Build/crates/saikuro-core/tests/invocation.rs | 20 ++ Build/crates/saikuro-core/tests/resource.rs | 24 +++ Build/crates/saikuro-core/tests/value.rs | 96 ++++++++++ Build/crates/saikuro-exec/Cargo.toml | 7 +- .../saikuro-exec/src/embassy_backend.rs | 178 ++++++++++++++---- Build/crates/saikuro-exec/src/lib.rs | 22 ++- .../crates/saikuro-runtime/src/connection.rs | 14 +- Build/crates/saikuro-runtime/src/handle.rs | 4 +- Build/crates/saikuro-runtime/src/lib.rs | 51 ----- .../tests/schema_registration.rs | 45 +++++ Build/crates/saikuro-schema/src/registry.rs | 19 +- Build/crates/saikuro-schema/src/validator.rs | 21 +-- .../crates/saikuro-schema/tests/validator.rs | 17 ++ Build/crates/saikuro-storage/src/util.rs | 137 ++------------ Build/crates/saikuro-storage/tests/util.rs | 112 +++++++++++ 25 files changed, 597 insertions(+), 420 deletions(-) create mode 100644 Build/adapters/rust/tests/schema_capacity.rs create mode 100644 Build/crates/saikuro-core/tests/invocation.rs create mode 100644 Build/crates/saikuro-core/tests/resource.rs create mode 100644 Build/crates/saikuro-core/tests/value.rs create mode 100644 Build/crates/saikuro-runtime/tests/schema_registration.rs create mode 100644 Build/crates/saikuro-schema/tests/validator.rs create mode 100644 Build/crates/saikuro-storage/tests/util.rs diff --git a/Build/Cargo.toml b/Build/Cargo.toml index fa0760d6..569b669a 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -25,9 +25,16 @@ rust-version = "1.75" [workspace.dependencies] # Serialization -serde = { version = "1.0", features = ["derive"] } +# no_std-compatible by default; workspace members that need std behaviour +# enable it explicitly via `features = ["std"]` on their serde dependency. +serde = { version = "1.0", default-features = false, features = [ + "alloc", + "derive", +] } serde_json = { version = "1.0", default-features = false } -serde_bytes = "0.11" +serde_bytes = { version = "0.11", default-features = false, features = [ + "alloc", +] } messagepack-serde = { version = "0.2.4", default-features = false, features = [ "alloc", ] } diff --git a/Build/adapters/rust/src/error.rs b/Build/adapters/rust/src/error.rs index 81a23d36..508190a1 100644 --- a/Build/adapters/rust/src/error.rs +++ b/Build/adapters/rust/src/error.rs @@ -42,6 +42,10 @@ pub enum Error { /// The client or provider is not in the correct state for this operation. #[error("invalid state: {0}")] InvalidState(String), + + /// The provider's schema exceeds the fixed capacity of the core schema maps. + #[error("schema capacity exceeded while building the announcement")] + SchemaCapacityExceeded, } impl Error { diff --git a/Build/adapters/rust/src/provider.rs b/Build/adapters/rust/src/provider.rs index 335183c1..014675c2 100644 --- a/Build/adapters/rust/src/provider.rs +++ b/Build/adapters/rust/src/provider.rs @@ -113,7 +113,7 @@ impl Provider { // Schema /// Build the schema announcement for this provider. - fn build_schema(&self) -> Schema { + fn build_schema(&self) -> Result { let mut ns_schema = NamespaceSchema::new(); for (name, entry) in &self.handlers { if let Some(schema) = &entry.schema { @@ -203,7 +203,15 @@ impl Provider { // Announce async fn announce(&self, transport: &mut dyn AdapterTransport) { - let schema = self.build_schema(); + // A capacity overflow here means the announcement would be silently + // truncated; fail the announce instead of publishing a partial schema. + let schema = match self.build_schema() { + Ok(schema) => schema, + Err(e) => { + warn!(error = %e, "failed to build schema announcement"); + return; + } + }; let schema_value = match serde_json::to_value(&schema) { Ok(v) => json_to_core(v), Err(e) => { diff --git a/Build/adapters/rust/src/schema.rs b/Build/adapters/rust/src/schema.rs index c0dfa1dc..449f6d2f 100644 --- a/Build/adapters/rust/src/schema.rs +++ b/Build/adapters/rust/src/schema.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; +use crate::error::{Error, Result}; use saikuro_core::schema::{ ArgumentDescriptor, FunctionSchema as CoreFunctionSchema, NamespaceSchema as CoreNamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility, @@ -55,7 +56,10 @@ impl NamespaceSchema { } /// Convert to the core `NamespaceSchema` for announcement. - pub fn to_core(&self) -> CoreNamespaceSchema { + /// + /// Fails when the function count exceeds the core schema's fixed map + /// capacity, so a provider never announces a silently truncated namespace. + pub fn to_core(&self) -> Result { let mut functions = saikuro_core::schema::FunctionMap::new(); for (name, fs) in &self.functions { let args: Vec = fs @@ -84,21 +88,32 @@ impl NamespaceSchema { idempotent: fs.idempotent, doc: fs.doc.clone(), }; - functions.insert(name.clone(), core_fn).ok(); + functions + .insert(name.clone(), core_fn) + .map_err(|_| Error::SchemaCapacityExceeded)?; } - CoreNamespaceSchema { + Ok(CoreNamespaceSchema { functions: Box::new(functions), doc: self.doc.clone(), - } + }) } } /// Build a full [`Schema`] from the given namespaces. -pub(crate) fn build_schema(namespaces: &HashMap) -> Schema { +/// +/// Fails when the namespace count exceeds the core schema's fixed map +/// capacity, so a provider never announces a silently truncated schema. +/// +/// Internal helper exposed for the crate's integration tests. +#[doc(hidden)] +pub fn build_schema(namespaces: &HashMap) -> Result { let mut schema = Schema::new(); for (ns_name, ns) in namespaces { - schema.namespaces.insert(ns_name.clone(), ns.to_core()).ok(); + schema + .namespaces + .insert(ns_name.clone(), ns.to_core()?) + .map_err(|_| Error::SchemaCapacityExceeded)?; } - schema + Ok(schema) } diff --git a/Build/adapters/rust/tests/integration.rs b/Build/adapters/rust/tests/integration.rs index 60035055..e811cc78 100644 --- a/Build/adapters/rust/tests/integration.rs +++ b/Build/adapters/rust/tests/integration.rs @@ -132,7 +132,7 @@ fn schema_build_basic() { }, ); // to_core should not panic - let _ = ns.to_core(); + let _ = ns.to_core().expect("schema conversion failed"); }) } @@ -148,7 +148,7 @@ fn schema_capabilities_convert() { ..Default::default() }, ); - let core_ns = ns.to_core(); + let core_ns = ns.to_core().expect("schema conversion failed"); let fn_schema = core_ns.functions.get("op").expect("op function missing"); let cap_strs: Vec = fn_schema .capabilities diff --git a/Build/adapters/rust/tests/schema_capacity.rs b/Build/adapters/rust/tests/schema_capacity.rs new file mode 100644 index 00000000..9078384f --- /dev/null +++ b/Build/adapters/rust/tests/schema_capacity.rs @@ -0,0 +1,26 @@ +use saikuro::schema::{build_schema, FunctionSchema, NamespaceSchema}; +use saikuro::Error; +use saikuro_core::schema::{SCHEMA_FUNCTIONS_CAPACITY, SCHEMA_NAMESPACES_CAPACITY}; +use std::collections::HashMap; + +#[test] +fn to_core_overflow_functions_returns_capacity_error() { + let mut ns = NamespaceSchema::new(); + for i in 0..=SCHEMA_FUNCTIONS_CAPACITY { + ns.insert(format!("fn_{i}"), FunctionSchema::default()); + } + let err = ns.to_core().unwrap_err(); + assert!(matches!(err, Error::SchemaCapacityExceeded)); +} + +#[test] +fn build_schema_overflow_namespaces_returns_capacity_error() { + let mut namespaces = HashMap::new(); + for i in 0..=SCHEMA_NAMESPACES_CAPACITY { + let mut ns = NamespaceSchema::new(); + ns.insert("f", FunctionSchema::default()); + namespaces.insert(format!("ns_{i}"), ns); + } + let err = build_schema(&namespaces).unwrap_err(); + assert!(matches!(err, Error::SchemaCapacityExceeded)); +} diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index bda652c2..9282dabb 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -20,16 +20,11 @@ custom = ["saikuro-random/custom"] drbg = ["saikuro-random/drbg"] [dependencies] -serde = { version = "1.0", default-features = false, features = [ - "derive", - "alloc", -] } +serde = { workspace = true } serde_json = { workspace = true, default-features = false, features = [ "alloc", ] } -serde_bytes = { version = "0.11", default-features = false, features = [ - "alloc", -] } +serde_bytes = { workspace = true } uuid = { workspace = true } saikuro-random = { workspace = true, default-features = false } thiserror = { workspace = true, default-features = false } diff --git a/Build/crates/saikuro-core/src/invocation.rs b/Build/crates/saikuro-core/src/invocation.rs index 0ed5730b..25c418ff 100644 --- a/Build/crates/saikuro-core/src/invocation.rs +++ b/Build/crates/saikuro-core/src/invocation.rs @@ -137,29 +137,3 @@ impl core::str::FromStr for InvocationId { Ok(Self(Uuid::parse_str(s)?)) } } - -#[cfg(test)] -mod tests { - use super::InvocationId; - use alloc::string::ToString; - - #[test] - fn msgpack_roundtrip_uses_binary_uuid() { - let id = InvocationId::new(); - let encoded = crate::msgpack::to_vec(&id).expect("encode invocation id"); - let decoded: InvocationId = - crate::msgpack::from_slice(&encoded).expect("decode invocation id"); - assert_eq!(id, decoded); - } - - #[test] - fn msgpack_accepts_uuid_string_for_compatibility() { - let uuid_text = "6f9619ff-8b86-d011-b42d-00cf4fc964ff"; - let encoded = crate::msgpack::to_vec(&uuid_text).expect("encode uuid string payload"); - let decoded: InvocationId = - crate::msgpack::from_slice(&encoded).expect("decode uuid string payload"); - - let text = decoded.to_string(); - assert_eq!(text, uuid_text); - } -} diff --git a/Build/crates/saikuro-core/src/resource.rs b/Build/crates/saikuro-core/src/resource.rs index 354761e7..e275626a 100644 --- a/Build/crates/saikuro-core/src/resource.rs +++ b/Build/crates/saikuro-core/src/resource.rs @@ -166,33 +166,3 @@ impl fmt::Display for ResourceHandle { Ok(()) } } - -// Tests (minimal inline) - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resource_handle_roundtrips_through_value() { - let h = ResourceHandle::new("abc-123") - .with_mime_type("text/plain") - .with_size(42) - .with_uri("saikuro://res/abc-123"); - - let v = h.to_value(); - let decoded = ResourceHandle::from_value(&v).expect("decode"); - assert_eq!(decoded, h); - } - - #[test] - fn resource_handle_minimal_roundtrip() { - let h = ResourceHandle::new("xyz"); - let v = h.to_value(); - let decoded = ResourceHandle::from_value(&v).expect("decode"); - assert_eq!(decoded.id, "xyz"); - assert!(decoded.mime_type.is_none()); - assert!(decoded.size.is_none()); - assert!(decoded.uri.is_none()); - } -} diff --git a/Build/crates/saikuro-core/src/value.rs b/Build/crates/saikuro-core/src/value.rs index 62d2e87b..989abf22 100644 --- a/Build/crates/saikuro-core/src/value.rs +++ b/Build/crates/saikuro-core/src/value.rs @@ -284,102 +284,3 @@ impl> From> for Value { } } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::schema::{ - FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, - TypeDescriptor, Visibility, - }; - - /// Regression: Schema -> msgpack bytes -> Value -> msgpack bytes -> Schema must round-trip. - #[test] - fn schema_round_trip_via_value() { - let mut functions = FunctionMap::new(); - functions - .insert( - "hello".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ) - .expect("schema fits in FunctionMap capacity"); - let mut namespaces = NamespaceMap::new(); - namespaces - .insert( - "svc".to_owned(), - NamespaceSchema { - functions: Box::new(functions), - doc: None, - }, - ) - .expect("schema fits in NamespaceMap capacity"); - let schema = Schema { - version: 1, - namespaces: Box::new(namespaces), - types: Box::new(crate::schema::TypeMap::new()), - }; - - let bytes1 = crate::msgpack::to_vec(&schema).expect("schema to msgpack"); - let value: Value = crate::msgpack::from_slice(&bytes1).expect("msgpack to Value"); - let bytes2 = crate::msgpack::to_vec(&value).expect("Value to msgpack"); - let schema2: Schema = crate::msgpack::from_slice(&bytes2).expect("msgpack to Schema"); - - assert_eq!(schema2.version, 1); - assert!( - schema2.namespaces.contains_key("svc"), - "namespace 'svc' not found after round-trip" - ); - } - - /// Regression: Value::Array must not be confused with Value::Bytes. - #[test] - fn array_not_confused_with_bytes() { - let original = Value::Array(vec![Value::Int(1), Value::Int(2)]); - let bytes = crate::msgpack::to_vec(&original).expect("serialize"); - let decoded: Value = crate::msgpack::from_slice(&bytes).expect("deserialize"); - assert!( - matches!(decoded, Value::Array(_)), - "Expected Array, got: {decoded:?}" - ); - } - - /// Regression: Value::Bytes must survive a round-trip as msgpack bin. - #[test] - fn bytes_round_trip() { - let original = Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef]); - let bytes = crate::msgpack::to_vec(&original).expect("serialize"); - let decoded: Value = crate::msgpack::from_slice(&bytes).expect("deserialize"); - assert!( - matches!(decoded, Value::Bytes(_)), - "Expected Bytes, got: {decoded:?}" - ); - } - - #[test] - fn check_sizes() { - std::eprintln!("Value: {} bytes", std::mem::size_of::()); - std::eprintln!("ValueMap: {} bytes", std::mem::size_of::()); - } - - /// Value::Map with a nested map must round-trip. - #[test] - fn simple_map_round_trip() { - let mut inner = ValueMap::new(); - inner.insert("b".to_owned(), Value::Int(2)).expect("fits"); - let mut outer = ValueMap::new(); - outer - .insert("a".to_owned(), Value::Map(Box::new(inner))) - .expect("fits"); - let original = Value::Map(Box::new(outer)); - let bytes = crate::msgpack::to_vec(&original).expect("serialize"); - let decoded: Value = crate::msgpack::from_slice(&bytes).expect("deserialize"); - assert_eq!(original, decoded); - } -} diff --git a/Build/crates/saikuro-core/tests/invocation.rs b/Build/crates/saikuro-core/tests/invocation.rs new file mode 100644 index 00000000..802adfa2 --- /dev/null +++ b/Build/crates/saikuro-core/tests/invocation.rs @@ -0,0 +1,20 @@ +use saikuro_core::msgpack; +use saikuro_core::InvocationId; + +#[test] +fn msgpack_roundtrip_uses_binary_uuid() { + let id = InvocationId::new(); + let encoded = msgpack::to_vec(&id).expect("encode invocation id"); + let decoded: InvocationId = msgpack::from_slice(&encoded).expect("decode invocation id"); + assert_eq!(id, decoded); +} + +#[test] +fn msgpack_accepts_uuid_string_for_compatibility() { + let uuid_text = "6f9619ff-8b86-d011-b42d-00cf4fc964ff"; + let encoded = msgpack::to_vec(&uuid_text).expect("encode uuid string payload"); + let decoded: InvocationId = msgpack::from_slice(&encoded).expect("decode uuid string payload"); + + let text = decoded.to_string(); + assert_eq!(text, uuid_text); +} diff --git a/Build/crates/saikuro-core/tests/resource.rs b/Build/crates/saikuro-core/tests/resource.rs new file mode 100644 index 00000000..0496f5d0 --- /dev/null +++ b/Build/crates/saikuro-core/tests/resource.rs @@ -0,0 +1,24 @@ +use saikuro_core::ResourceHandle; + +#[test] +fn resource_handle_roundtrips_through_value() { + let h = ResourceHandle::new("abc-123") + .with_mime_type("text/plain") + .with_size(42) + .with_uri("saikuro://res/abc-123"); + + let v = h.to_value(); + let decoded = ResourceHandle::from_value(&v).expect("decode"); + assert_eq!(decoded, h); +} + +#[test] +fn resource_handle_minimal_roundtrip() { + let h = ResourceHandle::new("xyz"); + let v = h.to_value(); + let decoded = ResourceHandle::from_value(&v).expect("decode"); + assert_eq!(decoded.id, "xyz"); + assert!(decoded.mime_type.is_none()); + assert!(decoded.size.is_none()); + assert!(decoded.uri.is_none()); +} diff --git a/Build/crates/saikuro-core/tests/value.rs b/Build/crates/saikuro-core/tests/value.rs new file mode 100644 index 00000000..080b7d16 --- /dev/null +++ b/Build/crates/saikuro-core/tests/value.rs @@ -0,0 +1,96 @@ +use saikuro_core::msgpack; +use saikuro_core::schema::{ + FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, + TypeDescriptor, TypeMap, Visibility, +}; +use saikuro_core::value::{Value, ValueMap}; + +/// Regression: Schema -> msgpack bytes -> Value -> msgpack bytes -> Schema must round-trip. +#[test] +fn schema_round_trip_via_value() { + let mut functions = FunctionMap::new(); + functions + .insert( + "hello".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .expect("schema fits in FunctionMap capacity"); + let mut namespaces = NamespaceMap::new(); + namespaces + .insert( + "svc".to_owned(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .expect("schema fits in NamespaceMap capacity"); + let schema = Schema { + version: 1, + namespaces: Box::new(namespaces), + types: Box::new(TypeMap::new()), + }; + + let bytes1 = msgpack::to_vec(&schema).expect("schema to msgpack"); + let value: Value = msgpack::from_slice(&bytes1).expect("msgpack to Value"); + let bytes2 = msgpack::to_vec(&value).expect("Value to msgpack"); + let schema2: Schema = msgpack::from_slice(&bytes2).expect("msgpack to Schema"); + + assert_eq!(schema2.version, 1); + assert!( + schema2.namespaces.contains_key("svc"), + "namespace 'svc' not found after round-trip" + ); +} + +/// Regression: Value::Array must not be confused with Value::Bytes. +#[test] +fn array_not_confused_with_bytes() { + let original = Value::Array(vec![Value::Int(1), Value::Int(2)]); + let bytes = msgpack::to_vec(&original).expect("serialize"); + let decoded: Value = msgpack::from_slice(&bytes).expect("deserialize"); + assert!( + matches!(decoded, Value::Array(_)), + "Expected Array, got: {decoded:?}" + ); +} + +/// Regression: Value::Bytes must survive a round-trip as msgpack bin. +#[test] +fn bytes_round_trip() { + let original = Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef]); + let bytes = msgpack::to_vec(&original).expect("serialize"); + let decoded: Value = msgpack::from_slice(&bytes).expect("deserialize"); + assert!( + matches!(decoded, Value::Bytes(_)), + "Expected Bytes, got: {decoded:?}" + ); +} + +#[test] +fn check_sizes() { + eprintln!("Value: {} bytes", std::mem::size_of::()); + eprintln!("ValueMap: {} bytes", std::mem::size_of::()); +} + +/// Value::Map with a nested map must round-trip. +#[test] +fn simple_map_round_trip() { + let mut inner = ValueMap::new(); + inner.insert("b".to_owned(), Value::Int(2)).expect("fits"); + let mut outer = ValueMap::new(); + outer + .insert("a".to_owned(), Value::Map(Box::new(inner))) + .expect("fits"); + let original = Value::Map(Box::new(outer)); + let bytes = msgpack::to_vec(&original).expect("serialize"); + let decoded: Value = msgpack::from_slice(&bytes).expect("deserialize"); + assert_eq!(original, decoded); +} diff --git a/Build/crates/saikuro-exec/Cargo.toml b/Build/crates/saikuro-exec/Cargo.toml index 57c1ed92..90c004e3 100644 --- a/Build/crates/saikuro-exec/Cargo.toml +++ b/Build/crates/saikuro-exec/Cargo.toml @@ -11,7 +11,12 @@ repository.workspace = true default = ["tokio-runtime"] tokio-runtime = ["dep:tokio", "tokio/full", "dep:tokio-util", "futures/std"] wasm-runtime = ["dep:tokio", "wasm-bindgen-futures", "fluvio-wasm-timer", "futures/std"] -embassy-runtime = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"] +embassy-runtime = [ + "dep:embassy-sync", + "dep:embassy-time", + "dep:embassy-futures", + "futures/async-await", +] [dependencies] tokio = { version = "1.52.3", default-features = false, features = ["macros"], optional = true } diff --git a/Build/crates/saikuro-exec/src/embassy_backend.rs b/Build/crates/saikuro-exec/src/embassy_backend.rs index 1f146d59..4a4f819e 100644 --- a/Build/crates/saikuro-exec/src/embassy_backend.rs +++ b/Build/crates/saikuro-exec/src/embassy_backend.rs @@ -42,23 +42,28 @@ use embassy_sync::channel::Channel as EmbChannel; use embassy_sync::channel::TrySendError as EmbTrySendError; use embassy_sync::waitqueue::MultiWakerRegistration; use embassy_time::{Duration as EmbDuration, Timer}; +use futures::future::{Fuse, FutureExt}; // Sleep / Timeout / Yield +/// Convert a `std::time::Duration` to the embassy representation. +/// +/// Preserves microsecond resolution (embassy timers tick at microseconds) and +/// saturates at the `u64` microsecond range instead of wrapping via an +/// `as` cast. +fn emb_duration(dur: Duration) -> EmbDuration { + EmbDuration::from_micros(dur.as_micros().min(u64::MAX as u128) as u64) +} + pub async fn sleep(dur: Duration) { - Timer::after(EmbDuration::from_millis(dur.as_millis() as u64)).await; + Timer::after(emb_duration(dur)).await; } pub async fn timeout(dur: Duration, fut: F) -> Result where F: Future, { - match embassy_futures::select::select( - fut, - Timer::after(EmbDuration::from_millis(dur.as_millis() as u64)), - ) - .await - { + match embassy_futures::select::select(fut, Timer::after(emb_duration(dur))).await { embassy_futures::select::Either::First(res) => Ok(res), embassy_futures::select::Either::Second(_) => Err(()), } @@ -68,6 +73,16 @@ pub async fn yield_now() { embassy_futures::yield_now().await; } +/// Fuse a future for use in `saikuro_exec::select!` branches. +/// +/// `futures::select_biased!` requires every branch to implement +/// `FusedFuture`; fusing each branch at the facade boundary lets call sites +/// pass plain futures such as `listener.accept()` or `forward_rx.recv()`. +#[doc(hidden)] +pub fn fuse_select(fut: F) -> Fuse { + FutureExt::fuse(fut) +} + // Spawn / Block-on // See the module documentation: the application owns the executor and its // Spawner, so the facade cannot provide a global spawn or block_on. @@ -226,6 +241,9 @@ pub mod mpsc { } struct ChannelState { + /// Requested capacity. The backing `EmbChannel` is fixed at + /// `CHANNEL_CAPACITY`; this bound is enforced on enqueue. + capacity: usize, senders: usize, receivers: usize, senders_waiting: MultiWakerRegistration, @@ -233,8 +251,9 @@ pub mod mpsc { } impl ChannelState { - const fn new() -> Self { + const fn new(capacity: usize) -> Self { ChannelState { + capacity, senders: 0, receivers: 0, senders_waiting: MultiWakerRegistration::new(), @@ -277,20 +296,52 @@ pub mod mpsc { } } + /// Outcome of an atomic enqueue attempt against the channel state. + enum EnqueueOutcome { + Sent, + Full(T), + Disconnected(T), + } + impl Sender { /// Returns true once the receiver has been dropped. pub fn is_closed(&self) -> bool { self.inner.state.lock(|s| s.borrow().receivers == 0) } + /// Enqueue `value` under the channel-state lock, enforcing the + /// requested capacity. Holding the state lock makes the length check + /// and the push atomic against other senders. + fn enqueue(&self, value: T) -> EnqueueOutcome { + self.inner.state.lock(|s| { + let state = s.borrow_mut(); + if state.receivers == 0 { + return EnqueueOutcome::Disconnected(value); + } + if self.inner.channel.len() >= state.capacity { + return EnqueueOutcome::Full(value); + } + match self.inner.channel.try_send(value) { + Ok(()) => EnqueueOutcome::Sent, + Err(EmbTrySendError::Full(value)) => EnqueueOutcome::Full(value), + } + }) + } + + /// Whether the queue is below its requested capacity right now. + fn has_capacity(&self) -> bool { + self.inner.state.lock(|s| { + let state = s.borrow(); + self.inner.channel.len() < state.capacity + }) + } + /// Attempt to enqueue `value` without waiting. pub fn try_send(&self, value: T) -> Result<(), TrySendError> { - if self.is_closed() { - return Err(TrySendError::Disconnected(value)); - } - match self.inner.channel.try_send(value) { - Ok(()) => Ok(()), - Err(EmbTrySendError::Full(value)) => Err(TrySendError::Full(value)), + match self.enqueue(value) { + EnqueueOutcome::Sent => Ok(()), + EnqueueOutcome::Full(value) => Err(TrySendError::Full(value)), + EnqueueOutcome::Disconnected(value) => Err(TrySendError::Disconnected(value)), } } @@ -314,22 +365,26 @@ pub mod mpsc { let message = pending .take() .expect("mpsc send message is restored on the Full path"); - match self.inner.channel.try_send(message) { - Ok(()) => return Poll::Ready(Ok(())), - Err(EmbTrySendError::Full(message)) => { + match self.enqueue(message) { + EnqueueOutcome::Sent => return Poll::Ready(Ok(())), + EnqueueOutcome::Disconnected(message) => { + return Poll::Ready(Err(SendError(message))) + } + EnqueueOutcome::Full(message) => { pending = Some(message); self.inner .state .lock(|s| s.borrow_mut().senders_waiting.register(cx.waker())); // Re-check after registering so a wake that fired - // between try_send and register is not missed. + // between the enqueue attempt and the register is + // not missed. if self.is_closed() { let message = pending .take() .expect("mpsc send message is restored on the Full path"); return Poll::Ready(Err(SendError(message))); } - if !self.inner.channel.is_full() { + if self.has_capacity() { continue; } return Poll::Pending; @@ -427,7 +482,7 @@ pub mod mpsc { embassy capacity {CHANNEL_CAPACITY}" ); let inner = Arc::new(ChannelInner { - state: CriticalSectionMutex::new(RefCell::new(ChannelState::new())), + state: CriticalSectionMutex::new(RefCell::new(ChannelState::new(capacity))), channel: EmbChannel::new(), }); inner.state.lock(|s| { @@ -546,7 +601,13 @@ pub mod oneshot { self.get_mut().inner.state.lock(|s| { let mut data = s.borrow_mut(); match core::mem::replace(&mut data.channel, State::Empty) { - State::Ready(value) => Poll::Ready(Ok(value)), + State::Ready(value) => { + // Terminate the channel so a re-poll (for example by a + // select! that re-checks a completed branch) observes + // the closure instead of parking a fresh waker forever. + data.channel = State::Closed; + Poll::Ready(Ok(value)) + } State::Closed => Poll::Ready(Err(RecvError)), State::Empty => { data.channel = State::Waiting(cx.waker().clone()); @@ -589,13 +650,55 @@ pub mod sync { use super::*; /// Async mutual-exclusion lock. - pub use embassy_sync::mutex::Mutex; + /// + /// Single-parameter facade matching the tokio and wasm backends. The raw + /// embassy mutex is bound to `CriticalSectionRawMutex`, like [`RwLock`]. + pub struct Mutex { + inner: embassy_sync::mutex::Mutex, + } + + impl Mutex { + pub const fn new(value: T) -> Self { + Mutex { + inner: embassy_sync::mutex::Mutex::new(value), + } + } + + /// Acquire the lock, waiting until it is released by any holder. + pub async fn lock(&self) -> MutexGuard<'_, T> { + MutexGuard { + inner: self.inner.lock().await, + } + } + } + + /// Guard returned by [`Mutex::lock`]. Derefs to the guarded value. + pub struct MutexGuard<'a, T> { + inner: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, + } + + impl core::ops::Deref for MutexGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.inner + } + } + + impl core::ops::DerefMut for MutexGuard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.inner + } + } /// Read/write lock. /// /// Backed by a single async `embassy_sync::mutex::Mutex`. Readers are - /// serialized with writers rather than running concurrently; this is a safe - /// subset of the tokio semantics. Guards deref to the guarded value. + /// serialized with writers rather than running concurrently, so only one + /// task holds the lock at a time regardless of kind. A task must not hold + /// one read guard while awaiting another read guard on the same lock: the + /// second acquire would deadlock because the first guard is still held. + /// This differs from tokio's `RwLock`, where concurrent reads are allowed + /// and read guards are reentrant. Guards deref to the guarded value. pub struct RwLock { inner: embassy_sync::mutex::Mutex, } @@ -686,22 +789,24 @@ pub mod sync { /// Wait until all `n` tasks have called `wait`. Returns immediately for /// the task that releases the barrier. pub async fn wait(&self) { - let released = self.inner.state.lock(|s| { + // Capture the pre-arrival generation in the same critical section + // that increments `arrived` so a release completing between the + // arrival and the wait loop cannot be missed. + let pre_release_generation = self.inner.state.lock(|s| { let mut state = s.borrow_mut(); state.arrived += 1; if state.arrived == state.count { state.arrived = 0; state.generation += 1; state.waiting.wake(); - true + None } else { - false + Some(state.generation) } }); - if released { + let Some(mut gen) = pre_release_generation else { return; - } - let mut gen = self.inner.state.lock(|s| s.borrow().generation); + }; poll_fn(move |cx| { self.inner.state.lock(|s| { let mut state = s.borrow_mut(); @@ -877,16 +982,17 @@ pub mod watch { // Register before checking so a send that races with the // registration is not missed. state.waiting.register(cx.waker()); - if state.senders == 0 { - return Poll::Ready(Err(RecvError)); - } let version = state.version; + // Deliver a pending change before reporting closure: a value + // sent before the last sender dropped must still be observed. if this.receiver.version != version { this.receiver.version = version; - Poll::Ready(Ok(())) - } else { - Poll::Pending + return Poll::Ready(Ok(())); + } + if state.senders == 0 { + return Poll::Ready(Err(RecvError)); } + Poll::Pending }) } } diff --git a/Build/crates/saikuro-exec/src/lib.rs b/Build/crates/saikuro-exec/src/lib.rs index b1ff998f..5a2df5ae 100644 --- a/Build/crates/saikuro-exec/src/lib.rs +++ b/Build/crates/saikuro-exec/src/lib.rs @@ -64,15 +64,27 @@ macro_rules! select_impl { }; } -/// Embassy-compatible `select!` for two branches. +/// Embassy-compatible `select!`. /// -/// Each branch future must be fused (`.fuse()`) because `futures::select!` -/// requires `FusedFuture` +/// Delegates to `futures::select_biased!`, which requires every branch future +/// to implement `FusedFuture`. Each branch is fused at the facade boundary so +/// call sites pass plain futures (`listener.accept()`, `forward_rx.recv()`, +/// and friends). `select_biased!` is used rather than `futures::select!` +/// because the latter is gated behind the `std` feature and cannot resolve on +/// `no_std` MCU targets. #[doc(hidden)] #[cfg(feature = "embassy-runtime")] #[macro_export] macro_rules! select_impl { - ($($tt:tt)*) => { - $crate::_futures::select! { $($tt)* } + ( + $( + $pattern:pat = $fut:expr => $handler:block $(,)? + )+ + ) => { + $crate::_futures::select_biased! { + $( + $pattern = $crate::fuse_select($fut) => $handler , + )+ + } }; } diff --git a/Build/crates/saikuro-runtime/src/connection.rs b/Build/crates/saikuro-runtime/src/connection.rs index b84190ea..d29caaca 100644 --- a/Build/crates/saikuro-runtime/src/connection.rs +++ b/Build/crates/saikuro-runtime/src/connection.rs @@ -225,7 +225,7 @@ where // If sandbox mode is on and the announce succeeded, build the // filtered schema to push back to the peer. let sandbox_schema = if self.capability_engine.is_sandboxed() && response.ok { - Some(self.build_filtered_schema()) + self.build_filtered_schema() } else { None }; @@ -467,8 +467,14 @@ where /// /// Only namespaces and functions visible to `peer_capabilities` (and not /// `Internal` or `Private`) are included. - fn build_filtered_schema(&self) -> Schema { - let full = self.schema_registry.snapshot(); + fn build_filtered_schema(&self) -> Option { + let full = match self.schema_registry.snapshot() { + Ok(schema) => schema, + Err(e) => { + error!(peer = %self.peer_id, error = %e, "schema snapshot capacity exceeded"); + return None; + } + }; let mut filtered = Schema::new(); // Copy types: they are passive descriptors and always included. filtered.types = full.types.clone(); @@ -501,7 +507,7 @@ where .ok(); } - filtered + Some(filtered) } /// Encode `filtered_schema` as a `Value` and push it as an unsolicited diff --git a/Build/crates/saikuro-runtime/src/handle.rs b/Build/crates/saikuro-runtime/src/handle.rs index bd4d48be..5872af60 100644 --- a/Build/crates/saikuro-runtime/src/handle.rs +++ b/Build/crates/saikuro-runtime/src/handle.rs @@ -61,8 +61,8 @@ impl RuntimeHandle { } /// Export a snapshot of the current schema state. - pub fn schema_snapshot(&self) -> Schema { - self.schema_registry.snapshot() + pub fn schema_snapshot(&self) -> Result { + self.schema_registry.snapshot().map_err(Into::into) } // Providers diff --git a/Build/crates/saikuro-runtime/src/lib.rs b/Build/crates/saikuro-runtime/src/lib.rs index 82a0a306..77718b62 100644 --- a/Build/crates/saikuro-runtime/src/lib.rs +++ b/Build/crates/saikuro-runtime/src/lib.rs @@ -12,54 +12,3 @@ pub use config::RuntimeConfig; pub use error::RuntimeError; pub use handle::RuntimeHandle; pub use runtime::SaikuroRuntime; - -// A small number of doc-test-only inline tests live here -#[cfg(test)] -mod tests { - use crate::runtime::SaikuroRuntime; - use saikuro_core::schema::{ - FunctionMap, FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, - Visibility, - }; - - /// Smoke test: build a runtime, register a schema, verify lookup works. - #[test] - fn schema_registration_roundtrip() { - let rt = SaikuroRuntime::builder().build(); - - let mut functions = FunctionMap::new(); - functions - .insert( - "ping".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::String), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: true, - doc: Some("Returns 'pong'".to_owned()), - }, - ) - .ok(); - - let ns = NamespaceSchema { - functions: Box::new(functions), - doc: None, - }; - - let mut schema = Schema::new(); - schema.namespaces.insert("health".to_owned(), ns).ok(); - - rt.schema_registry() - .merge_schema(schema, "test-provider") - .expect("merge failed"); - - let func_ref = rt - .schema_registry() - .lookup_function("health.ping") - .expect("lookup failed"); - - assert_eq!(func_ref.function, "ping"); - assert_eq!(func_ref.provider_id, "test-provider"); - } -} diff --git a/Build/crates/saikuro-runtime/tests/schema_registration.rs b/Build/crates/saikuro-runtime/tests/schema_registration.rs new file mode 100644 index 00000000..a96013f1 --- /dev/null +++ b/Build/crates/saikuro-runtime/tests/schema_registration.rs @@ -0,0 +1,45 @@ +use saikuro_core::schema::{ + FunctionMap, FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility, +}; +use saikuro_runtime::SaikuroRuntime; + +/// Smoke test: build a runtime, register a schema, verify lookup works. +#[test] +fn schema_registration_roundtrip() { + let rt = SaikuroRuntime::builder().build(); + + let mut functions = FunctionMap::new(); + functions + .insert( + "ping".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::String), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: true, + doc: Some("Returns 'pong'".to_owned()), + }, + ) + .ok(); + + let ns = NamespaceSchema { + functions: Box::new(functions), + doc: None, + }; + + let mut schema = Schema::new(); + schema.namespaces.insert("health".to_owned(), ns).ok(); + + rt.schema_registry() + .merge_schema(schema, "test-provider") + .expect("merge failed"); + + let func_ref = rt + .schema_registry() + .lookup_function("health.ping") + .expect("lookup failed"); + + assert_eq!(func_ref.function, "ping"); + assert_eq!(func_ref.provider_id, "test-provider"); +} diff --git a/Build/crates/saikuro-schema/src/registry.rs b/Build/crates/saikuro-schema/src/registry.rs index 7607503a..602dc545 100644 --- a/Build/crates/saikuro-schema/src/registry.rs +++ b/Build/crates/saikuro-schema/src/registry.rs @@ -245,19 +245,27 @@ impl SchemaRegistry { } /// Export a snapshot of the full schema at this instant. - pub fn snapshot(&self) -> Schema { + /// + /// The registry stores its maps in unbounded `BTreeMap`s while the + /// exported [`Schema`] uses fixed-capacity heapless maps, so a registry + /// larger than the schema's capacity fails rather than truncating the + /// snapshot silently. + pub fn snapshot(&self) -> Result { let mut schema = Schema::new(); let schemata = self.inner.read(); for (name, entry) in schemata.namespaces.iter() { schema .namespaces .insert(name.clone(), entry.schema.clone()) - .ok(); + .map_err(|_| RegistryError::SchemaCapacity)?; } for (name, type_def) in schemata.types.iter() { - schema.types.insert(name.clone(), type_def.clone()).ok(); + schema + .types + .insert(name.clone(), type_def.clone()) + .map_err(|_| RegistryError::SchemaCapacity)?; } - schema + Ok(schema) } /// Freeze the registry, preventing any further schema changes. @@ -307,6 +315,9 @@ pub enum RegistryError { #[error("validation error: {0}")] Validation(#[from] ValidationError), + + #[error("schema capacity exceeded while exporting snapshot")] + SchemaCapacity, } // Helpers diff --git a/Build/crates/saikuro-schema/src/validator.rs b/Build/crates/saikuro-schema/src/validator.rs index 2df5b657..cddf20d6 100644 --- a/Build/crates/saikuro-schema/src/validator.rs +++ b/Build/crates/saikuro-schema/src/validator.rs @@ -94,6 +94,7 @@ impl ValidationError { RegistryError::MalformedTarget(_) => ErrorCode::MalformedEnvelope, RegistryError::FrozenSchema(_) => ErrorCode::Internal, RegistryError::Validation(_) => ErrorCode::InvalidArguments, + RegistryError::SchemaCapacity => ErrorCode::Internal, }, Self::ArgumentArity { .. } | Self::ArgumentType { .. } => ErrorCode::InvalidArguments, Self::VisibilityDenied { .. } => ErrorCode::CapabilityDenied, @@ -421,23 +422,3 @@ impl InvocationValidator { } } } - -#[cfg(test)] -mod tests { - use super::*; - use saikuro_core::envelope::{Envelope, InvocationType}; - - #[test] - fn batch_with_empty_items_returns_empty_batch_error() { - let registry = crate::registry::SchemaRegistry::new(); - let validator = InvocationValidator::new(registry); - - let mut batch = Envelope::call("", vec![]); - batch.invocation_type = InvocationType::Batch; - batch.target = String::new(); - batch.batch_items = Some(vec![]); - - let result = validator.validate(&batch); - assert!(matches!(result, Err(ValidationError::EmptyBatch))); - } -} diff --git a/Build/crates/saikuro-schema/tests/validator.rs b/Build/crates/saikuro-schema/tests/validator.rs new file mode 100644 index 00000000..f34574ac --- /dev/null +++ b/Build/crates/saikuro-schema/tests/validator.rs @@ -0,0 +1,17 @@ +use saikuro_core::envelope::{Envelope, InvocationType}; +use saikuro_schema::registry::SchemaRegistry; +use saikuro_schema::validator::{InvocationValidator, ValidationError}; + +#[test] +fn batch_with_empty_items_returns_empty_batch_error() { + let registry = SchemaRegistry::new(); + let validator = InvocationValidator::new(registry); + + let mut batch = Envelope::call("", vec![]); + batch.invocation_type = InvocationType::Batch; + batch.target = String::new(); + batch.batch_items = Some(vec![]); + + let result = validator.validate(&batch); + assert!(matches!(result, Err(ValidationError::EmptyBatch))); +} diff --git a/Build/crates/saikuro-storage/src/util.rs b/Build/crates/saikuro-storage/src/util.rs index a3bc44b5..73a33abc 100644 --- a/Build/crates/saikuro-storage/src/util.rs +++ b/Build/crates/saikuro-storage/src/util.rs @@ -2,10 +2,9 @@ // These are re-exported from the wasm32-gated webstorage module so the // impl_web_storage! macro can reach them via $crate::webstorage::*. // -// The dead_code allow is needed because on native these are only referenced -// from #[cfg(test)] and from the wasm32-gated webstorage module. - -#![allow(dead_code)] +// The functions are `pub` (doc-hidden) rather than `pub(crate)` so the +// crate's integration tests can exercise them; on native they are otherwise +// only referenced from the wasm32-gated backends. use bytes::Bytes; @@ -13,27 +12,32 @@ use super::config::StorageConfig; pub(crate) const NAMESPACE_SEPARATOR: char = ':'; -pub(crate) fn encode_bytes(val: &Bytes) -> String { +#[doc(hidden)] +pub fn encode_bytes(val: &Bytes) -> String { val.iter().map(|&b| b as char).collect() } -pub(crate) fn decode_bytes(s: &str) -> Bytes { +#[doc(hidden)] +pub fn decode_bytes(s: &str) -> Bytes { let vec: Vec = s.chars().map(|c| c as u8).collect(); Bytes::from(vec) } -pub(crate) fn make_key(namespace: &str, key: &str) -> String { +#[doc(hidden)] +pub fn make_key(namespace: &str, key: &str) -> String { format!( "{namespace}{SEPARATOR}{key}", SEPARATOR = NAMESPACE_SEPARATOR ) } -pub(crate) fn key_prefix(namespace: &str) -> String { +#[doc(hidden)] +pub fn key_prefix(namespace: &str) -> String { format!("{namespace}{SEPARATOR}", SEPARATOR = NAMESPACE_SEPARATOR) } -pub(crate) fn apply_prefix(config: &StorageConfig, namespace: &str) -> String { +#[doc(hidden)] +pub fn apply_prefix(config: &StorageConfig, namespace: &str) -> String { match &config.namespace_prefix { Some(prefix) => format!( "{prefix}{SEPARATOR}{namespace}", @@ -43,7 +47,8 @@ pub(crate) fn apply_prefix(config: &StorageConfig, namespace: &str) -> String { } } -pub(crate) fn strip_prefix(config: &StorageConfig, stored: &str) -> String { +#[doc(hidden)] +pub fn strip_prefix(config: &StorageConfig, stored: &str) -> String { match &config.namespace_prefix { Some(prefix) => { let prefix_str = format!("{prefix}{SEPARATOR}", SEPARATOR = NAMESPACE_SEPARATOR); @@ -56,115 +61,3 @@ pub(crate) fn strip_prefix(config: &StorageConfig, stored: &str) -> String { None => stored.to_owned(), } } - -#[cfg(test)] -mod tests { - use super::*; - - // encode_bytes / decode_bytes - - #[test] - fn encode_decode_roundtrip_empty() { - let b = Bytes::new(); - assert_eq!(decode_bytes(&encode_bytes(&b)), b); - } - - #[test] - fn encode_decode_roundtrip_ascii() { - let b = Bytes::from("hello"); - assert_eq!(decode_bytes(&encode_bytes(&b)), b); - } - - #[test] - fn encode_decode_roundtrip_all_bytes() { - let b: Bytes = (0..=255).collect(); - assert_eq!(decode_bytes(&encode_bytes(&b)), b); - } - - #[test] - fn encode_decode_roundtrip_binary() { - let b = Bytes::from(&[0x00, 0x01, 0x7f, 0x80, 0xff, 0xab][..]); - assert_eq!(decode_bytes(&encode_bytes(&b)), b); - } - - // make_key / key_prefix - - #[test] - fn make_key_joins_with_separator() { - assert_eq!(make_key("ns", "k"), "ns:k"); - } - - #[test] - fn make_key_with_empty_namespace() { - assert_eq!(make_key("", "k"), ":k"); - } - - #[test] - fn make_key_with_empty_key() { - assert_eq!(make_key("ns", ""), "ns:"); - } - - #[test] - fn key_prefix_ends_with_separator() { - assert_eq!(key_prefix("ns"), "ns:"); - } - - #[test] - fn key_prefix_empty_namespace() { - assert_eq!(key_prefix(""), ":"); - } - - // apply_prefix / strip_prefix - - fn config_with_prefix(prefix: &str) -> StorageConfig { - StorageConfig::default().with_prefix(prefix) - } - - #[test] - fn apply_prefix_without_config_prefix_is_identity() { - let cfg = StorageConfig::default(); - assert_eq!(apply_prefix(&cfg, "myns"), "myns"); - } - - #[test] - fn apply_prefix_prepends_global_prefix() { - let cfg = config_with_prefix("app"); - assert_eq!(apply_prefix(&cfg, "myns"), "app:myns"); - } - - #[test] - fn strip_prefix_without_config_prefix_is_identity() { - let cfg = StorageConfig::default(); - assert_eq!(strip_prefix(&cfg, "myns"), "myns"); - } - - #[test] - fn strip_prefix_removes_global_prefix() { - let cfg = config_with_prefix("app"); - assert_eq!(strip_prefix(&cfg, "app:myns"), "myns"); - } - - #[test] - fn strip_prefix_does_not_strip_unprefixed() { - let cfg = config_with_prefix("app"); - assert_eq!(strip_prefix(&cfg, "other:myns"), "other:myns"); - } - - #[test] - fn apply_prefix_then_strip_prefix_roundtrip() { - let cfg = config_with_prefix("app"); - let original = "myns"; - let applied = apply_prefix(&cfg, original); - let stripped = strip_prefix(&cfg, &applied); - assert_eq!(stripped, original); - } - - #[test] - fn apply_prefix_then_strip_prefix_no_prefix() { - let cfg = StorageConfig::default(); - let original = "myns"; - let applied = apply_prefix(&cfg, original); - let stripped = strip_prefix(&cfg, &applied); - assert_eq!(stripped, original); - } -} diff --git a/Build/crates/saikuro-storage/tests/util.rs b/Build/crates/saikuro-storage/tests/util.rs new file mode 100644 index 00000000..754981cb --- /dev/null +++ b/Build/crates/saikuro-storage/tests/util.rs @@ -0,0 +1,112 @@ +use bytes::Bytes; +use saikuro_storage::util::{ + apply_prefix, decode_bytes, encode_bytes, key_prefix, make_key, strip_prefix, +}; +use saikuro_storage::StorageConfig; + +// encode_bytes / decode_bytes + +#[test] +fn encode_decode_roundtrip_empty() { + let b = Bytes::new(); + assert_eq!(decode_bytes(&encode_bytes(&b)), b); +} + +#[test] +fn encode_decode_roundtrip_ascii() { + let b = Bytes::from("hello"); + assert_eq!(decode_bytes(&encode_bytes(&b)), b); +} + +#[test] +fn encode_decode_roundtrip_all_bytes() { + let b: Bytes = (0..=255).collect(); + assert_eq!(decode_bytes(&encode_bytes(&b)), b); +} + +#[test] +fn encode_decode_roundtrip_binary() { + let b = Bytes::from(&[0x00, 0x01, 0x7f, 0x80, 0xff, 0xab][..]); + assert_eq!(decode_bytes(&encode_bytes(&b)), b); +} + +// make_key / key_prefix + +#[test] +fn make_key_joins_with_separator() { + assert_eq!(make_key("ns", "k"), "ns:k"); +} + +#[test] +fn make_key_with_empty_namespace() { + assert_eq!(make_key("", "k"), ":k"); +} + +#[test] +fn make_key_with_empty_key() { + assert_eq!(make_key("ns", ""), "ns:"); +} + +#[test] +fn key_prefix_ends_with_separator() { + assert_eq!(key_prefix("ns"), "ns:"); +} + +#[test] +fn key_prefix_empty_namespace() { + assert_eq!(key_prefix(""), ":"); +} + +// apply_prefix / strip_prefix + +fn config_with_prefix(prefix: &str) -> StorageConfig { + StorageConfig::default().with_prefix(prefix) +} + +#[test] +fn apply_prefix_without_config_prefix_is_identity() { + let cfg = StorageConfig::default(); + assert_eq!(apply_prefix(&cfg, "myns"), "myns"); +} + +#[test] +fn apply_prefix_prepends_global_prefix() { + let cfg = config_with_prefix("app"); + assert_eq!(apply_prefix(&cfg, "myns"), "app:myns"); +} + +#[test] +fn strip_prefix_without_config_prefix_is_identity() { + let cfg = StorageConfig::default(); + assert_eq!(strip_prefix(&cfg, "myns"), "myns"); +} + +#[test] +fn strip_prefix_removes_global_prefix() { + let cfg = config_with_prefix("app"); + assert_eq!(strip_prefix(&cfg, "app:myns"), "myns"); +} + +#[test] +fn strip_prefix_does_not_strip_unprefixed() { + let cfg = config_with_prefix("app"); + assert_eq!(strip_prefix(&cfg, "other:myns"), "other:myns"); +} + +#[test] +fn apply_prefix_then_strip_prefix_roundtrip() { + let cfg = config_with_prefix("app"); + let original = "myns"; + let applied = apply_prefix(&cfg, original); + let stripped = strip_prefix(&cfg, &applied); + assert_eq!(stripped, original); +} + +#[test] +fn apply_prefix_then_strip_prefix_no_prefix() { + let cfg = StorageConfig::default(); + let original = "myns"; + let applied = apply_prefix(&cfg, original); + let stripped = strip_prefix(&cfg, &applied); + assert_eq!(stripped, original); +} From d46f8fdebbad6689aa27c831cb124bbe75c7e7c5 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Tue, 4 Aug 2026 13:42:15 -0600 Subject: [PATCH 06/43] Review comments --- Build/crates/saikuro-core/src/envelope.rs | 30 +++++++++- Build/crates/saikuro-core/src/sync.rs | 15 +++-- Build/crates/saikuro-random/src/lib.rs | 13 ++++ Build/crates/saikuro-router/src/provider.rs | 66 ++++++++++++++------- Build/tests/tests/envelope_roundtrip.rs | 20 +++++++ 5 files changed, 114 insertions(+), 30 deletions(-) diff --git a/Build/crates/saikuro-core/src/envelope.rs b/Build/crates/saikuro-core/src/envelope.rs index bed07d49..f4b53b2f 100644 --- a/Build/crates/saikuro-core/src/envelope.rs +++ b/Build/crates/saikuro-core/src/envelope.rs @@ -6,7 +6,10 @@ //! the types here are the canonical in-memory representation. use alloc::{borrow::ToOwned, string::String, vec::Vec}; -use serde::{Deserialize, Serialize}; +use serde::{ + ser::{SerializeMap, Serializer}, + Deserialize, Serialize, +}; use crate::{ capability::CapabilityToken, invocation::InvocationId, value::Value, PROTOCOL_VERSION, @@ -18,6 +21,25 @@ pub const ENVELOPE_META_CAPACITY: usize = 16; /// Fixed-capacity map of metadata entries on an [`Envelope`]. pub type MetaMap = heapless::FnvIndexMap; +/// Serialize the metadata map with keys sorted, so equivalent metadata always +/// produces identical bytes regardless of the caller's insertion order. +/// +/// `MetaMap` is an insertion-ordered `FnvIndexMap`, so serde would otherwise +/// emit keys in insertion order and two semantically-equal envelopes could +/// differ on the wire. +fn serialize_meta(meta: &MetaMap, serializer: S) -> Result +where + S: Serializer, +{ + let mut pairs: Vec<(&str, &Value)> = meta.iter().map(|(k, v)| (k.as_str(), v)).collect(); + pairs.sort_unstable_by(|a, b| a.0.cmp(b.0)); + let mut map = serializer.serialize_map(Some(pairs.len()))?; + for (key, value) in pairs { + map.serialize_entry(key, value)?; + } + map.end() +} + /// The type of an outgoing invocation. /// /// This is the primary discriminator that tells the runtime and the @@ -96,7 +118,11 @@ pub struct Envelope { pub args: Vec, /// Optional key/value metadata bag (trace IDs, deadlines, …). - #[serde(default, skip_serializing_if = "MetaMap::is_empty")] + #[serde( + default, + skip_serializing_if = "MetaMap::is_empty", + serialize_with = "serialize_meta" + )] pub meta: MetaMap, /// Capability token presented by the caller. Required when the target diff --git a/Build/crates/saikuro-core/src/sync.rs b/Build/crates/saikuro-core/src/sync.rs index ee73fc93..7a20b357 100644 --- a/Build/crates/saikuro-core/src/sync.rs +++ b/Build/crates/saikuro-core/src/sync.rs @@ -11,9 +11,9 @@ //! and MCU targets. The guards are only ever held for short map mutations; //! they are never held across an `await`. //! -//! Lock poisoning is deliberately ignored: a panic while one of these guards -//! is held is a bug that should surface immediately, not be silently recovered -//! from. +//! Lock poisoning is not recovered from: a panic while one of these guards is +//! held poisons the lock, and the next acquisition panics too, so the bug +//! surfaces immediately instead of being silently recovered from. use core::fmt; use core::ops::{Deref, DerefMut}; @@ -67,18 +67,21 @@ trait MutexAccess { #[cfg(feature = "std")] impl RwLockAccess for imp::RwLock { fn read_guard(&self) -> imp::RwLockReadGuard<'_, T> { - self.read().unwrap_or_else(|poison| poison.into_inner()) + self.read() + .expect("RwLock poisoned by a panicking guard holder") } fn write_guard(&self) -> imp::RwLockWriteGuard<'_, T> { - self.write().unwrap_or_else(|poison| poison.into_inner()) + self.write() + .expect("RwLock poisoned by a panicking guard holder") } } #[cfg(feature = "std")] impl MutexAccess for imp::Mutex { fn lock_guard(&self) -> imp::MutexGuard<'_, T> { - self.lock().unwrap_or_else(|poison| poison.into_inner()) + self.lock() + .expect("Mutex poisoned by a panicking guard holder") } } diff --git a/Build/crates/saikuro-random/src/lib.rs b/Build/crates/saikuro-random/src/lib.rs index 8b354965..023f510e 100644 --- a/Build/crates/saikuro-random/src/lib.rs +++ b/Build/crates/saikuro-random/src/lib.rs @@ -16,6 +16,19 @@ extern crate std; use core::mem::MaybeUninit; +// `drbg` is the deterministic override for MCU targets without an OS entropy +// source. Combining it with a platform backend would compile getrandom for +// nothing and let the drbg implementation win silently, so reject the +// combination at build time and force `--no-default-features --features drbg`. +#[cfg(all( + feature = "drbg", + any(feature = "os", feature = "wasm", feature = "custom") +))] +compile_error!( + "saikuro-random: `drbg` conflicts with the `os`, `wasm`, or `custom` backend; \ + build with `--no-default-features --features drbg`" +); + #[cfg(feature = "drbg")] mod drbg; diff --git a/Build/crates/saikuro-router/src/provider.rs b/Build/crates/saikuro-router/src/provider.rs index 23880ccb..3ef71352 100644 --- a/Build/crates/saikuro-router/src/provider.rs +++ b/Build/crates/saikuro-router/src/provider.rs @@ -120,15 +120,21 @@ impl Provider for ProviderHandle { /// Thread-safe registry mapping namespace names to provider handles. /// -/// The two maps are guarded by separate [`RwLock`]s. `register` and -/// `deregister` never hold both locks simultaneously (each map operation uses -/// a single-statement guard), so there is no lock-order inversion. +/// Both indexes live behind a single [`RwLock`] so `register` and `deregister` +/// keep them consistent atomically. A namespace taken over by a new provider +/// is removed from the old provider's record, and deregistration never removes +/// a namespace that a later provider now owns. #[derive(Clone, Default)] pub struct ProviderRegistry { + inner: Arc>, +} + +#[derive(Default)] +struct RegistryState { /// namespace -> provider handle - by_namespace: Arc>>, + by_namespace: BTreeMap, /// provider_id -> list of namespaces (for cleanup on disconnect) - by_provider: Arc>>>, + by_provider: BTreeMap>, } impl ProviderRegistry { @@ -138,34 +144,49 @@ impl ProviderRegistry { /// Register a provider handle for the given namespaces. /// - /// If a namespace already has a provider, the old one is replaced and a - /// warning is emitted. + /// If a namespace already has a provider, the old one is replaced. The + /// namespace is then removed from the old provider's record so a later + /// deregistration of the old provider cannot reclaim the new provider's + /// namespace. pub fn register(&self, handle: ProviderHandle) { let provider_id = handle.id().to_owned(); let namespaces = handle.namespaces().to_vec(); - { - let mut ns_guard = self.by_namespace.write(); - for ns in &namespaces { - if ns_guard.contains_key(ns.as_str()) { + let mut state = self.inner.write(); + for ns in &namespaces { + match state.by_namespace.insert(ns.clone(), handle.clone()) { + Some(old) => { warn!(namespace = %ns, provider = %provider_id, "replacing existing namespace provider"); - } else { - debug!(namespace = %ns, provider = %provider_id, "registering provider for namespace"); + if old.id() != provider_id { + if let Some(old_ns_list) = state.by_provider.get_mut(old.id()) { + old_ns_list.retain(|n| n != ns); + } + } + } + None => { + debug!(namespace = %ns, provider = %provider_id, "registering provider for namespace") } - ns_guard.insert(ns.clone(), handle.clone()); } } - - self.by_provider.write().insert(provider_id, namespaces); + state.by_provider.insert(provider_id, namespaces); } /// Remove all namespace registrations for the given provider ID. + /// + /// A namespace is removed from the lookup index only while it still points + /// at this provider; a namespace a newer provider took over is left alone. pub fn deregister(&self, provider_id: &str) { - // Take the provider record first; the namespace removals each use a - // fresh guard so the two locks are never nested. - if let Some(namespaces) = self.by_provider.write().remove(provider_id) { + let mut state = self.inner.write(); + if let Some(namespaces) = state.by_provider.remove(provider_id) { for ns in namespaces { - self.by_namespace.write().remove(&ns); + if state + .by_namespace + .get(&ns) + .map(|h| h.id() == provider_id) + .unwrap_or(false) + { + state.by_namespace.remove(&ns); + } debug!(namespace = %ns, provider = %provider_id, "deregistered namespace provider"); } } @@ -173,13 +194,14 @@ impl ProviderRegistry { /// Look up the provider for a namespace. pub fn get(&self, namespace: &str) -> Option { - self.by_namespace.read().get(namespace).cloned() + self.inner.read().by_namespace.get(namespace).cloned() } /// Return `true` if a live provider exists for the namespace. pub fn has_live_provider(&self, namespace: &str) -> bool { - self.by_namespace + self.inner .read() + .by_namespace .get(namespace) .map(|h| h.is_alive()) .unwrap_or(false) diff --git a/Build/tests/tests/envelope_roundtrip.rs b/Build/tests/tests/envelope_roundtrip.rs index 2d897c76..0b0dd747 100644 --- a/Build/tests/tests/envelope_roundtrip.rs +++ b/Build/tests/tests/envelope_roundtrip.rs @@ -84,6 +84,26 @@ fn envelope_with_meta_roundtrip() { assert_eq!(decoded.meta["deadline-ms"], Value::Int(5000)); } +#[test] +fn envelope_meta_serializes_canonically_regardless_of_insertion_order() { + let id = InvocationId::new(); + let mut a = Envelope::call("trace.op", vec![]); + a.id = id; + a.meta.insert("z".into(), Value::Int(1)).ok(); + a.meta.insert("a".into(), Value::Int(2)).ok(); + a.meta.insert("m".into(), Value::Int(3)).ok(); + + let mut b = Envelope::call("trace.op", vec![]); + b.id = id; + b.meta.insert("m".into(), Value::Int(3)).ok(); + b.meta.insert("a".into(), Value::Int(2)).ok(); + b.meta.insert("z".into(), Value::Int(1)).ok(); + + let (ba, bb) = (a.to_msgpack().unwrap(), b.to_msgpack().unwrap()); + assert_eq!(ba, bb, "insertion order must not affect wire bytes"); + assert!(ba.windows(6).any(|w| w == b"\xa1a\x02\xa1m\x03")); +} + #[test] fn batch_envelope_roundtrip() { let item1 = Envelope::call("math.add", vec![Value::Int(1), Value::Int(2)]); From 10fc50957d1f8d6bbaab1450ad68d90abea21dfa Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Wed, 5 Aug 2026 00:31:01 -0600 Subject: [PATCH 07/43] some saikuro-transport and saikuro-router embedded improvements --- Build/Cargo.lock | 2 + Build/Cargo.toml | 2 +- .../crates/saikuro-exec/src/tokio_backend.rs | 4 + Build/crates/saikuro-router/Cargo.toml | 4 +- Build/crates/saikuro-schema/Cargo.toml | 8 +- Build/crates/saikuro-transport/Cargo.toml | 35 +- Build/crates/saikuro-transport/src/error.rs | 7 +- Build/crates/saikuro-transport/src/framing.rs | 233 ++++++- Build/crates/saikuro-transport/src/lib.rs | 24 +- Build/crates/saikuro-transport/src/memory.rs | 2 + .../crates/saikuro-transport/src/selector.rs | 2 + Build/crates/saikuro-transport/src/tcp.rs | 12 +- Build/crates/saikuro-transport/src/traits.rs | 1 + Build/crates/saikuro-transport/src/unix.rs | 12 +- Build/tests/Cargo.toml | 1 + Build/tests/tests/transport_framing.rs | 264 +++++++ Build/tests/tests/transport_memory_stress.rs | 18 +- Demo/wasm/c/Cargo.lock | 636 ++--------------- Demo/wasm/cpp/Cargo.lock | 636 ++--------------- Demo/wasm/runtime/Cargo.lock | 337 +++------ Demo/wasm/rust/Cargo.lock | 658 ++---------------- 21 files changed, 875 insertions(+), 2023 deletions(-) create mode 100644 Build/tests/tests/transport_framing.rs diff --git a/Build/Cargo.lock b/Build/Cargo.lock index 6de5f6c1..1c4e7dbc 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -1563,6 +1563,7 @@ name = "saikuro-tests" version = "0.1.0" dependencies = [ "bytes", + "futures", "js-sys", "rmp-serde", "saikuro", @@ -1595,6 +1596,7 @@ dependencies = [ "pin-project-lite", "saikuro-core", "saikuro-exec", + "saikuro-random", "send_wrapper", "serde", "thiserror 2.0.18", diff --git a/Build/Cargo.toml b/Build/Cargo.toml index 569b669a..fe0570e2 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -96,7 +96,7 @@ chrono = { version = "0.4", features = ["serde", "wasmbind"] } # Internal crates saikuro-core = { path = "crates/saikuro-core" } -saikuro-schema = { path = "crates/saikuro-schema" } +saikuro-schema = { path = "crates/saikuro-schema", default-features = false } saikuro-storage = { path = "crates/saikuro-storage", default-features = false } saikuro-transport = { path = "crates/saikuro-transport", default-features = false } saikuro-router = { path = "crates/saikuro-router", default-features = false } diff --git a/Build/crates/saikuro-exec/src/tokio_backend.rs b/Build/crates/saikuro-exec/src/tokio_backend.rs index 82a8b564..61fc31e9 100644 --- a/Build/crates/saikuro-exec/src/tokio_backend.rs +++ b/Build/crates/saikuro-exec/src/tokio_backend.rs @@ -34,6 +34,10 @@ pub mod net { pub use tokio::net::*; } +pub mod io { + pub use tokio::io::*; +} + pub mod signal { pub use tokio::signal::*; } diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index 726420a9..3e77345d 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -11,11 +11,11 @@ keywords = ["ipc", "cross-language", "saikuro", "router", "rpc"] [features] default = ["std"] std = ["saikuro-core/std", "saikuro-exec/tokio-runtime"] -embassy = ["saikuro-exec/embassy-runtime"] +embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/drbg"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } -saikuro-schema = { workspace = true } +saikuro-schema = { workspace = true, default-features = false } saikuro-exec = { workspace = true, default-features = false } async-trait = { workspace = true } diff --git a/Build/crates/saikuro-schema/Cargo.toml b/Build/crates/saikuro-schema/Cargo.toml index a8b1a34b..8385e3c3 100644 --- a/Build/crates/saikuro-schema/Cargo.toml +++ b/Build/crates/saikuro-schema/Cargo.toml @@ -8,7 +8,13 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "schema", "validation"] -# This crate is always `no_std` + `alloc` +# This crate is always `no_std` + `alloc` +[features] +default = ["std"] +std = ["saikuro-core/std"] +custom = ["saikuro-core/custom"] +drbg = ["saikuro-core/drbg"] + [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index 22f009e4..9a2c70ba 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -10,32 +10,47 @@ keywords = ["ipc", "cross-language", "saikuro", "transport", "async"] # Feature flags control which transport backends are compiled in. # -# native-transport: Unix socket + TCP (requires std networking; disabled on wasm32) -# ws-transport: WebSocket module (works on both native and wasm32) -# native-ws: WebSocket + tokio-tungstenite on native (non-wasm32 only) +# std: gate for std-only code (the Io error variant and the +# native/WebSocket/wasm backends). The crate is no_std + +# alloc without it. +# native-transport: Unix socket + TCP (requires std networking; disabled on wasm32) +# ws-transport: WebSocket module (compiled on wasm32, or on native with native-ws) +# native-ws: WebSocket + tokio-tungstenite on native (non-wasm32 only) # wasm-host-transport: BroadcastChannel transport (wasm32 only) +# embassy: no_std embassy-executor backend for MCU targets; forwards the +# drbg entropy source so the crate is self-contained under +# `--no-default-features --features embassy` # # The in-memory transport is always compiled; it has zero OS dependencies. [features] -default = ["native-transport"] -native-transport = ["saikuro-exec/tokio-runtime"] +default = ["std", "native-transport"] +std = ["saikuro-core/std"] +embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/drbg"] +native-transport = ["std", "saikuro-exec/tokio-runtime"] ws-transport = [] -wasm-runtime = ["saikuro-exec/wasm-runtime"] +# wasm32 has no OS entropy source; the js getrandom backend is forwarded here +# (matching saikuro-runtime's own wasm-runtime feature). +wasm-runtime = ["std", "saikuro-exec/wasm-runtime", "saikuro-random/wasm"] # native-ws is only available on non-wasm32 (where tokio-tungstenite exists) -native-ws = ["ws-transport", "saikuro-exec/tokio-runtime", "tokio-tungstenite", "tungstenite"] +native-ws = ["ws-transport", "std", "saikuro-exec/tokio-runtime", "tokio-tungstenite", "tungstenite"] [dependencies] -saikuro-core = { workspace = true } +saikuro-core = { path = "../saikuro-core", default-features = false } serde = { workspace = true } -bytes = { workspace = true } +# bytes is declared directly so the `std` feature stays off on MCU targets; +# native builds still get it via tokio's own dependency. +bytes = { version = "1.7", default-features = false } async-trait = { workspace = true } futures = { workspace = true } pin-project-lite = { workspace = true } thiserror = { workspace = true } -tracing = { workspace = true } +# tracing is declared directly so the `std` feature stays off on MCU; only the +# event macros are used (no `#[instrument]`). +tracing = { version = "0.1", default-features = false } saikuro-exec = { workspace = true, default-features = false } +saikuro-random = { workspace = true, default-features = false } # WebSocket support on native (tokio-tungstenite uses mio which doesn't compile on wasm32) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/Build/crates/saikuro-transport/src/error.rs b/Build/crates/saikuro-transport/src/error.rs index 8e54ab4d..b10bb21d 100644 --- a/Build/crates/saikuro-transport/src/error.rs +++ b/Build/crates/saikuro-transport/src/error.rs @@ -1,5 +1,9 @@ //! Transport error type. +//! +//! The crate is `no_std` + `alloc` without the `std` feature, so the raw +//! `std::io::Error` variant is gated the same way as in saikuro-core. +use alloc::string::String; use thiserror::Error; #[derive(Debug, Error)] @@ -25,6 +29,7 @@ pub enum TransportError { #[error("transport not supported on this platform")] NotSupported, + #[cfg(feature = "std")] #[error("I/O error: {0}")] Io(#[from] std::io::Error), @@ -38,4 +43,4 @@ pub enum TransportError { ChannelClosed, } -pub type Result = std::result::Result; +pub type Result = core::result::Result; diff --git a/Build/crates/saikuro-transport/src/framing.rs b/Build/crates/saikuro-transport/src/framing.rs index 4a8c9931..30dd20e6 100644 --- a/Build/crates/saikuro-transport/src/framing.rs +++ b/Build/crates/saikuro-transport/src/framing.rs @@ -3,15 +3,26 @@ //! Raw stream transports (TCP, Unix sockets) deliver an unbroken river of //! bytes with no inherent message boundaries. We impose message framing with //! a simple 4-byte big-endian length prefix before every frame: +//! +//! +--------+------------------------------+ +//! | u32 | payload | +//! | len | len bytes | +//! +--------+------------------------------+ +//! +//! The [`LengthPrefixedCodec`] is pure byte-slicing over `BytesMut` and +//! compiles without `std` or any tokio dependency, so the same framing logic +//! is reused verbatim by native transports and the future embedded-io +//! backend. [`FramedStream`] wraps the codec around an async byte stream and +//! is the native (`tokio`) adapter used by [`crate::tcp::TcpTransport`] and +//! [`crate::unix::UnixTransport`]. use bytes::{Buf, BufMut, Bytes, BytesMut}; -use saikuro_exec::tokio_util::codec::{Decoder, Encoder}; use crate::error::{Result, TransportError}; pub use crate::MAX_FRAME_SIZE; -/// Tokio codec that frames a byte stream into discrete length-prefixed messages. +/// Codec that frames a byte stream into discrete length-prefixed messages. #[derive(Debug, Clone, Default)] pub struct LengthPrefixedCodec { /// Once we've read the length header we cache it here to avoid re-parsing. @@ -22,13 +33,11 @@ impl LengthPrefixedCodec { pub fn new() -> Self { Self::default() } -} - -impl Decoder for LengthPrefixedCodec { - type Item = Bytes; - type Error = TransportError; - fn decode(&mut self, src: &mut BytesMut) -> Result> { + /// Decode the next complete frame from `src`, returning `Ok(None)` until a + /// full frame is buffered. Consumes the header and payload from the front + /// of `src` when a frame is returned. + pub fn decode(&mut self, src: &mut BytesMut) -> Result> { // Phase 1: read the 4-byte length header if we don't have it yet. let frame_len = match self.pending_len { Some(len) => len, @@ -45,16 +54,13 @@ impl Decoder for LengthPrefixedCodec { }; let frame_len = - usize::try_from(frame_len).map_err(|_| TransportError::MessageTooLarge { - size: frame_len as usize, - limit: MAX_FRAME_SIZE, - })?; + usize::try_from(frame_len).map_err(|_| message_too_large(frame_len as usize))?; if frame_len > MAX_FRAME_SIZE { - return Err(TransportError::MessageTooLarge { - size: frame_len, - limit: MAX_FRAME_SIZE, - }); + // Reset so the next call re-reads a fresh header instead of + // erroring forever on the same bogus length. + self.pending_len = None; + return Err(message_too_large(frame_len)); } // Phase 2: wait until the full payload has arrived. @@ -69,28 +75,193 @@ impl Decoder for LengthPrefixedCodec { let payload = src.split_to(frame_len).freeze(); Ok(Some(payload)) } -} - -impl Encoder for LengthPrefixedCodec { - type Error = TransportError; - fn encode(&mut self, item: Bytes, dst: &mut BytesMut) -> Result<()> { + /// Encode `item` as a length-prefixed frame appended to `dst`. + pub fn encode(&mut self, item: Bytes, dst: &mut BytesMut) -> Result<()> { let len = item.len(); if len > MAX_FRAME_SIZE { - return Err(TransportError::MessageTooLarge { - size: len, - limit: MAX_FRAME_SIZE, - }); + return Err(message_too_large(len)); } dst.reserve(4 + len); - dst.put_u32( - u32::try_from(len).map_err(|_| TransportError::MessageTooLarge { - size: len, - limit: MAX_FRAME_SIZE, - })?, - ); + dst.put_u32(u32::try_from(len).map_err(|_| message_too_large(len))?); dst.put(item); Ok(()) } } + +fn message_too_large(size: usize) -> TransportError { + TransportError::MessageTooLarge { + size, + limit: MAX_FRAME_SIZE, + } +} + +/// Async byte stream framed into discrete messages. +/// +/// Drop-in replacement for `tokio_util::codec::Framed` that stays within this +/// crate's own codec so we don't depend on tokio-util for framing. Implements +/// `Stream>` for reads and `Sink` for writes; use +/// [`FramedStream::split`] to obtain independent halves. +#[cfg(feature = "native-transport")] +pub mod framed { + use core::pin::Pin; + use core::task::{Context, Poll}; + + use bytes::Buf; + use futures::{ready, Sink, Stream}; + use pin_project_lite::pin_project; + use saikuro_exec::io::{AsyncRead, AsyncWrite}; + + use super::LengthPrefixedCodec; + use crate::error::{Result, TransportError}; + + /// Bytes to request from the underlying stream on each read. Large enough + /// to amortize syscalls without over-committing memory on small frames. + const READ_CHUNK: usize = 4096; + + pin_project! { + pub struct FramedStream { + #[pin] + inner: S, + codec: LengthPrefixedCodec, + read_buf: bytes::BytesMut, + write_buf: bytes::BytesMut, + } + } + + impl FramedStream { + pub fn new(inner: S) -> Self { + Self { + inner, + codec: LengthPrefixedCodec::new(), + read_buf: bytes::BytesMut::new(), + write_buf: bytes::BytesMut::new(), + } + } + + /// Split into a sink (write half) and a stream (read half). + /// + /// `StreamExt::split` produces both halves from a single underlying + /// `BiLock` so they stay safely paired. + pub fn split( + self, + ) -> ( + futures::stream::SplitSink, + futures::stream::SplitStream, + ) { + futures::StreamExt::split::(self) + } + } + + impl Stream for FramedStream { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut this = self.project(); + + loop { + // decode any complete frames already buffered. + match this.codec.decode(this.read_buf) { + Ok(Some(frame)) => return Poll::Ready(Some(Ok(frame))), + Ok(None) => {} + Err(e) => { + // Corrupt or oversized frame; the byte stream is no + // longer aligned, so surface the error and terminate. + this.read_buf.clear(); + return Poll::Ready(Some(Err(e))); + } + } + + // Read into a stack chunk and append only the filled bytes. + // Reading directly into a zero-fill-resized read_buf would + // leave phantom zero bytes behind if poll_read returns + // Pending, and those would decode as bogus zero-length frames. + let mut chunk = [0u8; READ_CHUNK]; + let mut read_buf = saikuro_exec::io::ReadBuf::new(&mut chunk); + let filled = match ready!(this.inner.as_mut().poll_read(cx, &mut read_buf)) { + Ok(()) => read_buf.filled().len(), + Err(e) => return Poll::Ready(Some(Err(TransportError::from(e)))), + }; + this.read_buf.extend_from_slice(read_buf.filled()); + + if filled == 0 { + // EOF from the peer. A clean close happens only at a + // frame boundary; leftover bytes mean a truncated frame. + if this.read_buf.is_empty() { + return Poll::Ready(None); + } + return Poll::Ready(Some(Err(TransportError::FramingError( + "connection closed mid-frame".into(), + )))); + } + + // More bytes arrived; loop back to decode them. + } + } + } + + impl Sink for FramedStream { + type Error = TransportError; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.as_ref().project_ref().write_buf.is_empty() { + return Poll::Ready(Ok(())); + } + self.poll_flush(cx) + } + + fn start_send(self: Pin<&mut Self>, item: bytes::Bytes) -> Result<()> { + let this = self.project(); + this.codec.encode(item, this.write_buf)?; + Ok(()) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + ready!(flush_write_buf(self.as_mut(), cx))?; + self.project() + .inner + .poll_flush(cx) + .map_err(TransportError::from) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + ready!(flush_write_buf(self.as_mut(), cx))?; + let flushed = ready!(self.as_mut().project().inner.poll_flush(cx)); + match flushed { + Err(e) => Poll::Ready(Err(TransportError::from(e))), + Ok(()) => self + .project() + .inner + .poll_shutdown(cx) + .map_err(TransportError::from), + } + } + } + + /// Drain `write_buf` into the underlying stream until it is empty. + fn flush_write_buf( + mut stream: Pin<&mut FramedStream>, + cx: &mut Context<'_>, + ) -> Poll> { + while !stream.as_ref().project_ref().write_buf.is_empty() { + let this = stream.as_mut().project(); + let n = match ready!(this.inner.poll_write(cx, this.write_buf)) { + Ok(n) => n, + Err(e) => return Poll::Ready(Err(TransportError::from(e))), + }; + if n == 0 { + // The stream refuses to take bytes; treat as a write failure + // rather than spinning forever. + return Poll::Ready(Err(TransportError::FramingError( + "write made no progress".into(), + ))); + } + this.write_buf.advance(n); + } + Poll::Ready(Ok(())) + } +} + +#[cfg(feature = "native-transport")] +pub use framed::FramedStream; diff --git a/Build/crates/saikuro-transport/src/lib.rs b/Build/crates/saikuro-transport/src/lib.rs index 48572f6e..88892e8a 100644 --- a/Build/crates/saikuro-transport/src/lib.rs +++ b/Build/crates/saikuro-transport/src/lib.rs @@ -8,11 +8,19 @@ //! | [`memory`] | always on | native + wasm32 | //! | [`unix`] | `native-transport` | Unix only | //! | [`tcp`] | `native-transport` | native only | -//! | [`websocket`] | `ws-transport` | native + wasm32 | +//! | [`websocket`] | `native-ws`/wasm32 | native + wasm32 | //! | [`wasm_host`] | always on (wasm32) | wasm32 only | +//! +//! The crate is `no_std` + `alloc` without the `std` feature; the in-memory +//! transport, selector, traits, and error types compile for bare-metal MCU +//! targets. Native backends (Unix/TCP/WebSocket) require `std`. + +#![cfg_attr(not(feature = "std"), no_std)] + +#[macro_use] +extern crate alloc; pub mod error; -#[cfg(feature = "native-transport")] pub mod framing; pub mod memory; pub mod selector; @@ -28,7 +36,10 @@ pub mod tcp; ))] pub mod unix; -#[cfg(feature = "ws-transport")] +#[cfg(all( + feature = "ws-transport", + any(feature = "native-ws", target_arch = "wasm32") +))] pub mod websocket; #[cfg(target_arch = "wasm32")] @@ -49,10 +60,13 @@ pub use tcp::TcpTransport; ))] pub use unix::UnixTransport; -#[cfg(feature = "ws-transport")] +#[cfg(all( + feature = "ws-transport", + any(feature = "native-ws", target_arch = "wasm32") +))] pub use websocket::WebSocketTransport; -#[cfg(all(feature = "ws-transport", not(target_arch = "wasm32")))] +#[cfg(all(feature = "native-ws", not(target_arch = "wasm32")))] pub use websocket::WsTransportListener; #[cfg(target_arch = "wasm32")] diff --git a/Build/crates/saikuro-transport/src/memory.rs b/Build/crates/saikuro-transport/src/memory.rs index 25528280..fa3b7727 100644 --- a/Build/crates/saikuro-transport/src/memory.rs +++ b/Build/crates/saikuro-transport/src/memory.rs @@ -4,6 +4,8 @@ //! channels. There is no serialisation overhead beyond MessagePack (which //! the runtime performs regardless of transport); frames arrive as //! `Bytes` objects with zero copying. +use alloc::boxed::Box; +use alloc::string::String; use async_trait::async_trait; use bytes::Bytes; use saikuro_exec::mpsc; diff --git a/Build/crates/saikuro-transport/src/selector.rs b/Build/crates/saikuro-transport/src/selector.rs index 210305dc..647bdaf3 100644 --- a/Build/crates/saikuro-transport/src/selector.rs +++ b/Build/crates/saikuro-transport/src/selector.rs @@ -15,6 +15,8 @@ //! The user can override any of these choices by supplying an explicit //! [`TransportConfig`]. +use alloc::borrow::ToOwned; +use alloc::string::String; use serde::{Deserialize, Serialize}; /// The set of transport backends Saikuro knows about. diff --git a/Build/crates/saikuro-transport/src/tcp.rs b/Build/crates/saikuro-transport/src/tcp.rs index 9950ed07..3265190d 100644 --- a/Build/crates/saikuro-transport/src/tcp.rs +++ b/Build/crates/saikuro-transport/src/tcp.rs @@ -6,15 +6,13 @@ use crate::{impl_native_receiver, impl_native_sender}; use async_trait::async_trait; use bytes::Bytes; -use futures::StreamExt; use saikuro_exec::net::{TcpListener, TcpStream}; -use saikuro_exec::tokio_util::codec::Framed; use std::net::SocketAddr; use tracing::debug; use crate::{ error::Result, - framing::LengthPrefixedCodec, + framing::FramedStream, traits::{Transport, TransportConnector, TransportListener}, }; @@ -26,7 +24,7 @@ use crate::{ /// Use [`TcpConnector`] to establish outgoing connections and /// [`TcpTransportListener`] to accept incoming ones. pub struct TcpTransport { - framed: Framed, + framed: FramedStream, peer_addr: SocketAddr, } @@ -38,7 +36,7 @@ impl TcpTransport { // matters more than segment coalescing. stream.set_nodelay(true)?; Ok(Self { - framed: Framed::new(stream, LengthPrefixedCodec::new()), + framed: FramedStream::new(stream), peer_addr, }) } @@ -71,14 +69,14 @@ impl Transport for TcpTransport { // Sender / Receiver pub struct TcpSender { - inner: futures::stream::SplitSink, Bytes>, + inner: futures::stream::SplitSink, Bytes>, peer_addr: SocketAddr, } impl_native_sender!(TcpSender, peer_addr, "tcp"); pub struct TcpReceiver { - inner: futures::stream::SplitStream>, + inner: futures::stream::SplitStream>, peer_addr: SocketAddr, } diff --git a/Build/crates/saikuro-transport/src/traits.rs b/Build/crates/saikuro-transport/src/traits.rs index a803a66e..75a14c7b 100644 --- a/Build/crates/saikuro-transport/src/traits.rs +++ b/Build/crates/saikuro-transport/src/traits.rs @@ -5,6 +5,7 @@ //! to WebSocket when moving to WASM) without touching any routing or //! schema logic. +use alloc::boxed::Box; use async_trait::async_trait; use bytes::Bytes; diff --git a/Build/crates/saikuro-transport/src/unix.rs b/Build/crates/saikuro-transport/src/unix.rs index 1f2e4fe1..cb5a2b5a 100644 --- a/Build/crates/saikuro-transport/src/unix.rs +++ b/Build/crates/saikuro-transport/src/unix.rs @@ -8,15 +8,13 @@ use crate::{impl_native_receiver, impl_native_sender}; use async_trait::async_trait; use bytes::Bytes; -use futures::StreamExt; use saikuro_exec::net::{UnixListener, UnixStream}; -use saikuro_exec::tokio_util::codec::Framed; use std::path::{Path, PathBuf}; use tracing::debug; use crate::{ error::Result, - framing::LengthPrefixedCodec, + framing::FramedStream, traits::{Transport, TransportConnector, TransportListener}, }; @@ -24,7 +22,7 @@ use crate::{ /// A Unix domain socket transport connection. pub struct UnixTransport { - framed: Framed, + framed: FramedStream, path: PathBuf, } @@ -32,7 +30,7 @@ impl UnixTransport { /// Wrap an already-connected [`UnixStream`]. pub fn new(stream: UnixStream, path: PathBuf) -> Self { Self { - framed: Framed::new(stream, LengthPrefixedCodec::new()), + framed: FramedStream::new(stream), path, } } @@ -65,14 +63,14 @@ impl Transport for UnixTransport { // Sender / Receiver pub struct UnixSender { - inner: futures::stream::SplitSink, Bytes>, + inner: futures::stream::SplitSink, Bytes>, path: PathBuf, } impl_native_sender!(UnixSender, path, "unix"); pub struct UnixReceiver { - inner: futures::stream::SplitStream>, + inner: futures::stream::SplitStream>, path: PathBuf, } diff --git a/Build/tests/Cargo.toml b/Build/tests/Cargo.toml index 21b9a8b6..f0834f2f 100644 --- a/Build/tests/Cargo.toml +++ b/Build/tests/Cargo.toml @@ -19,6 +19,7 @@ saikuro = { workspace = true } saikuro-exec = { workspace = true } bytes = { workspace = true } +futures = { workspace = true } rmp-serde = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/Build/tests/tests/transport_framing.rs b/Build/tests/tests/transport_framing.rs new file mode 100644 index 00000000..2752ba36 --- /dev/null +++ b/Build/tests/tests/transport_framing.rs @@ -0,0 +1,264 @@ +//! Length-prefixed framing tests for stream transports. +//! +//! Covers the no_std [`LengthPrefixedCodec`] directly and the native +//! [`FramedStream`] adapter over an in-memory duplex stream and over real +//! TCP. The native adapter is not available on wasm32 (no +//! `native-transport` feature there), so this file is native-only. + +#![cfg(not(target_arch = "wasm32"))] + +use bytes::{BufMut, Bytes, BytesMut}; +use futures::{SinkExt, StreamExt}; +use saikuro_exec::block_on; +use saikuro_exec::io::AsyncWriteExt; +use saikuro_transport::error::TransportError; +use saikuro_transport::framing::{FramedStream, LengthPrefixedCodec}; + +fn encode_frames(items: &[Bytes]) -> BytesMut { + let mut codec = LengthPrefixedCodec::new(); + let mut out = BytesMut::new(); + for item in items { + codec.encode(item.clone(), &mut out).expect("encode"); + } + out +} + +#[test] +fn codec_roundtrip_preserves_frames() { + let items = vec![ + Bytes::from_static(b"hello"), + Bytes::new(), + Bytes::from(vec![0xAB; 100_000]), + Bytes::from_static(b"goodbye"), + ]; + let mut wire = encode_frames(&items); + + let mut codec = LengthPrefixedCodec::new(); + for expected in &items { + let got = codec.decode(&mut wire).expect("decode").expect("frame"); + assert_eq!(&got, expected); + } + // Every byte should have been consumed. + assert!(wire.is_empty()); + // Decoding an empty buffer yields nothing, not an error. + assert!(codec.decode(&mut wire).expect("decode").is_none()); +} + +#[test] +fn codec_handles_partial_input() { + let wire = encode_frames(&[Bytes::from_static(b"ping")]); + let mut codec = LengthPrefixedCodec::new(); + + // Feed the wire bytes one at a time; only the final byte completes a frame. + let mut buf = BytesMut::new(); + let mut remaining = wire; + let got = loop { + if !remaining.is_empty() { + let byte = remaining.split_to(1); + buf.extend_from_slice(&byte); + } + match codec.decode(&mut buf) { + Ok(Some(frame)) => break frame, + Ok(None) if remaining.is_empty() => { + panic!("frame never completed"); + } + Ok(None) => continue, + Err(e) => panic!("unexpected decode error: {e}"), + } + }; + assert_eq!(got, Bytes::from_static(b"ping")); +} + +#[test] +fn codec_rejects_oversized_frame_then_recovers() { + // Forge a 4 GiB length header (u32 max) that exceeds MAX_FRAME_SIZE. + let mut wire = BytesMut::from(&[0xFF, 0xFF, 0xFF, 0xFF][..]); + + let mut codec = LengthPrefixedCodec::new(); + match codec.decode(&mut wire) { + Err(TransportError::MessageTooLarge { .. }) => {} + other => panic!("expected MessageTooLarge, got {other:?}"), + } + + // The codec must reset after the bogus header so a subsequent valid frame + // decodes instead of erroring forever. + let valid = encode_frames(&[Bytes::from_static(b"ok")]); + wire.extend_from_slice(&valid); + let got = codec.decode(&mut wire).expect("decode").expect("frame"); + assert_eq!(got, Bytes::from_static(b"ok")); +} + +#[test] +fn codec_encode_rejects_oversized_frame() { + let too_big = Bytes::from(vec![0u8; saikuro_transport::MAX_FRAME_SIZE + 1]); + let mut codec = LengthPrefixedCodec::new(); + let mut out = BytesMut::new(); + match codec.encode(too_big, &mut out) { + Err(TransportError::MessageTooLarge { .. }) => {} + other => panic!("expected MessageTooLarge, got {other:?}"), + } +} + +#[test] +fn framed_stream_roundtrips_multiple_frames() { + block_on(async { + let (client, server) = saikuro_exec::io::duplex(1024 * 1024); + let framed_client = FramedStream::new(client); + let (mut tx, _rx) = framed_client.split(); + let mut framed_server = FramedStream::new(server); + + let frames = vec![ + Bytes::from_static(b"a"), + Bytes::from_static(b"bb"), + Bytes::from(vec![0x42; 100_000]), + ]; + for frame in &frames { + tx.send(frame.clone()).await.expect("send"); + } + tx.close().await.expect("close"); + + for expected in &frames { + let got = framed_server.next().await.expect("stream").expect("frame"); + assert_eq!(&got, expected); + } + // Clean EOF after the sender closed. + assert!(framed_server.next().await.is_none()); + }) +} + +#[test] +fn framed_stream_truncated_frame_errors() { + block_on(async { + let (client, server) = saikuro_exec::io::duplex(4096); + // Write a length header promising 100 bytes, then only 3 bytes, and + // drop the write half: the reader must report a framing error, not + // silently return a short frame or hang. + let (_rx, mut tx) = saikuro_exec::io::split(client); + let mut framed_server = FramedStream::new(server); + + let mut partial = BytesMut::new(); + partial.put_u32(100); + partial.put_slice(b"abc"); + tx.write_all(&partial).await.expect("write"); + // Shut down the write half so the reader sees EOF after the partial + // frame (dropping the half alone does not signal EOF on a duplex). + tx.shutdown().await.expect("shutdown"); + drop(tx); + + match framed_server.next().await { + Some(Err(TransportError::FramingError(_))) => {} + other => panic!("expected FramingError, got {other:?}"), + } + }) +} + +#[test] +fn framed_stream_supports_bidirectional_use() { + block_on(async { + let (client, server) = saikuro_exec::io::duplex(4096); + let (mut client_tx, mut client_rx) = FramedStream::new(client).split(); + let (mut server_tx, mut server_rx) = FramedStream::new(server).split(); + + client_tx.send(Bytes::from_static(b"ping")).await.unwrap(); + let got = server_rx.next().await.unwrap().unwrap(); + assert_eq!(got, Bytes::from_static(b"ping")); + + server_tx.send(Bytes::from_static(b"pong")).await.unwrap(); + let got = client_rx.next().await.unwrap().unwrap(); + assert_eq!(got, Bytes::from_static(b"pong")); + }) +} + +#[test] +fn framed_stream_tcp_roundtrip_concurrent() { + block_on(async { + let listener = saikuro_exec::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + let server_task = saikuro_exec::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut framed = FramedStream::new(stream); + let mut frames = Vec::new(); + while let Some(frame) = framed.next().await { + frames.push(frame.expect("frame")); + } + frames + }); + + let client_task = saikuro_exec::spawn(async move { + let stream = saikuro_exec::net::TcpStream::connect(addr) + .await + .expect("connect"); + let (mut tx, _rx) = FramedStream::new(stream).split(); + for i in 0..5 { + let payload = Bytes::from(vec![i as u8; 300_000]); + tx.send(payload.clone()).await.expect("send"); + } + tx.close().await.expect("close"); + }); + + let (client_res, server_res) = (client_task.await, server_task.await); + client_res.expect("client task"); + let frames = server_res.expect("server task"); + assert_eq!(frames.len(), 5, "expected 5 frames, got {}", frames.len()); + for (i, frame) in frames.iter().enumerate() { + assert_eq!(frame.len(), 300_000, "frame {i} wrong length"); + assert!( + frame.iter().all(|&b| b == i as u8), + "frame {i} content wrong" + ); + } + }) +} + +#[test] +fn framed_stream_tcp_raw_writer() { + // Server side uses FramedStream; client writes pre-encoded wire bytes + // directly, isolating the read path. + block_on(async { + let listener = saikuro_exec::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + let server_task = saikuro_exec::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut framed = FramedStream::new(stream); + let mut frames = Vec::new(); + while let Some(frame) = framed.next().await { + frames.push(frame.expect("frame")); + } + frames + }); + + let client_task = saikuro_exec::spawn(async move { + let stream = saikuro_exec::net::TcpStream::connect(addr) + .await + .expect("connect"); + let (_r, mut w) = saikuro_exec::io::split(stream); + let mut codec = LengthPrefixedCodec::new(); + let mut wire = BytesMut::new(); + for i in 0..5 { + let payload = Bytes::from(vec![i as u8; 300_000]); + codec.encode(payload, &mut wire).expect("encode"); + } + w.write_all(&wire).await.expect("write_all"); + w.shutdown().await.expect("shutdown"); + drop(w); + }); + + let (client_res, server_res) = (client_task.await, server_task.await); + client_res.expect("client task"); + let frames = server_res.expect("server task"); + assert_eq!(frames.len(), 5, "expected 5 frames, got {}", frames.len()); + for (i, frame) in frames.iter().enumerate() { + assert_eq!(frame.len(), 300_000, "frame {i} wrong length"); + assert!( + frame.iter().all(|&b| b == i as u8), + "frame {i} content wrong" + ); + } + }) +} diff --git a/Build/tests/tests/transport_memory_stress.rs b/Build/tests/tests/transport_memory_stress.rs index 763fc405..7fdf124b 100644 --- a/Build/tests/tests/transport_memory_stress.rs +++ b/Build/tests/tests/transport_memory_stress.rs @@ -219,13 +219,21 @@ fn drop_receiver_while_sender_is_sending() { }); // Fill the channel then try to send one more (which will block, - // then fail when the receiver is dropped). + // then fail when the receiver is dropped). The abort task may drop + // the receiver mid-fill on a loaded machine, which is the same + // acceptable outcome as the final send below. + let mut receiver_dropped = false; for _ in 0..256 { - sender.send(Bytes::from_static(b"x")).await.unwrap(); + if sender.send(Bytes::from_static(b"x")).await.is_err() { + receiver_dropped = true; + break; + } + } + if !receiver_dropped { + // This send may return an error (receiver dropped) or succeed + // (if the abort task hasn't run yet). Either is acceptable. + let _ = sender.send(Bytes::from_static(b"last")).await; } - // This send may return an error (receiver dropped) or succeed - // (if the abort task hasn't run yet). Either is acceptable. - let _ = sender.send(Bytes::from_static(b"last")).await; abort.await.unwrap(); }) } diff --git a/Demo/wasm/c/Cargo.lock b/Demo/wasm/c/Cargo.lock index 3154b1ce..b96da4a3 100644 --- a/Demo/wasm/c/Cargo.lock +++ b/Demo/wasm/c/Cargo.lock @@ -11,15 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -93,12 +84,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bitflags" version = "1.3.2" @@ -111,21 +96,18 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -148,20 +130,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "clap" version = "4.6.1" @@ -208,52 +176,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "dashmap" version = "6.2.1" @@ -262,34 +190,12 @@ checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", - "hashbrown 0.14.5", + "hashbrown", "lock_api", "once_cell", "parking_lot_core 0.9.12", ] -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -311,12 +217,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "futures" version = "0.3.32" @@ -407,24 +307,26 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", "wasip2", - "wasip3", "wasm-bindgen", ] [[package]] -name = "hashbrown" -version = "0.12.3" +name = "hash32" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] [[package]] name = "hashbrown" @@ -433,91 +335,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] -name = "hashbrown" -version = "0.15.5" +name = "heapless" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "foldhash", + "hash32", + "serde", + "stable_deref_trait", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - [[package]] name = "instant" version = "0.1.13" @@ -554,12 +387,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -575,12 +402,6 @@ dependencies = [ "scopeguard", ] -[[package]] -name = "log" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" - [[package]] name = "memchr" version = "2.8.1" @@ -588,10 +409,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] -name = "num-conv" -version = "0.2.2" +name = "messagepack-core" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95fe590f7b7d58bfefd83d9995c0b09eaecb8607e4bb66a658c95c0bd691f876" +dependencies = [ + "num-traits", +] + +[[package]] +name = "messagepack-serde" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "4a630d3ff4e4c893267925baffa3f8e75f0cdb20d212170ee7d8576a45ac1869" +dependencies = [ + "messagepack-core", + "num-traits", + "serde", +] [[package]] name = "num-traits" @@ -665,20 +500,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "powerfmt" -version = "0.2.0" +name = "portable-atomic" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "proc-macro2" @@ -700,9 +525,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "6.0.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "redox_syscall" @@ -722,26 +547,6 @@ dependencies = [ "bitflags 2.13.0", ] -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "regex" version = "1.12.3" @@ -790,17 +595,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rmpv" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" -dependencies = [ - "rmp", - "serde", - "serde_bytes", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -820,6 +614,7 @@ dependencies = [ "rmp-serde", "saikuro-core", "saikuro-exec", + "saikuro-random", "saikuro-storage", "saikuro-transport", "serde", @@ -827,7 +622,6 @@ dependencies = [ "syn", "thiserror", "tracing", - "uuid", ] [[package]] @@ -856,14 +650,13 @@ dependencies = [ name = "saikuro-core" version = "0.1.0" dependencies = [ - "bytes", - "chrono", - "rmp-serde", - "rmpv", + "heapless", + "messagepack-serde", + "saikuro-random", "serde", "serde_bytes", "serde_json", - "serde_with", + "spin", "strum", "thiserror", "uuid", @@ -879,6 +672,14 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "saikuro-random" +version = "0.1.0" +dependencies = [ + "getrandom", + "uuid", +] + [[package]] name = "saikuro-storage" version = "0.1.0" @@ -886,7 +687,6 @@ dependencies = [ "async-trait", "bytes", "futures", - "rmp-serde", "saikuro-core", "saikuro-exec", "serde", @@ -902,12 +702,11 @@ dependencies = [ "async-trait", "bytes", "futures", - "getrandom", "js-sys", "pin-project-lite", - "rmp-serde", "saikuro-core", "saikuro-exec", + "saikuro-random", "send_wrapper", "serde", "thiserror", @@ -917,42 +716,12 @@ dependencies = [ "web-sys", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "send_wrapper" version = "0.6.0" @@ -1012,38 +781,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_with" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" -dependencies = [ - "base64", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "shlex" version = "2.0.1" @@ -1062,6 +799,21 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spin" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1120,52 +872,6 @@ dependencies = [ "syn", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.52.3" @@ -1224,12 +930,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "utf8parse" version = "0.2.2" @@ -1241,12 +941,6 @@ name = "uuid" version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" -dependencies = [ - "getrandom", - "js-sys", - "serde_core", - "wasm-bindgen", -] [[package]] name = "wasip2" @@ -1254,16 +948,7 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -1321,40 +1006,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" version = "0.3.99" @@ -1387,65 +1038,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1455,100 +1053,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/Demo/wasm/cpp/Cargo.lock b/Demo/wasm/cpp/Cargo.lock index fb8fcaf7..2f1ae0d3 100644 --- a/Demo/wasm/cpp/Cargo.lock +++ b/Demo/wasm/cpp/Cargo.lock @@ -11,15 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -93,12 +84,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bitflags" version = "1.3.2" @@ -111,21 +96,18 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -148,20 +130,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "clap" version = "4.6.1" @@ -208,52 +176,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "dashmap" version = "6.2.1" @@ -262,34 +190,12 @@ checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", - "hashbrown 0.14.5", + "hashbrown", "lock_api", "once_cell", "parking_lot_core 0.9.12", ] -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -311,12 +217,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "futures" version = "0.3.32" @@ -407,24 +307,26 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", "wasip2", - "wasip3", "wasm-bindgen", ] [[package]] -name = "hashbrown" -version = "0.12.3" +name = "hash32" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] [[package]] name = "hashbrown" @@ -433,91 +335,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] -name = "hashbrown" -version = "0.15.5" +name = "heapless" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "foldhash", + "hash32", + "serde", + "stable_deref_trait", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - [[package]] name = "instant" version = "0.1.13" @@ -554,12 +387,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -575,12 +402,6 @@ dependencies = [ "scopeguard", ] -[[package]] -name = "log" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" - [[package]] name = "memchr" version = "2.8.1" @@ -588,10 +409,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] -name = "num-conv" -version = "0.2.2" +name = "messagepack-core" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95fe590f7b7d58bfefd83d9995c0b09eaecb8607e4bb66a658c95c0bd691f876" +dependencies = [ + "num-traits", +] + +[[package]] +name = "messagepack-serde" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "4a630d3ff4e4c893267925baffa3f8e75f0cdb20d212170ee7d8576a45ac1869" +dependencies = [ + "messagepack-core", + "num-traits", + "serde", +] [[package]] name = "num-traits" @@ -665,20 +500,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "powerfmt" -version = "0.2.0" +name = "portable-atomic" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "proc-macro2" @@ -700,9 +525,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "6.0.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "redox_syscall" @@ -722,26 +547,6 @@ dependencies = [ "bitflags 2.13.0", ] -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "regex" version = "1.12.3" @@ -790,17 +595,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rmpv" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" -dependencies = [ - "rmp", - "serde", - "serde_bytes", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -820,6 +614,7 @@ dependencies = [ "rmp-serde", "saikuro-core", "saikuro-exec", + "saikuro-random", "saikuro-storage", "saikuro-transport", "serde", @@ -827,7 +622,6 @@ dependencies = [ "syn", "thiserror", "tracing", - "uuid", ] [[package]] @@ -847,14 +641,13 @@ dependencies = [ name = "saikuro-core" version = "0.1.0" dependencies = [ - "bytes", - "chrono", - "rmp-serde", - "rmpv", + "heapless", + "messagepack-serde", + "saikuro-random", "serde", "serde_bytes", "serde_json", - "serde_with", + "spin", "strum", "thiserror", "uuid", @@ -879,6 +672,14 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "saikuro-random" +version = "0.1.0" +dependencies = [ + "getrandom", + "uuid", +] + [[package]] name = "saikuro-storage" version = "0.1.0" @@ -886,7 +687,6 @@ dependencies = [ "async-trait", "bytes", "futures", - "rmp-serde", "saikuro-core", "saikuro-exec", "serde", @@ -902,12 +702,11 @@ dependencies = [ "async-trait", "bytes", "futures", - "getrandom", "js-sys", "pin-project-lite", - "rmp-serde", "saikuro-core", "saikuro-exec", + "saikuro-random", "send_wrapper", "serde", "thiserror", @@ -917,42 +716,12 @@ dependencies = [ "web-sys", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "send_wrapper" version = "0.6.0" @@ -1012,38 +781,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_with" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" -dependencies = [ - "base64", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "shlex" version = "2.0.1" @@ -1062,6 +799,21 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spin" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1120,52 +872,6 @@ dependencies = [ "syn", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.52.3" @@ -1224,12 +930,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "utf8parse" version = "0.2.2" @@ -1241,12 +941,6 @@ name = "uuid" version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" -dependencies = [ - "getrandom", - "js-sys", - "serde_core", - "wasm-bindgen", -] [[package]] name = "wasip2" @@ -1254,16 +948,7 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -1321,40 +1006,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" version = "0.3.99" @@ -1387,65 +1038,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1455,100 +1053,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/Demo/wasm/runtime/Cargo.lock b/Demo/wasm/runtime/Cargo.lock index 8fd8e94e..17cf7691 100644 --- a/Demo/wasm/runtime/Cargo.lock +++ b/Demo/wasm/runtime/Cargo.lock @@ -126,6 +126,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -155,10 +161,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", - "js-sys", "num-traits", "serde", - "wasm-bindgen", "windows-link", ] @@ -224,6 +228,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -321,12 +331,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "futures" version = "0.3.32" @@ -417,19 +421,27 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", "wasip2", - "wasip3", "wasm-bindgen", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -444,18 +456,20 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] -name = "hashbrown" -version = "0.17.1" +name = "heapless" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "serde", + "stable_deref_trait", +] [[package]] name = "heck" @@ -493,12 +507,6 @@ dependencies = [ "cc", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -570,12 +578,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -612,6 +614,26 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "messagepack-core" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95fe590f7b7d58bfefd83d9995c0b09eaecb8607e4bb66a658c95c0bd691f876" +dependencies = [ + "num-traits", +] + +[[package]] +name = "messagepack-serde" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a630d3ff4e4c893267925baffa3f8e75f0cdb20d212170ee7d8576a45ac1869" +dependencies = [ + "messagepack-core", + "num-traits", + "serde", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -709,20 +731,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "powerfmt" -version = "0.2.0" +name = "portable-atomic" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +dependencies = [ + "critical-section", +] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "proc-macro2" @@ -744,9 +765,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "6.0.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "redox_syscall" @@ -803,36 +824,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - -[[package]] -name = "rmpv" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" -dependencies = [ - "rmp", - "serde", - "serde_bytes", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -843,14 +834,13 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" name = "saikuro-core" version = "0.1.0" dependencies = [ - "bytes", - "chrono", - "rmp-serde", - "rmpv", + "heapless", + "messagepack-serde", + "saikuro-random", "serde", "serde_bytes", "serde_json", - "serde_with", + "spin", "strum", "thiserror", "uuid", @@ -866,22 +856,25 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "saikuro-random" +version = "0.1.0" +dependencies = [ + "getrandom", + "uuid", +] + [[package]] name = "saikuro-router" version = "0.1.0" dependencies = [ "async-trait", - "bytes", - "dashmap", - "futures", - "rmp-serde", + "portable-atomic", "saikuro-core", "saikuro-exec", "saikuro-schema", - "serde", "thiserror", "tracing", - "uuid", ] [[package]] @@ -895,9 +888,9 @@ dependencies = [ "dashmap", "futures", "parking_lot 0.12.5", - "rmp-serde", "saikuro-core", "saikuro-exec", + "saikuro-random", "saikuro-router", "saikuro-schema", "saikuro-transport", @@ -907,18 +900,13 @@ dependencies = [ "thiserror", "tracing", "tracing-subscriber", - "uuid", ] [[package]] name = "saikuro-schema" version = "0.1.0" dependencies = [ - "dashmap", - "parking_lot 0.12.5", "saikuro-core", - "serde", - "serde_json", "thiserror", "tracing", ] @@ -930,12 +918,11 @@ dependencies = [ "async-trait", "bytes", "futures", - "getrandom", "js-sys", "pin-project-lite", - "rmp-serde", "saikuro-core", "saikuro-exec", + "saikuro-random", "send_wrapper", "serde", "thiserror", @@ -989,12 +976,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "send_wrapper" version = "0.6.0" @@ -1113,6 +1094,21 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spin" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1327,12 +1323,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "utf8parse" version = "0.2.2" @@ -1344,12 +1334,6 @@ name = "uuid" version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" -dependencies = [ - "getrandom", - "js-sys", - "serde_core", - "wasm-bindgen", -] [[package]] name = "valuable" @@ -1363,16 +1347,7 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -1430,40 +1405,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" version = "0.3.99" @@ -1564,100 +1505,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/Demo/wasm/rust/Cargo.lock b/Demo/wasm/rust/Cargo.lock index 433ff5a4..ed49a935 100644 --- a/Demo/wasm/rust/Cargo.lock +++ b/Demo/wasm/rust/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -84,12 +75,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bitflags" version = "1.3.2" @@ -102,15 +87,6 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -118,20 +94,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "bytes" -version = "1.12.1" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] -name = "cc" -version = "1.2.62" +name = "bytes" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" -dependencies = [ - "find-msvc-tools", - "shlex", -] +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cfg-if" @@ -139,20 +111,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "clap" version = "4.6.1" @@ -209,52 +167,12 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "dashmap" version = "6.2.1" @@ -263,40 +181,12 @@ checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", - "hashbrown 0.14.5", + "hashbrown", "lock_api", "once_cell", "parking_lot_core 0.9.12", ] -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - [[package]] name = "fluvio-wasm-timer" version = "0.2.5" @@ -312,12 +202,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "futures" version = "0.3.32" @@ -408,24 +292,26 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", "wasip2", - "wasip3", "wasm-bindgen", ] [[package]] -name = "hashbrown" -version = "0.12.3" +name = "hash32" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] [[package]] name = "hashbrown" @@ -434,91 +320,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] -name = "hashbrown" -version = "0.15.5" +name = "heapless" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "foldhash", + "hash32", + "serde", + "stable_deref_trait", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - [[package]] name = "instant" version = "0.1.13" @@ -555,12 +372,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -576,12 +387,6 @@ dependencies = [ "scopeguard", ] -[[package]] -name = "log" -version = "0.4.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" - [[package]] name = "memchr" version = "2.8.1" @@ -589,10 +394,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] -name = "num-conv" -version = "0.2.2" +name = "messagepack-core" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "95fe590f7b7d58bfefd83d9995c0b09eaecb8607e4bb66a658c95c0bd691f876" +dependencies = [ + "num-traits", +] + +[[package]] +name = "messagepack-serde" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a630d3ff4e4c893267925baffa3f8e75f0cdb20d212170ee7d8576a45ac1869" +dependencies = [ + "messagepack-core", + "num-traits", + "serde", +] [[package]] name = "num-traits" @@ -666,20 +485,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "prettyplease" -version = "0.2.37" +name = "portable-atomic" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "proc-macro2" @@ -701,9 +510,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "6.0.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "redox_syscall" @@ -723,26 +532,6 @@ dependencies = [ "bitflags 2.11.1", ] -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "rmp" version = "0.8.15" @@ -762,17 +551,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rmpv" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" -dependencies = [ - "rmp", - "serde", - "serde_bytes", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -792,6 +570,7 @@ dependencies = [ "rmp-serde", "saikuro-core", "saikuro-exec", + "saikuro-random", "saikuro-storage", "saikuro-transport", "serde", @@ -799,21 +578,19 @@ dependencies = [ "syn", "thiserror", "tracing", - "uuid", ] [[package]] name = "saikuro-core" version = "0.1.0" dependencies = [ - "bytes", - "chrono", - "rmp-serde", - "rmpv", + "heapless", + "messagepack-serde", + "saikuro-random", "serde", "serde_bytes", "serde_json", - "serde_with", + "spin", "strum", "thiserror", "uuid", @@ -829,6 +606,14 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "saikuro-random" +version = "0.1.0" +dependencies = [ + "getrandom", + "uuid", +] + [[package]] name = "saikuro-storage" version = "0.1.0" @@ -836,7 +621,6 @@ dependencies = [ "async-trait", "bytes", "futures", - "rmp-serde", "saikuro-core", "saikuro-exec", "serde", @@ -852,12 +636,11 @@ dependencies = [ "async-trait", "bytes", "futures", - "getrandom", "js-sys", "pin-project-lite", - "rmp-serde", "saikuro-core", "saikuro-exec", + "saikuro-random", "send_wrapper", "serde", "thiserror", @@ -880,42 +663,12 @@ dependencies = [ "wasm-bindgen-futures", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "send_wrapper" version = "0.6.0" @@ -975,44 +728,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_with" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" -dependencies = [ - "base64", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "slab" version = "0.4.12" @@ -1025,6 +740,21 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spin" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1083,52 +813,6 @@ dependencies = [ "syn", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.52.3" @@ -1187,12 +871,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "utf8parse" version = "0.2.2" @@ -1204,12 +882,6 @@ name = "uuid" version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" -dependencies = [ - "getrandom", - "js-sys", - "serde_core", - "wasm-bindgen", -] [[package]] name = "wasip2" @@ -1217,16 +889,7 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -1284,40 +947,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" version = "0.3.99" @@ -1350,65 +979,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1418,100 +994,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "zmij" version = "1.0.21" From 372047446060af325800cc761ebebe8bcb00ee11 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Wed, 5 Aug 2026 02:04:03 -0600 Subject: [PATCH 08/43] Review comments --- Build/Cargo.lock | 1 - Build/adapters/rust/src/provider.rs | 14 ++-- Build/crates/saikuro-core/Cargo.toml | 9 ++- Build/crates/saikuro-core/src/error.rs | 4 +- Build/crates/saikuro-core/src/lib.rs | 8 +- Build/crates/saikuro-core/src/log.rs | 2 +- Build/crates/saikuro-core/src/sync.rs | 20 ++--- Build/crates/saikuro-core/tests/invocation.rs | 7 ++ Build/crates/saikuro-core/tests/value.rs | 18 +++-- .../saikuro-exec/src/embassy_backend.rs | 5 ++ Build/crates/saikuro-exec/src/lib.rs | 7 ++ .../crates/saikuro-exec/src/tokio_backend.rs | 4 +- Build/crates/saikuro-router/Cargo.toml | 1 + Build/crates/saikuro-router/src/provider.rs | 31 +++++++- .../saikuro-router/tests/provider_registry.rs | 47 +++++++++++ Build/crates/saikuro-storage/Cargo.toml | 16 ++-- Build/crates/saikuro-storage/src/config.rs | 6 +- Build/crates/saikuro-storage/src/error.rs | 9 ++- Build/crates/saikuro-storage/src/inmemory.rs | 2 +- Build/crates/saikuro-storage/src/lib.rs | 10 +++ Build/crates/saikuro-storage/src/traits.rs | 4 + Build/crates/saikuro-storage/src/util.rs | 3 + Build/crates/saikuro-transport/Cargo.toml | 12 ++- Build/crates/saikuro-transport/src/framing.rs | 78 +++++++++++++++---- Build/tests/tests/transport_framing.rs | 43 ++++++++-- 25 files changed, 287 insertions(+), 74 deletions(-) create mode 100644 Build/crates/saikuro-router/tests/provider_registry.rs diff --git a/Build/Cargo.lock b/Build/Cargo.lock index 1c4e7dbc..2a6c5204 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -1542,7 +1542,6 @@ dependencies = [ "dashmap", "futures", "js-sys", - "parking_lot 0.12.5", "rusqlite", "saikuro-core", "saikuro-exec", diff --git a/Build/adapters/rust/src/provider.rs b/Build/adapters/rust/src/provider.rs index 014675c2..38a81a49 100644 --- a/Build/adapters/rust/src/provider.rs +++ b/Build/adapters/rust/src/provider.rs @@ -144,7 +144,7 @@ impl Provider { /// Serve on an already-connected transport. pub async fn serve_on(self, mut transport: Box) -> Result<()> { // Announce schema. - self.announce(&mut *transport).await; + self.announce(&mut *transport).await?; // Serve loop. info!(namespace = %self.namespace, "provider ready, entering serve loop"); @@ -202,21 +202,21 @@ impl Provider { // Announce - async fn announce(&self, transport: &mut dyn AdapterTransport) { + async fn announce(&self, transport: &mut dyn AdapterTransport) -> Result<()> { // A capacity overflow here means the announcement would be silently // truncated; fail the announce instead of publishing a partial schema. let schema = match self.build_schema() { Ok(schema) => schema, Err(e) => { warn!(error = %e, "failed to build schema announcement"); - return; + return Err(e); } }; let schema_value = match serde_json::to_value(&schema) { Ok(v) => json_to_core(v), Err(e) => { warn!(error = %e, "failed to serialize schema for announcement"); - return; + return Err(Error::Codec(e.to_string())); } }; @@ -225,13 +225,13 @@ impl Provider { Ok(b) => Bytes::from(b), Err(e) => { warn!(error = %e, "failed to encode announce envelope"); - return; + return Err(Error::Codec(e.to_string())); } }; if let Err(e) = transport.send(frame).await { warn!(error = %e, "failed to send schema announce"); - return; + return Err(Error::Transport(e.to_string())); } // Wait for the runtime ack. The runtime must reply with ok_empty @@ -263,6 +263,8 @@ impl Provider { debug!(namespace = %self.namespace, "schema announce ack timed out, continuing"); } } + + Ok(()) } } // Dispatch helpers diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index 9282dabb..4dc2722a 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -10,12 +10,15 @@ keywords = ["ipc", "cross-language", "saikuro", "rpc", "msgpack"] # The crate is always `no_std` + `alloc`. The default `std` feature adds the # std-only conveniences (`std::io::Error` variant, stderr log sink) and selects -# the OS entropy backend; `custom` selects the caller-provided getrandom -# backend and `drbg` the deterministic chacha20 DRBG for bare-metal targets -# (see saikuro-random). `std` is mutually exclusive with `custom`/`drbg`. +# the OS entropy backend; `std-no-os` is the same crate-level conveniences +# without the OS entropy backend, for targets that cannot reach it (wasm32); +# `custom` selects the caller-provided getrandom backend and `drbg` the +# deterministic chacha20 DRBG for bare-metal targets (see saikuro-random). +# `std` is mutually exclusive with `custom`/`drbg`. [features] default = ["std"] std = ["saikuro-random/os"] +std-no-os = [] custom = ["saikuro-random/custom"] drbg = ["saikuro-random/drbg"] diff --git a/Build/crates/saikuro-core/src/error.rs b/Build/crates/saikuro-core/src/error.rs index 00f62e46..35fff910 100644 --- a/Build/crates/saikuro-core/src/error.rs +++ b/Build/crates/saikuro-core/src/error.rs @@ -217,7 +217,7 @@ pub enum SaikuroError { MsgpackDecode(#[from] crate::msgpack::DecodeError), // I/O - #[cfg(feature = "std")] + #[cfg(any(feature = "std", feature = "std-no-os"))] #[error("I/O error: {0}")] Io(#[from] std::io::Error), @@ -254,7 +254,7 @@ impl From for ErrorDetail { SaikuroError::ChannelClosed => ErrorCode::ChannelClosed, SaikuroError::OutOfOrder { .. } => ErrorCode::OutOfOrder, SaikuroError::MsgpackEncode(_) | SaikuroError::MsgpackDecode(_) => ErrorCode::Internal, - #[cfg(feature = "std")] + #[cfg(any(feature = "std", feature = "std-no-os"))] SaikuroError::Io(_) => ErrorCode::Internal, SaikuroError::CapacityExceeded(_) | SaikuroError::Internal(_) => ErrorCode::Internal, }; diff --git a/Build/crates/saikuro-core/src/lib.rs b/Build/crates/saikuro-core/src/lib.rs index e1d08d96..da63d074 100644 --- a/Build/crates/saikuro-core/src/lib.rs +++ b/Build/crates/saikuro-core/src/lib.rs @@ -7,15 +7,15 @@ //! //! The crate is always `#![no_std]` + `alloc`: strings and vectors come from //! `alloc`, and all maps/sets are fixed-capacity `heapless` collections. The -//! default `std` feature adds std-only conveniences (the msgpack codec helpers -//! on envelopes and the `Io` error variant). +//! msgpack codec is available on every target; the default `std` feature adds +//! the `Io` error variant, a stderr log sink, and std-backed sync primitives. #![no_std] #[macro_use] extern crate alloc; -#[cfg(feature = "std")] +#[cfg(any(feature = "std", feature = "std-no-os"))] extern crate std; pub mod capability; @@ -33,7 +33,7 @@ pub use capability::{CapabilitySet, CapabilityToken}; pub use envelope::{split_target, Envelope, InvocationType, ResponseEnvelope}; pub use error::{ErrorCode, ErrorDetail, SaikuroError}; pub use invocation::InvocationId; -#[cfg(feature = "std")] +#[cfg(any(feature = "std", feature = "std-no-os"))] pub use log::stderr_log_sink; pub use log::{LogLevel, LogRecord, LogSink}; pub use resource::ResourceHandle; diff --git a/Build/crates/saikuro-core/src/log.rs b/Build/crates/saikuro-core/src/log.rs index f12d0016..1aa0cf1f 100644 --- a/Build/crates/saikuro-core/src/log.rs +++ b/Build/crates/saikuro-core/src/log.rs @@ -174,7 +174,7 @@ pub type LogSink = Box; /// A simple log sink that serialises each [`LogRecord`] as a JSON line and /// writes it to stderr. Used when no richer sink is configured. -#[cfg(feature = "std")] +#[cfg(any(feature = "std", feature = "std-no-os"))] pub fn stderr_log_sink() -> LogSink { Box::new(|record: LogRecord| { if let Ok(json) = serde_json::to_string(&record) { diff --git a/Build/crates/saikuro-core/src/sync.rs b/Build/crates/saikuro-core/src/sync.rs index 7a20b357..44b7454c 100644 --- a/Build/crates/saikuro-core/src/sync.rs +++ b/Build/crates/saikuro-core/src/sync.rs @@ -11,17 +11,19 @@ //! and MCU targets. The guards are only ever held for short map mutations; //! they are never held across an `await`. //! -//! Lock poisoning is not recovered from: a panic while one of these guards is -//! held poisons the lock, and the next acquisition panics too, so the bug -//! surfaces immediately instead of being silently recovered from. +//! Poisoning behaviour is backend-specific. `std::sync::Mutex` and +//! `std::sync::RwLock` write guards become poisoned if a panic unwinds while +//! they are held, and the next acquisition then panics, surfacing the bug +//! immediately. `std::sync::RwLock` read guards never poison a lock, and the +//! `spin` guards used on no_std builds expose no poison state at all. use core::fmt; use core::ops::{Deref, DerefMut}; -#[cfg(feature = "std")] +#[cfg(any(feature = "std", feature = "std-no-os"))] use std::sync as imp; -#[cfg(not(feature = "std"))] +#[cfg(not(any(feature = "std", feature = "std-no-os")))] use spin as imp; /// A reader-writer lock. `read`/`write` return guards that deref to the @@ -64,7 +66,7 @@ trait MutexAccess { fn lock_guard(&self) -> imp::MutexGuard<'_, T>; } -#[cfg(feature = "std")] +#[cfg(any(feature = "std", feature = "std-no-os"))] impl RwLockAccess for imp::RwLock { fn read_guard(&self) -> imp::RwLockReadGuard<'_, T> { self.read() @@ -77,7 +79,7 @@ impl RwLockAccess for imp::RwLock { } } -#[cfg(feature = "std")] +#[cfg(any(feature = "std", feature = "std-no-os"))] impl MutexAccess for imp::Mutex { fn lock_guard(&self) -> imp::MutexGuard<'_, T> { self.lock() @@ -85,7 +87,7 @@ impl MutexAccess for imp::Mutex { } } -#[cfg(not(feature = "std"))] +#[cfg(not(any(feature = "std", feature = "std-no-os")))] impl RwLockAccess for imp::RwLock { fn read_guard(&self) -> imp::RwLockReadGuard<'_, T> { self.read() @@ -96,7 +98,7 @@ impl RwLockAccess for imp::RwLock { } } -#[cfg(not(feature = "std"))] +#[cfg(not(any(feature = "std", feature = "std-no-os")))] impl MutexAccess for imp::Mutex { fn lock_guard(&self) -> imp::MutexGuard<'_, T> { self.lock() diff --git a/Build/crates/saikuro-core/tests/invocation.rs b/Build/crates/saikuro-core/tests/invocation.rs index 802adfa2..b360b203 100644 --- a/Build/crates/saikuro-core/tests/invocation.rs +++ b/Build/crates/saikuro-core/tests/invocation.rs @@ -6,6 +6,13 @@ fn msgpack_roundtrip_uses_binary_uuid() { let id = InvocationId::new(); let encoded = msgpack::to_vec(&id).expect("encode invocation id"); let decoded: InvocationId = msgpack::from_slice(&encoded).expect("decode invocation id"); + // The wire form must be msgpack bin8: 0xC4 marker, one length byte of 16, + // then the raw UUID bytes. Pinning the exact encoding keeps the binary + // contract stable across future format changes. + assert_eq!(encoded.len(), 18, "expected bin8 header plus 16 UUID bytes"); + assert_eq!(encoded[0], 0xC4, "expected msgpack bin8 marker"); + assert_eq!(encoded[1], 16, "expected 16-byte payload length"); + assert_eq!(id, decoded); } diff --git a/Build/crates/saikuro-core/tests/value.rs b/Build/crates/saikuro-core/tests/value.rs index 080b7d16..676bc781 100644 --- a/Build/crates/saikuro-core/tests/value.rs +++ b/Build/crates/saikuro-core/tests/value.rs @@ -43,11 +43,12 @@ fn schema_round_trip_via_value() { let bytes2 = msgpack::to_vec(&value).expect("Value to msgpack"); let schema2: Schema = msgpack::from_slice(&bytes2).expect("msgpack to Schema"); - assert_eq!(schema2.version, 1); - assert!( - schema2.namespaces.contains_key("svc"), - "namespace 'svc' not found after round-trip" - ); + // `Schema` does not derive PartialEq (its heapless map and doc fields do + // not support it), so compare the decoded schema's re-encoding with the + // original bytes. A byte-identical re-encoding proves the round-trip lost + // no field and changed nothing. + let bytes3 = msgpack::to_vec(&schema2).expect("schema2 to msgpack"); + assert_eq!(bytes3, bytes1, "schema changed across the Value round-trip"); } /// Regression: Value::Array must not be confused with Value::Bytes. @@ -68,10 +69,11 @@ fn bytes_round_trip() { let original = Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef]); let bytes = msgpack::to_vec(&original).expect("serialize"); let decoded: Value = msgpack::from_slice(&bytes).expect("deserialize"); - assert!( - matches!(decoded, Value::Bytes(_)), - "Expected Bytes, got: {decoded:?}" + assert_eq!( + decoded, original, + "bytes payload changed across the round-trip" ); + assert_eq!(bytes[0], 0xC4, "Value::Bytes must encode as msgpack bin8"); } #[test] diff --git a/Build/crates/saikuro-exec/src/embassy_backend.rs b/Build/crates/saikuro-exec/src/embassy_backend.rs index 4a4f819e..093bc481 100644 --- a/Build/crates/saikuro-exec/src/embassy_backend.rs +++ b/Build/crates/saikuro-exec/src/embassy_backend.rs @@ -476,6 +476,11 @@ pub mod mpsc { /// buffer, so `capacity` must not exceed it. The channel state is /// reference-counted and freed once all handles are dropped. pub fn channel(capacity: usize) -> (Sender, Receiver) { + assert!( + capacity > 0, + "saikuro-exec: mpsc capacity 0 is unsupported; a channel must hold \ + at least one message" + ); assert!( capacity <= CHANNEL_CAPACITY, "saikuro-exec: mpsc capacity {capacity} exceeds the fixed \ diff --git a/Build/crates/saikuro-exec/src/lib.rs b/Build/crates/saikuro-exec/src/lib.rs index 5a2df5ae..fcecb129 100644 --- a/Build/crates/saikuro-exec/src/lib.rs +++ b/Build/crates/saikuro-exec/src/lib.rs @@ -48,6 +48,13 @@ pub use embassy_backend::*; #[cfg(feature = "embassy-runtime")] pub use futures as _futures; +/// Branch on the first future to complete. +/// +/// The tokio and wasm backends delegate to `tokio::select!` and accept its +/// full syntax. The embassy backend only supports `pattern = future => { ... }` +/// branches (see `select_impl!`); it rejects `else`, `biased;`, guards, and +/// expression handlers. Cross-backend code must stay within the shared subset +/// so it compiles on every backend. #[macro_export] macro_rules! select { ($($tt:tt)*) => { diff --git a/Build/crates/saikuro-exec/src/tokio_backend.rs b/Build/crates/saikuro-exec/src/tokio_backend.rs index 61fc31e9..9884b98e 100644 --- a/Build/crates/saikuro-exec/src/tokio_backend.rs +++ b/Build/crates/saikuro-exec/src/tokio_backend.rs @@ -35,7 +35,9 @@ pub mod net { } pub mod io { - pub use tokio::io::*; + pub use tokio::io::{ + duplex, split, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf, + }; } pub mod signal { diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index 3e77345d..f62db04b 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -28,4 +28,5 @@ portable-atomic = { workspace = true } tracing = { version = "0.1", default-features = false, features = ["attributes"] } [dev-dependencies] +saikuro-exec = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/Build/crates/saikuro-router/src/provider.rs b/Build/crates/saikuro-router/src/provider.rs index 3ef71352..fa485d88 100644 --- a/Build/crates/saikuro-router/src/provider.rs +++ b/Build/crates/saikuro-router/src/provider.rs @@ -147,12 +147,41 @@ impl ProviderRegistry { /// If a namespace already has a provider, the old one is replaced. The /// namespace is then removed from the old provider's record so a later /// deregistration of the old provider cannot reclaim the new provider's - /// namespace. + /// namespace. Re-registering a provider with fewer namespaces releases + /// the routes it no longer owns (unless a newer provider took them over). pub fn register(&self, handle: ProviderHandle) { let provider_id = handle.id().to_owned(); let namespaces = handle.namespaces().to_vec(); let mut state = self.inner.write(); + + // A re-registering provider that dropped a namespace must release its + // route. Remove each previously-owned namespace that is absent from + // the new list, but only while it still points at this provider (a + // newer provider may have taken it over). + let dropped: Vec = state + .by_provider + .get(&provider_id) + .map(|owned| { + owned + .iter() + .filter(|ns| !namespaces.contains(ns)) + .cloned() + .collect() + }) + .unwrap_or_default(); + for ns in &dropped { + if state + .by_namespace + .get(ns) + .map(|h| h.id() == provider_id) + .unwrap_or(false) + { + state.by_namespace.remove(ns); + debug!(namespace = %ns, provider = %provider_id, "released dropped namespace route"); + } + } + for ns in &namespaces { match state.by_namespace.insert(ns.clone(), handle.clone()) { Some(old) => { diff --git a/Build/crates/saikuro-router/tests/provider_registry.rs b/Build/crates/saikuro-router/tests/provider_registry.rs new file mode 100644 index 00000000..740340e0 --- /dev/null +++ b/Build/crates/saikuro-router/tests/provider_registry.rs @@ -0,0 +1,47 @@ +use saikuro_router::provider::{Provider, ProviderHandle, ProviderRegistry, ProviderWorkItem}; + +fn handle(id: &str, namespaces: &[&str]) -> ProviderHandle { + let (sender, _receiver) = saikuro_exec::mpsc::channel::(4); + ProviderHandle::new( + id.to_owned(), + namespaces.iter().map(|s| s.to_string()).collect(), + sender, + ) +} + +/// Re-registering the same provider with fewer namespaces must release the +/// routes it no longer owns. +#[test] +fn register_with_fewer_namespaces_releases_dropped_routes() { + let registry = ProviderRegistry::new(); + + registry.register(handle("p", &["a", "b"])); + assert!(registry.get("a").is_some()); + assert!(registry.get("b").is_some()); + + registry.register(handle("p", &["a"])); + assert!( + registry.get("b").is_none(), + "dropped namespace 'b' still routed after re-register" + ); + assert!(registry.get("a").is_some()); +} + +/// A dropped namespace that a newer provider took over must not be released; +/// only the still-owned route is removed. +#[test] +fn register_with_fewer_namespaces_keeps_taken_over_routes() { + let registry = ProviderRegistry::new(); + + registry.register(handle("p", &["a", "b"])); + registry.register(handle("q", &["b"])); + registry.register(handle("p", &["a"])); + + assert!(registry.get("a").is_some()); + let b = registry.get("b").expect("'b' is owned by q"); + assert_eq!( + b.id(), + "q", + "taken-over namespace 'b' must still route to q" + ); +} diff --git a/Build/crates/saikuro-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index e927e9ae..caca80e7 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -9,20 +9,23 @@ repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "storage", "key-value"] [features] -default = ["native-storage"] +default = ["std", "native-storage"] +std = [] native-storage = [ + "std", "inmemory", "local-storage", "session-storage", "saikuro-exec/tokio-runtime", ] -inmemory = ["dashmap", "parking_lot"] +inmemory = ["std", "dashmap"] local-storage = ["inmemory"] session-storage = ["inmemory"] -fs-storage = ["dep:tokio", "saikuro-exec/tokio-runtime"] -sled-storage = ["dep:tokio", "dep:sled", "saikuro-exec/tokio-runtime"] -sqlite-storage = ["dep:tokio", "dep:rusqlite", "saikuro-exec/tokio-runtime"] +fs-storage = ["std", "dep:tokio", "saikuro-exec/tokio-runtime"] +sled-storage = ["std", "dep:tokio", "dep:sled", "saikuro-exec/tokio-runtime"] +sqlite-storage = ["std", "dep:tokio", "dep:rusqlite", "saikuro-exec/tokio-runtime"] wasm-storage = [ + "std", "inmemory", "local-storage", "session-storage", @@ -39,7 +42,7 @@ saikuro-core = { workspace = true } saikuro-exec = { workspace = true, default-features = false } serde = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["alloc"] } bytes = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } @@ -47,7 +50,6 @@ thiserror = { workspace = true } tracing = { workspace = true } dashmap = { workspace = true, optional = true } -parking_lot = { workspace = true, optional = true } tokio = { version = "1.52.3", features = ["rt"], optional = true } sled = { version = "0.34.7", optional = true } rusqlite = { version = "0.40.0", features = ["bundled"], optional = true } diff --git a/Build/crates/saikuro-storage/src/config.rs b/Build/crates/saikuro-storage/src/config.rs index 3337c377..dd67973b 100644 --- a/Build/crates/saikuro-storage/src/config.rs +++ b/Build/crates/saikuro-storage/src/config.rs @@ -1,6 +1,7 @@ //! Configuration for storage backends. -use std::time::Duration; +use alloc::string::String; +use core::time::Duration; /// Selects which storage backend implementation to use at runtime. /// @@ -86,6 +87,7 @@ pub struct StorageConfig { /// /// When `None`, the factory uses a built-in default /// (`./saikuro_data`, `./saikuro_sled`, `./saikuro.sqlite`). + #[cfg(feature = "std")] pub storage_path: Option, /// Automatic cleanup policy. @@ -110,6 +112,7 @@ impl Default for StorageConfig { namespace_prefix: None, auto_create_namespaces: true, sync_on_write: false, + #[cfg(feature = "std")] storage_path: None, } } @@ -140,6 +143,7 @@ impl StorageConfig { } /// Set the filesystem / database path for native persistent backends. + #[cfg(feature = "std")] pub fn with_storage_path(mut self, path: impl Into) -> Self { self.storage_path = Some(path.into()); self diff --git a/Build/crates/saikuro-storage/src/error.rs b/Build/crates/saikuro-storage/src/error.rs index 5c6a8b69..45cbb751 100644 --- a/Build/crates/saikuro-storage/src/error.rs +++ b/Build/crates/saikuro-storage/src/error.rs @@ -1,9 +1,13 @@ //! Error types for the storage backend abstraction. -use std::io; +use alloc::string::String; +use alloc::string::ToString; use thiserror::Error; -pub type Result = std::result::Result; +#[cfg(feature = "std")] +use std::io; + +pub type Result = core::result::Result; /// Error type for all storage backend operations. #[derive(Error, Debug)] @@ -20,6 +24,7 @@ pub enum StorageError { #[error("namespace already exists: {0}")] NamespaceAlreadyExists(String), + #[cfg(feature = "std")] #[error("io error: {0}")] Io(#[from] io::Error), diff --git a/Build/crates/saikuro-storage/src/inmemory.rs b/Build/crates/saikuro-storage/src/inmemory.rs index 6f28cca5..b9443d28 100644 --- a/Build/crates/saikuro-storage/src/inmemory.rs +++ b/Build/crates/saikuro-storage/src/inmemory.rs @@ -3,10 +3,10 @@ //! This is the reference implementation and the default backend for //! Saikuro's ephemeral storage needs. +use alloc::sync::Arc; use async_trait::async_trait; use bytes::Bytes; use dashmap::DashMap; -use std::sync::Arc; use tracing::debug; use super::{ diff --git a/Build/crates/saikuro-storage/src/lib.rs b/Build/crates/saikuro-storage/src/lib.rs index 8fc523f3..99973d1a 100644 --- a/Build/crates/saikuro-storage/src/lib.rs +++ b/Build/crates/saikuro-storage/src/lib.rs @@ -3,6 +3,16 @@ //! Provides a platform-agnostic storage interface for key-value and file-like //! operations. Works across native (std::fs, databases) and WASM environments //! (OPFS, IndexedDB, localStorage, sessionStorage). +//! +//! The crate is `no_std` + `alloc` without the `std` feature: the config, +//! error, trait, and util modules compile for bare-metal MCU targets, and the +//! concrete backends (in-memory, native fs/sled/sqlite, wasm storage) all +//! require `std`. + +#![cfg_attr(not(feature = "std"), no_std)] + +#[macro_use] +extern crate alloc; pub mod config; pub mod error; diff --git a/Build/crates/saikuro-storage/src/traits.rs b/Build/crates/saikuro-storage/src/traits.rs index 260a8a19..aa516732 100644 --- a/Build/crates/saikuro-storage/src/traits.rs +++ b/Build/crates/saikuro-storage/src/traits.rs @@ -1,5 +1,9 @@ //! Storage backend traits and utilities. +use alloc::boxed::Box; +use alloc::string::String; +use alloc::string::ToString; +use alloc::vec::Vec; use async_trait::async_trait; use bytes::Bytes; use serde::{de::DeserializeOwned, Serialize}; diff --git a/Build/crates/saikuro-storage/src/util.rs b/Build/crates/saikuro-storage/src/util.rs index 73a33abc..8597fbb0 100644 --- a/Build/crates/saikuro-storage/src/util.rs +++ b/Build/crates/saikuro-storage/src/util.rs @@ -6,6 +6,9 @@ // crate's integration tests can exercise them; on native they are otherwise // only referenced from the wasm32-gated backends. +use alloc::borrow::ToOwned; +use alloc::string::String; +use alloc::vec::Vec; use bytes::Bytes; use super::config::StorageConfig; diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index 9a2c70ba..d6578a35 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -12,7 +12,8 @@ keywords = ["ipc", "cross-language", "saikuro", "transport", "async"] # # std: gate for std-only code (the Io error variant and the # native/WebSocket/wasm backends). The crate is no_std + -# alloc without it. +# alloc without it. Forwards core's std-no-os so wasm32 +# never selects the OS entropy backend through transport. # native-transport: Unix socket + TCP (requires std networking; disabled on wasm32) # ws-transport: WebSocket module (compiled on wasm32, or on native with native-ws) # native-ws: WebSocket + tokio-tungstenite on native (non-wasm32 only) @@ -24,16 +25,19 @@ keywords = ["ipc", "cross-language", "saikuro", "transport", "async"] # The in-memory transport is always compiled; it has zero OS dependencies. [features] default = ["std", "native-transport"] -std = ["saikuro-core/std"] +std = ["saikuro-core/std-no-os"] embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/drbg"] -native-transport = ["std", "saikuro-exec/tokio-runtime"] +# native features also forward core/std so native builds keep the OS entropy +# backend that core/std selects; only the crate-level lifter (std) switches to +# std-no-os for wasm32. +native-transport = ["std", "saikuro-exec/tokio-runtime", "saikuro-core/std"] ws-transport = [] # wasm32 has no OS entropy source; the js getrandom backend is forwarded here # (matching saikuro-runtime's own wasm-runtime feature). wasm-runtime = ["std", "saikuro-exec/wasm-runtime", "saikuro-random/wasm"] # native-ws is only available on non-wasm32 (where tokio-tungstenite exists) -native-ws = ["ws-transport", "std", "saikuro-exec/tokio-runtime", "tokio-tungstenite", "tungstenite"] +native-ws = ["ws-transport", "std", "saikuro-exec/tokio-runtime", "saikuro-core/std", "tokio-tungstenite", "tungstenite"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } diff --git a/Build/crates/saikuro-transport/src/framing.rs b/Build/crates/saikuro-transport/src/framing.rs index 30dd20e6..50045e3c 100644 --- a/Build/crates/saikuro-transport/src/framing.rs +++ b/Build/crates/saikuro-transport/src/framing.rs @@ -27,6 +27,10 @@ pub use crate::MAX_FRAME_SIZE; pub struct LengthPrefixedCodec { /// Once we've read the length header we cache it here to avoid re-parsing. pending_len: Option, + /// Payload bytes still owed for a frame whose length header exceeded + /// [`MAX_FRAME_SIZE`]. The header is consumed but the declared payload + /// must be swallowed so it is not misinterpreted as a fresh header. + discard_remaining: u64, } impl LengthPrefixedCodec { @@ -36,8 +40,21 @@ impl LengthPrefixedCodec { /// Decode the next complete frame from `src`, returning `Ok(None)` until a /// full frame is buffered. Consumes the header and payload from the front - /// of `src` when a frame is returned. + /// of `src` when a frame is returned. A header over the size limit yields + /// `MessageTooLarge` and the codec then discards the declared payload on + /// subsequent calls so it resynchronizes at the next real header. pub fn decode(&mut self, src: &mut BytesMut) -> Result> { + // Swallow any payload owed by a rejected oversized frame before + // touching normal framing state. + if self.discard_remaining > 0 { + let take = core::cmp::min(self.discard_remaining, src.len() as u64); + src.advance(take as usize); + self.discard_remaining -= take; + if self.discard_remaining > 0 { + return Ok(None); + } + } + // Phase 1: read the 4-byte length header if we don't have it yet. let frame_len = match self.pending_len { Some(len) => len, @@ -57,9 +74,11 @@ impl LengthPrefixedCodec { usize::try_from(frame_len).map_err(|_| message_too_large(frame_len as usize))?; if frame_len > MAX_FRAME_SIZE { - // Reset so the next call re-reads a fresh header instead of - // erroring forever on the same bogus length. + // The declared payload will never be decoded, so count it against + // the discard budget instead of resetting and letting the next + // call misread payload bytes as a length header. self.pending_len = None; + self.discard_remaining = frame_len as u64; return Err(message_too_large(frame_len)); } @@ -108,7 +127,7 @@ pub mod framed { use core::pin::Pin; use core::task::{Context, Poll}; - use bytes::Buf; + use bytes::{Buf, BufMut}; use futures::{ready, Sink, Stream}; use pin_project_lite::pin_project; use saikuro_exec::io::{AsyncRead, AsyncWrite}; @@ -116,8 +135,10 @@ pub mod framed { use super::LengthPrefixedCodec; use crate::error::{Result, TransportError}; - /// Bytes to request from the underlying stream on each read. Large enough - /// to amortize syscalls without over-committing memory on small frames. + /// Minimum capacity to make available for each read when no frame is + /// pending. Large enough to amortize syscalls without over-committing + /// memory on small frames; when a frame is pending, decode reserves the + /// exact remaining frame bytes so the read spans the whole frame. const READ_CHUNK: usize = 4096; pin_project! { @@ -127,6 +148,10 @@ pub mod framed { codec: LengthPrefixedCodec, read_buf: bytes::BytesMut, write_buf: bytes::BytesMut, + // Set once a framing, I/O, or truncation error is surfaced so the + // stream stays terminal and later polls report the end instead of + // resuming on an unaligned byte stream. + failed: bool, } } @@ -137,6 +162,7 @@ pub mod framed { codec: LengthPrefixedCodec::new(), read_buf: bytes::BytesMut::new(), write_buf: bytes::BytesMut::new(), + failed: false, } } @@ -160,6 +186,10 @@ pub mod framed { fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let mut this = self.project(); + if *this.failed { + return Poll::Ready(None); + } + loop { // decode any complete frames already buffered. match this.codec.decode(this.read_buf) { @@ -168,22 +198,35 @@ pub mod framed { Err(e) => { // Corrupt or oversized frame; the byte stream is no // longer aligned, so surface the error and terminate. - this.read_buf.clear(); + *this.failed = true; return Poll::Ready(Some(Err(e))); } } - // Read into a stack chunk and append only the filled bytes. - // Reading directly into a zero-fill-resized read_buf would - // leave phantom zero bytes behind if poll_read returns - // Pending, and those would decode as bogus zero-length frames. - let mut chunk = [0u8; READ_CHUNK]; - let mut read_buf = saikuro_exec::io::ReadBuf::new(&mut chunk); - let filled = match ready!(this.inner.as_mut().poll_read(cx, &mut read_buf)) { - Ok(()) => read_buf.filled().len(), - Err(e) => return Poll::Ready(Some(Err(TransportError::from(e)))), + // Read directly into the uninitialized tail of read_buf. When + // a frame is pending, decode already reserved the remaining + // frame bytes so chunk_mut spans the whole frame; otherwise + // reserve the chunk size so the read still has a writable + // target. advance_mut only appends the filled bytes, so a + // Pending read leaves no phantom bytes behind. + this.read_buf.reserve(READ_CHUNK); + let filled = { + let dst = this.read_buf.chunk_mut(); + // SAFETY: chunk_mut borrows the uninitialized tail of the + // buffer; the slice is only filled by poll_read below + // before we advance_mut by the filled length. + let dst = unsafe { dst.as_uninit_slice_mut() }; + let mut read_buf = saikuro_exec::io::ReadBuf::uninit(dst); + match ready!(this.inner.as_mut().poll_read(cx, &mut read_buf)) { + Ok(()) => read_buf.filled().len(), + Err(e) => { + *this.failed = true; + return Poll::Ready(Some(Err(TransportError::from(e)))); + } + } }; - this.read_buf.extend_from_slice(read_buf.filled()); + // SAFETY: poll_read initialized the first `filled` bytes. + unsafe { this.read_buf.advance_mut(filled) }; if filled == 0 { // EOF from the peer. A clean close happens only at a @@ -191,6 +234,7 @@ pub mod framed { if this.read_buf.is_empty() { return Poll::Ready(None); } + *this.failed = true; return Poll::Ready(Some(Err(TransportError::FramingError( "connection closed mid-frame".into(), )))); diff --git a/Build/tests/tests/transport_framing.rs b/Build/tests/tests/transport_framing.rs index 2752ba36..7d533a74 100644 --- a/Build/tests/tests/transport_framing.rs +++ b/Build/tests/tests/transport_framing.rs @@ -71,8 +71,16 @@ fn codec_handles_partial_input() { #[test] fn codec_rejects_oversized_frame_then_recovers() { - // Forge a 4 GiB length header (u32 max) that exceeds MAX_FRAME_SIZE. - let mut wire = BytesMut::from(&[0xFF, 0xFF, 0xFF, 0xFF][..]); + // Forge a length header just over MAX_FRAME_SIZE and retain the declared + // trailing payload on the wire. The codec must swallow exactly that many + // bytes so they are not misread as a fresh header, then resynchronize at + // the valid frame that follows on the same buffer. + let forged = saikuro_transport::MAX_FRAME_SIZE as u32 + 3; + let valid = encode_frames(&[Bytes::from_static(b"ok")]); + let mut wire = BytesMut::new(); + wire.put_u32(forged); + wire.resize(4 + forged as usize, 0); + wire.extend_from_slice(&valid); let mut codec = LengthPrefixedCodec::new(); match codec.decode(&mut wire) { @@ -80,12 +88,9 @@ fn codec_rejects_oversized_frame_then_recovers() { other => panic!("expected MessageTooLarge, got {other:?}"), } - // The codec must reset after the bogus header so a subsequent valid frame - // decodes instead of erroring forever. - let valid = encode_frames(&[Bytes::from_static(b"ok")]); - wire.extend_from_slice(&valid); let got = codec.decode(&mut wire).expect("decode").expect("frame"); assert_eq!(got, Bytes::from_static(b"ok")); + assert!(wire.is_empty(), "all wire bytes consumed"); } #[test] @@ -149,6 +154,32 @@ fn framed_stream_truncated_frame_errors() { Some(Err(TransportError::FramingError(_))) => {} other => panic!("expected FramingError, got {other:?}"), } + // The stream is terminal after a framing error. + assert!(framed_server.next().await.is_none()); + }) +} + +#[test] +fn framed_stream_stays_terminal_after_oversized_frame_error() { + block_on(async { + let (client, server) = saikuro_exec::io::duplex(4096); + let (_rx, mut tx) = saikuro_exec::io::split(client); + let mut framed_server = FramedStream::new(server); + + // Forge an oversized length header. The byte stream is unaligned + // after it, so the reader must error once and stay terminal rather + // than resuming and misreading payload bytes as a header. + let mut wire = BytesMut::new(); + wire.put_u32(u32::MAX); + tx.write_all(&wire).await.expect("write"); + tx.shutdown().await.expect("shutdown"); + drop(tx); + + match framed_server.next().await { + Some(Err(TransportError::MessageTooLarge { .. })) => {} + other => panic!("expected MessageTooLarge, got {other:?}"), + } + assert!(framed_server.next().await.is_none()); }) } From a9a4093a7f6434ef496ac35c535a3aaf3c4303aa Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Mon, 10 Aug 2026 23:52:42 -0600 Subject: [PATCH 09/43] Some cleanup and like doing it properly --- Build/Cargo.toml | 2 +- Build/adapters/c/Cargo.toml | 2 +- Build/adapters/rust/Cargo.toml | 4 +- Build/crates/saikuro-codegen/Cargo.toml | 2 +- Build/crates/saikuro-core/Cargo.toml | 2 +- Build/crates/saikuro-core/src/invocation.rs | 15 +- Build/crates/saikuro-core/src/value.rs | 38 +++-- Build/crates/saikuro-core/tests/value.rs | 19 +++ .../saikuro-exec/src/embassy_backend.rs | 132 +----------------- Build/crates/saikuro-random/Cargo.toml | 3 +- Build/crates/saikuro-random/src/drbg.rs | 34 ++++- Build/crates/saikuro-random/src/lib.rs | 40 ++++-- Build/crates/saikuro-random/tests/drbg.rs | 2 +- Build/crates/saikuro-router/src/router.rs | 19 +++ .../crates/saikuro-router/src/stream_state.rs | 78 +++++++---- Build/crates/saikuro-runtime/Cargo.toml | 12 +- Build/crates/saikuro-schema/src/registry.rs | 35 ++++- Build/crates/saikuro-schema/tests/registry.rs | 23 +++ Build/crates/saikuro-storage/Cargo.toml | 12 +- Build/crates/saikuro-transport/Cargo.toml | 9 +- Build/crates/saikuro-transport/src/framing.rs | 8 +- Build/tests/Cargo.toml | 2 + Build/tests/tests/transport_framing.rs | 21 +++ 23 files changed, 303 insertions(+), 211 deletions(-) create mode 100644 Build/crates/saikuro-schema/tests/registry.rs diff --git a/Build/Cargo.toml b/Build/Cargo.toml index fe0570e2..460bf1cb 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -95,7 +95,7 @@ spin = { version = "0.12", default-features = false, features = [ chrono = { version = "0.4", features = ["serde", "wasmbind"] } # Internal crates -saikuro-core = { path = "crates/saikuro-core" } +saikuro-core = { path = "crates/saikuro-core", default-features = false } saikuro-schema = { path = "crates/saikuro-schema", default-features = false } saikuro-storage = { path = "crates/saikuro-storage", default-features = false } saikuro-transport = { path = "crates/saikuro-transport", default-features = false } diff --git a/Build/adapters/c/Cargo.toml b/Build/adapters/c/Cargo.toml index a9d8c2c2..4b8996da 100644 --- a/Build/adapters/c/Cargo.toml +++ b/Build/adapters/c/Cargo.toml @@ -31,6 +31,6 @@ clap = { version = "4.5", features = ["derive"] } regex = "1.11" [dev-dependencies] -saikuro-core = { workspace = true } +saikuro-core = { workspace = true, features = ["std"] } saikuro-runtime = { workspace = true } saikuro-transport = { workspace = true } diff --git a/Build/adapters/rust/Cargo.toml b/Build/adapters/rust/Cargo.toml index 34544667..515f88f2 100644 --- a/Build/adapters/rust/Cargo.toml +++ b/Build/adapters/rust/Cargo.toml @@ -29,10 +29,10 @@ storage-sqlite = ["saikuro-storage/sqlite-storage"] wasm-storage = ["saikuro-storage/wasm-storage"] [dependencies] -saikuro-core = { path = "../../crates/saikuro-core" } +saikuro-core = { path = "../../crates/saikuro-core", default-features = false } saikuro-storage = { path = "../../crates/saikuro-storage", default-features = false } saikuro-transport = { path = "../../crates/saikuro-transport", default-features = false } -saikuro-random = { path = "../../crates/saikuro-random" } +saikuro-random = { path = "../../crates/saikuro-random", default-features = false } anyhow = { workspace = true } serde = { version = "1.0", features = ["derive"] } diff --git a/Build/crates/saikuro-codegen/Cargo.toml b/Build/crates/saikuro-codegen/Cargo.toml index 4c9df9a4..91e81ea3 100644 --- a/Build/crates/saikuro-codegen/Cargo.toml +++ b/Build/crates/saikuro-codegen/Cargo.toml @@ -17,7 +17,7 @@ required-features = ["cli"] cli = ["dep:clap"] [dependencies] -saikuro-core = { workspace = true } +saikuro-core = { workspace = true, default-features = false } saikuro-schema = { workspace = true } serde = { workspace = true } diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index 4dc2722a..dc891f07 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -18,7 +18,7 @@ keywords = ["ipc", "cross-language", "saikuro", "rpc", "msgpack"] [features] default = ["std"] std = ["saikuro-random/os"] -std-no-os = [] +std-no-os = ["saikuro-random/wasm"] custom = ["saikuro-random/custom"] drbg = ["saikuro-random/drbg"] diff --git a/Build/crates/saikuro-core/src/invocation.rs b/Build/crates/saikuro-core/src/invocation.rs index 25c418ff..2e385254 100644 --- a/Build/crates/saikuro-core/src/invocation.rs +++ b/Build/crates/saikuro-core/src/invocation.rs @@ -73,12 +73,21 @@ impl<'de> Deserialize<'de> for InvocationId { } impl InvocationId { + /// Try to generate a fresh invocation identifier from the active entropy + /// backend. + #[inline] + pub fn try_new() -> Result { + saikuro_random::uuid_v4().map(Self) + } + /// Generate a fresh, globally-unique invocation identifier. + /// + /// Panics when the configured entropy backend is unavailable. Embedded + /// startup code should seed its DRBG first or use [`Self::try_new`] to + /// propagate initialization failures. #[inline] pub fn new() -> Self { - // Entropy failure is a platform-level fault: without it no invocation - // id can ever be minted, so aborting is the only sane response. - Self(saikuro_random::uuid_v4().expect("entropy backend unavailable")) + Self::try_new().expect("entropy backend unavailable") } /// Construct from an existing UUID. diff --git a/Build/crates/saikuro-core/src/value.rs b/Build/crates/saikuro-core/src/value.rs index 989abf22..18adf924 100644 --- a/Build/crates/saikuro-core/src/value.rs +++ b/Build/crates/saikuro-core/src/value.rs @@ -8,7 +8,7 @@ //! schema field descriptor. use alloc::{borrow::ToOwned, boxed::Box, string::String, vec::Vec}; -use serde::{Deserialize, Serialize}; +use serde::{ser::SerializeMap, Deserialize, Serialize, Serializer}; /// Maximum number of entries a [`Value::Map`] can hold. /// @@ -18,12 +18,11 @@ use serde::{Deserialize, Serialize}; /// fails cleanly with a serde error rather than truncating. pub const VALUE_MAP_CAPACITY: usize = 64; -/// Fixed-capacity, insertion-ordered map backing [`Value::Map`]. +/// Fixed-capacity map backing [`Value::Map`]. /// -/// Insertion order is deterministic for a given construction sequence, which -/// keeps serialisation order stable for content-addressed hashing. Entries are -/// serialised in insertion order, so two semantically-equal maps built in -/// different orders are not byte-identical (and are not `PartialEq`-equal). +/// The map retains insertion order internally, but [`Value`] serializes map +/// entries in key order and compares them by key so construction order does +/// not affect protocol bytes or semantic equality. pub type ValueMap = heapless::FnvIndexMap; /// A dynamically-typed value that can appear in an invocation argument list, @@ -75,18 +74,30 @@ pub enum Value { /// String-keyed mapping of values. A `Box` breaks the recursive /// `Value -> ValueMap -> Value` cycle: heapless maps are stored inline, so /// without indirection `Value` would have infinite size. The `ValueMap` is - /// an insertion-ordered fixed-capacity map, so serialisation order is - /// deterministic, which makes content-addressed hashing predictable. - Map(Box), + /// a fixed-capacity map. Serialization sorts entries by key to preserve + /// the canonical ordering previously provided by `BTreeMap`. + Map(#[serde(serialize_with = "serialize_value_map")] Box), +} + +fn serialize_value_map(map: &ValueMap, serializer: S) -> Result +where + S: Serializer, +{ + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_unstable_by_key(|(key, _)| *key); + let mut output = serializer.serialize_map(Some(entries.len()))?; + for (key, value) in entries { + output.serialize_entry(key, value)?; + } + output.end() } /// Equality for [`Value`]. /// /// Implemented manually because the fixed-capacity map backing `Map` only /// implements `PartialEq` when the value type is `Eq`, which `Value` cannot be -/// (it contains `f64`). Map equality is order-sensitive: two maps with the same -/// entries inserted in different orders are *not* equal, matching the byte-level -/// serialisation behaviour (see [`ValueMap`]). +/// (it contains `f64`). Map equality is key-based and independent of insertion +/// order. impl PartialEq for Value { fn eq(&self, other: &Self) -> bool { match (self, other) { @@ -101,8 +112,7 @@ impl PartialEq for Value { (Self::Map(a), Self::Map(b)) => { a.len() == b.len() && a.iter() - .zip(b.iter()) - .all(|((ka, va), (kb, vb))| ka == kb && va == vb) + .all(|(key, value)| b.get(key).is_some_and(|other| other == value)) } _ => false, } diff --git a/Build/crates/saikuro-core/tests/value.rs b/Build/crates/saikuro-core/tests/value.rs index 676bc781..cab92bb0 100644 --- a/Build/crates/saikuro-core/tests/value.rs +++ b/Build/crates/saikuro-core/tests/value.rs @@ -96,3 +96,22 @@ fn simple_map_round_trip() { let decoded: Value = msgpack::from_slice(&bytes).expect("deserialize"); assert_eq!(original, decoded); } + +#[test] +fn map_equality_and_encoding_ignore_insertion_order() { + let mut first = ValueMap::new(); + first.insert("b".to_owned(), Value::Int(2)).expect("fits"); + first.insert("a".to_owned(), Value::Int(1)).expect("fits"); + + let mut second = ValueMap::new(); + second.insert("a".to_owned(), Value::Int(1)).expect("fits"); + second.insert("b".to_owned(), Value::Int(2)).expect("fits"); + + let first = Value::Map(Box::new(first)); + let second = Value::Map(Box::new(second)); + assert_eq!(first, second); + assert_eq!( + msgpack::to_vec(&first).expect("serialize first"), + msgpack::to_vec(&second).expect("serialize second") + ); +} diff --git a/Build/crates/saikuro-exec/src/embassy_backend.rs b/Build/crates/saikuro-exec/src/embassy_backend.rs index 093bc481..ae24dbd4 100644 --- a/Build/crates/saikuro-exec/src/embassy_backend.rs +++ b/Build/crates/saikuro-exec/src/embassy_backend.rs @@ -21,13 +21,13 @@ //! //! # Task lifecycle //! -//! There's no `spawn` or `block_on` here. The embassy executor owns task +//! There is no `spawn` or `block_on` here. The embassy executor owns task //! scheduling: the application stands up a static `embassy_executor::Executor` -//! and hands out `Spawner`s. A facade can't conjure its own global executor -//! without clashing with the application's. The stubs are only here so that -//! host-only crates selecting `tokio-runtime` still resolve, call one and it -//! panics, pointing you at the embassy equivalent. `net`, `signal`, and -//! `runtime` are missing from the embassy model for the same reason. +//! and hands out `Spawner`s. A facade cannot create a global executor without +//! clashing with the application's. Tokio-style task and runtime APIs are not +//! exported for this backend, so unsupported shared code fails at compile time +//! instead of panicking on device. `net`, `signal`, and `runtime` are absent for +//! the same reason. use alloc::sync::Arc; use core::cell::RefCell; @@ -83,92 +83,6 @@ pub fn fuse_select(fut: F) -> Fuse { FutureExt::fuse(fut) } -// Spawn / Block-on -// See the module documentation: the application owns the executor and its -// Spawner, so the facade cannot provide a global spawn or block_on. -pub fn spawn(_fut: F) -> JoinHandle -where - F: Future + Send + 'static, - T: Send + 'static, -{ - panic!( - "saikuro-exec: embassy spawn requires a Spawner; \ - use embassy_executor::Spawner::spawn() directly" - ) -} - -pub fn block_on(_future: F) -> F::Output -where - F: Future, -{ - panic!( - "saikuro-exec: block_on is not available on embassy-runtime; \ - use embassy_executor::Executor instead" - ) -} - -// JoinHandle / Runtime stubs -pub struct JoinHandle { - _marker: core::marker::PhantomData, -} - -impl Future for JoinHandle { - type Output = Result; - - fn poll( - self: core::pin::Pin<&mut Self>, - _cx: &mut core::task::Context<'_>, - ) -> core::task::Poll { - unreachable!("saikuro-exec: JoinHandle::poll on embassy-runtime (spawn is not provided)") - } -} - -#[derive(Debug)] -pub struct JoinError; - -impl core::fmt::Display for JoinError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str("task was cancelled") - } -} - -pub struct Runtime { - _private: (), -} - -pub struct RuntimeBuilder { - _private: (), -} - -impl RuntimeBuilder { - pub fn enable_all(self) -> Self { - self - } - - pub fn build(self) -> Result { - Ok(Runtime { _private: () }) - } -} - -#[derive(Debug)] -pub struct RuntimeBuildError; - -impl core::fmt::Display for RuntimeBuildError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str("embassy runtime does not support tokio-style Builder") - } -} - -pub fn new_runtime() -> RuntimeBuilder { - RuntimeBuilder { _private: () } -} - -impl Runtime { - pub fn block_on(&self, _future: F) -> F::Output { - panic!("saikuro-exec: Runtime::block_on is not available on embassy-runtime") - } -} - // mpsc /// Bounded multi-producer, single-consumer channel. pub mod mpsc { @@ -1021,37 +935,3 @@ pub mod watch { (Sender { inner }, receiver) } } - -pub mod net { - // Empty. networking on embedded uses embassy-net, not tokio::net. -} - -pub mod runtime { - pub struct Builder { - _private: (), - } - - pub struct Runtime { - _private: (), - } - - impl Builder { - pub fn new_current_thread() -> Self { - Builder { _private: () } - } - - pub fn enable_all(self) -> Self { - self - } - - pub fn build(self) -> Result { - Ok(Runtime { _private: () }) - } - } - - impl Runtime { - pub fn block_on(&self, _future: F) -> F::Output { - panic!("saikuro-exec: runtime::Runtime::block_on is not available on embassy-runtime") - } - } -} diff --git a/Build/crates/saikuro-random/Cargo.toml b/Build/crates/saikuro-random/Cargo.toml index 43fa3a14..812e932f 100644 --- a/Build/crates/saikuro-random/Cargo.toml +++ b/Build/crates/saikuro-random/Cargo.toml @@ -8,8 +8,7 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "random", "rng"] -# The active randomness source is selected by exactly one of the `os`, `wasm`, -# or `custom` features (plus the `drbg` deterministic override) +# The active randomness source is selected by exactly one backend feature. [features] default = ["os"] os = ["dep:getrandom", "getrandom/std", "std"] diff --git a/Build/crates/saikuro-random/src/drbg.rs b/Build/crates/saikuro-random/src/drbg.rs index 70403e60..e177b401 100644 --- a/Build/crates/saikuro-random/src/drbg.rs +++ b/Build/crates/saikuro-random/src/drbg.rs @@ -30,6 +30,8 @@ const NONCE_LEN: usize = 24; const SEED_LEN: usize = KEY_LEN + NONCE_LEN; /// Global seed stored as `SEED_LEN / 8` independent `u64` words. const SEED_WORDS: usize = SEED_LEN / 8; +/// ChaCha20 exposes a 32-bit block counter for each key and nonce. +const MAX_BLOCKS: u64 = 1u64 << 32; /// Generate keystream block `index` for the given key and nonce. /// @@ -90,7 +92,12 @@ impl Drbg { pub fn fill(&mut self, dest: &mut [u8]) -> Result<(), crate::Error> { let blocks = dest.len().div_ceil(BLOCK_LEN); let start = self.counter; - self.counter = self.counter.saturating_add(blocks as u64); + let block_count = blocks as u64; + let end = start + .checked_add(block_count) + .filter(|&end| end <= MAX_BLOCKS) + .ok_or(crate::Error::DrbgExhausted)?; + self.counter = end; for i in 0..blocks { let block = keystream_block(&self.key, &self.nonce, start + i as u64)?; let from = i * BLOCK_LEN; @@ -114,6 +121,7 @@ impl Drbg { } static SEEDED: AtomicBool = AtomicBool::new(false); +static INITIALIZING: AtomicBool = AtomicBool::new(false); static COUNTER: AtomicU64 = AtomicU64::new(0); // Written out longhand on purpose: array-repeat of a non-Copy type wants inline // const blocks, and those need rustc >= 1.79 while our workspace floor is 1.75. @@ -138,6 +146,13 @@ pub fn seed_from_slice(seed: &[u8]) -> Result<(), crate::Error> { if seed.len() < SEED_LEN { return Err(crate::Error::InvalidSeed); } + if SEEDED.load(Ordering::Acquire) + || INITIALIZING + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + return Err(crate::Error::AlreadySeeded); + } for (i, word) in SEED.iter().enumerate() { let mut bytes = [0u8; 8]; bytes.copy_from_slice(&seed[i * 8..i * 8 + 8]); @@ -145,6 +160,7 @@ pub fn seed_from_slice(seed: &[u8]) -> Result<(), crate::Error> { } COUNTER.store(0, Ordering::Relaxed); SEEDED.store(true, Ordering::Release); + INITIALIZING.store(false, Ordering::Release); Ok(()) } @@ -176,7 +192,7 @@ pub fn fill(dest: &mut [u8]) -> Result<(), crate::Error> { } let (key, nonce) = read_seed(); let blocks = dest.len().div_ceil(BLOCK_LEN); - let start = COUNTER.fetch_add(blocks as u64, Ordering::Relaxed); + let start = reserve_blocks(blocks as u64)?; for i in 0..blocks { let block = keystream_block(&key, &nonce, start + i as u64)?; let from = i * BLOCK_LEN; @@ -186,6 +202,20 @@ pub fn fill(dest: &mut [u8]) -> Result<(), crate::Error> { Ok(()) } +fn reserve_blocks(blocks: u64) -> Result { + let mut current = COUNTER.load(Ordering::Relaxed); + loop { + let next = current + .checked_add(blocks) + .filter(|&next| next <= MAX_BLOCKS) + .ok_or(crate::Error::DrbgExhausted)?; + match COUNTER.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => return Ok(current), + Err(observed) => current = observed, + } + } +} + /// Fill potentially uninitialized `dest` from the process-wide DRBG. pub fn fill_uninit(dest: &mut [core::mem::MaybeUninit]) -> Result<(), crate::Error> { // SAFETY: `MaybeUninit` has no validity constraints, so writing diff --git a/Build/crates/saikuro-random/src/lib.rs b/Build/crates/saikuro-random/src/lib.rs index 023f510e..e671ac2e 100644 --- a/Build/crates/saikuro-random/src/lib.rs +++ b/Build/crates/saikuro-random/src/lib.rs @@ -29,6 +29,13 @@ compile_error!( build with `--no-default-features --features drbg`" ); +#[cfg(any( + all(feature = "os", feature = "wasm"), + all(feature = "os", feature = "custom"), + all(feature = "wasm", feature = "custom") +))] +compile_error!("saikuro-random: select exactly one of `os`, `wasm`, or `custom`"); + #[cfg(feature = "drbg")] mod drbg; @@ -57,6 +64,12 @@ pub enum Error { /// The DRBG keystream for the current seed ran out. #[cfg(feature = "drbg")] DrbgExhausted, + /// No entropy backend was selected. + #[cfg(not(any(feature = "os", feature = "wasm", feature = "custom", feature = "drbg")))] + NoBackend, + /// The process-wide DRBG was already initialized. + #[cfg(feature = "drbg")] + AlreadySeeded, } impl core::fmt::Display for Error { @@ -70,6 +83,15 @@ impl core::fmt::Display for Error { Error::InvalidSeed => write!(f, "DRBG seed must be at least 56 bytes"), #[cfg(feature = "drbg")] Error::DrbgExhausted => write!(f, "DRBG keystream exhausted; reseed required"), + #[cfg(feature = "drbg")] + Error::AlreadySeeded => write!(f, "DRBG has already been seeded"), + #[cfg(not(any( + feature = "os", + feature = "wasm", + feature = "custom", + feature = "drbg" + )))] + Error::NoBackend => write!(f, "no entropy backend selected"), } } } @@ -171,12 +193,12 @@ fn fill_uninit_impl(dest: &mut [MaybeUninit]) -> Result<(), Error> { .map(|_| ()) } -#[cfg(all( - not(feature = "os"), - not(feature = "wasm"), - not(feature = "custom"), - not(feature = "drbg") -))] -compile_error!( - "saikuro-random requires exactly one entropy backend: enable `os`, `wasm`, `custom`, or `drbg`" -); +#[cfg(not(any(feature = "os", feature = "wasm", feature = "custom", feature = "drbg")))] +fn fill_impl(_dest: &mut [u8]) -> Result<(), Error> { + Err(Error::NoBackend) +} + +#[cfg(not(any(feature = "os", feature = "wasm", feature = "custom", feature = "drbg")))] +fn fill_uninit_impl(_dest: &mut [MaybeUninit]) -> Result<(), Error> { + Err(Error::NoBackend) +} diff --git a/Build/crates/saikuro-random/tests/drbg.rs b/Build/crates/saikuro-random/tests/drbg.rs index ea6fa335..bc158041 100644 --- a/Build/crates/saikuro-random/tests/drbg.rs +++ b/Build/crates/saikuro-random/tests/drbg.rs @@ -6,7 +6,6 @@ use saikuro_random::{fill, is_seeded, seed_from_slice, Drbg, Error}; const SEED_LEN: usize = 56; const KEY_LEN: usize = 32; -const NONCE_LEN: usize = 24; const BLOCK_LEN: usize = 64; const SEED_ONE: [u8; SEED_LEN] = [ @@ -71,6 +70,7 @@ fn short_seed_is_rejected() { #[test] fn global_stream_matches_a_seeded_local_drbg_and_advances() { seed_from_slice(&SEED_ONE).expect("valid seed"); + assert_eq!(seed_from_slice(&SEED_ONE), Err(Error::AlreadySeeded)); assert!(is_seeded()); let mut first = [0u8; 32]; diff --git a/Build/crates/saikuro-router/src/router.rs b/Build/crates/saikuro-router/src/router.rs index c4e46dee..fb2b34b9 100644 --- a/Build/crates/saikuro-router/src/router.rs +++ b/Build/crates/saikuro-router/src/router.rs @@ -228,6 +228,10 @@ impl InvocationRouter { async fn dispatch_stream_open(&self, envelope: Envelope) -> ResponseEnvelope { let id = envelope.id; + if !valid_channel_capacity(self.config.stream_channel_capacity) { + return error_response(id, SaikuroError::BufferOverflow.into()); + } + let provider = match self.resolve_namespace(&envelope.target) { Ok(p) => p, Err(e) => return error_response(id, e.into()), @@ -290,6 +294,10 @@ impl InvocationRouter { } // Otherwise, open a new channel as before + if !valid_channel_capacity(self.config.channel_capacity) { + return error_response(id, SaikuroError::BufferOverflow.into()); + } + let provider = match self.resolve_namespace(&envelope.target) { Ok(p) => p, Err(e) => return error_response(id, e.into()), @@ -507,6 +515,17 @@ impl InvocationRouter { } } +fn valid_channel_capacity(capacity: usize) -> bool { + if capacity == 0 { + return false; + } + #[cfg(feature = "embassy")] + if capacity > saikuro_exec::mpsc::CHANNEL_CAPACITY { + return false; + } + true +} + // Helpers fn namespace_of(target: &str) -> Option<&str> { diff --git a/Build/crates/saikuro-router/src/stream_state.rs b/Build/crates/saikuro-router/src/stream_state.rs index 3dd17dc9..3a06fdff 100644 --- a/Build/crates/saikuro-router/src/stream_state.rs +++ b/Build/crates/saikuro-router/src/stream_state.rs @@ -135,18 +135,19 @@ impl ChannelState { /// `Ord`, so `BTreeMap` keys keep iteration deterministic. #[derive(Clone, Default)] pub struct StreamStateStore { - streams: Arc>>>, - /// Receivers for stream item channels. Stored here so the channel stays - /// live (i.e. `item_tx.send()` does not fail with "channel closed") until - /// a caller explicitly takes and consumes the receiver. - stream_receivers: Arc>>>, - channels: Arc>>>, - /// Receivers for channel inbound messages. - channel_inbound_receivers: - Arc>>>, - /// Receivers for channel outbound messages. - channel_outbound_receivers: - Arc>>>, + streams: Arc>>, + channels: Arc>>, +} + +struct StreamEntry { + state: Arc, + receiver: Option>, +} + +struct ChannelEntry { + state: Arc, + inbound_receiver: Option>, + outbound_receiver: Option>, } impl StreamStateStore { @@ -166,17 +167,21 @@ impl StreamStateStore { state: Arc, receiver: mpsc::Receiver, ) { - self.streams.write().insert(id, state); - self.stream_receivers.write().insert(id, receiver); + self.streams.write().insert( + id, + StreamEntry { + state, + receiver: Some(receiver), + }, + ); } pub fn get_stream(&self, id: &InvocationId) -> Option> { - self.streams.read().get(id).cloned() + self.streams.read().get(id).map(|entry| entry.state.clone()) } pub fn remove_stream(&self, id: &InvocationId) -> Option> { - self.stream_receivers.write().remove(id); - self.streams.write().remove(id) + self.streams.write().remove(id).map(|entry| entry.state) } /// Take the receiver half of the stream item channel. @@ -188,7 +193,10 @@ impl StreamStateStore { &self, id: &InvocationId, ) -> Option> { - self.stream_receivers.write().remove(id) + self.streams + .write() + .get_mut(id) + .and_then(|entry| entry.receiver.take()) } // Channel @@ -200,23 +208,25 @@ impl StreamStateStore { inbound_rx: mpsc::Receiver, outbound_rx: mpsc::Receiver, ) { - self.channels.write().insert(id, state); - self.channel_inbound_receivers - .write() - .insert(id, inbound_rx); - self.channel_outbound_receivers - .write() - .insert(id, outbound_rx); + self.channels.write().insert( + id, + ChannelEntry { + state, + inbound_receiver: Some(inbound_rx), + outbound_receiver: Some(outbound_rx), + }, + ); } pub fn get_channel(&self, id: &InvocationId) -> Option> { - self.channels.read().get(id).cloned() + self.channels + .read() + .get(id) + .map(|entry| entry.state.clone()) } pub fn remove_channel(&self, id: &InvocationId) -> Option> { - self.channel_inbound_receivers.write().remove(id); - self.channel_outbound_receivers.write().remove(id); - self.channels.write().remove(id) + self.channels.write().remove(id).map(|entry| entry.state) } /// Take the inbound receiver (client -> provider) for a channel. @@ -224,7 +234,10 @@ impl StreamStateStore { &self, id: &InvocationId, ) -> Option> { - self.channel_inbound_receivers.write().remove(id) + self.channels + .write() + .get_mut(id) + .and_then(|entry| entry.inbound_receiver.take()) } /// Take the outbound receiver (provider -> client) for a channel. @@ -232,6 +245,9 @@ impl StreamStateStore { &self, id: &InvocationId, ) -> Option> { - self.channel_outbound_receivers.write().remove(id) + self.channels + .write() + .get_mut(id) + .and_then(|entry| entry.outbound_receiver.take()) } } diff --git a/Build/crates/saikuro-runtime/Cargo.toml b/Build/crates/saikuro-runtime/Cargo.toml index 82c5e870..ddbed6eb 100644 --- a/Build/crates/saikuro-runtime/Cargo.toml +++ b/Build/crates/saikuro-runtime/Cargo.toml @@ -16,16 +16,20 @@ required-features = ["native-transport"] [features] default = ["native-transport"] native-transport = ["saikuro-transport/native-transport", "saikuro-exec/tokio-runtime"] -ws-transport = ["saikuro-transport/ws-transport", "dep:tokio-tungstenite", "dep:tungstenite"] -wasm-runtime = ["saikuro-exec/wasm-runtime", "saikuro-random/wasm"] +ws-transport = ["saikuro-transport/native-ws", "dep:tokio-tungstenite", "dep:tungstenite"] +wasm-runtime = [ + "saikuro-transport/wasm-runtime", + "saikuro-exec/wasm-runtime", + "saikuro-random/wasm", +] [dependencies] -saikuro-core = { workspace = true } +saikuro-core = { path = "../saikuro-core", default-features = false } saikuro-schema = { workspace = true } saikuro-transport = { workspace = true } saikuro-router = { workspace = true } saikuro-exec = { workspace = true, default-features = false } -saikuro-random = { workspace = true } +saikuro-random = { path = "../saikuro-random", default-features = false } serde = { workspace = true } serde_json = { workspace = true } diff --git a/Build/crates/saikuro-schema/src/registry.rs b/Build/crates/saikuro-schema/src/registry.rs index 602dc545..c3b69a5e 100644 --- a/Build/crates/saikuro-schema/src/registry.rs +++ b/Build/crates/saikuro-schema/src/registry.rs @@ -16,7 +16,10 @@ //! `BTreeMap`s for deterministic iteration on both host and MCU targets. use alloc::{borrow::ToOwned, collections::BTreeMap, string::String, sync::Arc, vec::Vec}; -use saikuro_core::schema::{FunctionSchema, NamespaceSchema, Schema, TypeDefinition}; +use saikuro_core::schema::{ + FunctionSchema, NamespaceSchema, Schema, TypeDefinition, SCHEMA_NAMESPACES_CAPACITY, + SCHEMA_TYPES_CAPACITY, +}; use saikuro_core::sync::RwLock; use tracing::{debug, info, warn}; @@ -124,6 +127,11 @@ impl SchemaRegistry { } let ns = registration.namespace.clone(); + if !schemata.namespaces.contains_key(&ns) + && schemata.namespaces.len() == SCHEMA_NAMESPACES_CAPACITY + { + return Err(RegistryError::SchemaCapacity); + } if schemata.namespaces.contains_key(&ns) { warn!(namespace = %ns, "overwriting existing namespace schema"); } else { @@ -155,13 +163,27 @@ impl SchemaRegistry { // `freeze()` cannot interleave between the type and namespace phases. let mut schemata = self.inner.write(); - // In production mode only namespace registration is forbidden; an empty - // namespace list is therefore a no-op merge (types alone are permitted). - if schemata.mode == RegistryMode::Production && !schema.namespaces.is_empty() { + if schemata.mode == RegistryMode::Production { let ns = schema.namespaces.keys().next().cloned().unwrap_or_default(); return Err(RegistryError::FrozenSchema(ns)); } + let new_namespaces = schema + .namespaces + .keys() + .filter(|name| !schemata.namespaces.contains_key(*name)) + .count(); + let new_types = schema + .types + .keys() + .filter(|name| !schemata.types.contains_key(*name)) + .count(); + if schemata.namespaces.len() + new_namespaces > SCHEMA_NAMESPACES_CAPACITY + || schemata.types.len() + new_types > SCHEMA_TYPES_CAPACITY + { + return Err(RegistryError::SchemaCapacity); + } + // Merge types first (functions may reference them). for (name, typedef) in (*schema.types).into_iter() { schemata.types.insert(name, typedef); @@ -189,6 +211,9 @@ impl SchemaRegistry { /// Called when a provider disconnects. pub fn deregister_provider(&self, provider_id: &str) { let mut schemata = self.inner.write(); + if schemata.mode == RegistryMode::Production { + return; + } schemata.namespaces.retain(|_ns, entry| { let keep = entry.provider_id != provider_id; if !keep { @@ -316,7 +341,7 @@ pub enum RegistryError { #[error("validation error: {0}")] Validation(#[from] ValidationError), - #[error("schema capacity exceeded while exporting snapshot")] + #[error("schema registry capacity exceeded")] SchemaCapacity, } diff --git a/Build/crates/saikuro-schema/tests/registry.rs b/Build/crates/saikuro-schema/tests/registry.rs new file mode 100644 index 00000000..ab86c841 --- /dev/null +++ b/Build/crates/saikuro-schema/tests/registry.rs @@ -0,0 +1,23 @@ +use saikuro_core::schema::{PrimitiveType, Schema, TypeDefinition, TypeDescriptor}; +use saikuro_schema::registry::{RegistryError, SchemaRegistry}; + +#[test] +fn frozen_registry_rejects_type_only_merge() { + let registry = SchemaRegistry::from_frozen_schema(Schema::new()); + let mut update = Schema::new(); + update + .types + .insert( + "UserId".into(), + TypeDefinition::Alias { + inner: TypeDescriptor::primitive(PrimitiveType::String), + }, + ) + .expect("type fits"); + + assert!(matches!( + registry.merge_schema(update, "provider"), + Err(RegistryError::FrozenSchema(_)) + )); + assert!(registry.snapshot().expect("snapshot").types.is_empty()); +} diff --git a/Build/crates/saikuro-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index caca80e7..fbb50b9e 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -10,7 +10,9 @@ keywords = ["ipc", "cross-language", "saikuro", "storage", "key-value"] [features] default = ["std", "native-storage"] -std = [] +std = ["saikuro-core/std"] +custom = ["saikuro-core/custom"] +drbg = ["saikuro-core/drbg"] native-storage = [ "std", "inmemory", @@ -38,16 +40,15 @@ wasm-storage = [ fs-access = ["wasm-storage"] [dependencies] -saikuro-core = { workspace = true } -saikuro-exec = { workspace = true, default-features = false } +saikuro-core = { path = "../saikuro-core", default-features = false } serde = { workspace = true } serde_json = { workspace = true, features = ["alloc"] } -bytes = { workspace = true } +bytes = { version = "1.7", default-features = false } async-trait = { workspace = true } futures = { workspace = true } thiserror = { workspace = true } -tracing = { workspace = true } +tracing = { version = "0.1", default-features = false } dashmap = { workspace = true, optional = true } tokio = { version = "1.52.3", features = ["rt"], optional = true } @@ -90,4 +91,5 @@ web-sys = { version = "0.3.99", optional = true, features = [ ] } [dev-dependencies] +saikuro-exec = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index d6578a35..ebd0ccce 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -25,7 +25,7 @@ keywords = ["ipc", "cross-language", "saikuro", "transport", "async"] # The in-memory transport is always compiled; it has zero OS dependencies. [features] default = ["std", "native-transport"] -std = ["saikuro-core/std-no-os"] +std = [] embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/drbg"] # native features also forward core/std so native builds keep the OS entropy # backend that core/std selects; only the crate-level lifter (std) switches to @@ -34,7 +34,12 @@ native-transport = ["std", "saikuro-exec/tokio-runtime", "saikuro-core/std"] ws-transport = [] # wasm32 has no OS entropy source; the js getrandom backend is forwarded here # (matching saikuro-runtime's own wasm-runtime feature). -wasm-runtime = ["std", "saikuro-exec/wasm-runtime", "saikuro-random/wasm"] +wasm-runtime = [ + "std", + "saikuro-core/std-no-os", + "saikuro-exec/wasm-runtime", + "saikuro-random/wasm", +] # native-ws is only available on non-wasm32 (where tokio-tungstenite exists) native-ws = ["ws-transport", "std", "saikuro-exec/tokio-runtime", "saikuro-core/std", "tokio-tungstenite", "tungstenite"] diff --git a/Build/crates/saikuro-transport/src/framing.rs b/Build/crates/saikuro-transport/src/framing.rs index 50045e3c..7999f709 100644 --- a/Build/crates/saikuro-transport/src/framing.rs +++ b/Build/crates/saikuro-transport/src/framing.rs @@ -38,6 +38,12 @@ impl LengthPrefixedCodec { Self::default() } + /// Return whether decoding has consumed a header and still expects bytes. + #[cfg(feature = "native-transport")] + pub(crate) fn has_pending_frame(&self) -> bool { + self.pending_len.is_some() || self.discard_remaining != 0 + } + /// Decode the next complete frame from `src`, returning `Ok(None)` until a /// full frame is buffered. Consumes the header and payload from the front /// of `src` when a frame is returned. A header over the size limit yields @@ -231,7 +237,7 @@ pub mod framed { if filled == 0 { // EOF from the peer. A clean close happens only at a // frame boundary; leftover bytes mean a truncated frame. - if this.read_buf.is_empty() { + if this.read_buf.is_empty() && !this.codec.has_pending_frame() { return Poll::Ready(None); } *this.failed = true; diff --git a/Build/tests/Cargo.toml b/Build/tests/Cargo.toml index f0834f2f..08132049 100644 --- a/Build/tests/Cargo.toml +++ b/Build/tests/Cargo.toml @@ -28,11 +28,13 @@ tracing-subscriber = { workspace = true } # Native-only: enable TCP/Unix transport backends [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +saikuro-core = { workspace = true, features = ["std"] } saikuro-transport = { workspace = true, features = ["native-transport"] } saikuro-runtime = { workspace = true, features = ["native-transport"] } # WASM-only: BroadcastChannel-based host transport, no native-transport [target.'cfg(target_arch = "wasm32")'.dependencies] +saikuro-core = { workspace = true, features = ["std-no-os"] } saikuro-transport = { workspace = true, features = ["wasm-runtime"] } saikuro-runtime = { workspace = true, default-features = false } # WASM has no OS entropy; saikuro-core draws randomness via saikuro-random, diff --git a/Build/tests/tests/transport_framing.rs b/Build/tests/tests/transport_framing.rs index 7d533a74..1da8c2cf 100644 --- a/Build/tests/tests/transport_framing.rs +++ b/Build/tests/tests/transport_framing.rs @@ -159,6 +159,27 @@ fn framed_stream_truncated_frame_errors() { }) } +#[test] +fn framed_stream_rejects_header_only_eof() { + block_on(async { + let (client, server) = saikuro_exec::io::duplex(4096); + let (_rx, mut tx) = saikuro_exec::io::split(client); + let mut framed_server = FramedStream::new(server); + + let mut header = BytesMut::new(); + header.put_u32(100); + tx.write_all(&header).await.expect("write"); + tx.shutdown().await.expect("shutdown"); + drop(tx); + + match framed_server.next().await { + Some(Err(TransportError::FramingError(_))) => {} + other => panic!("expected FramingError, got {other:?}"), + } + assert!(framed_server.next().await.is_none()); + }) +} + #[test] fn framed_stream_stays_terminal_after_oversized_frame_error() { block_on(async { From 25012f37e38ddb041cb52df8c8ec47d74a0ec1ee Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Tue, 11 Aug 2026 01:13:16 -0600 Subject: [PATCH 10/43] Holy tedium serves me right for writing bad code before :/ --- Build/Cargo.lock | 157 ++----------- Build/adapters/c/tests/c_api_protocol.rs | 3 +- Build/adapters/c/tests/cpp_wrapper_runtime.rs | 3 +- Build/adapters/rust/src/client.rs | 29 +-- Build/adapters/rust/src/error.rs | 4 + Build/adapters/rust/src/provider.rs | 2 +- Build/adapters/rust/tests/integration.rs | 5 +- Build/crates/saikuro-core/Cargo.toml | 1 + Build/crates/saikuro-core/src/envelope.rs | 72 +++--- Build/crates/saikuro-core/src/invocation.rs | 21 +- Build/crates/saikuro-core/src/lib.rs | 2 + Build/crates/saikuro-core/src/registration.rs | 33 +++ Build/crates/saikuro-core/tests/invocation.rs | 2 +- Build/crates/saikuro-exec/src/capacity.rs | 73 +++++++ .../saikuro-exec/src/embassy_backend.rs | 27 +-- Build/crates/saikuro-exec/src/lib.rs | 3 + .../crates/saikuro-exec/src/tokio_backend.rs | 8 +- Build/crates/saikuro-exec/src/wasm_backend.rs | 5 +- Build/crates/saikuro-router/Cargo.toml | 3 - Build/crates/saikuro-router/src/provider.rs | 48 ++-- Build/crates/saikuro-router/src/router.rs | 163 +++++--------- .../crates/saikuro-router/src/stream_state.rs | 206 +++++++++--------- .../saikuro-router/tests/provider_registry.rs | 59 ++++- Build/crates/saikuro-runtime/Cargo.toml | 6 +- Build/crates/saikuro-runtime/src/config.rs | 33 ++- .../crates/saikuro-runtime/src/connection.rs | 80 +++++-- Build/crates/saikuro-runtime/src/error.rs | 3 + Build/crates/saikuro-runtime/src/handle.rs | 53 ++++- .../saikuro-runtime/tests/config_capacity.rs | 37 ++++ .../tests/schema_registration.rs | 77 +++++++ Build/crates/saikuro-schema/src/registry.rs | 28 ++- Build/crates/saikuro-schema/tests/registry.rs | 35 +++ .../crates/saikuro-schema/tests/validator.rs | 2 +- Build/crates/saikuro-transport/Cargo.toml | 3 +- Build/crates/saikuro-transport/src/lib.rs | 3 +- .../crates/saikuro-transport/src/wasm_host.rs | 6 +- Build/tests/tests/announce_dispatch.rs | 9 +- Build/tests/tests/batch_dispatch.rs | 35 +-- Build/tests/tests/call_dispatch.rs | 27 ++- Build/tests/tests/channel_dispatch.rs | 80 +++++-- Build/tests/tests/common/mod.rs | 9 +- Build/tests/tests/cross_language_wire.rs | 51 +++-- Build/tests/tests/envelope_roundtrip.rs | 37 ++-- Build/tests/tests/error_propagation.rs | 10 +- Build/tests/tests/exec_channels.rs | 40 +++- Build/tests/tests/exec_select.rs | 38 ++-- Build/tests/tests/log_dispatch.rs | 16 +- Build/tests/tests/resource_dispatch.rs | 28 ++- Build/tests/tests/sandbox_dispatch.rs | 5 +- Build/tests/tests/schema_validation.rs | 26 ++- Build/tests/tests/stream_dispatch.rs | 61 +++++- 51 files changed, 1087 insertions(+), 680 deletions(-) create mode 100644 Build/crates/saikuro-core/src/registration.rs create mode 100644 Build/crates/saikuro-exec/src/capacity.rs create mode 100644 Build/crates/saikuro-runtime/tests/config_capacity.rs diff --git a/Build/Cargo.lock b/Build/Cargo.lock index 2a6c5204..1a131ba1 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -120,15 +120,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - [[package]] name = "bs58" version = "0.5.1" @@ -186,18 +177,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "cpufeatures", ] [[package]] @@ -218,7 +198,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "inout", ] @@ -268,12 +248,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -289,15 +263,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -338,15 +303,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - [[package]] name = "darling" version = "0.23.0" @@ -417,19 +373,8 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", + "block-buffer", + "crypto-common", ] [[package]] @@ -732,23 +677,11 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 5.3.0", + "r-efi", "wasip2", "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", -] - [[package]] name = "hash32" version = "0.3.1" @@ -836,15 +769,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "hybrid-array" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" -dependencies = [ - "typenum", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1221,12 +1145,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - [[package]] name = "rand" version = "0.8.6" @@ -1235,18 +1153,7 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20 0.10.1", - "getrandom 0.4.3", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -1256,7 +1163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -1268,12 +1175,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "redox_syscall" version = "0.2.16" @@ -1449,6 +1350,7 @@ version = "0.1.0" dependencies = [ "heapless", "messagepack-serde", + "portable-atomic", "saikuro-random", "serde", "serde_bytes", @@ -1477,7 +1379,7 @@ dependencies = [ name = "saikuro-random" version = "0.1.0" dependencies = [ - "chacha20 0.9.1", + "chacha20", "getrandom 0.3.4", "portable-atomic", "uuid", @@ -1488,7 +1390,6 @@ name = "saikuro-router" version = "0.1.0" dependencies = [ "async-trait", - "portable-atomic", "saikuro-core", "saikuro-exec", "saikuro-schema", @@ -1518,10 +1419,8 @@ dependencies = [ "serde_json", "serde_with", "thiserror 2.0.18", - "tokio-tungstenite", "tracing", "tracing-subscriber", - "tungstenite 0.30.0", ] [[package]] @@ -1602,7 +1501,6 @@ dependencies = [ "tokio-tungstenite", "tracing", "tracing-subscriber", - "tungstenite 0.30.0", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -1745,19 +1643,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -2020,7 +1907,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.24.0", + "tungstenite", ] [[package]] @@ -2122,28 +2009,12 @@ dependencies = [ "http", "httparse", "log", - "rand 0.8.6", - "sha1 0.10.6", + "rand", + "sha1", "thiserror 1.0.69", "utf-8", ] -[[package]] -name = "tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.10.2", - "sha1 0.11.0", - "thiserror 2.0.18", -] - [[package]] name = "typenum" version = "1.20.1" diff --git a/Build/adapters/c/tests/c_api_protocol.rs b/Build/adapters/c/tests/c_api_protocol.rs index 89d27f32..9ef54a09 100644 --- a/Build/adapters/c/tests/c_api_protocol.rs +++ b/Build/adapters/c/tests/c_api_protocol.rs @@ -321,7 +321,8 @@ fn spawn_scripted_server_for_provider() -> (String, thread::JoinHandle (String, thread::JoinHandle, ) -> Result { let target = target.into(); - let envelope = make_envelope(InvocationType::Call, &target, args, None); + let envelope = make_envelope(InvocationType::Call, &target, args, None)?; let id = envelope.id; let (tx, rx) = oneshot::channel(); @@ -370,7 +371,7 @@ impl Client { /// Fire-and-forget invocation. No response is expected. pub async fn cast(&self, target: impl Into, args: Vec) -> Result<()> { let target = target.into(); - let envelope = make_envelope(InvocationType::Cast, &target, args, None); + let envelope = make_envelope(InvocationType::Cast, &target, args, None)?; self.send_envelope(&envelope).await } @@ -383,7 +384,7 @@ impl Client { args: Vec, ) -> Result { let target = target.into(); - let envelope = make_envelope(InvocationType::Stream, &target, args, None); + let envelope = make_envelope(InvocationType::Stream, &target, args, None)?; let id = envelope.id; let (tx, rx) = mpsc::channel(STREAM_CHANNEL_CAPACITY); @@ -405,12 +406,12 @@ impl Client { let batch_items: Vec = calls .into_iter() .map(|(target, args)| make_envelope(InvocationType::Call, &target, args, None)) - .collect(); + .collect::>()?; let batch_env = Envelope { version: PROTOCOL_VERSION, invocation_type: InvocationType::Batch, - id: InvocationId::new(), + id: InvocationId::new()?, target: "$batch".into(), args: vec![], meta: Default::default(), @@ -450,7 +451,7 @@ impl Client { args: Vec, ) -> Result { let target = target.into(); - let envelope = make_envelope(InvocationType::Channel, &target, args, None); + let envelope = make_envelope(InvocationType::Channel, &target, args, None)?; let id = envelope.id; let (tx, rx) = mpsc::channel(STREAM_CHANNEL_CAPACITY); @@ -469,7 +470,7 @@ impl Client { /// Invoke a resource-producing function and return the resource payload. pub async fn resource(&self, target: impl Into, args: Vec) -> Result { let target = target.into(); - let envelope = make_envelope(InvocationType::Resource, &target, args, None); + let envelope = make_envelope(InvocationType::Resource, &target, args, None)?; let id = envelope.id; let (tx, rx) = oneshot::channel(); @@ -510,7 +511,7 @@ impl Client { "$log", vec![Value::Object(record)], None, - ); + )?; self.send_envelope(&envelope).await } @@ -730,15 +731,15 @@ fn make_envelope( target: &str, args: Vec, capability: Option, -) -> Envelope { - make_envelope_with_id( - InvocationId::new(), +) -> Result { + Ok(make_envelope_with_id( + InvocationId::new()?, inv_type, target, args, capability, None, - ) + )) } fn make_envelope_with_id( diff --git a/Build/adapters/rust/src/error.rs b/Build/adapters/rust/src/error.rs index 508190a1..11ac8f5d 100644 --- a/Build/adapters/rust/src/error.rs +++ b/Build/adapters/rust/src/error.rs @@ -46,6 +46,10 @@ pub enum Error { /// The provider's schema exceeds the fixed capacity of the core schema maps. #[error("schema capacity exceeded while building the announcement")] SchemaCapacityExceeded, + + /// The configured entropy source could not generate an invocation ID. + #[error("entropy error: {0}")] + Entropy(#[from] saikuro_random::Error), } impl Error { diff --git a/Build/adapters/rust/src/provider.rs b/Build/adapters/rust/src/provider.rs index 38a81a49..4bccd74e 100644 --- a/Build/adapters/rust/src/provider.rs +++ b/Build/adapters/rust/src/provider.rs @@ -220,7 +220,7 @@ impl Provider { } }; - let announce_env = Envelope::announce(schema_value); + let announce_env = Envelope::announce(schema_value)?; let frame = match announce_env.to_msgpack() { Ok(b) => Bytes::from(b), Err(e) => { diff --git a/Build/adapters/rust/tests/integration.rs b/Build/adapters/rust/tests/integration.rs index e811cc78..3b08584f 100644 --- a/Build/adapters/rust/tests/integration.rs +++ b/Build/adapters/rust/tests/integration.rs @@ -549,7 +549,7 @@ fn client_acknowledges_announce_on_connect() { let (client_side, mut runtime_side) = InMemoryTransport::pair(); - let announce_id = saikuro_core::invocation::InvocationId::new(); + let announce_id = saikuro_core::invocation::InvocationId::new().expect("entropy available"); let announce = Envelope { version: saikuro_core::PROTOCOL_VERSION, invocation_type: InvocationType::Announce, @@ -594,7 +594,8 @@ fn envelope_roundtrip_msgpack_preserves_fields() { saikuro_core::value::Value::Int(1), saikuro_core::value::Value::Int(2), ], - ); + ) + .expect("entropy available"); let bytes = original.to_msgpack().expect("encode envelope"); let decoded = Envelope::from_msgpack(&bytes).expect("decode envelope"); diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index dc891f07..c0b6b6ce 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -35,3 +35,4 @@ strum = { version = "0.28", default-features = false, features = ["derive"] } heapless = { workspace = true } spin = { workspace = true } messagepack-serde = { workspace = true } +portable-atomic = { workspace = true } diff --git a/Build/crates/saikuro-core/src/envelope.rs b/Build/crates/saikuro-core/src/envelope.rs index f4b53b2f..06b772e0 100644 --- a/Build/crates/saikuro-core/src/envelope.rs +++ b/Build/crates/saikuro-core/src/envelope.rs @@ -5,7 +5,7 @@ //! serialised to binary using MessagePack via `crate::msgpack` before transit; //! the types here are the canonical in-memory representation. -use alloc::{borrow::ToOwned, string::String, vec::Vec}; +use alloc::{string::String, vec::Vec}; use serde::{ ser::{SerializeMap, Serializer}, Deserialize, Serialize, @@ -168,11 +168,14 @@ impl_msgpack!(ResponseEnvelope); impl Envelope { /// Construct the simplest possible call envelope. - pub fn call(target: impl Into, args: Vec) -> Self { - Self { + pub fn call( + target: impl Into, + args: Vec, + ) -> Result { + Ok(Self { version: PROTOCOL_VERSION, invocation_type: InvocationType::Call, - id: InvocationId::new(), + id: InvocationId::new()?, target: target.into(), args, meta: MetaMap::new(), @@ -180,44 +183,47 @@ impl Envelope { batch_items: None, stream_control: None, seq: None, - } + }) } /// Construct a fire-and-forget cast envelope. - pub fn cast(target: impl Into, args: Vec) -> Self { - Self { - invocation_type: InvocationType::Cast, - ..Self::call(target, args) - } + pub fn cast( + target: impl Into, + args: Vec, + ) -> Result { + let mut envelope = Self::call(target, args)?; + envelope.invocation_type = InvocationType::Cast; + Ok(envelope) } /// Construct the initial envelope that opens a stream. - pub fn stream_open(target: impl Into, args: Vec) -> Self { - Self { - invocation_type: InvocationType::Stream, - ..Self::call(target, args) - } + pub fn stream_open( + target: impl Into, + args: Vec, + ) -> Result { + let mut envelope = Self::call(target, args)?; + envelope.invocation_type = InvocationType::Stream; + Ok(envelope) } /// Construct the initial envelope that opens a bidirectional channel. - pub fn channel_open(target: impl Into, args: Vec) -> Self { - Self { - invocation_type: InvocationType::Channel, - ..Self::call(target, args) - } + pub fn channel_open( + target: impl Into, + args: Vec, + ) -> Result { + let mut envelope = Self::call(target, args)?; + envelope.invocation_type = InvocationType::Channel; + Ok(envelope) } /// Construct a schema-announcement envelope. /// /// `schema_bytes` is the MessagePack-encoded [`Schema`](crate::schema::Schema) /// stored as a raw `Bytes` value in `args[0]`. - pub fn announce(schema_value: Value) -> Self { - Self { - invocation_type: InvocationType::Announce, - target: "$saikuro.announce".to_owned(), - args: vec![schema_value], - ..Self::call("$saikuro.announce", vec![]) - } + pub fn announce(schema_value: Value) -> Result { + let mut envelope = Self::call("$saikuro.announce", vec![schema_value])?; + envelope.invocation_type = InvocationType::Announce; + Ok(envelope) } /// Construct a resource-access envelope. @@ -225,11 +231,13 @@ impl Envelope { /// `target` is the provider function that manages the resource. /// `args` are provider-specific arguments that identify or parameterise /// the resource request (e.g. a resource ID, byte range, or query). - pub fn resource(target: impl Into, args: Vec) -> Self { - Self { - invocation_type: InvocationType::Resource, - ..Self::call(target, args) - } + pub fn resource( + target: impl Into, + args: Vec, + ) -> Result { + let mut envelope = Self::call(target, args)?; + envelope.invocation_type = InvocationType::Resource; + Ok(envelope) } /// Return the namespace portion of `target` (everything before the last `.`). diff --git a/Build/crates/saikuro-core/src/invocation.rs b/Build/crates/saikuro-core/src/invocation.rs index 2e385254..09f7f582 100644 --- a/Build/crates/saikuro-core/src/invocation.rs +++ b/Build/crates/saikuro-core/src/invocation.rs @@ -73,21 +73,12 @@ impl<'de> Deserialize<'de> for InvocationId { } impl InvocationId { - /// Try to generate a fresh invocation identifier from the active entropy - /// backend. - #[inline] - pub fn try_new() -> Result { - saikuro_random::uuid_v4().map(Self) - } - /// Generate a fresh, globally-unique invocation identifier. /// - /// Panics when the configured entropy backend is unavailable. Embedded - /// startup code should seed its DRBG first or use [`Self::try_new`] to - /// propagate initialization failures. + /// Returns an error when the configured entropy backend is unavailable. #[inline] - pub fn new() -> Self { - Self::try_new().expect("entropy backend unavailable") + pub fn new() -> Result { + saikuro_random::uuid_v4().map(Self) } /// Construct from an existing UUID. @@ -109,12 +100,6 @@ impl InvocationId { } } -impl Default for InvocationId { - fn default() -> Self { - Self::new() - } -} - impl fmt::Display for InvocationId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) diff --git a/Build/crates/saikuro-core/src/lib.rs b/Build/crates/saikuro-core/src/lib.rs index da63d074..a07d2638 100644 --- a/Build/crates/saikuro-core/src/lib.rs +++ b/Build/crates/saikuro-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod error; pub mod invocation; pub mod log; pub mod msgpack; +pub mod registration; pub mod resource; pub mod schema; pub mod sync; @@ -36,6 +37,7 @@ pub use invocation::InvocationId; #[cfg(any(feature = "std", feature = "std-no-os"))] pub use log::stderr_log_sink; pub use log::{LogLevel, LogRecord, LogSink}; +pub use registration::RegistrationToken; pub use resource::ResourceHandle; pub use value::Value; diff --git a/Build/crates/saikuro-core/src/registration.rs b/Build/crates/saikuro-core/src/registration.rs new file mode 100644 index 00000000..5064688f --- /dev/null +++ b/Build/crates/saikuro-core/src/registration.rs @@ -0,0 +1,33 @@ +//! Process-unique provider registration identity. + +use portable_atomic::{AtomicU64, Ordering}; + +static NEXT_REGISTRATION_TOKEN: AtomicU64 = AtomicU64::new(1); + +/// Opaque, monotonically increasing identity for one provider registration. +/// +/// A token distinguishes successive connections that use the same provider ID. +/// Tokens are unique for the lifetime of the process and are not wire values. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RegistrationToken(u64); + +impl RegistrationToken { + /// Allocate the next process-unique registration token. + /// + /// Panics if all `u64` token values have been exhausted. The counter does + /// not wrap, so a token is never reused within a process. + pub fn new() -> Self { + let value = NEXT_REGISTRATION_TOKEN + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .expect("registration token space exhausted without wrapping"); + Self(value) + } +} + +impl Default for RegistrationToken { + fn default() -> Self { + Self::new() + } +} diff --git a/Build/crates/saikuro-core/tests/invocation.rs b/Build/crates/saikuro-core/tests/invocation.rs index b360b203..3da7a269 100644 --- a/Build/crates/saikuro-core/tests/invocation.rs +++ b/Build/crates/saikuro-core/tests/invocation.rs @@ -3,7 +3,7 @@ use saikuro_core::InvocationId; #[test] fn msgpack_roundtrip_uses_binary_uuid() { - let id = InvocationId::new(); + let id = InvocationId::new().expect("entropy available"); let encoded = msgpack::to_vec(&id).expect("encode invocation id"); let decoded: InvocationId = msgpack::from_slice(&encoded).expect("decode invocation id"); // The wire form must be msgpack bin8: 0xC4 marker, one length byte of 16, diff --git a/Build/crates/saikuro-exec/src/capacity.rs b/Build/crates/saikuro-exec/src/capacity.rs new file mode 100644 index 00000000..35a9a4a3 --- /dev/null +++ b/Build/crates/saikuro-exec/src/capacity.rs @@ -0,0 +1,73 @@ +use core::fmt; + +const MIN_CHANNEL_CAPACITY: usize = 1; +const MAX_CHANNEL_CAPACITY: usize = 256; + +/// A validated capacity for a bounded execution channel. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct ChannelCapacity(usize); + +impl ChannelCapacity { + /// The default channel capacity used by the execution facade. + pub const DEFAULT: Self = Self(128); + + /// The smallest supported channel capacity. + pub const MIN: Self = Self(MIN_CHANNEL_CAPACITY); + + /// The largest supported channel capacity. + pub const MAX: Self = Self(MAX_CHANNEL_CAPACITY); + + /// Construct a capacity after checking the shared backend bounds. + pub const fn new(value: usize) -> Result { + if value < MIN_CHANNEL_CAPACITY || value > MAX_CHANNEL_CAPACITY { + Err(InvalidChannelCapacity { value }) + } else { + Ok(Self(value)) + } + } + + /// Return the validated capacity as a `usize`. + pub const fn get(self) -> usize { + self.0 + } +} + +/// Error returned when a channel capacity is outside the supported bounds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InvalidChannelCapacity { + value: usize, +} + +impl InvalidChannelCapacity { + /// Return the rejected capacity. + pub const fn value(self) -> usize { + self.value + } +} + +impl fmt::Display for InvalidChannelCapacity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "channel capacity {} is outside the range {}..={}", + self.value, MIN_CHANNEL_CAPACITY, MAX_CHANNEL_CAPACITY + ) + } +} + +#[cfg(any(feature = "tokio-runtime", feature = "wasm-runtime"))] +impl std::error::Error for InvalidChannelCapacity {} + +impl TryFrom for ChannelCapacity { + type Error = InvalidChannelCapacity; + + fn try_from(value: usize) -> Result { + Self::new(value) + } +} + +impl From for usize { + fn from(value: ChannelCapacity) -> Self { + value.get() + } +} diff --git a/Build/crates/saikuro-exec/src/embassy_backend.rs b/Build/crates/saikuro-exec/src/embassy_backend.rs index ae24dbd4..f0ad27f8 100644 --- a/Build/crates/saikuro-exec/src/embassy_backend.rs +++ b/Build/crates/saikuro-exec/src/embassy_backend.rs @@ -87,14 +87,11 @@ pub fn fuse_select(fut: F) -> Fuse { /// Bounded multi-producer, single-consumer channel. pub mod mpsc { use super::*; + use crate::ChannelCapacity; /// Fixed backing capacity of an embassy mpsc channel. /// - /// `saikuro-exec::mpsc::channel` takes a runtime capacity to match tokio's - /// API, but embassy-sync's `Channel` needs the capacity as a const generic. - /// The facade allocates a queue of this size and asserts that the requested - /// capacity fits within it. The router's default channel capacity is 128; - /// 256 leaves headroom for runtime configuration. + /// The fixed queue backing every Embassy channel. pub const CHANNEL_CAPACITY: usize = 256; /// Waker slots for senders blocked on a full channel. @@ -386,22 +383,12 @@ pub mod mpsc { /// Create a bounded channel with the given capacity. /// - /// The embassy backend stores the queue in a fixed `CHANNEL_CAPACITY` - /// buffer, so `capacity` must not exceed it. The channel state is - /// reference-counted and freed once all handles are dropped. - pub fn channel(capacity: usize) -> (Sender, Receiver) { - assert!( - capacity > 0, - "saikuro-exec: mpsc capacity 0 is unsupported; a channel must hold \ - at least one message" - ); - assert!( - capacity <= CHANNEL_CAPACITY, - "saikuro-exec: mpsc capacity {capacity} exceeds the fixed \ - embassy capacity {CHANNEL_CAPACITY}" - ); + /// The Embassy backend stores the queue in a fixed `CHANNEL_CAPACITY` + /// buffer. The validated capacity cannot exceed that backing queue. The + /// channel state is reference-counted and freed once all handles are dropped. + pub fn channel(capacity: ChannelCapacity) -> (Sender, Receiver) { let inner = Arc::new(ChannelInner { - state: CriticalSectionMutex::new(RefCell::new(ChannelState::new(capacity))), + state: CriticalSectionMutex::new(RefCell::new(ChannelState::new(capacity.get()))), channel: EmbChannel::new(), }); inner.state.lock(|s| { diff --git a/Build/crates/saikuro-exec/src/lib.rs b/Build/crates/saikuro-exec/src/lib.rs index fcecb129..3d722bcf 100644 --- a/Build/crates/saikuro-exec/src/lib.rs +++ b/Build/crates/saikuro-exec/src/lib.rs @@ -8,6 +8,9 @@ #[cfg(feature = "embassy-runtime")] extern crate alloc; +mod capacity; +pub use capacity::{ChannelCapacity, InvalidChannelCapacity}; + #[cfg(all(feature = "tokio-runtime", feature = "wasm-runtime"))] compile_error!("Features `tokio-runtime` and `wasm-runtime` are mutually exclusive."); diff --git a/Build/crates/saikuro-exec/src/tokio_backend.rs b/Build/crates/saikuro-exec/src/tokio_backend.rs index 9884b98e..88b5aa08 100644 --- a/Build/crates/saikuro-exec/src/tokio_backend.rs +++ b/Build/crates/saikuro-exec/src/tokio_backend.rs @@ -19,7 +19,13 @@ where } pub mod mpsc { - pub use tokio::sync::mpsc::{channel, Receiver, Sender}; + use crate::ChannelCapacity; + pub use tokio::sync::mpsc::{Receiver, Sender}; + + /// Create a bounded channel with a validated capacity. + pub fn channel(capacity: ChannelCapacity) -> (Sender, Receiver) { + tokio::sync::mpsc::channel(capacity.get()) + } } pub mod oneshot { diff --git a/Build/crates/saikuro-exec/src/wasm_backend.rs b/Build/crates/saikuro-exec/src/wasm_backend.rs index c91a34d2..fcc84262 100644 --- a/Build/crates/saikuro-exec/src/wasm_backend.rs +++ b/Build/crates/saikuro-exec/src/wasm_backend.rs @@ -105,8 +105,9 @@ pub mod mpsc { inner: inner::Receiver, } - pub fn channel(buffer: usize) -> (Sender, Receiver) { - let (tx, rx) = inner::channel(buffer); + /// Create a bounded channel with a validated capacity. + pub fn channel(buffer: crate::ChannelCapacity) -> (Sender, Receiver) { + let (tx, rx) = inner::channel(buffer.get()); ( Sender { inner: Arc::new(Mutex::new(tx)), diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index f62db04b..800569f4 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -20,9 +20,6 @@ saikuro-exec = { workspace = true, default-features = false } async-trait = { workspace = true } thiserror = { workspace = true } -# Atomics for sequence tracking that also work on MCU targets without native -# 64-bit CAS (thumbv7m); native instructions are used where available. -portable-atomic = { workspace = true } # tracing is declared directly so the `std` feature stays off on MCU; the # `attributes` feature is required by `#[instrument]` on `dispatch`. tracing = { version = "0.1", default-features = false, features = ["attributes"] } diff --git a/Build/crates/saikuro-router/src/provider.rs b/Build/crates/saikuro-router/src/provider.rs index fa485d88..8c40b6e8 100644 --- a/Build/crates/saikuro-router/src/provider.rs +++ b/Build/crates/saikuro-router/src/provider.rs @@ -12,7 +12,7 @@ use alloc::{ borrow::ToOwned, boxed::Box, collections::BTreeMap, string::String, sync::Arc, vec::Vec, }; use async_trait::async_trait; -use saikuro_core::{envelope::Envelope, sync::RwLock, ResponseEnvelope}; +use saikuro_core::{envelope::Envelope, sync::RwLock, RegistrationToken, ResponseEnvelope}; use saikuro_exec::{mpsc, oneshot}; use tracing::{debug, warn}; @@ -69,6 +69,7 @@ pub struct ProviderWorkItem { #[derive(Clone)] pub struct ProviderHandle { id: String, + registration_token: RegistrationToken, namespaces: Vec, sender: mpsc::Sender, } @@ -78,13 +79,29 @@ impl ProviderHandle { id: impl Into, namespaces: Vec, sender: mpsc::Sender, + ) -> Self { + Self::with_registration_token(id, RegistrationToken::new(), namespaces, sender) + } + + /// Build a provider handle for an existing registration. + pub fn with_registration_token( + id: impl Into, + registration_token: RegistrationToken, + namespaces: Vec, + sender: mpsc::Sender, ) -> Self { Self { id: id.into(), + registration_token, namespaces, sender, } } + + /// Return the identity of this specific provider registration. + pub fn registration_token(&self) -> RegistrationToken { + self.registration_token + } } #[async_trait] @@ -133,8 +150,8 @@ pub struct ProviderRegistry { struct RegistryState { /// namespace -> provider handle by_namespace: BTreeMap, - /// provider_id -> list of namespaces (for cleanup on disconnect) - by_provider: BTreeMap>, + /// provider identity -> list of namespaces (for cleanup on disconnect) + by_provider: BTreeMap<(String, RegistrationToken), Vec>, } impl ProviderRegistry { @@ -151,6 +168,8 @@ impl ProviderRegistry { /// the routes it no longer owns (unless a newer provider took them over). pub fn register(&self, handle: ProviderHandle) { let provider_id = handle.id().to_owned(); + let registration_token = handle.registration_token(); + let provider_key = (provider_id.clone(), registration_token); let namespaces = handle.namespaces().to_vec(); let mut state = self.inner.write(); @@ -161,7 +180,7 @@ impl ProviderRegistry { // newer provider may have taken it over). let dropped: Vec = state .by_provider - .get(&provider_id) + .get(&provider_key) .map(|owned| { owned .iter() @@ -174,7 +193,7 @@ impl ProviderRegistry { if state .by_namespace .get(ns) - .map(|h| h.id() == provider_id) + .map(|h| h.id() == provider_id && h.registration_token() == registration_token) .unwrap_or(false) { state.by_namespace.remove(ns); @@ -186,8 +205,9 @@ impl ProviderRegistry { match state.by_namespace.insert(ns.clone(), handle.clone()) { Some(old) => { warn!(namespace = %ns, provider = %provider_id, "replacing existing namespace provider"); - if old.id() != provider_id { - if let Some(old_ns_list) = state.by_provider.get_mut(old.id()) { + if old.id() != provider_id || old.registration_token() != registration_token { + let old_key = (old.id().to_owned(), old.registration_token()); + if let Some(old_ns_list) = state.by_provider.get_mut(&old_key) { old_ns_list.retain(|n| n != ns); } } @@ -197,21 +217,23 @@ impl ProviderRegistry { } } } - state.by_provider.insert(provider_id, namespaces); + state.by_provider.insert(provider_key, namespaces); } - /// Remove all namespace registrations for the given provider ID. + /// Remove all namespaces owned by one specific provider registration. /// /// A namespace is removed from the lookup index only while it still points - /// at this provider; a namespace a newer provider took over is left alone. - pub fn deregister(&self, provider_id: &str) { + /// at this registration; namespaces taken over by a newer registration are + /// left alone even when it uses the same provider ID. + pub fn deregister(&self, provider_id: &str, registration_token: RegistrationToken) { let mut state = self.inner.write(); - if let Some(namespaces) = state.by_provider.remove(provider_id) { + let provider_key = (provider_id.to_owned(), registration_token); + if let Some(namespaces) = state.by_provider.remove(&provider_key) { for ns in namespaces { if state .by_namespace .get(&ns) - .map(|h| h.id() == provider_id) + .map(|h| h.id() == provider_id && h.registration_token() == registration_token) .unwrap_or(false) { state.by_namespace.remove(&ns); diff --git a/Build/crates/saikuro-router/src/router.rs b/Build/crates/saikuro-router/src/router.rs index fb2b34b9..6b4cab5e 100644 --- a/Build/crates/saikuro-router/src/router.rs +++ b/Build/crates/saikuro-router/src/router.rs @@ -16,19 +16,19 @@ use alloc::{borrow::ToOwned, boxed::Box, string::ToString, sync::Arc, vec::Vec}; use core::time::Duration; use saikuro_core::{ - envelope::{Envelope, InvocationType, StreamControl}, + envelope::{Envelope, InvocationType}, error::{ErrorDetail, SaikuroError}, invocation::InvocationId, log::{LogLevel, LogRecord, LogSink}, ResponseEnvelope, }; -use saikuro_exec::{mpsc, oneshot, timeout}; +use saikuro_exec::{mpsc, oneshot, timeout, ChannelCapacity}; use tracing::{debug, instrument, warn}; use crate::{ error::{Result, RouterError}, provider::{Provider, ProviderRegistry}, - stream_state::{ChannelState, StreamState, StreamStateStore}, + stream_state::{ChannelState, DeliveryOutcome, StreamState, StreamStateStore}, }; // Config @@ -41,18 +41,18 @@ pub struct RouterConfig { pub call_timeout: Duration, /// Capacity of per-stream item channels. - pub stream_channel_capacity: usize, + pub stream_channel_capacity: ChannelCapacity, /// Capacity of per-channel inbound/outbound item channels. - pub channel_capacity: usize, + pub channel_capacity: ChannelCapacity, } impl Default for RouterConfig { fn default() -> Self { Self { call_timeout: Duration::from_secs(30), - stream_channel_capacity: 128, - channel_capacity: 128, + stream_channel_capacity: ChannelCapacity::DEFAULT, + channel_capacity: ChannelCapacity::DEFAULT, } } } @@ -228,10 +228,6 @@ impl InvocationRouter { async fn dispatch_stream_open(&self, envelope: Envelope) -> ResponseEnvelope { let id = envelope.id; - if !valid_channel_capacity(self.config.stream_channel_capacity) { - return error_response(id, SaikuroError::BufferOverflow.into()); - } - let provider = match self.resolve_namespace(&envelope.target) { Ok(p) => p, Err(e) => return error_response(id, e.into()), @@ -267,20 +263,19 @@ impl InvocationRouter { seq: envelope.seq, stream_control: envelope.stream_control, }; - if let Err(e) = channel.inbound_tx().send(resp).await { - return error_response( - id, - SaikuroError::ProviderUnavailable(format!("channel data delivery failed: {e}")) - .into(), - ); - } - // If this is a terminal frame, clean up the channel and return ok_empty - if matches!( - envelope.stream_control, - Some(StreamControl::End | StreamControl::Abort) - ) { - self.streams.remove_channel(&id); - return ResponseEnvelope::ok_empty(id); + match channel.deliver(resp, true).await { + DeliveryOutcome::Terminal => { + self.streams.remove_channel_if(&id, &channel); + return ResponseEnvelope::ok_empty(id); + } + DeliveryOutcome::Closed => { + self.streams.remove_channel_if(&id, &channel); + return error_response(id, RouterError::ChannelClosed(id.to_string()).into()); + } + DeliveryOutcome::OutOfOrder => { + warn!(%id, "out-of-order channel item dropped"); + } + DeliveryOutcome::Delivered => {} } // For non-terminal frames, do not return a response (one-way) return ResponseEnvelope { @@ -294,10 +289,6 @@ impl InvocationRouter { } // Otherwise, open a new channel as before - if !valid_channel_capacity(self.config.channel_capacity) { - return error_response(id, SaikuroError::BufferOverflow.into()); - } - let provider = match self.resolve_namespace(&envelope.target) { Ok(p) => p, Err(e) => return error_response(id, e.into()), @@ -389,55 +380,32 @@ impl InvocationRouter { /// opened channel (i.e. a `Channel`-type envelope whose ID matches an /// existing channel state entry). /// Route a channel item in the given direction. - async fn route_channel_item( - &self, - response: ResponseEnvelope, - pick_tx: impl FnOnce(&ChannelState) -> &mpsc::Sender, - advance_seq: impl FnOnce(&ChannelState, u64) -> bool, - ) -> Result<()> { + async fn route_channel_item(&self, response: ResponseEnvelope, inbound: bool) -> Result<()> { let id = response.id; let state = self .streams .get_channel(&id) .ok_or_else(|| RouterError::ChannelNotFound(id.to_string()))?; - if state.is_closed() { - return Err(RouterError::ChannelClosed(id.to_string())); - } - - // Sequence check. - if let Some(seq) = response.seq { - if !advance_seq(&state, seq) { - warn!(%id, seq, "out-of-order channel item dropped"); - return Ok(()); + match state.deliver(response, inbound).await { + DeliveryOutcome::Closed => { + self.streams.remove_channel_if(&id, &state); + Err(RouterError::ChannelClosed(id.to_string())) } + DeliveryOutcome::OutOfOrder => { + warn!(%id, "out-of-order channel item dropped"); + Ok(()) + } + DeliveryOutcome::Terminal => { + self.streams.remove_channel_if(&id, &state); + Ok(()) + } + DeliveryOutcome::Delivered => Ok(()), } - - let is_terminal = matches!( - response.stream_control, - Some(StreamControl::End) | Some(StreamControl::Abort) - ); - - pick_tx(&state) - .send(response) - .await - .map_err(|_| RouterError::ChannelClosed(id.to_string()))?; - - if is_terminal { - state.mark_closed(); - self.streams.remove_channel(&id); - } - - Ok(()) } pub async fn route_channel_inbound(&self, response: ResponseEnvelope) -> Result<()> { - self.route_channel_item( - response, - |s| s.inbound_tx(), - |s, seq| s.advance_inbound(seq), - ) - .await + self.route_channel_item(response, true).await } /// Route an outbound channel item (provider -> client direction) to the @@ -446,12 +414,7 @@ impl InvocationRouter { /// Called by the provider adapter when it wants to push a message to the /// client side of an open channel. pub async fn route_channel_outbound(&self, response: ResponseEnvelope) -> Result<()> { - self.route_channel_item( - response, - |s| s.outbound_tx(), - |s, seq| s.advance_outbound(seq), - ) - .await + self.route_channel_item(response, false).await } /// Route an inbound stream item to the appropriate open stream. @@ -462,38 +425,21 @@ impl InvocationRouter { .get_stream(&id) .ok_or_else(|| RouterError::StreamNotFound(id.to_string()))?; - if state.is_closed() { - return Err(RouterError::StreamClosed(id.to_string())); - } - - // Sequence check. - if let Some(seq) = response.seq { - if !state.advance_seq(seq) { - warn!(%id, seq, "out-of-order stream item dropped"); - return Ok(()); + match state.deliver(response).await { + DeliveryOutcome::Closed => { + self.streams.remove_stream_if(&id, &state); + Err(RouterError::StreamClosed(id.to_string())) } + DeliveryOutcome::OutOfOrder => { + warn!(%id, "out-of-order stream item dropped"); + Ok(()) + } + DeliveryOutcome::Terminal => { + self.streams.remove_stream_if(&id, &state); + Ok(()) + } + DeliveryOutcome::Delivered => Ok(()), } - - // Determine if this is a terminal frame before consuming `response`. - let is_terminal = matches!( - response.stream_control, - Some(StreamControl::End) | Some(StreamControl::Abort) - ); - - // Send the item first so the receiver is still alive when we deliver. - state - .item_tx() - .send(response) - .await - .map_err(|_| RouterError::StreamClosed(id.to_string()))?; - - // Only after successful delivery, mark closed and drop the receiver. - if is_terminal { - state.mark_closed(); - self.streams.remove_stream(&id); - } - - Ok(()) } // Helpers @@ -515,17 +461,6 @@ impl InvocationRouter { } } -fn valid_channel_capacity(capacity: usize) -> bool { - if capacity == 0 { - return false; - } - #[cfg(feature = "embassy")] - if capacity > saikuro_exec::mpsc::CHANNEL_CAPACITY { - return false; - } - true -} - // Helpers fn namespace_of(target: &str) -> Option<&str> { diff --git a/Build/crates/saikuro-router/src/stream_state.rs b/Build/crates/saikuro-router/src/stream_state.rs index 3a06fdff..79804abb 100644 --- a/Build/crates/saikuro-router/src/stream_state.rs +++ b/Build/crates/saikuro-router/src/stream_state.rs @@ -1,89 +1,63 @@ //! Per-stream and per-channel lifecycle state. -//! -//! When a `Stream` or `Channel` invocation is opened the router creates an -//! entry in the [`StreamStateStore`]. Subsequent messages that carry the -//! same invocation ID are correlated back to that entry for sequence checking -//! and backpressure enforcement. use alloc::{collections::BTreeMap, sync::Arc}; -use core::sync::atomic::Ordering; -use portable_atomic::{AtomicBool, AtomicU64}; use saikuro_core::invocation::InvocationId; use saikuro_core::sync::RwLock; use saikuro_core::ResponseEnvelope; -use saikuro_exec::mpsc; - -/// Extension trait for atomic sequence-number advancement. -/// -/// Replaces three identical load/compare/store patterns in `StreamState` -/// and `ChannelState`. -trait TryAdvanceSeq { - fn try_advance(&self, seq: u64) -> bool; +use saikuro_exec::{mpsc, sync::Mutex}; + +/// Result of attempting to deliver one frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeliveryOutcome { + Delivered, + Terminal, + Closed, + OutOfOrder, } -impl TryAdvanceSeq for AtomicU64 { - fn try_advance(&self, seq: u64) -> bool { - let Some(next) = seq.checked_add(1) else { - return false; - }; - self.compare_exchange(seq, next, Ordering::AcqRel, Ordering::Acquire) - .is_ok() - } +#[derive(Default)] +struct Lifecycle { + inbound_seq: u64, + outbound_seq: u64, + closed: bool, } -// Stream state - /// Lifecycle state for an open server-to-client stream. pub struct StreamState { - /// Next expected inbound sequence number (for in-order delivery enforcement). - next_seq: AtomicU64, - /// Whether the stream has been closed (end-of-stream sentinel received). - closed: AtomicBool, - /// Channel to deliver stream items to the waiting client receiver. + lifecycle: Mutex, item_tx: mpsc::Sender, } impl StreamState { pub fn new(item_tx: mpsc::Sender) -> Arc { Arc::new(Self { - next_seq: AtomicU64::new(0), - closed: AtomicBool::new(false), + lifecycle: Mutex::new(Lifecycle::default()), item_tx, }) } - /// Record receipt of the next item. Returns `false` if the sequence - /// number is out of order (caller should produce an `OutOfOrder` error). - pub fn advance_seq(&self, seq: u64) -> bool { - self.next_seq.try_advance(seq) - } - - pub fn mark_closed(&self) { - self.closed.store(true, Ordering::Release); - } - - pub fn is_closed(&self) -> bool { - self.closed.load(Ordering::Acquire) - } - - pub fn item_tx(&self) -> &mpsc::Sender { - &self.item_tx + pub async fn deliver(&self, response: ResponseEnvelope) -> DeliveryOutcome { + let mut lifecycle = self.lifecycle.lock().await; + let expected_seq = lifecycle.inbound_seq; + let has_seq = response.seq.is_some(); + let outcome = + deliver_locked(&mut lifecycle.closed, expected_seq, &self.item_tx, response).await; + if matches!( + outcome, + DeliveryOutcome::Delivered | DeliveryOutcome::Terminal + ) && has_seq + && expected_seq < u64::MAX + { + lifecycle.inbound_seq = expected_seq + 1; + } + outcome } } -// Channel state - /// Lifecycle state for an open bidirectional channel. pub struct ChannelState { - /// Sequence counter for inbound messages (client -> server). - inbound_seq: AtomicU64, - /// Sequence counter for outbound messages (server -> client). - outbound_seq: AtomicU64, - /// Whether the channel has been fully closed. - closed: AtomicBool, - /// Channel to deliver inbound messages to the provider. + lifecycle: Mutex, inbound_tx: mpsc::Sender, - /// Channel to deliver outbound messages back to the client. outbound_tx: mpsc::Sender, } @@ -93,46 +67,69 @@ impl ChannelState { outbound_tx: mpsc::Sender, ) -> Arc { Arc::new(Self { - inbound_seq: AtomicU64::new(0), - outbound_seq: AtomicU64::new(0), - closed: AtomicBool::new(false), + lifecycle: Mutex::new(Lifecycle::default()), inbound_tx, outbound_tx, }) } - pub fn advance_inbound(&self, seq: u64) -> bool { - self.inbound_seq.try_advance(seq) - } - - pub fn advance_outbound(&self, seq: u64) -> bool { - self.outbound_seq.try_advance(seq) + pub async fn deliver(&self, response: ResponseEnvelope, inbound: bool) -> DeliveryOutcome { + let mut lifecycle = self.lifecycle.lock().await; + let (expected_seq, tx) = if inbound { + (lifecycle.inbound_seq, &self.inbound_tx) + } else { + (lifecycle.outbound_seq, &self.outbound_tx) + }; + let has_seq = response.seq.is_some(); + let outcome = deliver_locked(&mut lifecycle.closed, expected_seq, tx, response).await; + if matches!( + outcome, + DeliveryOutcome::Delivered | DeliveryOutcome::Terminal + ) && has_seq + && expected_seq < u64::MAX + { + if inbound { + lifecycle.inbound_seq = expected_seq + 1; + } else { + lifecycle.outbound_seq = expected_seq + 1; + } + } + outcome } +} - pub fn mark_closed(&self) { - self.closed.store(true, Ordering::Release); +async fn deliver_locked( + closed: &mut bool, + expected_seq: u64, + tx: &mpsc::Sender, + response: ResponseEnvelope, +) -> DeliveryOutcome { + if *closed { + return DeliveryOutcome::Closed; } - - pub fn is_closed(&self) -> bool { - self.closed.load(Ordering::Acquire) + if let Some(seq) = response.seq { + if seq.checked_add(1).is_none() || seq != expected_seq { + return DeliveryOutcome::OutOfOrder; + } } - - pub fn inbound_tx(&self) -> &mpsc::Sender { - &self.inbound_tx + let terminal = matches!( + response.stream_control, + Some(saikuro_core::envelope::StreamControl::End) + | Some(saikuro_core::envelope::StreamControl::Abort) + ); + if tx.send(response).await.is_err() { + *closed = true; + return DeliveryOutcome::Closed; } - - pub fn outbound_tx(&self) -> &mpsc::Sender { - &self.outbound_tx + if terminal { + *closed = true; + DeliveryOutcome::Terminal + } else { + DeliveryOutcome::Delivered } } -// Store - /// Thread-safe store for all open stream and channel states. -/// -/// Each map has its own [`RwLock`]; every access is a single-statement guard -/// so no two locks are ever held simultaneously. `InvocationId` is -/// `Ord`, so `BTreeMap` keys keep iteration deterministic. #[derive(Clone, Default)] pub struct StreamStateStore { streams: Arc>>, @@ -155,12 +152,6 @@ impl StreamStateStore { Self::default() } - // Stream - - /// Insert a stream state together with the corresponding receiver. - /// - /// Keeping the receiver here ensures the mpsc channel stays open so that - /// `item_tx.send()` succeeds until someone takes the receiver. pub fn insert_stream( &self, id: InvocationId, @@ -184,11 +175,19 @@ impl StreamStateStore { self.streams.write().remove(id).map(|entry| entry.state) } - /// Take the receiver half of the stream item channel. - /// - /// After this call the router no longer holds the receiver; the caller is - /// responsible for consuming it. The channel remains live because `item_tx` - /// is still held inside `StreamState`. + pub fn remove_stream_if(&self, id: &InvocationId, state: &Arc) -> bool { + let mut streams = self.streams.write(); + if streams + .get(id) + .is_some_and(|entry| Arc::ptr_eq(&entry.state, state)) + { + streams.remove(id); + true + } else { + false + } + } + pub fn take_stream_receiver( &self, id: &InvocationId, @@ -199,8 +198,6 @@ impl StreamStateStore { .and_then(|entry| entry.receiver.take()) } - // Channel - pub fn insert_channel( &self, id: InvocationId, @@ -229,7 +226,19 @@ impl StreamStateStore { self.channels.write().remove(id).map(|entry| entry.state) } - /// Take the inbound receiver (client -> provider) for a channel. + pub fn remove_channel_if(&self, id: &InvocationId, state: &Arc) -> bool { + let mut channels = self.channels.write(); + if channels + .get(id) + .is_some_and(|entry| Arc::ptr_eq(&entry.state, state)) + { + channels.remove(id); + true + } else { + false + } + } + pub fn take_channel_inbound_receiver( &self, id: &InvocationId, @@ -240,7 +249,6 @@ impl StreamStateStore { .and_then(|entry| entry.inbound_receiver.take()) } - /// Take the outbound receiver (provider -> client) for a channel. pub fn take_channel_outbound_receiver( &self, id: &InvocationId, diff --git a/Build/crates/saikuro-router/tests/provider_registry.rs b/Build/crates/saikuro-router/tests/provider_registry.rs index 740340e0..7c5b96bd 100644 --- a/Build/crates/saikuro-router/tests/provider_registry.rs +++ b/Build/crates/saikuro-router/tests/provider_registry.rs @@ -1,25 +1,71 @@ +use saikuro_core::RegistrationToken; use saikuro_router::provider::{Provider, ProviderHandle, ProviderRegistry, ProviderWorkItem}; fn handle(id: &str, namespaces: &[&str]) -> ProviderHandle { - let (sender, _receiver) = saikuro_exec::mpsc::channel::(4); - ProviderHandle::new( + handle_with_token(id, RegistrationToken::new(), namespaces) +} + +fn handle_with_token( + id: &str, + registration_token: RegistrationToken, + namespaces: &[&str], +) -> ProviderHandle { + let (sender, _receiver) = saikuro_exec::mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), + ); + ProviderHandle::with_registration_token( id.to_owned(), + registration_token, namespaces.iter().map(|s| s.to_string()).collect(), sender, ) } +#[test] +fn stale_same_id_deregistration_preserves_new_registration() { + let registry = ProviderRegistry::new(); + let old_token = RegistrationToken::new(); + let new_token = RegistrationToken::new(); + let (old_sender, _old_receiver) = saikuro_exec::mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), + ); + let (new_sender, _new_receiver) = saikuro_exec::mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), + ); + + registry.register(ProviderHandle::with_registration_token( + "p", + old_token, + vec!["service".into()], + old_sender, + )); + registry.register(ProviderHandle::with_registration_token( + "p", + new_token, + vec!["service".into()], + new_sender, + )); + registry.deregister("p", old_token); + + let provider = registry + .get("service") + .expect("new provider registration remains routed"); + assert_eq!(provider.id(), "p"); + assert_eq!(provider.registration_token(), new_token); +} + /// Re-registering the same provider with fewer namespaces must release the /// routes it no longer owns. #[test] fn register_with_fewer_namespaces_releases_dropped_routes() { let registry = ProviderRegistry::new(); + let registration_token = RegistrationToken::new(); - registry.register(handle("p", &["a", "b"])); + registry.register(handle_with_token("p", registration_token, &["a", "b"])); assert!(registry.get("a").is_some()); assert!(registry.get("b").is_some()); - registry.register(handle("p", &["a"])); + registry.register(handle_with_token("p", registration_token, &["a"])); assert!( registry.get("b").is_none(), "dropped namespace 'b' still routed after re-register" @@ -32,10 +78,11 @@ fn register_with_fewer_namespaces_releases_dropped_routes() { #[test] fn register_with_fewer_namespaces_keeps_taken_over_routes() { let registry = ProviderRegistry::new(); + let registration_token = RegistrationToken::new(); - registry.register(handle("p", &["a", "b"])); + registry.register(handle_with_token("p", registration_token, &["a", "b"])); registry.register(handle("q", &["b"])); - registry.register(handle("p", &["a"])); + registry.register(handle_with_token("p", registration_token, &["a"])); assert!(registry.get("a").is_some()); let b = registry.get("b").expect("'b' is owned by q"); diff --git a/Build/crates/saikuro-runtime/Cargo.toml b/Build/crates/saikuro-runtime/Cargo.toml index ddbed6eb..164c78ad 100644 --- a/Build/crates/saikuro-runtime/Cargo.toml +++ b/Build/crates/saikuro-runtime/Cargo.toml @@ -16,7 +16,7 @@ required-features = ["native-transport"] [features] default = ["native-transport"] native-transport = ["saikuro-transport/native-transport", "saikuro-exec/tokio-runtime"] -ws-transport = ["saikuro-transport/native-ws", "dep:tokio-tungstenite", "dep:tungstenite"] +ws-transport = ["saikuro-transport/native-ws"] wasm-runtime = [ "saikuro-transport/wasm-runtime", "saikuro-exec/wasm-runtime", @@ -47,8 +47,4 @@ serde_with = { workspace = true } anyhow = { workspace = true } clap = { version = "4.5", features = ["derive", "env"] } -# WebSocket server-side (optional, depends on ws-transport feature) -tokio-tungstenite = { version = "0.24", optional = true } -tungstenite = { version = "0.30", optional = true } - [dev-dependencies] diff --git a/Build/crates/saikuro-runtime/src/config.rs b/Build/crates/saikuro-runtime/src/config.rs index f017c06e..8a8315d5 100644 --- a/Build/crates/saikuro-runtime/src/config.rs +++ b/Build/crates/saikuro-runtime/src/config.rs @@ -1,9 +1,10 @@ //! Runtime configuration. -use serde::{Deserialize, Serialize}; +use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; use serde_with::serde_as; use std::time::Duration; +use saikuro_exec::ChannelCapacity; use saikuro_router::router::RouterConfig; use saikuro_schema::registry::RegistryMode; use saikuro_transport::selector::TransportConfig; @@ -34,8 +35,12 @@ pub struct RuntimeConfig { pub max_message_size: usize, /// Buffer capacity for per-stream item queues. - #[serde(default = "default_stream_capacity")] - pub stream_buffer_capacity: usize, + #[serde( + default = "default_stream_capacity", + deserialize_with = "deserialize_channel_capacity", + serialize_with = "serialize_channel_capacity" + )] + pub stream_buffer_capacity: ChannelCapacity, /// Enable structured JSON logging via `tracing-subscriber`. #[serde(default)] @@ -95,6 +100,24 @@ fn default_call_timeout() -> Duration { fn default_max_message_size() -> usize { 16 * 1024 * 1024 } -fn default_stream_capacity() -> usize { - 128 +fn default_stream_capacity() -> ChannelCapacity { + ChannelCapacity::DEFAULT +} + +fn deserialize_channel_capacity<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = usize::deserialize(deserializer)?; + ChannelCapacity::try_from(value).map_err(D::Error::custom) +} + +fn serialize_channel_capacity( + capacity: &ChannelCapacity, + serializer: S, +) -> Result +where + S: Serializer, +{ + serializer.serialize_u64(capacity.get() as u64) } diff --git a/Build/crates/saikuro-runtime/src/connection.rs b/Build/crates/saikuro-runtime/src/connection.rs index d29caaca..c8887fa0 100644 --- a/Build/crates/saikuro-runtime/src/connection.rs +++ b/Build/crates/saikuro-runtime/src/connection.rs @@ -46,7 +46,7 @@ use saikuro_core::{ invocation::InvocationId, schema::Schema, value::Value, - ResponseEnvelope, + RegistrationToken, ResponseEnvelope, }; use saikuro_exec::{mpsc, oneshot, spawn}; use saikuro_router::{ @@ -86,6 +86,8 @@ where R: TransportReceiver, { pub peer_id: String, + /// Identity of this connection's provider registration. + pub registration_token: RegistrationToken, pub sender: S, pub receiver: R, pub validator: InvocationValidator, @@ -147,7 +149,8 @@ where // Channel through which the ForwardTask sends frames TO the peer. // The recv loop serialises all outbound writes through `self.sender`. - let (forward_tx, mut forward_rx) = mpsc::channel::(256); + let (forward_tx, mut forward_rx) = + mpsc::channel::(saikuro_exec::ChannelCapacity::MAX); loop { saikuro_exec::select! { @@ -191,8 +194,10 @@ where } // Clean up: deregister any provider the peer announced. - self.provider_registry.deregister(&self.peer_id); - self.schema_registry.deregister_provider(&self.peer_id); + self.provider_registry + .deregister(&self.peer_id, self.registration_token); + self.schema_registry + .deregister_provider(&self.peer_id, self.registration_token); info!(peer = %self.peer_id, "connection handler exiting"); } @@ -208,11 +213,12 @@ where frame: Bytes, pending: &PendingCalls, forward_tx: &mpsc::Sender, - ) -> (ResponseEnvelope, Option) { + ) -> Option<(ResponseEnvelope, Option)> { // 1. Decode the MessagePack envelope. let envelope = match self.decode_envelope(&frame) { Ok(e) => e, - Err(resp) => return (*resp, None), + Err(Some(resp)) => return Some((*resp, None)), + Err(None) => return None, }; let id = envelope.id; @@ -229,11 +235,11 @@ where } else { None }; - return (response, sandbox_schema); + return Some((response, sandbox_schema)); } InvocationType::Log => { // Let the router's log sink handle it: no validation needed. - return (self.router.dispatch(envelope).await, None); + return Some((self.router.dispatch(envelope).await, None)); } _ => {} } @@ -242,10 +248,10 @@ where let validation = match self.validator.validate(&envelope) { Ok(report) => report, Err(e) => { - return ( + return Some(( ResponseEnvelope::err(id, ErrorDetail::new(e.error_code(), e.to_string())), None, - ); + )); } }; @@ -256,7 +262,7 @@ where { CapabilityOutcome::Granted => {} CapabilityOutcome::Denied { missing } => { - return ( + return Some(( ResponseEnvelope::err( id, ErrorDetail::new( @@ -265,28 +271,35 @@ where ), ), None, - ); + )); } } // 5. Route to provider. - (self.router.dispatch(envelope).await, None) + Some((self.router.dispatch(envelope).await, None)) } /// Decode a MessagePack frame into an [`Envelope`], or return an error /// response on failure. - fn decode_envelope(&self, frame: &[u8]) -> Result> { + fn decode_envelope(&self, frame: &[u8]) -> Result>> { match saikuro_core::msgpack::from_slice(frame) { Ok(env) => Ok(env), Err(e) => { warn!(peer = %self.peer_id, "envelope decode failed: {e}"); - Err(Box::new(ResponseEnvelope::err( - InvocationId::new(), + let id = match InvocationId::new() { + Ok(id) => id, + Err(error) => { + error!(peer = %self.peer_id, %error, "cannot generate malformed-envelope response ID"); + return Err(None); + } + }; + Err(Some(Box::new(ResponseEnvelope::err( + id, ErrorDetail::new( saikuro_core::error::ErrorCode::MalformedEnvelope, format!("msgpack decode error: {e}"), ), - ))) + )))) } } } @@ -307,7 +320,14 @@ where self.max_message_size ), ); - let response = ResponseEnvelope::err(InvocationId::new(), err); + let id = match InvocationId::new() { + Ok(id) => id, + Err(error) => { + error!(peer = %self.peer_id, %error, "cannot generate oversized-frame response ID"); + return false; + } + }; + let response = ResponseEnvelope::err(id, err); let _ = self.send_response(response).await; return true; } @@ -325,7 +345,10 @@ where } } - let (response, sandbox_schema) = self.handle_frame(frame, pending, forward_tx).await; + let Some((response, sandbox_schema)) = self.handle_frame(frame, pending, forward_tx).await + else { + return false; + }; if let Err(e) = self.send_response(response).await { error!(peer = %self.peer_id, "send error: {e}"); @@ -366,7 +389,11 @@ where let ns_count = s.namespaces.len(); let namespaces: Vec = s.namespaces.keys().cloned().collect(); - match self.schema_registry.merge_schema(s, &self.peer_id) { + match self.schema_registry.merge_schema_with_token( + s, + &self.peer_id, + self.registration_token, + ) { Ok(()) => { info!( peer = %self.peer_id, @@ -418,8 +445,14 @@ where pending: &PendingCalls, forward_tx: &mpsc::Sender, ) { - let (work_tx, mut work_rx) = mpsc::channel::(256); - let handle = ProviderHandle::new(self.peer_id.clone(), namespaces, work_tx); + let (work_tx, mut work_rx) = + mpsc::channel::(saikuro_exec::ChannelCapacity::MAX); + let handle = ProviderHandle::with_registration_token( + self.peer_id.clone(), + self.registration_token, + namespaces, + work_tx, + ); self.provider_registry.register(handle); let pending_clone = pending.clone(); @@ -520,7 +553,8 @@ where saikuro_core::msgpack::from_slice::(&bytes) .map_err(|e| format!("sandbox schema value decode error: {e}"))? }; - let announce = Envelope::announce(schema_value); + let announce = Envelope::announce(schema_value) + .map_err(|e| format!("announce invocation ID error: {e}"))?; let frame = encode_bytes(&announce).map_err(|e| format!("announce frame encode error: {e}"))?; info!(peer = %self.peer_id, "pushing sandbox-filtered schema to peer"); diff --git a/Build/crates/saikuro-runtime/src/error.rs b/Build/crates/saikuro-runtime/src/error.rs index 840e24c5..5185a29a 100644 --- a/Build/crates/saikuro-runtime/src/error.rs +++ b/Build/crates/saikuro-runtime/src/error.rs @@ -30,6 +30,9 @@ pub enum RuntimeError { #[error("internal error: {0}")] Internal(String), + + #[error("entropy error: {0}")] + Entropy(#[from] saikuro_random::Error), } pub type Result = std::result::Result; diff --git a/Build/crates/saikuro-runtime/src/handle.rs b/Build/crates/saikuro-runtime/src/handle.rs index 5872af60..dd500e35 100644 --- a/Build/crates/saikuro-runtime/src/handle.rs +++ b/Build/crates/saikuro-runtime/src/handle.rs @@ -11,7 +11,8 @@ use std::sync::Arc; use parking_lot::RwLock; use saikuro_core::{ - capability::CapabilitySet, envelope::Envelope, schema::Schema, ResponseEnvelope, + capability::CapabilitySet, envelope::Envelope, schema::Schema, RegistrationToken, + ResponseEnvelope, }; use saikuro_exec::mpsc; use saikuro_router::{ @@ -50,14 +51,31 @@ impl RuntimeHandle { .map_err(Into::into) } + /// Register or merge a schema under an existing provider registration. + pub fn register_schema_with_token( + &self, + schema: Schema, + provider_id: impl Into, + registration_token: RegistrationToken, + ) -> Result<()> { + self.schema_registry + .merge_schema_with_token(schema, provider_id, registration_token) + .map_err(Into::into) + } + /// Register a single namespace from a provider. pub fn register_namespace(&self, reg: NamespaceRegistration) -> Result<()> { self.schema_registry.register(reg).map_err(Into::into) } /// Deregister all schemas owned by a provider (called on disconnect). - pub fn deregister_provider_schema(&self, provider_id: &str) { - self.schema_registry.deregister_provider(provider_id); + pub fn deregister_provider_schema( + &self, + provider_id: &str, + registration_token: RegistrationToken, + ) { + self.schema_registry + .deregister_provider(provider_id, registration_token); } /// Export a snapshot of the current schema state. @@ -72,10 +90,12 @@ impl RuntimeHandle { self.provider_registry.register(handle); } - /// Deregister a provider by ID (called on disconnect). - pub fn deregister_provider(&self, provider_id: &str) { - self.provider_registry.deregister(provider_id); - self.schema_registry.deregister_provider(provider_id); + /// Deregister one provider generation from routing and schema ownership. + pub fn deregister_provider(&self, provider_id: &str, registration_token: RegistrationToken) { + self.provider_registry + .deregister(provider_id, registration_token); + self.schema_registry + .deregister_provider(provider_id, registration_token); } // Dispatch @@ -140,6 +160,7 @@ impl RuntimeHandle { let (sender, receiver) = transport.split(); let handler = ConnectionHandler { peer_id: peer_id.clone(), + registration_token: RegistrationToken::new(), sender, receiver, validator: InvocationValidator::new(self.schema_registry.clone()), @@ -168,14 +189,22 @@ impl RuntimeHandle { provider_id: impl Into, namespaces: Vec, handler: F, - ) where + ) -> RegistrationToken + where F: Fn(Envelope) -> Fut + Send + Sync + 'static, Fut: std::future::Future + Send + 'static, { let provider_id = provider_id.into(); - let (work_tx, mut work_rx) = mpsc::channel::(256); - - let handle = ProviderHandle::new(provider_id.clone(), namespaces.clone(), work_tx); + let registration_token = RegistrationToken::new(); + let (work_tx, mut work_rx) = + mpsc::channel::(saikuro_exec::ChannelCapacity::MAX); + + let handle = ProviderHandle::with_registration_token( + provider_id.clone(), + registration_token, + namespaces.clone(), + work_tx, + ); self.provider_registry.register(handle); let handler = Arc::new(handler); @@ -193,6 +222,8 @@ impl RuntimeHandle { }); } }); + + registration_token } // Helpers diff --git a/Build/crates/saikuro-runtime/tests/config_capacity.rs b/Build/crates/saikuro-runtime/tests/config_capacity.rs new file mode 100644 index 00000000..303ccbb6 --- /dev/null +++ b/Build/crates/saikuro-runtime/tests/config_capacity.rs @@ -0,0 +1,37 @@ +use saikuro_exec::ChannelCapacity; +use saikuro_runtime::RuntimeConfig; + +#[test] +fn runtime_config_rejects_channel_capacity_below_minimum() { + let error = serde_json::from_str::(r#"{"stream_buffer_capacity":0}"#) + .expect_err("zero capacity must be rejected"); + + assert!(error.to_string().contains("outside the range 1..=256")); +} + +#[test] +fn runtime_config_rejects_channel_capacity_above_maximum() { + let error = serde_json::from_str::(r#"{"stream_buffer_capacity":257}"#) + .expect_err("capacity above 256 must be rejected"); + + assert!(error.to_string().contains("outside the range 1..=256")); +} + +#[test] +fn runtime_config_preserves_valid_channel_capacity() { + let config = serde_json::from_str::(r#"{"stream_buffer_capacity":64}"#) + .expect("valid capacity must deserialize"); + + assert_eq!( + config.stream_buffer_capacity, + ChannelCapacity::try_from(64).expect("64 is a valid channel capacity") + ); + assert_eq!( + config.router_config().stream_channel_capacity, + config.stream_buffer_capacity + ); + assert_eq!( + config.router_config().channel_capacity, + config.stream_buffer_capacity + ); +} diff --git a/Build/crates/saikuro-runtime/tests/schema_registration.rs b/Build/crates/saikuro-runtime/tests/schema_registration.rs index a96013f1..96cffeae 100644 --- a/Build/crates/saikuro-runtime/tests/schema_registration.rs +++ b/Build/crates/saikuro-runtime/tests/schema_registration.rs @@ -1,6 +1,8 @@ use saikuro_core::schema::{ FunctionMap, FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility, }; +use saikuro_core::RegistrationToken; +use saikuro_router::provider::{Provider, ProviderHandle, ProviderWorkItem}; use saikuro_runtime::SaikuroRuntime; /// Smoke test: build a runtime, register a schema, verify lookup works. @@ -43,3 +45,78 @@ fn schema_registration_roundtrip() { assert_eq!(func_ref.function, "ping"); assert_eq!(func_ref.provider_id, "test-provider"); } + +#[test] +fn stale_same_id_cleanup_preserves_new_provider_and_schema() { + let runtime = SaikuroRuntime::builder().build(); + let handle = runtime.handle(); + let old_token = RegistrationToken::new(); + let new_token = RegistrationToken::new(); + let (old_sender, _old_receiver) = saikuro_exec::mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), + ); + let (new_sender, _new_receiver) = saikuro_exec::mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), + ); + + handle.register_provider(ProviderHandle::with_registration_token( + "provider", + old_token, + vec!["service".into()], + old_sender, + )); + handle + .register_schema_with_token(schema_for("service", "old"), "provider", old_token) + .expect("old schema registers"); + handle.register_provider(ProviderHandle::with_registration_token( + "provider", + new_token, + vec!["service".into()], + new_sender, + )); + handle + .register_schema_with_token(schema_for("service", "new"), "provider", new_token) + .expect("new schema registers"); + + handle.deregister_provider("provider", old_token); + + let provider = runtime + .provider_registry() + .get("service") + .expect("new provider remains routed"); + assert_eq!(provider.id(), "provider"); + assert_eq!(provider.registration_token(), new_token); + assert!(runtime + .schema_registry() + .lookup_function("service.new") + .is_ok()); +} + +fn schema_for(namespace: &str, function: &str) -> Schema { + let mut functions = FunctionMap::new(); + functions + .insert( + function.to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::String), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: true, + doc: None, + }, + ) + .expect("function fits"); + let mut schema = Schema::new(); + schema + .namespaces + .insert( + namespace.to_owned(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) + .expect("namespace fits"); + schema +} diff --git a/Build/crates/saikuro-schema/src/registry.rs b/Build/crates/saikuro-schema/src/registry.rs index c3b69a5e..72a7fb20 100644 --- a/Build/crates/saikuro-schema/src/registry.rs +++ b/Build/crates/saikuro-schema/src/registry.rs @@ -21,6 +21,7 @@ use saikuro_core::schema::{ SCHEMA_TYPES_CAPACITY, }; use saikuro_core::sync::RwLock; +use saikuro_core::RegistrationToken; use tracing::{debug, info, warn}; use crate::validator::ValidationError; @@ -47,6 +48,8 @@ pub struct NamespaceRegistration { pub schema: NamespaceSchema, /// Opaque identifier for the provider connection (used for routing). pub provider_id: String, + /// Identity of this specific provider registration. + pub registration_token: RegistrationToken, } // Registry @@ -65,6 +68,7 @@ struct Schemata { struct NamespaceEntry { schema: NamespaceSchema, provider_id: String, + registration_token: RegistrationToken, } /// The live schema registry. @@ -96,12 +100,14 @@ impl SchemaRegistry { types: BTreeMap::new(), mode: RegistryMode::Production, }; + let frozen_token = RegistrationToken::new(); for (ns_name, ns_schema) in (*schema.namespaces).into_iter() { schemata.namespaces.insert( ns_name, NamespaceEntry { schema: ns_schema, provider_id: "frozen".to_owned(), + registration_token: frozen_token, }, ); } @@ -143,6 +149,7 @@ impl SchemaRegistry { NamespaceEntry { schema: registration.schema, provider_id: registration.provider_id, + registration_token: registration.registration_token, }, ); Ok(()) @@ -156,6 +163,16 @@ impl SchemaRegistry { &self, schema: Schema, provider_id: impl Into, + ) -> Result<(), RegistryError> { + self.merge_schema_with_token(schema, provider_id, RegistrationToken::new()) + } + + /// Merge a schema document under an existing provider registration. + pub fn merge_schema_with_token( + &self, + schema: Schema, + provider_id: impl Into, + registration_token: RegistrationToken, ) -> Result<(), RegistryError> { let provider_id = provider_id.into(); @@ -200,22 +217,25 @@ impl SchemaRegistry { NamespaceEntry { schema: ns_schema, provider_id: provider_id.clone(), + registration_token, }, ); } Ok(()) } - /// Remove all namespaces owned by `provider_id`. + /// Remove namespaces owned by one specific provider registration. /// - /// Called when a provider disconnects. - pub fn deregister_provider(&self, provider_id: &str) { + /// A stale disconnect cannot remove schemas from a newer registration that + /// reused the same provider ID. + pub fn deregister_provider(&self, provider_id: &str, registration_token: RegistrationToken) { let mut schemata = self.inner.write(); if schemata.mode == RegistryMode::Production { return; } schemata.namespaces.retain(|_ns, entry| { - let keep = entry.provider_id != provider_id; + let keep = + entry.provider_id != provider_id || entry.registration_token != registration_token; if !keep { debug!(provider = %provider_id, "deregistered namespace on disconnect"); } diff --git a/Build/crates/saikuro-schema/tests/registry.rs b/Build/crates/saikuro-schema/tests/registry.rs index ab86c841..9b662c5e 100644 --- a/Build/crates/saikuro-schema/tests/registry.rs +++ b/Build/crates/saikuro-schema/tests/registry.rs @@ -1,4 +1,5 @@ use saikuro_core::schema::{PrimitiveType, Schema, TypeDefinition, TypeDescriptor}; +use saikuro_core::RegistrationToken; use saikuro_schema::registry::{RegistryError, SchemaRegistry}; #[test] @@ -21,3 +22,37 @@ fn frozen_registry_rejects_type_only_merge() { )); assert!(registry.snapshot().expect("snapshot").types.is_empty()); } + +#[test] +fn stale_same_id_deregistration_preserves_new_schema() { + let registry = SchemaRegistry::new(); + let old_token = RegistrationToken::new(); + let new_token = RegistrationToken::new(); + let mut old_schema = Schema::new(); + old_schema + .namespaces + .insert("service".into(), empty_namespace()) + .expect("namespace fits"); + let mut new_schema = Schema::new(); + new_schema + .namespaces + .insert("service".into(), empty_namespace()) + .expect("namespace fits"); + + registry + .merge_schema_with_token(old_schema, "provider", old_token) + .expect("old schema registers"); + registry + .merge_schema_with_token(new_schema, "provider", new_token) + .expect("new schema registers"); + registry.deregister_provider("provider", old_token); + + assert!(registry.has_namespace("service")); +} + +fn empty_namespace() -> saikuro_core::schema::NamespaceSchema { + saikuro_core::schema::NamespaceSchema { + functions: Box::default(), + doc: None, + } +} diff --git a/Build/crates/saikuro-schema/tests/validator.rs b/Build/crates/saikuro-schema/tests/validator.rs index f34574ac..b149b1db 100644 --- a/Build/crates/saikuro-schema/tests/validator.rs +++ b/Build/crates/saikuro-schema/tests/validator.rs @@ -7,7 +7,7 @@ fn batch_with_empty_items_returns_empty_batch_error() { let registry = SchemaRegistry::new(); let validator = InvocationValidator::new(registry); - let mut batch = Envelope::call("", vec![]); + let mut batch = Envelope::call("", vec![]).expect("entropy available"); batch.invocation_type = InvocationType::Batch; batch.target = String::new(); batch.batch_items = Some(vec![]); diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index ebd0ccce..a6540eff 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -42,7 +42,7 @@ wasm-runtime = [ ] # native-ws is only available on non-wasm32 (where tokio-tungstenite exists) -native-ws = ["ws-transport", "std", "saikuro-exec/tokio-runtime", "saikuro-core/std", "tokio-tungstenite", "tungstenite"] +native-ws = ["ws-transport", "std", "saikuro-exec/tokio-runtime", "saikuro-core/std", "tokio-tungstenite"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } @@ -64,7 +64,6 @@ saikuro-random = { workspace = true, default-features = false } # WebSocket support on native (tokio-tungstenite uses mio which doesn't compile on wasm32) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect"], optional = true } -tungstenite = { version = "0.30", optional = true } # WASM host transport + WASM WebSocket [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/Build/crates/saikuro-transport/src/lib.rs b/Build/crates/saikuro-transport/src/lib.rs index 88892e8a..8ac3bac9 100644 --- a/Build/crates/saikuro-transport/src/lib.rs +++ b/Build/crates/saikuro-transport/src/lib.rs @@ -125,4 +125,5 @@ macro_rules! impl_native_receiver { /// /// This bounds memory usage and provides backpressure: if the receiver is /// slow the sender's `send` call will yield until space frees up. -pub const DEFAULT_CHANNEL_CAPACITY: usize = 256; +pub const DEFAULT_CHANNEL_CAPACITY: saikuro_exec::ChannelCapacity = + saikuro_exec::ChannelCapacity::MAX; diff --git a/Build/crates/saikuro-transport/src/wasm_host.rs b/Build/crates/saikuro-transport/src/wasm_host.rs index 873755cf..f6b34cff 100644 --- a/Build/crates/saikuro-transport/src/wasm_host.rs +++ b/Build/crates/saikuro-transport/src/wasm_host.rs @@ -251,7 +251,7 @@ impl TransportConnector for WasmHostConnector { .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; // Signal channel: listener's accept reply will unblock us. - let (signal_tx, mut signal_rx) = mpsc::channel::<()>(1); + let (signal_tx, mut signal_rx) = mpsc::channel::<()>(saikuro_exec::ChannelCapacity::MIN); // Temporary accept handler on the private channel. // Scoped in a block so the raw `Closure` is consumed into the @@ -323,7 +323,9 @@ impl WasmHostListener { let base = BroadcastChannel::new(&base_name) .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; - let (tx, rx) = mpsc::channel::(32); + let (tx, rx) = mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(32).expect("32 is a valid channel capacity"), + ); let handler_tx = tx; let handler: Closure = Closure::new(move |event: MessageEvent| { diff --git a/Build/tests/tests/announce_dispatch.rs b/Build/tests/tests/announce_dispatch.rs index 273997fe..24cb1445 100644 --- a/Build/tests/tests/announce_dispatch.rs +++ b/Build/tests/tests/announce_dispatch.rs @@ -48,6 +48,7 @@ async fn round_trip_while_alive( let handler = ConnectionHandler { peer_id: "test-peer".to_owned(), + registration_token: saikuro_core::RegistrationToken::new(), sender: handler_sender, receiver: handler_receiver, validator, @@ -183,7 +184,7 @@ fn announce_with_invalid_schema_returns_error() { let bad_env = Envelope { version: PROTOCOL_VERSION, invocation_type: InvocationType::Announce, - id: InvocationId::new(), + id: InvocationId::new().expect("entropy available"), target: "$saikuro.announce".to_owned(), args: vec![Value::String("not a schema".into())], meta: Default::default(), @@ -216,7 +217,7 @@ fn announce_with_no_args_returns_error() { let empty_env = Envelope { version: PROTOCOL_VERSION, invocation_type: InvocationType::Announce, - id: InvocationId::new(), + id: InvocationId::new().expect("entropy available"), target: "$saikuro.announce".to_owned(), args: vec![], meta: Default::default(), @@ -248,7 +249,9 @@ fn announce_does_not_route_to_provider() { saikuro_exec::block_on(async { let registry = SchemaRegistry::new(); - let (work_tx, mut work_rx) = mpsc::channel::(4); + let (work_tx, mut work_rx) = mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), + ); let handle = ProviderHandle::new("interceptor", vec!["$saikuro".to_owned()], work_tx); let providers = ProviderRegistry::new(); providers.register(handle); diff --git a/Build/tests/tests/batch_dispatch.rs b/Build/tests/tests/batch_dispatch.rs index a6aaf8a2..39c6f0b1 100644 --- a/Build/tests/tests/batch_dispatch.rs +++ b/Build/tests/tests/batch_dispatch.rs @@ -15,7 +15,9 @@ use saikuro_router::{ // Helpers fn register_echo_provider(registry: &ProviderRegistry, namespace: &str, response: Value) { - let (work_tx, work_rx) = mpsc::channel::(64); + let (work_tx, work_rx) = mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(64).expect("64 is a valid channel capacity"), + ); let handle = ProviderHandle::new( format!("{namespace}-provider"), vec![namespace.to_owned()], @@ -47,8 +49,9 @@ fn batch_with_single_item_succeeds() { let router = InvocationRouter::with_providers(registry); - let item = Envelope::call("math.add", vec![Value::Int(3), Value::Int(4)]); - let mut batch = Envelope::call("", vec![]); + let item = Envelope::call("math.add", vec![Value::Int(3), Value::Int(4)]) + .expect("entropy available"); + let mut batch = Envelope::call("", vec![]).expect("entropy available"); batch.invocation_type = InvocationType::Batch; batch.target = String::new(); batch.batch_items = Some(vec![item]); @@ -73,10 +76,10 @@ fn batch_with_multiple_items_returns_all_results() { let router = InvocationRouter::with_providers(registry); let items: Vec = (0..5) - .map(|i| Envelope::call("svc.op", vec![Value::Int(i)])) + .map(|i| Envelope::call("svc.op", vec![Value::Int(i)]).expect("entropy available")) .collect(); - let mut batch = Envelope::call("", vec![]); + let mut batch = Envelope::call("", vec![]).expect("entropy available"); batch.invocation_type = InvocationType::Batch; batch.target = String::new(); batch.batch_items = Some(items); @@ -100,7 +103,7 @@ fn batch_with_no_items_field_returns_malformed() { let registry = ProviderRegistry::new(); let router = InvocationRouter::with_providers(registry); - let mut batch = Envelope::call("", vec![]); + let mut batch = Envelope::call("", vec![]).expect("entropy available"); batch.invocation_type = InvocationType::Batch; batch.target = String::new(); batch.batch_items = None; // explicitly absent @@ -122,11 +125,11 @@ fn batch_items_targeting_different_namespaces() { let router = InvocationRouter::with_providers(registry); let items = vec![ - Envelope::call("ns_a.fn", vec![]), - Envelope::call("ns_b.fn", vec![]), + Envelope::call("ns_a.fn", vec![]).expect("entropy available"), + Envelope::call("ns_b.fn", vec![]).expect("entropy available"), ]; - let mut batch = Envelope::call("", vec![]); + let mut batch = Envelope::call("", vec![]).expect("entropy available"); batch.invocation_type = InvocationType::Batch; batch.target = String::new(); batch.batch_items = Some(items); @@ -155,11 +158,11 @@ fn batch_item_to_unknown_namespace_returns_null_in_result() { let router = InvocationRouter::with_providers(registry); let items = vec![ - Envelope::call("known.fn", vec![]), - Envelope::call("ghost.fn", vec![]), // no provider for this + Envelope::call("known.fn", vec![]).expect("entropy available"), + Envelope::call("ghost.fn", vec![]).expect("entropy available"), // no provider for this ]; - let mut batch = Envelope::call("", vec![]); + let mut batch = Envelope::call("", vec![]).expect("entropy available"); batch.invocation_type = InvocationType::Batch; batch.target = String::new(); batch.batch_items = Some(items); @@ -182,7 +185,9 @@ fn batch_result_is_ordered_array() { let registry = ProviderRegistry::new(); // Provider that echos back the first integer argument. - let (work_tx, mut work_rx) = mpsc::channel::(64); + let (work_tx, mut work_rx) = mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(64).expect("64 is a valid channel capacity"), + ); let handle = ProviderHandle::new("ordered", vec!["ord".to_owned()], work_tx); registry.register(handle); @@ -199,10 +204,10 @@ fn batch_result_is_ordered_array() { let items: Vec = vec![10i64, 20, 30, 40] .into_iter() - .map(|n| Envelope::call("ord.fn", vec![Value::Int(n)])) + .map(|n| Envelope::call("ord.fn", vec![Value::Int(n)]).expect("entropy available")) .collect(); - let mut batch = Envelope::call("", vec![]); + let mut batch = Envelope::call("", vec![]).expect("entropy available"); batch.invocation_type = InvocationType::Batch; batch.target = String::new(); batch.batch_items = Some(items); diff --git a/Build/tests/tests/call_dispatch.rs b/Build/tests/tests/call_dispatch.rs index 1ff2999e..22d4fd73 100644 --- a/Build/tests/tests/call_dispatch.rs +++ b/Build/tests/tests/call_dispatch.rs @@ -15,7 +15,9 @@ use std::time::Duration; /// Returns the [`ProviderRegistry`] with the provider registered, plus a /// join handle so callers can wait for completion. fn make_echo_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver) { - let (work_tx, work_rx) = mpsc::channel::(64); + let (work_tx, work_rx) = mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(64).expect("64 is a valid channel capacity"), + ); let handle = ProviderHandle::new( format!("{namespace}-provider"), vec![namespace.to_owned()], @@ -68,7 +70,8 @@ fn call_returns_provider_response() { let _responder = spawn_responder(work_rx, Value::Int(42)); let router = InvocationRouter::with_providers(registry); - let env = Envelope::call("math.add", vec![Value::Int(1), Value::Int(2)]); + let env = Envelope::call("math.add", vec![Value::Int(1), Value::Int(2)]) + .expect("entropy available"); let resp = router.dispatch(env).await; assert!(resp.ok, "call should succeed"); @@ -85,7 +88,8 @@ fn cast_returns_ok_empty_immediately() { saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); let router = InvocationRouter::with_providers(registry); - let env = Envelope::cast("logger.info", vec![Value::String("hello".into())]); + let env = Envelope::cast("logger.info", vec![Value::String("hello".into())]) + .expect("entropy available"); let resp = router.dispatch(env).await; assert!(resp.ok, "cast should always return ok"); @@ -99,7 +103,7 @@ fn call_to_unknown_namespace_returns_no_provider() { let registry = ProviderRegistry::new(); // empty let router = InvocationRouter::with_providers(registry); - let env = Envelope::call("nonexistent.fn", vec![]); + let env = Envelope::call("nonexistent.fn", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; assert!(!resp.ok); @@ -111,7 +115,8 @@ fn call_to_unknown_namespace_returns_no_provider() { #[test] fn call_to_dropped_provider_returns_unavailable() { saikuro_exec::block_on(async { - let (work_tx, work_rx) = mpsc::channel::(1); + let (work_tx, work_rx) = + mpsc::channel::(saikuro_exec::ChannelCapacity::MIN); let handle = ProviderHandle::new("gone", vec!["svc".to_owned()], work_tx); let registry = ProviderRegistry::new(); registry.register(handle); @@ -120,7 +125,7 @@ fn call_to_dropped_provider_returns_unavailable() { drop(work_rx); let router = InvocationRouter::with_providers(registry); - let env = Envelope::call("svc.op", vec![]); + let env = Envelope::call("svc.op", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; assert!(!resp.ok); @@ -145,7 +150,7 @@ fn call_times_out_when_provider_does_not_respond() { }; let router = InvocationRouter::new(registry, config); - let env = Envelope::call("slow.fn", vec![]); + let env = Envelope::call("slow.fn", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; assert!(!resp.ok); @@ -163,7 +168,7 @@ fn multiple_sequential_calls_all_succeed() { let router = InvocationRouter::with_providers(registry); for _ in 0..5 { - let env = Envelope::call("counter.inc", vec![]); + let env = Envelope::call("counter.inc", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; assert!(resp.ok); } @@ -182,7 +187,7 @@ fn concurrent_calls_all_succeed() { for _ in 0..20 { let r = router.clone(); handles.push(saikuro_exec::spawn(async move { - let env = Envelope::call("parallel.op", vec![]); + let env = Envelope::call("parallel.op", vec![]).expect("entropy available"); r.dispatch(env).await })); } @@ -201,7 +206,7 @@ fn call_with_null_target_returns_malformed_or_no_provider() { let router = InvocationRouter::with_providers(registry); // A target with no dot is malformed. - let env = Envelope::call("nodothere", vec![]); + let env = Envelope::call("nodothere", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; assert!(!resp.ok); let err = resp.error.unwrap(); @@ -222,7 +227,7 @@ fn cast_to_unknown_namespace_returns_ok() { let registry = ProviderRegistry::new(); let router = InvocationRouter::with_providers(registry); - let env = Envelope::cast("missing.fn", vec![]); + let env = Envelope::cast("missing.fn", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; // Our implementation returns ok_empty for casts even when the namespace // is missing, as per fire-and-forget semantics. diff --git a/Build/tests/tests/channel_dispatch.rs b/Build/tests/tests/channel_dispatch.rs index 40b1b49a..792fd4cf 100644 --- a/Build/tests/tests/channel_dispatch.rs +++ b/Build/tests/tests/channel_dispatch.rs @@ -1,5 +1,6 @@ //! Channel dispatch Tests +use futures::{pin_mut, poll}; use saikuro_core::{ envelope::{Envelope, StreamControl}, error::ErrorCode, @@ -10,6 +11,8 @@ use saikuro_core::{ use saikuro_exec::mpsc; use saikuro_router::provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}; use saikuro_router::router::InvocationRouter; +use saikuro_router::stream_state::{ChannelState, DeliveryOutcome}; +use std::task::Poll; mod common; @@ -56,7 +59,8 @@ fn channel_open_returns_ok_empty() { saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); let router = InvocationRouter::with_providers(registry); - let env = Envelope::channel_open("chat.open", vec![Value::String("room1".into())]); + let env = Envelope::channel_open("chat.open", vec![Value::String("room1".into())]) + .expect("entropy available"); let resp = router.dispatch(env).await; assert!(resp.ok, "channel open should return ok"); @@ -71,7 +75,7 @@ fn channel_open_to_unknown_namespace_returns_no_provider() { let registry = ProviderRegistry::new(); let router = InvocationRouter::with_providers(registry); - let env = Envelope::channel_open("ghost.open", vec![]); + let env = Envelope::channel_open("ghost.open", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; assert!(!resp.ok); @@ -86,7 +90,7 @@ fn route_channel_inbound_delivers_to_state() { let (registry, mut work_rx) = common::make_provider("pipe"); let router = InvocationRouter::with_providers(registry); - let open_env = Envelope::channel_open("pipe.connect", vec![]); + let open_env = Envelope::channel_open("pipe.connect", vec![]).expect("entropy available"); let channel_id = open_env.id; saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); @@ -122,7 +126,7 @@ fn route_channel_outbound_delivers_to_state() { let (registry, mut work_rx) = common::make_provider("pipe2"); let router = InvocationRouter::with_providers(registry); - let open_env = Envelope::channel_open("pipe2.connect", vec![]); + let open_env = Envelope::channel_open("pipe2.connect", vec![]).expect("entropy available"); let channel_id = open_env.id; saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); @@ -156,7 +160,7 @@ fn route_channel_inbound_end_removes_state() { let (registry, mut work_rx) = common::make_provider("fin_chan"); let router = InvocationRouter::with_providers(registry); - let open_env = Envelope::channel_open("fin_chan.open", vec![]); + let open_env = Envelope::channel_open("fin_chan.open", vec![]).expect("entropy available"); let channel_id = open_env.id; saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); @@ -183,7 +187,7 @@ fn route_channel_outbound_end_removes_state() { let (registry, mut work_rx) = common::make_provider("fin_out"); let router = InvocationRouter::with_providers(registry); - let open_env = Envelope::channel_open("fin_out.open", vec![]); + let open_env = Envelope::channel_open("fin_out.open", vec![]).expect("entropy available"); let channel_id = open_env.id; saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); @@ -210,7 +214,8 @@ fn route_channel_abort_removes_state() { let (registry, mut work_rx) = common::make_provider("abort_chan"); let router = InvocationRouter::with_providers(registry); - let open_env = Envelope::channel_open("abort_chan.open", vec![]); + let open_env = + Envelope::channel_open("abort_chan.open", vec![]).expect("entropy available"); let channel_id = open_env.id; saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); @@ -235,7 +240,7 @@ fn route_channel_inbound_to_unknown_channel_fails() { let registry = ProviderRegistry::new(); let router = InvocationRouter::with_providers(registry); - let phantom_id = InvocationId::new(); + let phantom_id = InvocationId::new().expect("entropy available"); let item = channel_item(phantom_id, 0, Value::Null); let err = router.route_channel_inbound(item).await; assert!(err.is_err(), "routing to non-existent channel should fail"); @@ -248,7 +253,7 @@ fn route_channel_outbound_to_unknown_channel_fails() { let registry = ProviderRegistry::new(); let router = InvocationRouter::with_providers(registry); - let phantom_id = InvocationId::new(); + let phantom_id = InvocationId::new().expect("entropy available"); let item = channel_item(phantom_id, 0, Value::Null); let err = router.route_channel_outbound(item).await; assert!( @@ -266,8 +271,8 @@ fn multiple_channels_are_independent() { saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); - let env1 = Envelope::channel_open("multi_chan.ch1", vec![]); - let env2 = Envelope::channel_open("multi_chan.ch2", vec![]); + let env1 = Envelope::channel_open("multi_chan.ch1", vec![]).expect("entropy available"); + let env2 = Envelope::channel_open("multi_chan.ch2", vec![]).expect("entropy available"); let id1 = env1.id; let id2 = env2.id; @@ -300,7 +305,8 @@ fn multiple_channels_are_independent() { #[test] fn channel_open_to_dropped_provider_returns_unavailable() { saikuro_exec::block_on(async { - let (work_tx, work_rx) = mpsc::channel::(1); + let (work_tx, work_rx) = + mpsc::channel::(saikuro_exec::ChannelCapacity::MIN); let handle = ProviderHandle::new( "dropped-provider".to_owned(), vec!["dropped".to_owned()], @@ -313,7 +319,7 @@ fn channel_open_to_dropped_provider_returns_unavailable() { drop(work_rx); let router = InvocationRouter::with_providers(registry); - let env = Envelope::channel_open("dropped.open", vec![]); + let env = Envelope::channel_open("dropped.open", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; assert!(!resp.ok); @@ -332,7 +338,8 @@ fn channel_pause_resume_round_trips() { let (registry, mut work_rx) = common::make_provider("bpressure"); let router = InvocationRouter::with_providers(registry); - let open_env = Envelope::channel_open("bpressure.stream", vec![]); + let open_env = + Envelope::channel_open("bpressure.stream", vec![]).expect("entropy available"); let channel_id = open_env.id; saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); @@ -379,3 +386,48 @@ fn channel_pause_resume_round_trips() { assert_eq!(received2.stream_control, Some(StreamControl::Resume)); }) } + +#[test] +fn concurrent_channel_delivery_preserves_order_and_terminal_closure() { + saikuro_exec::block_on(async { + let id = InvocationId::new().expect("entropy available"); + let (inbound_tx, mut inbound_rx) = mpsc::channel(saikuro_exec::ChannelCapacity::MIN); + let (outbound_tx, mut outbound_rx) = mpsc::channel(saikuro_exec::ChannelCapacity::MIN); + inbound_tx + .send(ResponseEnvelope::ok_empty(id)) + .await + .expect("receiver remains open"); + let state = ChannelState::new(inbound_tx, outbound_tx); + + let first = state.deliver(channel_item(id, 0, Value::Int(0)), true); + pin_mut!(first); + assert!(matches!(poll!(first.as_mut()), Poll::Pending)); + + let terminal = state.deliver(channel_end(id, 1), true); + pin_mut!(terminal); + assert!(matches!(poll!(terminal.as_mut()), Poll::Pending)); + + assert!(inbound_rx.recv().await.is_some()); + assert_eq!(first.await, DeliveryOutcome::Delivered); + assert_eq!( + inbound_rx.recv().await.and_then(|response| response.seq), + Some(0) + ); + assert_eq!(terminal.await, DeliveryOutcome::Terminal); + assert_eq!( + inbound_rx.recv().await.and_then(|response| response.seq), + Some(1) + ); + + assert_eq!( + state + .deliver(channel_item(id, 0, Value::Int(9)), false) + .await, + DeliveryOutcome::Closed + ); + assert!( + outbound_rx.try_recv().is_err(), + "post-terminal frame was not delivered" + ); + }) +} diff --git a/Build/tests/tests/common/mod.rs b/Build/tests/tests/common/mod.rs index 5e6f678c..d62a2faa 100644 --- a/Build/tests/tests/common/mod.rs +++ b/Build/tests/tests/common/mod.rs @@ -7,7 +7,7 @@ use saikuro_core::{ TypeDescriptor, TypeMap, Visibility, }, value::Value, - ResponseEnvelope, + RegistrationToken, ResponseEnvelope, }; use saikuro_exec::mpsc; use saikuro_router::{ @@ -24,7 +24,9 @@ use saikuro_transport::{ }; pub fn make_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver) { - let (work_tx, work_rx) = mpsc::channel::(64); + let (work_tx, work_rx) = mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(64).expect("64 is a valid channel capacity"), + ); let handle = ProviderHandle::new( format!("{namespace}-provider"), vec![namespace.to_owned()], @@ -73,7 +75,7 @@ pub fn schema_to_value(schema: &Schema) -> Value { } pub fn make_announce_envelope(schema: &Schema) -> Envelope { - Envelope::announce(schema_to_value(schema)) + Envelope::announce(schema_to_value(schema)).expect("entropy available") } pub fn register_namespace(registry: &SchemaRegistry, namespace: &str, function: &str) { @@ -97,6 +99,7 @@ pub async fn round_trip_via_handler( let handler = ConnectionHandler { peer_id: "test-peer".to_owned(), + registration_token: RegistrationToken::new(), sender: handler_sender, receiver: handler_receiver, validator, diff --git a/Build/tests/tests/cross_language_wire.rs b/Build/tests/tests/cross_language_wire.rs index cb089800..e28fb3cb 100644 --- a/Build/tests/tests/cross_language_wire.rs +++ b/Build/tests/tests/cross_language_wire.rs @@ -150,7 +150,8 @@ fn a_rust_provider_simulated_client_call() { let (mut tx, mut rx) = connect_simulated_peer(&handle, "py-client"); // 3: Send a raw `Call` envelope (exactly what Python SaikuroClient does). - let call_env = Envelope::call("math.add", vec![Value::Int(3), Value::Int(7)]); + let call_env = Envelope::call("math.add", vec![Value::Int(3), Value::Int(7)]) + .expect("entropy available"); let call_id = call_env.id; tx.send(encode_envelope(&call_env)) .await @@ -194,7 +195,8 @@ fn l_csharp_style_client_wire_fidelity() { // Encode exactly as a C# adapter would: named-field MessagePack. // C# adapters use the same rmp_serde::to_vec_named encoding as TypeScript. - let env = Envelope::call("buf.len", vec![Value::Bytes(b"hello".to_vec())]); + let env = Envelope::call("buf.len", vec![Value::Bytes(b"hello".to_vec())]) + .expect("entropy available"); let id = env.id; let raw = rmp_serde::to_vec_named(&env).expect("csharp-style encode"); tx.send(Bytes::from(raw)).await.expect("send"); @@ -384,7 +386,8 @@ fn n_rust_adapter_provider_serves_simulated_client() { // Connect a simulated client and call `words.reverse`. let (mut client_tx, mut client_rx) = connect_simulated_peer(&handle, "n-sim-client"); - let call = Envelope::call("words.reverse", vec![Value::String("saikuro".into())]); + let call = Envelope::call("words.reverse", vec![Value::String("saikuro".into())]) + .expect("entropy available"); let call_id = call.id; client_tx .send(encode_envelope(&call)) @@ -421,7 +424,7 @@ fn b_simulated_provider_rust_client_dispatch() { // 2: Send an Announce so the runtime learns about `greeter.hello`. let schema = make_schema("greeter", "hello"); - let announce = Envelope::announce(schema_to_value(&schema)); + let announce = Envelope::announce(schema_to_value(&schema)).expect("entropy available"); provider_tx .send(encode_envelope(&announce)) .await @@ -455,7 +458,7 @@ fn b_simulated_provider_rust_client_dispatch() { saikuro_exec::sleep(std::time::Duration::from_millis(20)).await; // 5: Rust-side dispatch through handle (simulates any Rust caller). - let call = Envelope::call("greeter.hello", vec![]); + let call = Envelope::call("greeter.hello", vec![]).expect("entropy available"); let resp = handle.dispatch(call, &CapabilitySet::empty()).await; assert!(resp.ok, "greeter.hello call must succeed: {:?}", resp.error); @@ -491,7 +494,7 @@ fn c_rust_and_simulated_providers_coexist() { let (mut ext_tx, mut ext_rx) = connect_simulated_peer(&handle, "ext-provider"); let ext_schema = make_schema_with_args("ext", "echo", 1); - let announce = Envelope::announce(schema_to_value(&ext_schema)); + let announce = Envelope::announce(schema_to_value(&ext_schema)).expect("entropy available"); ext_tx .send(encode_envelope(&announce)) .await @@ -519,7 +522,7 @@ fn c_rust_and_simulated_providers_coexist() { let (mut client_tx, mut client_rx) = connect_simulated_peer(&handle, "shared-client"); // Call the Rust provider. - let ping = Envelope::call("svc.ping", vec![]); + let ping = Envelope::call("svc.ping", vec![]).expect("entropy available"); let ping_id = ping.id; client_tx .send(encode_envelope(&ping)) @@ -532,7 +535,8 @@ fn c_rust_and_simulated_providers_coexist() { assert_eq!(ping_resp.result, Some(Value::String("pong".into()))); // Call the simulated external provider. - let echo = Envelope::call("ext.echo", vec![Value::String("hello".into())]); + let echo = Envelope::call("ext.echo", vec![Value::String("hello".into())]) + .expect("entropy available"); let echo_id = echo.id; client_tx .send(encode_envelope(&echo)) @@ -575,9 +579,9 @@ fn d_batch_call_from_simulated_client() { let (mut tx, mut rx) = connect_simulated_peer(&handle, "batch-client"); // Build two inner Call envelopes. - let item_a = Envelope::call("items.get", vec![Value::Int(1)]); - let item_b = Envelope::call("items.get", vec![Value::Int(2)]); - let batch_id = InvocationId::new(); + let item_a = Envelope::call("items.get", vec![Value::Int(1)]).expect("entropy available"); + let item_b = Envelope::call("items.get", vec![Value::Int(2)]).expect("entropy available"); + let batch_id = InvocationId::new().expect("entropy available"); // Construct the batch envelope exactly as TypeScript/Python adapters do. let batch_env = Envelope { @@ -624,7 +628,7 @@ fn e_call_unknown_namespace_returns_error_on_wire() { let (mut tx, mut rx) = connect_simulated_peer(&handle, "err-client"); - let env = Envelope::call("nope.fn", vec![]); + let env = Envelope::call("nope.fn", vec![]).expect("entropy available"); let id = env.id; tx.send(encode_envelope(&env)).await.expect("send"); @@ -690,7 +694,7 @@ fn f_announce_then_client_call_round_trip() { let (mut prov_tx, mut prov_rx) = connect_simulated_peer(&handle, "prov-f"); let schema = make_schema_with_args("calc", "square", 1); - let announce = Envelope::announce(schema_to_value(&schema)); + let announce = Envelope::announce(schema_to_value(&schema)).expect("entropy available"); prov_tx .send(encode_envelope(&announce)) .await @@ -719,7 +723,7 @@ fn f_announce_then_client_call_round_trip() { // Simulated client let (mut cli_tx, mut cli_rx) = connect_simulated_peer(&handle, "cli-f"); - let call = Envelope::call("calc.square", vec![Value::Int(9)]); + let call = Envelope::call("calc.square", vec![Value::Int(9)]).expect("entropy available"); let call_id = call.id; cli_tx .send(encode_envelope(&call)) @@ -765,7 +769,8 @@ fn g_concurrent_simulated_clients() { tasks.push(saikuro_exec::spawn(async move { let (mut tx, mut rx) = connect_simulated_peer(&handle_clone, &format!("concurrent-client-{i}")); - let env = Envelope::call("echo.run", vec![Value::Int(i as i64)]); + let env = Envelope::call("echo.run", vec![Value::Int(i as i64)]) + .expect("entropy available"); let id = env.id; tx.send(encode_envelope(&env)).await.expect("send"); let frame = rx.recv().await.expect("recv").expect("frame"); @@ -807,7 +812,8 @@ fn h_cast_fire_and_forget_returns_ok_empty() { let (mut tx, mut rx) = connect_simulated_peer(&handle, "cast-client"); - let cast = Envelope::cast("logger.info", vec![Value::String("fire!".into())]); + let cast = Envelope::cast("logger.info", vec![Value::String("fire!".into())]) + .expect("entropy available"); let cast_id = cast.id; tx.send(encode_envelope(&cast)).await.expect("send cast"); @@ -836,7 +842,7 @@ fn i_provider_reconnect_and_reannounce() { let (mut prov_tx, mut prov_rx) = connect_simulated_peer(&handle, "reconnect-prov-v1"); let schema = make_schema("svc2", "op"); - let announce = Envelope::announce(schema_to_value(&schema)); + let announce = Envelope::announce(schema_to_value(&schema)).expect("entropy available"); prov_tx .send(encode_envelope(&announce)) .await @@ -860,7 +866,7 @@ fn i_provider_reconnect_and_reannounce() { saikuro_exec::sleep(std::time::Duration::from_millis(20)).await; let (mut cli_tx, mut cli_rx) = connect_simulated_peer(&handle, "cli-reconnect-v1"); - let call = Envelope::call("svc2.op", vec![]); + let call = Envelope::call("svc2.op", vec![]).expect("entropy available"); cli_tx.send(encode_envelope(&call)).await.expect("send"); let resp = decode_response(cli_rx.recv().await.unwrap().unwrap()); assert!(resp.ok, "v1 call must succeed"); @@ -877,7 +883,7 @@ fn i_provider_reconnect_and_reannounce() { let (mut prov2_tx, mut prov2_rx) = connect_simulated_peer(&handle, "reconnect-prov-v2"); let schema2 = make_schema("svc2", "op"); - let announce2 = Envelope::announce(schema_to_value(&schema2)); + let announce2 = Envelope::announce(schema_to_value(&schema2)).expect("entropy available"); prov2_tx .send(encode_envelope(&announce2)) .await @@ -899,7 +905,7 @@ fn i_provider_reconnect_and_reannounce() { saikuro_exec::sleep(std::time::Duration::from_millis(20)).await; let (mut cli2_tx, mut cli2_rx) = connect_simulated_peer(&handle, "cli-reconnect-v2"); - let call2 = Envelope::call("svc2.op", vec![]); + let call2 = Envelope::call("svc2.op", vec![]).expect("entropy available"); cli2_tx .send(encode_envelope(&call2)) .await @@ -939,7 +945,8 @@ fn j_typescript_style_client_wire_fidelity() { let (mut tx, mut rx) = connect_simulated_peer(&handle, "ts-client"); // Encode exactly as TypeScript does: named field map via rmp_serde::to_vec_named. - let env = Envelope::call("str.upper", vec![Value::String("hello".into())]); + let env = Envelope::call("str.upper", vec![Value::String("hello".into())]) + .expect("entropy available"); let id = env.id; let raw = rmp_serde::to_vec_named(&env).expect("ts-style encode"); tx.send(Bytes::from(raw)).await.expect("send"); @@ -980,7 +987,7 @@ fn k_response_id_always_matches_request_id() { // Send 20 calls pipelined: don't wait for each response. let mut sent_ids: Vec = Vec::new(); for _ in 0..20 { - let env = Envelope::call("id_check.fn", vec![]); + let env = Envelope::call("id_check.fn", vec![]).expect("entropy available"); sent_ids.push(env.id); tx.send(encode_envelope(&env)).await.expect("send"); } diff --git a/Build/tests/tests/envelope_roundtrip.rs b/Build/tests/tests/envelope_roundtrip.rs index 0b0dd747..59fd611a 100644 --- a/Build/tests/tests/envelope_roundtrip.rs +++ b/Build/tests/tests/envelope_roundtrip.rs @@ -25,7 +25,8 @@ fn roundtrip_response(resp: &ResponseEnvelope) -> ResponseEnvelope { #[test] fn call_envelope_roundtrip() { - let env = Envelope::call("math.add", vec![Value::Int(1), Value::Int(2)]); + let env = + Envelope::call("math.add", vec![Value::Int(1), Value::Int(2)]).expect("entropy available"); let decoded = roundtrip_envelope(&env); assert_eq!(decoded.version, PROTOCOL_VERSION); assert_eq!(decoded.invocation_type, InvocationType::Call); @@ -40,7 +41,8 @@ fn call_envelope_roundtrip() { #[test] fn cast_envelope_roundtrip() { - let env = Envelope::cast("logger.info", vec![Value::String("hello".into())]); + let env = Envelope::cast("logger.info", vec![Value::String("hello".into())]) + .expect("entropy available"); let decoded = roundtrip_envelope(&env); assert_eq!(decoded.invocation_type, InvocationType::Cast); assert_eq!(decoded.args[0], Value::String("hello".into())); @@ -48,14 +50,15 @@ fn cast_envelope_roundtrip() { #[test] fn stream_open_envelope_roundtrip() { - let env = Envelope::stream_open("events.subscribe", vec![Value::String("topic".into())]); + let env = Envelope::stream_open("events.subscribe", vec![Value::String("topic".into())]) + .expect("entropy available"); let decoded = roundtrip_envelope(&env); assert_eq!(decoded.invocation_type, InvocationType::Stream); } #[test] fn channel_open_envelope_roundtrip() { - let env = Envelope::channel_open("chat.session", vec![]); + let env = Envelope::channel_open("chat.session", vec![]).expect("entropy available"); let decoded = roundtrip_envelope(&env); assert_eq!(decoded.invocation_type, InvocationType::Channel); assert!(decoded.args.is_empty()); @@ -63,7 +66,7 @@ fn channel_open_envelope_roundtrip() { #[test] fn envelope_with_capability_roundtrip() { - let mut env = Envelope::call("secure.op", vec![]); + let mut env = Envelope::call("secure.op", vec![]).expect("entropy available"); env.capability = Some(CapabilityToken::new("admin:write")); let decoded = roundtrip_envelope(&env); assert_eq!( @@ -74,7 +77,7 @@ fn envelope_with_capability_roundtrip() { #[test] fn envelope_with_meta_roundtrip() { - let mut env = Envelope::call("trace.op", vec![]); + let mut env = Envelope::call("trace.op", vec![]).expect("entropy available"); env.meta .insert("trace-id".into(), Value::String("abc-123".into())) .ok(); @@ -86,14 +89,14 @@ fn envelope_with_meta_roundtrip() { #[test] fn envelope_meta_serializes_canonically_regardless_of_insertion_order() { - let id = InvocationId::new(); - let mut a = Envelope::call("trace.op", vec![]); + let id = InvocationId::new().expect("entropy available"); + let mut a = Envelope::call("trace.op", vec![]).expect("entropy available"); a.id = id; a.meta.insert("z".into(), Value::Int(1)).ok(); a.meta.insert("a".into(), Value::Int(2)).ok(); a.meta.insert("m".into(), Value::Int(3)).ok(); - let mut b = Envelope::call("trace.op", vec![]); + let mut b = Envelope::call("trace.op", vec![]).expect("entropy available"); b.id = id; b.meta.insert("m".into(), Value::Int(3)).ok(); b.meta.insert("a".into(), Value::Int(2)).ok(); @@ -106,9 +109,11 @@ fn envelope_meta_serializes_canonically_regardless_of_insertion_order() { #[test] fn batch_envelope_roundtrip() { - let item1 = Envelope::call("math.add", vec![Value::Int(1), Value::Int(2)]); - let item2 = Envelope::call("math.mul", vec![Value::Int(3), Value::Int(4)]); - let mut env = Envelope::call("", vec![]); + let item1 = + Envelope::call("math.add", vec![Value::Int(1), Value::Int(2)]).expect("entropy available"); + let item2 = + Envelope::call("math.mul", vec![Value::Int(3), Value::Int(4)]).expect("entropy available"); + let mut env = Envelope::call("", vec![]).expect("entropy available"); env.invocation_type = InvocationType::Batch; env.target = String::new(); env.batch_items = Some(vec![item1, item2]); @@ -122,7 +127,7 @@ fn batch_envelope_roundtrip() { #[test] fn stream_item_with_seq_roundtrip() { - let id = InvocationId::new(); + let id = InvocationId::new().expect("entropy available"); let resp = ResponseEnvelope::stream_item(id, 42, Value::Float(std::f64::consts::PI)); let decoded = roundtrip_response(&resp); assert!(decoded.ok); @@ -133,7 +138,7 @@ fn stream_item_with_seq_roundtrip() { #[test] fn stream_end_sentinel_roundtrip() { - let id = InvocationId::new(); + let id = InvocationId::new().expect("entropy available"); let resp = ResponseEnvelope::stream_end(id, 99); let decoded = roundtrip_response(&resp); assert!(decoded.ok); @@ -144,7 +149,7 @@ fn stream_end_sentinel_roundtrip() { #[test] fn error_response_roundtrip() { - let id = InvocationId::new(); + let id = InvocationId::new().expect("entropy available"); let detail = ErrorDetail::new(ErrorCode::FunctionNotFound, "no such fn"); let resp = ResponseEnvelope::err(id, detail); let decoded = roundtrip_response(&resp); @@ -174,7 +179,7 @@ fn value_all_variants_roundtrip() { ]; for v in &cases { - let env = Envelope::call("ns.fn", vec![v.clone()]); + let env = Envelope::call("ns.fn", vec![v.clone()]).expect("entropy available"); let decoded = roundtrip_envelope(&env); assert_eq!( &decoded.args[0], diff --git a/Build/tests/tests/error_propagation.rs b/Build/tests/tests/error_propagation.rs index e5560ec7..181045f0 100644 --- a/Build/tests/tests/error_propagation.rs +++ b/Build/tests/tests/error_propagation.rs @@ -177,7 +177,7 @@ fn error_detail_display_includes_code_and_message() { #[test] fn error_response_survives_msgpack_roundtrip() { - let id = InvocationId::new(); + let id = InvocationId::new().expect("entropy available"); let detail = ErrorDetail::new(ErrorCode::InvalidArguments, "bad types") .with_detail("arg", Value::String("x".into())) .unwrap(); @@ -220,7 +220,7 @@ fn all_error_codes_survive_msgpack_roundtrip() { ]; for code in codes { - let id = InvocationId::new(); + let id = InvocationId::new().expect("entropy available"); let detail = ErrorDetail::new(code.clone(), format!("test for {code:?}")); let resp = ResponseEnvelope::err(id, detail); let bytes = resp.to_msgpack().expect("serialize"); @@ -245,7 +245,9 @@ fn provider_returns_error_response_to_caller() { router::InvocationRouter, }; - let (work_tx, mut work_rx) = mpsc::channel::(4); + let (work_tx, mut work_rx) = mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), + ); let handle = ProviderHandle::new("failing", vec!["fail".to_owned()], work_tx); let registry = ProviderRegistry::new(); registry.register(handle); @@ -261,7 +263,7 @@ fn provider_returns_error_response_to_caller() { }); let router = InvocationRouter::with_providers(registry); - let env = Envelope::call("fail.op", vec![]); + let env = Envelope::call("fail.op", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; assert!(!resp.ok, "call to failing provider should not be ok"); diff --git a/Build/tests/tests/exec_channels.rs b/Build/tests/tests/exec_channels.rs index 2bc20138..c9738bbe 100644 --- a/Build/tests/tests/exec_channels.rs +++ b/Build/tests/tests/exec_channels.rs @@ -7,12 +7,28 @@ use saikuro_exec::{mpsc, oneshot, watch}; use std::time::Duration; +fn capacity(value: usize) -> saikuro_exec::ChannelCapacity { + saikuro_exec::ChannelCapacity::try_from(value).expect("test channel capacity must be valid") +} + +#[test] +fn channel_capacity_enforces_shared_backend_bounds() { + use saikuro_exec::ChannelCapacity; + + assert_eq!(ChannelCapacity::MIN.get(), 1); + assert_eq!(ChannelCapacity::MAX.get(), 256); + assert_eq!(ChannelCapacity::try_from(1), Ok(ChannelCapacity::MIN)); + assert_eq!(ChannelCapacity::try_from(256), Ok(ChannelCapacity::MAX)); + assert_eq!(ChannelCapacity::try_from(0).unwrap_err().value(), 0); + assert_eq!(ChannelCapacity::try_from(257).unwrap_err().value(), 257); +} + // MPSC #[test] fn mpsc_send_recv_single() { saikuro_exec::block_on(async { - let (tx, mut rx) = mpsc::channel::(16); + let (tx, mut rx) = mpsc::channel::(capacity(16)); tx.send(42).await.unwrap(); assert_eq!(rx.recv().await, Some(42)); }) @@ -21,7 +37,7 @@ fn mpsc_send_recv_single() { #[test] fn mpsc_send_recv_multiple_in_order() { saikuro_exec::block_on(async { - let (tx, mut rx) = mpsc::channel::(32); + let (tx, mut rx) = mpsc::channel::(capacity(32)); for i in 0..10 { tx.send(i).await.unwrap(); } @@ -34,7 +50,7 @@ fn mpsc_send_recv_multiple_in_order() { #[test] fn mpsc_backpressure_sender_waits() { saikuro_exec::block_on(async { - let (tx, mut rx) = mpsc::channel::(3); + let (tx, mut rx) = mpsc::channel::(capacity(3)); // Fill the buffer: 3 slots. tx.send(1).await.unwrap(); tx.send(2).await.unwrap(); @@ -63,7 +79,7 @@ fn mpsc_backpressure_sender_waits() { #[test] fn mpsc_try_send_on_full_channel() { saikuro_exec::block_on(async { - let (tx, _rx) = mpsc::channel::(2); + let (tx, _rx) = mpsc::channel::(capacity(2)); tx.send(1).await.unwrap(); tx.send(2).await.unwrap(); // Channel is full; try_send should fail with the value returned. @@ -74,7 +90,7 @@ fn mpsc_try_send_on_full_channel() { #[test] fn mpsc_try_send_on_closed_channel() { saikuro_exec::block_on(async { - let (tx, rx) = mpsc::channel::(2); + let (tx, rx) = mpsc::channel::(capacity(2)); drop(rx); // Allow the drop to propagate. saikuro_exec::yield_now().await; @@ -85,7 +101,7 @@ fn mpsc_try_send_on_closed_channel() { #[test] fn mpsc_sender_clone() { saikuro_exec::block_on(async { - let (tx1, mut rx) = mpsc::channel::<&'static str>(8); + let (tx1, mut rx) = mpsc::channel::<&'static str>(capacity(8)); let tx2 = tx1.clone(); tx1.send("from-1").await.unwrap(); tx2.send("from-2").await.unwrap(); @@ -101,7 +117,7 @@ fn mpsc_sender_clone() { #[test] fn mpsc_send_after_all_receivers_dropped_errors() { saikuro_exec::block_on(async { - let (tx, rx) = mpsc::channel::(8); + let (tx, rx) = mpsc::channel::(capacity(8)); drop(rx); let result = tx.send(7).await; assert!(result.is_err(), "send should fail after receiver dropped"); @@ -111,7 +127,7 @@ fn mpsc_send_after_all_receivers_dropped_errors() { #[test] fn mpsc_recv_returns_none_when_all_senders_dropped() { saikuro_exec::block_on(async { - let (tx, mut rx) = mpsc::channel::(8); + let (tx, mut rx) = mpsc::channel::(capacity(8)); tx.send(1).await.unwrap(); drop(tx); // The buffered message must still be received. @@ -124,7 +140,7 @@ fn mpsc_recv_returns_none_when_all_senders_dropped() { #[test] fn mpsc_large_message() { saikuro_exec::block_on(async { - let (tx, mut rx) = mpsc::channel::>(8); + let (tx, mut rx) = mpsc::channel::>(capacity(8)); let big = vec![0xABu8; 1024 * 1024]; // 1 MiB tx.send(big.clone()).await.unwrap(); let got = rx.recv().await.unwrap(); @@ -137,7 +153,7 @@ fn mpsc_large_message() { #[test] fn mpsc_many_messages_in_order() { saikuro_exec::block_on(async { - let (tx, mut rx) = mpsc::channel::(1024); + let (tx, mut rx) = mpsc::channel::(saikuro_exec::ChannelCapacity::MAX); let n = 5000u64; let tx_clone = tx.clone(); let producer = saikuro_exec::spawn(async move { @@ -159,7 +175,7 @@ fn mpsc_many_messages_in_order() { #[test] fn mpsc_is_closed() { saikuro_exec::block_on(async { - let (tx, rx) = mpsc::channel::(8); + let (tx, rx) = mpsc::channel::(capacity(8)); assert!(!tx.is_closed()); drop(rx); saikuro_exec::yield_now().await; @@ -171,7 +187,7 @@ fn mpsc_is_closed() { #[test] fn mpsc_multiple_concurrent_senders() { saikuro_exec::block_on(async { - let (tx, mut rx) = mpsc::channel::(256); + let (tx, mut rx) = mpsc::channel::(saikuro_exec::ChannelCapacity::MAX); let mut handles = Vec::new(); for i in 0..10 { let t = tx.clone(); diff --git a/Build/tests/tests/exec_select.rs b/Build/tests/tests/exec_select.rs index b957c403..f87b5eb0 100644 --- a/Build/tests/tests/exec_select.rs +++ b/Build/tests/tests/exec_select.rs @@ -5,13 +5,17 @@ use saikuro_exec::{mpsc, oneshot, select}; +fn capacity(value: usize) -> saikuro_exec::ChannelCapacity { + saikuro_exec::ChannelCapacity::try_from(value).expect("test channel capacity must be valid") +} + // BASIC SELECT #[test] fn select_first_ready_branch_wins() { saikuro_exec::block_on(async { - let (tx1, mut rx1) = mpsc::channel::(8); - let (tx2, mut rx2) = mpsc::channel::(8); + let (tx1, mut rx1) = mpsc::channel::(capacity(8)); + let (tx2, mut rx2) = mpsc::channel::(capacity(8)); tx1.send(10).await.unwrap(); tx2.send(20).await.unwrap(); @@ -36,7 +40,7 @@ fn select_first_ready_branch_wins() { fn select_with_oneshot_and_mpsc() { saikuro_exec::block_on(async { let (otx, orx) = oneshot::channel::<&'static str>(); - let (mtx, mut mrx) = mpsc::channel::(8); + let (mtx, mut mrx) = mpsc::channel::(capacity(8)); mtx.send(7).await.unwrap(); otx.send("oneshot").unwrap(); @@ -70,7 +74,7 @@ fn select_with_oneshot_and_mpsc() { #[test] fn select_pattern_matching_extracts_value() { saikuro_exec::block_on(async { - let (tx, mut rx) = mpsc::channel::(8); + let (tx, mut rx) = mpsc::channel::(capacity(8)); tx.send(99).await.unwrap(); select! { @@ -84,7 +88,7 @@ fn select_pattern_matching_extracts_value() { #[test] fn select_non_exhaustive_pattern_skipped() { saikuro_exec::block_on(async { - let (tx, mut rx) = mpsc::channel::>(8); + let (tx, mut rx) = mpsc::channel::>(capacity(8)); tx.send(Some(42)).await.unwrap(); // Await the receiver directly to avoid double-borrowing in select @@ -100,8 +104,8 @@ fn select_non_exhaustive_pattern_skipped() { #[test] fn select_first_branch_preferred_when_both_ready() { saikuro_exec::block_on(async { - let (tx1, mut rx1) = mpsc::channel::(8); - let (tx2, mut rx2) = mpsc::channel::(8); + let (tx1, mut rx1) = mpsc::channel::(capacity(8)); + let (tx2, mut rx2) = mpsc::channel::(capacity(8)); tx1.send(1).await.unwrap(); tx2.send(2).await.unwrap(); @@ -122,7 +126,7 @@ fn select_first_branch_preferred_when_both_ready() { #[test] fn select_yields_when_no_branch_ready() { saikuro_exec::block_on(async { - let (tx, mut rx) = mpsc::channel::(8); + let (tx, mut rx) = mpsc::channel::(capacity(8)); let sender = saikuro_exec::spawn(async move { saikuro_exec::sleep(std::time::Duration::from_millis(20)).await; tx.send(7).await.unwrap(); @@ -139,10 +143,10 @@ fn select_yields_when_no_branch_ready() { #[test] fn select_one_branch_never_ready_other_receives() { saikuro_exec::block_on(async { - let (tx, mut dead_rx) = mpsc::channel::(8); + let (tx, mut dead_rx) = mpsc::channel::(capacity(8)); drop(tx); - let (live_tx, mut live_rx) = mpsc::channel::(8); + let (live_tx, mut live_rx) = mpsc::channel::(capacity(8)); live_tx.send(42).await.unwrap(); select! { @@ -161,9 +165,9 @@ fn select_one_branch_never_ready_other_receives() { #[test] fn select_with_three_branches() { saikuro_exec::block_on(async { - let (_tx1, mut rx1) = mpsc::channel::(8); - let (tx2, mut rx2) = mpsc::channel::(8); - let (_tx3, mut rx3) = mpsc::channel::(8); + let (_tx1, mut rx1) = mpsc::channel::(capacity(8)); + let (tx2, mut rx2) = mpsc::channel::(capacity(8)); + let (_tx3, mut rx3) = mpsc::channel::(capacity(8)); tx2.send(2).await.unwrap(); @@ -180,10 +184,10 @@ fn select_with_three_branches() { #[test] fn select_on_closed_channel_picks_other_branch() { saikuro_exec::block_on(async { - let (tx, mut closed_rx) = mpsc::channel::(8); + let (tx, mut closed_rx) = mpsc::channel::(capacity(8)); drop(tx); - let (live_tx, mut live_rx) = mpsc::channel::<&'static str>(8); + let (live_tx, mut live_rx) = mpsc::channel::<&'static str>(capacity(8)); live_tx.send("alive").await.unwrap(); select! { @@ -204,7 +208,7 @@ fn select_mpsc_then_oneshot_sequentially() { saikuro_exec::block_on(async { // First select: mpsc fires. let (_otx, orx) = oneshot::channel::<&'static str>(); - let (mtx, mut mrx) = mpsc::channel::(8); + let (mtx, mut mrx) = mpsc::channel::(capacity(8)); mtx.send(5).await.unwrap(); let mut result = None; @@ -219,7 +223,7 @@ fn select_mpsc_then_oneshot_sequentially() { // Second select: oneshot fires. let (otx2, orx2) = oneshot::channel::<&'static str>(); otx2.send("hello").unwrap(); - let (_mtx2, mut mrx2) = mpsc::channel::(8); + let (_mtx2, mut mrx2) = mpsc::channel::(capacity(8)); let mut msg_result = None; select! { diff --git a/Build/tests/tests/log_dispatch.rs b/Build/tests/tests/log_dispatch.rs index 11e13f69..8a70b2b2 100644 --- a/Build/tests/tests/log_dispatch.rs +++ b/Build/tests/tests/log_dispatch.rs @@ -35,7 +35,7 @@ fn make_log_envelope(level: LogLevel, name: &str, msg: &str) -> Envelope { Envelope { version: PROTOCOL_VERSION, invocation_type: InvocationType::Log, - id: InvocationId::new(), + id: InvocationId::new().expect("entropy available"), target: "$log".to_owned(), args: vec![value], meta: Default::default(), @@ -57,7 +57,9 @@ fn make_router_with_sink(sink: LogSink) -> InvocationRouter { fn log_envelope_is_not_routed_to_provider() { saikuro_exec::block_on(async { // Even with a registered provider, a Log envelope must NOT reach it. - let (work_tx, mut work_rx) = mpsc::channel::(8); + let (work_tx, mut work_rx) = mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(8).expect("8 is a valid channel capacity"), + ); let handle = ProviderHandle::new("logger", vec!["$log".to_owned()], work_tx); let registry = ProviderRegistry::new(); registry.register(handle); @@ -137,7 +139,7 @@ fn log_envelope_with_no_args_returns_ok_without_panicking() { let env = Envelope { version: PROTOCOL_VERSION, invocation_type: InvocationType::Log, - id: InvocationId::new(), + id: InvocationId::new().expect("entropy available"), target: "$log".to_owned(), args: vec![], meta: Default::default(), @@ -169,7 +171,7 @@ fn log_envelope_with_invalid_args_returns_ok_without_panicking() { let env = Envelope { version: PROTOCOL_VERSION, invocation_type: InvocationType::Log, - id: InvocationId::new(), + id: InvocationId::new().expect("entropy available"), target: "$log".to_owned(), args: vec![Value::String("not a log record".into())], meta: Default::default(), @@ -192,7 +194,9 @@ fn log_envelope_with_invalid_args_returns_ok_without_panicking() { fn router_with_custom_sink_still_routes_calls() { saikuro_exec::block_on(async { // A custom log sink must not interfere with normal call routing. - let (work_tx, work_rx) = mpsc::channel::(8); + let (work_tx, work_rx) = mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(8).expect("8 is a valid channel capacity"), + ); let handle = ProviderHandle::new("math", vec!["math".to_owned()], work_tx); let registry = ProviderRegistry::new(); registry.register(handle); @@ -213,7 +217,7 @@ fn router_with_custom_sink_still_routes_calls() { let (sink, _captured) = capturing_sink(); let router = InvocationRouter::with_log_sink(registry, RouterConfig::default(), sink); - let env = Envelope::call("math.compute", vec![]); + let env = Envelope::call("math.compute", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; assert!(resp.ok, "call should still succeed with custom log sink"); assert_eq!(resp.result, Some(Value::Int(99))); diff --git a/Build/tests/tests/resource_dispatch.rs b/Build/tests/tests/resource_dispatch.rs index a84ce3e0..c5a26173 100644 --- a/Build/tests/tests/resource_dispatch.rs +++ b/Build/tests/tests/resource_dispatch.rs @@ -20,7 +20,9 @@ mod common; /// Build a `ProviderRegistry` with a single provider subscribed to `namespace`. fn make_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver) { - let (work_tx, work_rx) = mpsc::channel::(64); + let (work_tx, work_rx) = mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(64).expect("64 is a valid channel capacity"), + ); let handle = ProviderHandle::new( format!("{namespace}-provider"), vec![namespace.to_owned()], @@ -56,7 +58,8 @@ fn handle_to_value(handle: &ResourceHandle) -> Value { /// check before testing the full dispatch path. #[test] fn resource_envelope_constructor_sets_correct_type() { - let env = Envelope::resource("files.open", vec![Value::String("/tmp/data.csv".into())]); + let env = Envelope::resource("files.open", vec![Value::String("/tmp/data.csv".into())]) + .expect("entropy available"); assert_eq!(env.invocation_type, InvocationType::Resource); assert_eq!(env.target, "files.open"); assert_eq!(env.args.len(), 1); @@ -77,7 +80,8 @@ fn resource_envelope_routes_as_call() { let _responder = spawn_responder(work_rx, result_value); let router = InvocationRouter::with_providers(registry); - let env = Envelope::resource("files.open", vec![Value::String("/tmp/data.csv".into())]); + let env = Envelope::resource("files.open", vec![Value::String("/tmp/data.csv".into())]) + .expect("entropy available"); let resp = router.dispatch(env).await; assert!( @@ -103,7 +107,8 @@ fn resource_envelope_returns_handle_from_provider() { let _responder = spawn_responder(work_rx, result_value); let router = InvocationRouter::with_providers(registry); - let env = Envelope::resource("storage.get", vec![Value::String("xyz-999".into())]); + let env = Envelope::resource("storage.get", vec![Value::String("xyz-999".into())]) + .expect("entropy available"); let resp = router.dispatch(env).await; assert!( @@ -131,7 +136,7 @@ fn resource_to_unknown_namespace_returns_no_provider() { let registry = ProviderRegistry::new(); // empty let router = InvocationRouter::with_providers(registry); - let env = Envelope::resource("missing.open", vec![]); + let env = Envelope::resource("missing.open", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; assert!(!resp.ok, "should fail for unknown namespace"); @@ -150,14 +155,15 @@ fn resource_to_unknown_namespace_returns_no_provider() { #[test] fn resource_to_dropped_provider_returns_unavailable() { saikuro_exec::block_on(async { - let (work_tx, work_rx) = mpsc::channel::(1); + let (work_tx, work_rx) = + mpsc::channel::(saikuro_exec::ChannelCapacity::MIN); let handle = ProviderHandle::new("gone", vec!["blobs".to_owned()], work_tx); let registry = ProviderRegistry::new(); registry.register(handle); drop(work_rx); // provider vanished let router = InvocationRouter::with_providers(registry); - let env = Envelope::resource("blobs.get", vec![]); + let env = Envelope::resource("blobs.get", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; assert!(!resp.ok, "should fail for dropped provider"); @@ -220,7 +226,7 @@ fn resource_dispatch_through_connection_handler() { let schema_registry = SchemaRegistry::new(); common::register_namespace(&schema_registry, "docs", "fetch"); - let env = Envelope::resource("docs.fetch", vec![]); + let env = Envelope::resource("docs.fetch", vec![]).expect("entropy available"); let resp = common::round_trip_via_handler(schema_registry, provider_registry, env).await; @@ -246,7 +252,7 @@ fn resource_to_unknown_namespace_via_handler_returns_namespace_not_found() { let schema_registry = SchemaRegistry::new(); // empty: no namespaces registered let provider_registry = ProviderRegistry::new(); - let env = Envelope::resource("unknown_ns.open", vec![]); + let env = Envelope::resource("unknown_ns.open", vec![]).expect("entropy available"); let resp = common::round_trip_via_handler(schema_registry, provider_registry, env).await; assert!(!resp.ok, "should fail for unregistered namespace"); @@ -271,7 +277,7 @@ fn resource_response_id_matches_request_id() { let _responder = spawn_responder(work_rx, result_value); let router = InvocationRouter::with_providers(registry); - let env = Envelope::resource("corr.get", vec![]); + let env = Envelope::resource("corr.get", vec![]).expect("entropy available"); let request_id = env.id; let resp = router.dispatch(env).await; @@ -300,7 +306,7 @@ fn concurrent_resource_invocations_all_succeed() { for _ in 0..10 { let r = router.clone(); joins.push(saikuro_exec::spawn(async move { - let env = Envelope::resource("bulk.fetch", vec![]); + let env = Envelope::resource("bulk.fetch", vec![]).expect("entropy available"); r.dispatch(env).await })); } diff --git a/Build/tests/tests/sandbox_dispatch.rs b/Build/tests/tests/sandbox_dispatch.rs index 3d369646..4451c697 100644 --- a/Build/tests/tests/sandbox_dispatch.rs +++ b/Build/tests/tests/sandbox_dispatch.rs @@ -103,7 +103,7 @@ fn schema_to_value(schema: &Schema) -> Value { } fn make_announce(schema: &Schema) -> Envelope { - Envelope::announce(schema_to_value(schema)) + Envelope::announce(schema_to_value(schema)).expect("entropy available") } /// Send `envelope` through a `ConnectionHandler` (optionally sandboxed) and @@ -132,6 +132,7 @@ async fn run_and_collect( let handler = ConnectionHandler { peer_id: "sandbox-peer".to_owned(), + registration_token: saikuro_core::RegistrationToken::new(), sender: handler_sender, receiver: handler_receiver, validator, @@ -352,7 +353,7 @@ fn sandbox_handler_denies_internal_function_invocation() { let invoke_env = Envelope { version: PROTOCOL_VERSION, invocation_type: InvocationType::Call, - id: InvocationId::new(), + id: InvocationId::new().expect("entropy available"), target: "svc.internal_fn".to_owned(), args: vec![], meta: Default::default(), diff --git a/Build/tests/tests/schema_validation.rs b/Build/tests/tests/schema_validation.rs index 8ecb090b..20c3c95a 100644 --- a/Build/tests/tests/schema_validation.rs +++ b/Build/tests/tests/schema_validation.rs @@ -83,6 +83,7 @@ fn make_registry_with_math() -> SchemaRegistry { doc: None, }, provider_id: "provider-1".into(), + registration_token: saikuro_core::RegistrationToken::new(), }) .unwrap(); @@ -122,7 +123,8 @@ fn lookup_unknown_function_in_known_namespace() { fn valid_call_passes_validation() { let registry = make_registry_with_math(); let validator = InvocationValidator::new(registry); - let env = Envelope::call("math.add", vec![Value::Int(1), Value::Int(2)]); + let env = + Envelope::call("math.add", vec![Value::Int(1), Value::Int(2)]).expect("entropy available"); assert!(validator.validate(&env).is_ok()); } @@ -132,7 +134,7 @@ fn wrong_arity_fails_validation() { let validator = InvocationValidator::new(registry); // too few args - let env_few = Envelope::call("math.add", vec![Value::Int(1)]); + let env_few = Envelope::call("math.add", vec![Value::Int(1)]).expect("entropy available"); let err = validator.validate(&env_few).unwrap_err(); assert!(matches!(err, ValidationError::ArgumentArity { .. })); assert_eq!(err.error_code(), ErrorCode::InvalidArguments); @@ -141,7 +143,8 @@ fn wrong_arity_fails_validation() { let env_many = Envelope::call( "math.add", vec![Value::Int(1), Value::Int(2), Value::Int(3)], - ); + ) + .expect("entropy available"); let err = validator.validate(&env_many).unwrap_err(); assert!(matches!(err, ValidationError::ArgumentArity { .. })); } @@ -155,7 +158,8 @@ fn wrong_type_fails_validation() { let env = Envelope::call( "math.add", vec![Value::String("hello".into()), Value::Int(2)], - ); + ) + .expect("entropy available"); let err = validator.validate(&env).unwrap_err(); assert!( matches!(err, ValidationError::ArgumentType { .. }), @@ -169,7 +173,7 @@ fn internal_visibility_denied_for_external_callers() { let registry = make_registry_with_math(); let validator = InvocationValidator::new(registry); - let env = Envelope::call("math.internal_op", vec![]); + let env = Envelope::call("math.internal_op", vec![]).expect("entropy available"); let err = validator.validate(&env).unwrap_err(); assert!( matches!(err, ValidationError::VisibilityDenied { .. }), @@ -183,7 +187,7 @@ fn private_function_denied_for_external_callers() { let registry = make_registry_with_math(); let validator = InvocationValidator::new(registry); - let env = Envelope::call("math.secret", vec![]); + let env = Envelope::call("math.secret", vec![]).expect("entropy available"); let err = validator.validate(&env).unwrap_err(); assert!( matches!(err, ValidationError::VisibilityDenied { .. }), @@ -196,7 +200,7 @@ fn batch_with_no_items_fails() { let registry = make_registry_with_math(); let validator = InvocationValidator::new(registry); - let mut env = Envelope::call("", vec![]); + let mut env = Envelope::call("", vec![]).expect("entropy available"); env.invocation_type = InvocationType::Batch; env.target = String::new(); env.batch_items = None; @@ -214,7 +218,7 @@ fn batch_with_empty_items_fails() { let registry = make_registry_with_math(); let validator = InvocationValidator::new(registry); - let mut env = Envelope::call("", vec![]); + let mut env = Envelope::call("", vec![]).expect("entropy available"); env.invocation_type = InvocationType::Batch; env.target = String::new(); env.batch_items = Some(vec![]); @@ -228,7 +232,7 @@ fn malformed_target_without_dot_fails() { let registry = make_registry_with_math(); let validator = InvocationValidator::new(registry); - let env = Envelope::call("nofunctionpart", vec![]); + let env = Envelope::call("nofunctionpart", vec![]).expect("entropy available"); let err = validator.validate(&env).unwrap_err(); assert!( matches!(err, ValidationError::MalformedEnvelope(_)), @@ -277,12 +281,14 @@ fn optional_argument_may_be_omitted() { doc: None, }, provider_id: "p".into(), + registration_token: saikuro_core::RegistrationToken::new(), }) .unwrap(); let validator = InvocationValidator::new(registry); // Providing only the required argument should pass. - let env = Envelope::call("greet.greet", vec![Value::String("Alice".into())]); + let env = Envelope::call("greet.greet", vec![Value::String("Alice".into())]) + .expect("entropy available"); assert!( validator.validate(&env).is_ok(), "one-arg call to two-arg fn (second optional) should pass" diff --git a/Build/tests/tests/stream_dispatch.rs b/Build/tests/tests/stream_dispatch.rs index 7debea2e..34d6d763 100644 --- a/Build/tests/tests/stream_dispatch.rs +++ b/Build/tests/tests/stream_dispatch.rs @@ -1,5 +1,6 @@ //! Stream dispatch tests. +use futures::{pin_mut, poll}; use saikuro_core::{ envelope::{Envelope, StreamControl}, error::ErrorCode, @@ -9,6 +10,8 @@ use saikuro_core::{ }; use saikuro_router::provider::ProviderRegistry; use saikuro_router::router::InvocationRouter; +use saikuro_router::stream_state::{DeliveryOutcome, StreamState}; +use std::task::Poll; mod common; @@ -23,7 +26,8 @@ fn stream_open_returns_ok_empty() { saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); let router = InvocationRouter::with_providers(registry); - let env = Envelope::stream_open("events.subscribe", vec![Value::String("topic".into())]); + let env = Envelope::stream_open("events.subscribe", vec![Value::String("topic".into())]) + .expect("entropy available"); let resp = router.dispatch(env).await; assert!(resp.ok, "stream open should return ok"); @@ -41,7 +45,7 @@ fn route_stream_item_delivers_to_state() { let router = InvocationRouter::with_providers(registry); // Open the stream to register it in the state store. - let open_env = Envelope::stream_open("data.feed", vec![]); + let open_env = Envelope::stream_open("data.feed", vec![]).expect("entropy available"); let stream_id = open_env.id; saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); @@ -62,7 +66,7 @@ fn route_stream_end_removes_state() { let (registry, mut work_rx) = common::make_provider("fin"); let router = InvocationRouter::with_providers(registry); - let open_env = Envelope::stream_open("fin.feed", vec![]); + let open_env = Envelope::stream_open("fin.feed", vec![]).expect("entropy available"); let stream_id = open_env.id; saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); @@ -87,7 +91,7 @@ fn route_to_unknown_stream_returns_error() { let registry = ProviderRegistry::new(); let router = InvocationRouter::with_providers(registry); - let phantom_id = InvocationId::new(); + let phantom_id = InvocationId::new().expect("entropy available"); let item = ResponseEnvelope::stream_item(phantom_id, 0, Value::Null); let err = router.route_stream_item(item).await; assert!(err.is_err(), "routing to non-existent stream should fail"); @@ -100,7 +104,7 @@ fn stream_open_to_unknown_namespace_returns_no_provider() { let registry = ProviderRegistry::new(); let router = InvocationRouter::with_providers(registry); - let env = Envelope::stream_open("ghost.feed", vec![]); + let env = Envelope::stream_open("ghost.feed", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; assert!(!resp.ok); @@ -118,8 +122,8 @@ fn multiple_streams_are_independent() { saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); // Open two streams. - let env1 = Envelope::stream_open("multi.s1", vec![]); - let env2 = Envelope::stream_open("multi.s2", vec![]); + let env1 = Envelope::stream_open("multi.s1", vec![]).expect("entropy available"); + let env2 = Envelope::stream_open("multi.s2", vec![]).expect("entropy available"); let id1 = env1.id; let id2 = env2.id; @@ -154,7 +158,7 @@ fn out_of_order_item_is_dropped_not_panicked() { saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); - let env = Envelope::stream_open("ooo.feed", vec![]); + let env = Envelope::stream_open("ooo.feed", vec![]).expect("entropy available"); let id = env.id; router.dispatch(env).await; @@ -177,7 +181,7 @@ fn stream_abort_control_removes_state() { saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); - let env = Envelope::stream_open("abort.feed", vec![]); + let env = Envelope::stream_open("abort.feed", vec![]).expect("entropy available"); let id = env.id; router.dispatch(env).await; @@ -199,3 +203,42 @@ fn stream_abort_control_removes_state() { assert!(router.route_stream_item(extra).await.is_err()); }) } + +#[test] +fn concurrent_stream_delivery_preserves_order_and_terminal_closure() { + saikuro_exec::block_on(async { + let id = InvocationId::new().expect("entropy available"); + let (tx, mut rx) = saikuro_exec::mpsc::channel(saikuro_exec::ChannelCapacity::MIN); + tx.send(ResponseEnvelope::ok_empty(id)) + .await + .expect("receiver remains open"); + let state = StreamState::new(tx); + + let first = state.deliver(ResponseEnvelope::stream_item(id, 0, Value::Int(0))); + pin_mut!(first); + assert!(matches!(poll!(first.as_mut()), Poll::Pending)); + + let terminal = state.deliver(ResponseEnvelope::stream_end(id, 1)); + pin_mut!(terminal); + assert!(matches!(poll!(terminal.as_mut()), Poll::Pending)); + + assert!(rx.recv().await.is_some()); + assert_eq!(first.await, DeliveryOutcome::Delivered); + assert_eq!(rx.recv().await.and_then(|response| response.seq), Some(0)); + assert_eq!(terminal.await, DeliveryOutcome::Terminal); + let end = rx.recv().await.expect("terminal frame is delivered"); + assert_eq!(end.seq, Some(1)); + assert_eq!(end.stream_control, Some(StreamControl::End)); + + assert_eq!( + state + .deliver(ResponseEnvelope::stream_item(id, 2, Value::Int(2))) + .await, + DeliveryOutcome::Closed + ); + assert!( + rx.try_recv().is_err(), + "post-terminal frame was not delivered" + ); + }) +} From a92dc1ee42f8c9ffeb088067fd5b6cb5055e2a3c Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Tue, 11 Aug 2026 01:44:27 -0600 Subject: [PATCH 11/43] some storage improvements --- Build/Cargo.lock | 20 +- Build/adapters/rust/src/lib.rs | 4 +- Build/crates/saikuro-storage/src/fs_access.rs | 38 +-- Build/crates/saikuro-storage/src/indexeddb.rs | 45 +--- Build/crates/saikuro-storage/src/lib.rs | 31 ++- Build/crates/saikuro-storage/src/opfs.rs | 38 +-- Build/crates/saikuro-storage/src/traits.rs | 76 +++++- Build/crates/saikuro-transport/Cargo.toml | 4 + .../saikuro-transport/src/embedded_io.rs | 176 ++++++++++++++ Build/crates/saikuro-transport/src/lib.rs | 9 + .../saikuro-transport/tests/embedded_io.rs | 216 ++++++++++++++++++ 11 files changed, 539 insertions(+), 118 deletions(-) create mode 100644 Build/crates/saikuro-transport/src/embedded_io.rs create mode 100644 Build/crates/saikuro-transport/tests/embedded_io.rs diff --git a/Build/Cargo.lock b/Build/Cargo.lock index 1a131ba1..c63d5b38 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -406,7 +406,7 @@ checksum = "8d2c8cdff05a7a51ba0087489ea44b0b1d97a296ca6b1d6d1a33ea7423d34049" dependencies = [ "cfg-if", "critical-section", - "embedded-io-async", + "embedded-io-async 0.6.1", "futures-sink", "futures-util", "heapless", @@ -476,13 +476,28 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + [[package]] name = "embedded-io-async" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" dependencies = [ - "embedded-io", + "embedded-io 0.6.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io 0.7.1", ] [[package]] @@ -1489,6 +1504,7 @@ version = "0.1.0" dependencies = [ "async-trait", "bytes", + "embedded-io-async 0.7.0", "futures", "js-sys", "pin-project-lite", diff --git a/Build/adapters/rust/src/lib.rs b/Build/adapters/rust/src/lib.rs index d90d5598..e2eb7114 100644 --- a/Build/adapters/rust/src/lib.rs +++ b/Build/adapters/rust/src/lib.rs @@ -12,7 +12,7 @@ pub mod schema; pub mod transport; pub mod value; -#[cfg(any(feature = "storage", feature = "wasm-storage"))] +#[cfg(all(not(target_arch = "wasm32"), feature = "storage"))] pub mod storage; pub use client::{Client, ClientOptions, SaikuroChannel, SaikuroStream}; @@ -23,5 +23,5 @@ pub use schema::{ArgDescriptor, FunctionSchema, NamespaceSchema}; pub use transport::InMemoryTransport; pub use value::Value; -#[cfg(any(feature = "storage", feature = "wasm-storage"))] +#[cfg(all(not(target_arch = "wasm32"), feature = "storage"))] pub use storage::{create_storage, create_transient_storage}; diff --git a/Build/crates/saikuro-storage/src/fs_access.rs b/Build/crates/saikuro-storage/src/fs_access.rs index 7efb7e37..f8f2a71c 100644 --- a/Build/crates/saikuro-storage/src/fs_access.rs +++ b/Build/crates/saikuro-storage/src/fs_access.rs @@ -1,13 +1,8 @@ #![cfg(target_arch = "wasm32")] -use std::cell::RefCell; -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; - -use async_trait::async_trait; use bytes::Bytes; use js_sys::{ArrayBuffer, Uint8Array}; +use std::cell::RefCell; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; @@ -20,27 +15,15 @@ use web_sys::{ use super::{ config::StorageConfig, error::{Result, StorageError}, - traits::{FileBackend, KeyValueBackend, StorageBackend}, + traits::{LocalFileBackend, LocalKeyValueBackend, LocalStorageBackend}, }; thread_local! { static ROOT_HANDLE: RefCell> = const { RefCell::new(None) }; } -struct SendJsFuture(JsFuture); - -unsafe impl Send for SendJsFuture {} - -impl Future for SendJsFuture { - type Output = ::Output; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - Pin::new(&mut self.get_mut().0).poll(cx) - } -} - -fn promise_await(promise: ::js_sys::Promise) -> SendJsFuture { - SendJsFuture(JsFuture::from(promise)) +fn promise_await(promise: ::js_sys::Promise) -> JsFuture { + JsFuture::from(promise) } async fn pick_directory() -> Result { @@ -346,8 +329,7 @@ impl FsAccessStorage { } } -#[async_trait] -impl KeyValueBackend for FsAccessStorage { +impl LocalKeyValueBackend for FsAccessStorage { fn config(&self) -> &StorageConfig { &self.config } @@ -429,8 +411,7 @@ impl KeyValueBackend for FsAccessStorage { } } -#[async_trait] -impl FileBackend for FsAccessStorage { +impl LocalFileBackend for FsAccessStorage { async fn read_file(&self, path: &str) -> Result { let (dirs, file_name) = navigate_path(path); let parent = self.navigate_to_dir(&dirs, false).await?; @@ -514,16 +495,11 @@ impl FileBackend for FsAccessStorage { } } -#[async_trait] -impl StorageBackend for FsAccessStorage { +impl LocalStorageBackend for FsAccessStorage { fn supports_files(&self) -> bool { true } - fn as_file_backend(&self) -> Option<&dyn FileBackend> { - Some(self) - } - async fn flush(&self) -> Result<()> { Ok(()) } diff --git a/Build/crates/saikuro-storage/src/indexeddb.rs b/Build/crates/saikuro-storage/src/indexeddb.rs index 1ef41511..3a2506fb 100644 --- a/Build/crates/saikuro-storage/src/indexeddb.rs +++ b/Build/crates/saikuro-storage/src/indexeddb.rs @@ -4,14 +4,9 @@ //! that survives page reloads. Enabled automatically when the `wasm-storage` //! feature is active on a `wasm32` target. -use std::cell::RefCell; -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; - -use async_trait::async_trait; use bytes::Bytes; use js_sys::Uint8Array; +use std::cell::RefCell; use wasm_bindgen::{prelude::*, JsCast}; use wasm_bindgen_futures::JsFuture; use web_sys::{ @@ -22,7 +17,7 @@ use web_sys::{ use super::{ config::StorageConfig, error::{Result, StorageError}, - traits::{KeyValueBackend, StorageBackend}, + traits::{LocalKeyValueBackend, LocalStorageBackend}, }; const DB_NAME: &str = "SaikuroStorage"; @@ -33,24 +28,6 @@ thread_local! { static DB_HANDLE: RefCell> = const { RefCell::new(None) }; } -// Send-safe JsFuture wrapper -/// A `JsFuture` wrapper that implements `Send`. -/// -/// SAFETY: On single-threaded `wasm32-unknown-unknown` no `JsValue` ever -/// crosses a thread boundary, so the `Send` requirement of the storage trait -/// is satisfied soundly. -struct SendJsFuture(JsFuture); - -unsafe impl Send for SendJsFuture {} - -impl Future for SendJsFuture { - type Output = ::Output; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - Pin::new(&mut self.get_mut().0).poll(cx) - } -} - // Helpers fn make_key(namespace: &str, key: &str) -> String { format!("{namespace}:{key}") @@ -62,7 +39,7 @@ fn key_prefix(namespace: &str) -> String { /// Convert an `IdbRequest` into a Rust `Future` by wrapping its `onsuccess` /// and `onerror` in a JavaScript Promise. -fn idb_await(request: &IdbRequest) -> SendJsFuture { +fn idb_await(request: &IdbRequest) -> JsFuture { let req = request.clone(); let promise = js_sys::Promise::new(&mut |resolve: js_sys::Function, reject: js_sys::Function| { @@ -87,7 +64,7 @@ fn idb_await(request: &IdbRequest) -> SendJsFuture { req.set_onerror(Some(err.as_ref().unchecked_ref())); err.forget(); }); - SendJsFuture(JsFuture::from(promise)) + JsFuture::from(promise) } /// Convert a JsValue containing an `ArrayBuffer` to `Bytes`. @@ -168,28 +145,28 @@ async fn tx( Ok((transaction, store)) } -fn store_get(store: &IdbObjectStore, key: &JsValue) -> Result { +fn store_get(store: &IdbObjectStore, key: &JsValue) -> Result { let request = store .get(key) .map_err(|_| StorageError::internal("IndexedDB get request failed"))?; Ok(idb_await(&request)) } -fn store_put(store: &IdbObjectStore, key: &JsValue, value: &JsValue) -> Result { +fn store_put(store: &IdbObjectStore, key: &JsValue, value: &JsValue) -> Result { let request = store .put_with_key(value, key) .map_err(|_| StorageError::internal("IndexedDB put request failed"))?; Ok(idb_await(&request)) } -fn store_delete(store: &IdbObjectStore, key: &JsValue) -> Result { +fn store_delete(store: &IdbObjectStore, key: &JsValue) -> Result { let request = store .delete(key) .map_err(|_| StorageError::internal("IndexedDB delete request failed"))?; Ok(idb_await(&request)) } -fn store_get_all_keys(store: &IdbObjectStore, query: Option<&JsValue>) -> Result { +fn store_get_all_keys(store: &IdbObjectStore, query: Option<&JsValue>) -> Result { let request = match query { Some(q) => store.get_all_keys_with_key(q), None => store.get_all_keys(), @@ -246,8 +223,7 @@ impl Default for IndexedDbStorage { } } -#[async_trait] -impl KeyValueBackend for IndexedDbStorage { +impl LocalKeyValueBackend for IndexedDbStorage { fn config(&self) -> &StorageConfig { &self.config } @@ -357,8 +333,7 @@ impl KeyValueBackend for IndexedDbStorage { } } -#[async_trait] -impl StorageBackend for IndexedDbStorage { +impl LocalStorageBackend for IndexedDbStorage { fn supports_files(&self) -> bool { false } diff --git a/Build/crates/saikuro-storage/src/lib.rs b/Build/crates/saikuro-storage/src/lib.rs index 99973d1a..f837226d 100644 --- a/Build/crates/saikuro-storage/src/lib.rs +++ b/Build/crates/saikuro-storage/src/lib.rs @@ -1,13 +1,20 @@ //! Saikuro Storage Backend Abstraction //! -//! Provides a platform-agnostic storage interface for key-value and file-like -//! operations. Works across native (std::fs, databases) and WASM environments -//! (OPFS, IndexedDB, localStorage, sessionStorage). +//! Provides two storage tiers for key-value and file-like operations: +//! +//! - [`StorageBackend`] is the object-safe, `Send + Sync` host API used by +//! native adapters as `Box`. +//! - [`LocalStorageBackend`] and [`LocalKeyValueBackend`] use native async +//! functions for statically selected single-threaded and `no_std` backends. +//! +//! Browser storage implements the local tier because JavaScript handles and +//! their futures are thread-local. Local futures must be awaited on their +//! owning executor and must not be passed to `tokio::spawn`. //! //! The crate is `no_std` + `alloc` without the `std` feature: the config, //! error, trait, and util modules compile for bare-metal MCU targets, and the -//! concrete backends (in-memory, native fs/sled/sqlite, wasm storage) all -//! require `std`. +//! concrete backends (in-memory, native fs/sled/sqlite, wasm storage) require +//! `std`. #![cfg_attr(not(feature = "std"), no_std)] @@ -48,9 +55,8 @@ pub mod session_storage; #[macro_export] macro_rules! impl_web_storage { ($name:ident, $storage_fn:ident) => { - use async_trait::async_trait; use bytes::Bytes; - use $crate::traits::{KeyValueBackend, StorageBackend}; + use $crate::traits::{LocalKeyValueBackend, LocalStorageBackend}; pub struct $name { config: $crate::StorageConfig, @@ -88,8 +94,7 @@ macro_rules! impl_web_storage { } } - #[async_trait] - impl KeyValueBackend for $name { + impl LocalKeyValueBackend for $name { fn config(&self) -> &$crate::StorageConfig { &self.config } @@ -171,8 +176,7 @@ macro_rules! impl_web_storage { } } - #[async_trait] - impl StorageBackend for $name { + impl LocalStorageBackend for $name { fn supports_files(&self) -> bool { false } @@ -182,7 +186,10 @@ macro_rules! impl_web_storage { pub use config::{BackendKind, CleanupPolicy, PersistenceMode, StorageConfig}; pub use error::{Result, StorageError}; -pub use traits::{FileBackend, KeyValueBackend, KeyValueBackendExt, StorageBackend}; +pub use traits::{ + FileBackend, KeyValueBackend, KeyValueBackendExt, LocalFileBackend, LocalKeyValueBackend, + LocalStorageBackend, StorageBackend, +}; #[cfg(feature = "inmemory")] pub use inmemory::InMemoryStorage; diff --git a/Build/crates/saikuro-storage/src/opfs.rs b/Build/crates/saikuro-storage/src/opfs.rs index b31cd3e5..2a75215a 100644 --- a/Build/crates/saikuro-storage/src/opfs.rs +++ b/Build/crates/saikuro-storage/src/opfs.rs @@ -1,11 +1,6 @@ -use std::cell::RefCell; -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; - -use async_trait::async_trait; use bytes::Bytes; use js_sys::{ArrayBuffer, Uint8Array}; +use std::cell::RefCell; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use web_sys::{ @@ -16,25 +11,13 @@ use web_sys::{ use super::{ config::StorageConfig, error::{Result, StorageError}, - traits::{FileBackend, KeyValueBackend, StorageBackend}, + traits::{LocalFileBackend, LocalKeyValueBackend, LocalStorageBackend}, }; const ROOT_DIR_NAME: &str = "SaikuroStorage"; -struct SendJsFuture(JsFuture); - -unsafe impl Send for SendJsFuture {} - -impl Future for SendJsFuture { - type Output = ::Output; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - Pin::new(&mut self.get_mut().0).poll(cx) - } -} - -fn promise_await(promise: ::js_sys::Promise) -> SendJsFuture { - SendJsFuture(JsFuture::from(promise)) +fn promise_await(promise: ::js_sys::Promise) -> JsFuture { + JsFuture::from(promise) } thread_local! { @@ -326,8 +309,7 @@ impl Default for OpfsStorage { } } -#[async_trait] -impl KeyValueBackend for OpfsStorage { +impl LocalKeyValueBackend for OpfsStorage { fn config(&self) -> &StorageConfig { &self.config } @@ -411,8 +393,7 @@ impl KeyValueBackend for OpfsStorage { } } -#[async_trait] -impl FileBackend for OpfsStorage { +impl LocalFileBackend for OpfsStorage { async fn read_file(&self, path: &str) -> Result { let (dirs, file_name) = navigate_path(path); let parent = self.navigate_to_dir(&dirs, false).await?; @@ -496,16 +477,11 @@ impl FileBackend for OpfsStorage { } } -#[async_trait] -impl StorageBackend for OpfsStorage { +impl LocalStorageBackend for OpfsStorage { fn supports_files(&self) -> bool { true } - fn as_file_backend(&self) -> Option<&dyn FileBackend> { - Some(self) - } - async fn flush(&self) -> Result<()> { Ok(()) } diff --git a/Build/crates/saikuro-storage/src/traits.rs b/Build/crates/saikuro-storage/src/traits.rs index aa516732..9ecab25b 100644 --- a/Build/crates/saikuro-storage/src/traits.rs +++ b/Build/crates/saikuro-storage/src/traits.rs @@ -4,14 +4,13 @@ use alloc::boxed::Box; use alloc::string::String; use alloc::string::ToString; use alloc::vec::Vec; -use async_trait::async_trait; use bytes::Bytes; use serde::{de::DeserializeOwned, Serialize}; use super::{config::StorageConfig, error::Result}; /// A key-value storage interface with namespace support. -#[async_trait] +#[async_trait::async_trait] pub trait KeyValueBackend: Send + Sync + 'static { /// Get the configuration for this backend. fn config(&self) -> &StorageConfig; @@ -45,7 +44,7 @@ pub trait KeyValueBackend: Send + Sync + 'static { } /// A file-like storage interface for hierarchical storage. -#[async_trait] +#[async_trait::async_trait] pub trait FileBackend: Send + Sync + 'static { /// Read a file's contents. async fn read_file(&self, path: &str) -> Result; @@ -73,7 +72,7 @@ pub trait FileBackend: Send + Sync + 'static { } /// Unified storage backend trait combining key-value and file operations. -#[async_trait] +#[async_trait::async_trait] pub trait StorageBackend: KeyValueBackend { /// Check if this backend supports file operations. fn supports_files(&self) -> bool; @@ -95,7 +94,7 @@ pub trait StorageBackend: KeyValueBackend { } /// Extension methods for KeyValueBackend providing JSON serialization. -#[async_trait] +#[async_trait::async_trait] pub trait KeyValueBackendExt: KeyValueBackend { /// Get a JSON-serialized value. async fn get_json(&self, namespace: &str, key: &str) -> Result> { @@ -151,3 +150,70 @@ pub trait KeyValueBackendExt: KeyValueBackend { } impl KeyValueBackendExt for B {} + +/// A key-value backend for single-threaded runtimes. +/// +/// Unlike [`KeyValueBackend`], this trait uses native async functions and does +/// not require `Send` or `Sync`. Implementations must be used directly or +/// behind a statically selected generic type; they cannot be erased into a +/// host `Box`. +#[allow(async_fn_in_trait)] +pub trait LocalKeyValueBackend: 'static { + /// Get the configuration for this backend. + fn config(&self) -> &StorageConfig; + + /// Check if a key exists in a namespace. + async fn exists(&self, namespace: &str, key: &str) -> Result; + /// Get raw bytes for a key. + async fn get(&self, namespace: &str, key: &str) -> Result>; + /// Put raw bytes for a key. + async fn put(&self, namespace: &str, key: &str, value: Bytes) -> Result<()>; + /// Delete a key. + async fn delete(&self, namespace: &str, key: &str) -> Result<()>; + /// List all keys in a namespace. + async fn list_keys(&self, namespace: &str) -> Result>; + /// List all namespaces. + async fn list_namespaces(&self) -> Result>; + /// Create a namespace explicitly. + async fn create_namespace(&self, namespace: &str) -> Result<()>; + /// Delete a namespace and all its keys. + async fn delete_namespace(&self, namespace: &str) -> Result<()>; + /// Clear all keys in a namespace without deleting the namespace. + async fn clear_namespace(&self, namespace: &str) -> Result<()>; +} + +/// A file backend for single-threaded runtimes. +#[allow(async_fn_in_trait)] +pub trait LocalFileBackend: 'static { + /// Read a file's contents. + async fn read_file(&self, path: &str) -> Result; + /// Write a file's contents. + async fn write_file(&self, path: &str, content: Bytes) -> Result<()>; + /// Append content to an existing file. + async fn append_file(&self, path: &str, content: Bytes) -> Result<()>; + /// Delete a file. + async fn delete_file(&self, path: &str) -> Result<()>; + /// Check if a file exists. + async fn file_exists(&self, path: &str) -> Result; + /// List files in a directory. + async fn list_dir(&self, path: &str) -> Result>; + /// Create a directory. + async fn create_dir(&self, path: &str) -> Result<()>; + /// Delete a directory and its contents. + async fn delete_dir(&self, path: &str) -> Result<()>; +} + +/// A single-threaded storage backend combining key-value and file operations. +#[allow(async_fn_in_trait)] +pub trait LocalStorageBackend: LocalKeyValueBackend { + /// Check if this backend supports file operations. + fn supports_files(&self) -> bool; + /// Flush pending writes. + async fn flush(&self) -> Result<()> { + Ok(()) + } + /// Release backend resources. + async fn close(&self) -> Result<()> { + Ok(()) + } +} diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index a6540eff..6e39943c 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -21,12 +21,14 @@ keywords = ["ipc", "cross-language", "saikuro", "transport", "async"] # embassy: no_std embassy-executor backend for MCU targets; forwards the # drbg entropy source so the crate is self-contained under # `--no-default-features --features embassy` +# embedded-io: local, statically-dispatched embedded-io-async transport # # The in-memory transport is always compiled; it has zero OS dependencies. [features] default = ["std", "native-transport"] std = [] embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/drbg"] +embedded-io = ["dep:embedded-io-async"] # native features also forward core/std so native builds keep the OS entropy # backend that core/std selects; only the crate-level lifter (std) switches to # std-no-os for wasm32. @@ -60,6 +62,7 @@ thiserror = { workspace = true } tracing = { version = "0.1", default-features = false } saikuro-exec = { workspace = true, default-features = false } saikuro-random = { workspace = true, default-features = false } +embedded-io-async = { version = "0.7.0", default-features = false, optional = true } # WebSocket support on native (tokio-tungstenite uses mio which doesn't compile on wasm32) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] @@ -84,3 +87,4 @@ web-sys = { version = "0.3", features = [ [dev-dependencies] tracing-subscriber = { workspace = true } +futures = { workspace = true, features = ["executor"] } diff --git a/Build/crates/saikuro-transport/src/embedded_io.rs b/Build/crates/saikuro-transport/src/embedded_io.rs new file mode 100644 index 00000000..58fe9112 --- /dev/null +++ b/Build/crates/saikuro-transport/src/embedded_io.rs @@ -0,0 +1,176 @@ +use alloc::string::ToString; +use bytes::{Bytes, BytesMut}; +use core::future::Future; +use embedded_io_async::{Error, Read, Write}; + +use crate::error::{Result, TransportError}; + +const HEADER_LEN: usize = 4; + +/// A local, statically-dispatched sender for a transport. +pub trait LocalTransportSender { + /// Send one length-prefixed binary frame and flush it to the writer. + fn send(&mut self, frame: Bytes) -> impl Future> + '_; + + /// Flush and close the writer if its implementation supports shutdown. + fn close(&mut self) -> impl Future> + '_; +} + +/// A local, statically-dispatched receiver for a transport. +pub trait LocalTransportReceiver { + /// Receive the next frame. `Ok(None)` is a clean EOF at a frame boundary. + fn recv(&mut self) -> impl Future>> + '_; +} + +/// A framed transport composed from independently owned reader and writer halves. +pub struct EmbeddedIoTransport { + reader: R, + writer: W, + max_frame_size: usize, +} + +/// The writer half of [`EmbeddedIoTransport`]. +pub struct EmbeddedIoSender { + writer: W, + max_frame_size: usize, +} + +/// The reader half of [`EmbeddedIoTransport`]. +pub struct EmbeddedIoReceiver { + reader: R, + max_frame_size: usize, +} + +impl EmbeddedIoTransport { + /// Creates a transport and rejects limits above [`crate::MAX_FRAME_SIZE`]. + /// + /// No payload allocation occurs during construction or while inspecting a + /// hostile header. A zero limit is valid and permits only empty frames. + pub fn new(reader: R, writer: W, max_frame_size: usize) -> Result { + if max_frame_size > crate::MAX_FRAME_SIZE { + return Err(TransportError::MessageTooLarge { + size: max_frame_size, + limit: crate::MAX_FRAME_SIZE, + }); + } + Ok(Self { + reader, + writer, + max_frame_size, + }) + } + + /// Splits the transport into its separately-owned local halves. + pub fn split(self) -> (EmbeddedIoSender, EmbeddedIoReceiver) { + ( + EmbeddedIoSender { + writer: self.writer, + max_frame_size: self.max_frame_size, + }, + EmbeddedIoReceiver { + reader: self.reader, + max_frame_size: self.max_frame_size, + }, + ) + } +} + +impl LocalTransportSender for EmbeddedIoSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + if frame.len() > self.max_frame_size { + return Err(TransportError::MessageTooLarge { + size: frame.len(), + limit: self.max_frame_size, + }); + } + + let mut header = [0; HEADER_LEN]; + header.copy_from_slice(&(frame.len() as u32).to_be_bytes()); + write_all(&mut self.writer, &header).await?; + write_all(&mut self.writer, &frame).await?; + self.writer + .flush() + .await + .map_err(|error| TransportError::SendFailed(error.kind().to_string())) + } + + async fn close(&mut self) -> Result<()> { + self.writer + .flush() + .await + .map_err(|error| TransportError::SendFailed(error.kind().to_string())) + } +} + +impl LocalTransportReceiver for EmbeddedIoReceiver { + async fn recv(&mut self) -> Result> { + let mut header = [0; HEADER_LEN]; + if read_first_byte(&mut self.reader, &mut header[0]).await? == 0 { + return Ok(None); + } + read_exact( + &mut self.reader, + &mut header[1..], + "connection closed during frame header", + ) + .await?; + + let frame_len = u32::from_be_bytes(header) as usize; + if frame_len > self.max_frame_size { + return Err(TransportError::MessageTooLarge { + size: frame_len, + limit: self.max_frame_size, + }); + } + + let mut payload = BytesMut::zeroed(frame_len); + read_exact( + &mut self.reader, + &mut payload, + "connection closed during frame payload", + ) + .await?; + Ok(Some(payload.freeze())) + } +} + +async fn read_first_byte(reader: &mut R, byte: &mut u8) -> Result { + reader + .read(core::slice::from_mut(byte)) + .await + .map_err(|error| TransportError::ReceiveFailed(error.kind().to_string())) +} + +async fn read_exact( + reader: &mut R, + mut target: &mut [u8], + eof_message: &'static str, +) -> Result<()> { + while !target.is_empty() { + let count = reader + .read(target) + .await + .map_err(|error| TransportError::ReceiveFailed(error.kind().to_string()))?; + if count == 0 { + return Err(TransportError::FramingError(eof_message.into())); + } + target = &mut target[count..]; + } + Ok(()) +} + +async fn write_all(writer: &mut W, mut source: &[u8]) -> Result<()> { + while !source.is_empty() { + let count = writer + .write(source) + .await + .map_err(|error| TransportError::SendFailed(error.kind().to_string()))?; + if count == 0 { + return Err(TransportError::FramingError( + "write made no progress".into(), + )); + } + source = &source[count..]; + } + Ok(()) +} diff --git a/Build/crates/saikuro-transport/src/lib.rs b/Build/crates/saikuro-transport/src/lib.rs index 8ac3bac9..954b291b 100644 --- a/Build/crates/saikuro-transport/src/lib.rs +++ b/Build/crates/saikuro-transport/src/lib.rs @@ -26,6 +26,9 @@ pub mod memory; pub mod selector; pub mod traits; +#[cfg(feature = "embedded-io")] +pub mod embedded_io; + #[cfg(all(feature = "native-transport", not(target_arch = "wasm32")))] pub mod tcp; @@ -50,6 +53,12 @@ pub use memory::MemoryTransport; pub use selector::{TransportConfig, TransportKind, TransportSelector}; pub use traits::{Transport, TransportReceiver, TransportSender}; +#[cfg(feature = "embedded-io")] +pub use embedded_io::{ + EmbeddedIoReceiver, EmbeddedIoSender, EmbeddedIoTransport, LocalTransportReceiver, + LocalTransportSender, +}; + #[cfg(all(feature = "native-transport", not(target_arch = "wasm32")))] pub use tcp::TcpTransport; diff --git a/Build/crates/saikuro-transport/tests/embedded_io.rs b/Build/crates/saikuro-transport/tests/embedded_io.rs new file mode 100644 index 00000000..6f45c1f3 --- /dev/null +++ b/Build/crates/saikuro-transport/tests/embedded_io.rs @@ -0,0 +1,216 @@ +#![cfg(feature = "embedded-io")] + +use std::cell::RefCell; +use std::rc::Rc; + +use bytes::Bytes; +use embedded_io_async::{ErrorType, Read, Write}; +use futures::executor::block_on; +use saikuro_transport::{ + EmbeddedIoTransport, LocalTransportReceiver, LocalTransportSender, TransportError, +}; + +struct FakeReader { + wire: Vec, + position: usize, + chunk_size: usize, +} + +impl FakeReader { + fn new(wire: Vec, chunk_size: usize) -> Self { + Self { + wire, + position: 0, + chunk_size, + } + } +} + +impl ErrorType for FakeReader { + type Error = embedded_io_async::ErrorKind; +} + +impl Read for FakeReader { + async fn read(&mut self, buf: &mut [u8]) -> Result { + let available = self.wire.len().saturating_sub(self.position); + let count = available.min(buf.len()).min(self.chunk_size); + buf[..count].copy_from_slice(&self.wire[self.position..self.position + count]); + self.position += count; + Ok(count) + } +} + +struct FakeWriter { + wire: Rc>>, + chunk_size: usize, + write_zero: bool, +} + +impl FakeWriter { + fn new(chunk_size: usize) -> (Self, Rc>>) { + let wire = Rc::new(RefCell::new(Vec::new())); + ( + Self { + wire: Rc::clone(&wire), + chunk_size, + write_zero: false, + }, + wire, + ) + } + + fn write_zero() -> Self { + Self { + wire: Rc::new(RefCell::new(Vec::new())), + chunk_size: 1, + write_zero: true, + } + } +} + +impl ErrorType for FakeWriter { + type Error = embedded_io_async::ErrorKind; +} + +impl Write for FakeWriter { + async fn write(&mut self, buf: &[u8]) -> Result { + if self.write_zero { + return Ok(0); + } + let count = buf.len().min(self.chunk_size); + self.wire.borrow_mut().extend_from_slice(&buf[..count]); + Ok(count) + } + + async fn flush(&mut self) -> Result<(), Self::Error> { + Ok(()) + } +} + +fn unused_writer() -> FakeWriter { + FakeWriter::new(usize::MAX).0 +} + +#[test] +fn roundtrip_preserves_order_with_partial_io() { + block_on(async { + let (writer, wire) = FakeWriter::new(2); + let transport = EmbeddedIoTransport::new(FakeReader::new(Vec::new(), 1), writer, 64) + .expect("valid frame limit"); + let (mut sender, _) = transport.split(); + + sender + .send(Bytes::from_static(b"first")) + .await + .expect("send first frame"); + sender + .send(Bytes::from_static(b"second")) + .await + .expect("send second frame"); + + let transport = EmbeddedIoTransport::new( + FakeReader::new(wire.borrow().clone(), 1), + unused_writer(), + 64, + ) + .expect("valid frame limit"); + let (_, mut receiver) = transport.split(); + assert_eq!( + receiver.recv().await.expect("receive first frame"), + Some(Bytes::from_static(b"first")) + ); + assert_eq!( + receiver.recv().await.expect("receive second frame"), + Some(Bytes::from_static(b"second")) + ); + assert_eq!(receiver.recv().await.expect("clean EOF"), None); + }); +} + +#[test] +fn clean_eof_returns_none() { + block_on(async { + let transport = + EmbeddedIoTransport::new(FakeReader::new(Vec::new(), 1), unused_writer(), 16) + .expect("valid frame limit"); + let (_, mut receiver) = transport.split(); + assert_eq!(receiver.recv().await.expect("clean EOF"), None); + }); +} + +#[test] +fn oversized_header_is_rejected_before_payload_read() { + block_on(async { + let transport = EmbeddedIoTransport::new( + FakeReader::new(17_u32.to_be_bytes().to_vec(), 4), + unused_writer(), + 16, + ) + .expect("valid frame limit"); + let (_, mut receiver) = transport.split(); + assert!(matches!( + receiver.recv().await, + Err(TransportError::MessageTooLarge { + size: 17, + limit: 16 + }) + )); + }); +} + +#[test] +fn constructor_rejects_limit_above_crate_maximum() { + let result = EmbeddedIoTransport::new( + FakeReader::new(Vec::new(), 1), + unused_writer(), + saikuro_transport::MAX_FRAME_SIZE + 1, + ); + assert!(matches!( + result, + Err(TransportError::MessageTooLarge { .. }) + )); +} + +#[test] +fn truncated_header_is_an_error() { + block_on(async { + let transport = + EmbeddedIoTransport::new(FakeReader::new(vec![0, 0, 0], 1), unused_writer(), 16) + .expect("valid frame limit"); + let (_, mut receiver) = transport.split(); + match receiver.recv().await { + Err(TransportError::FramingError(message)) => assert!(message.contains("header")), + other => panic!("expected truncated header error, got {other:?}"), + } + }); +} + +#[test] +fn truncated_payload_is_an_error() { + block_on(async { + let mut wire = 4_u32.to_be_bytes().to_vec(); + wire.extend_from_slice(b"abc"); + let transport = EmbeddedIoTransport::new(FakeReader::new(wire, 2), unused_writer(), 16) + .expect("valid frame limit"); + let (_, mut receiver) = transport.split(); + match receiver.recv().await { + Err(TransportError::FramingError(message)) => assert!(message.contains("payload")), + other => panic!("expected truncated payload error, got {other:?}"), + } + }); +} + +#[test] +fn write_zero_is_an_error() { + block_on(async { + let transport = + EmbeddedIoTransport::new(FakeReader::new(Vec::new(), 1), FakeWriter::write_zero(), 16) + .expect("valid frame limit"); + let (mut sender, _) = transport.split(); + assert!(matches!( + sender.send(Bytes::from_static(b"data")).await, + Err(TransportError::FramingError(message)) + if message == "write made no progress" + )); + }); +} From 7d30931bb3169107bb9b28ed3d8dfd28e08ff96d Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Tue, 11 Aug 2026 18:31:55 -0600 Subject: [PATCH 12/43] Cancellation --- Build/Cargo.lock | 1 + Build/crates/saikuro-core/src/sync.rs | 20 ++ Build/crates/saikuro-exec/Cargo.toml | 8 + .../tests/embassy_cancellation.rs | 222 ++++++++++++++++++ Build/crates/saikuro-storage/Cargo.toml | 10 +- 5 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 Build/crates/saikuro-exec/tests/embassy_cancellation.rs diff --git a/Build/Cargo.lock b/Build/Cargo.lock index c63d5b38..c6f582f5 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -1385,6 +1385,7 @@ dependencies = [ "embassy-time", "fluvio-wasm-timer", "futures", + "futures-executor", "tokio", "tokio-util", "wasm-bindgen-futures", diff --git a/Build/crates/saikuro-core/src/sync.rs b/Build/crates/saikuro-core/src/sync.rs index 44b7454c..5b62fea5 100644 --- a/Build/crates/saikuro-core/src/sync.rs +++ b/Build/crates/saikuro-core/src/sync.rs @@ -11,6 +11,26 @@ //! and MCU targets. The guards are only ever held for short map mutations; //! they are never held across an `await`. //! +//! # Task-context restriction (no_std) +//! +//! On `no_std` builds these locks are spinlocks: acquisition busy-waits and +//! never yields or sleeps. They are only safe when all three conditions hold: +//! +//! - the guard is never held across an `.await`. A task that yields while +//! holding a spinlock stalls any other task that spins on it, and on a +//! single-core cooperative executor such as Embassy that is a deadlock; +//! - the critical section is short and does not re-enter the same lock or call +//! anything that could yield or run another task; +//! - no interrupt context acquires a lock that a task can hold while interrupts +//! are enabled: a preempting ISR spinning on a held lock never makes +//! progress. Data shared with interrupts must use a +//! `critical-section`-backed primitive instead. +//! +//! `saikuro-router` uses these locks only for short synchronous map mutations +//! that never await, which satisfies the restriction. Async locks belong in +//! `saikuro-exec`, whose Embassy backend binds them to +//! `CriticalSectionRawMutex` so awaiters park instead of spinning. +//! //! Poisoning behaviour is backend-specific. `std::sync::Mutex` and //! `std::sync::RwLock` write guards become poisoned if a panic unwinds while //! they are held, and the next acquisition then panics, surfacing the bug diff --git a/Build/crates/saikuro-exec/Cargo.toml b/Build/crates/saikuro-exec/Cargo.toml index 90c004e3..472e7b68 100644 --- a/Build/crates/saikuro-exec/Cargo.toml +++ b/Build/crates/saikuro-exec/Cargo.toml @@ -17,6 +17,11 @@ embassy-runtime = [ "dep:embassy-futures", "futures/async-await", ] +embassy-test = [ + "embassy-runtime", + "embassy-time/std", + "embassy-time/generic-queue", +] [dependencies] tokio = { version = "1.52.3", default-features = false, features = ["macros"], optional = true } @@ -29,3 +34,6 @@ fluvio-wasm-timer = { version = "0.2.5", optional = true } embassy-sync = { workspace = true, optional = true } embassy-time = { workspace = true, optional = true } embassy-futures = { workspace = true, optional = true } + +[dev-dependencies] +futures-executor = "0.3" diff --git a/Build/crates/saikuro-exec/tests/embassy_cancellation.rs b/Build/crates/saikuro-exec/tests/embassy_cancellation.rs new file mode 100644 index 00000000..5ac42993 --- /dev/null +++ b/Build/crates/saikuro-exec/tests/embassy_cancellation.rs @@ -0,0 +1,222 @@ +//! Cancellation and closure tests for the Embassy execution backend. +//! +//! The Embassy backend's channel and barrier contract is normally +//! only exercised on hardware. The `embassy-test` feature runs the +//! same backend on the host std target: it selects `embassy-runtime` +//! and adds `embassy-time/std`, which also links the std +// `critical-section` implementation the raw-mutex channel state requires. +//! +//! Run with: +//! +//! ```text +//! cargo test -p saikuro-exec --no-default-features --features embassy-test +//! ``` +//! +//! The tests are deterministic: no threads, no sleeps, and the executor is the +//! single-threaded `futures-executor` block_on. A future that would block +//! forever is wrapped in `saikuro_exec::timeout` so a regression fails the test +//! instead of hanging the binary. + +#![cfg(feature = "embassy-test")] + +use std::future::Future; +use std::pin::Pin; +use std::task::Poll; +use std::time::Duration; + +use futures::future::poll_fn; +use futures_executor::block_on; +use saikuro_exec::{mpsc, oneshot, sync, watch, ChannelCapacity}; + +/// Poll `fut` once with the surrounding executor's waker and assert it is +/// still pending. The future parks exactly like an `.await` would, so a later +/// external event that wakes it is observable. +async fn assert_pending(fut: &mut F) { + poll_fn(|cx| { + assert!( + Pin::new(&mut *fut).poll(cx).is_pending(), + "expected the future to be pending" + ); + Poll::Ready(()) + }) + .await; +} + +/// Await `fut` with a fail-on-timeout guard. +async fn guarded(fut: F) -> F::Output { + saikuro_exec::timeout(Duration::from_secs(5), fut) + .await + .expect("test future timed out") +} + +fn capacity(n: usize) -> ChannelCapacity { + ChannelCapacity::new(n).expect("valid capacity") +} + +#[test] +fn mpsc_sender_blocked_on_full_errors_when_receiver_dropped() { + block_on(async { + let (tx, rx) = mpsc::channel::(capacity(2)); + tx.send(1).await.expect("send first value"); + tx.send(2).await.expect("send second value"); + + let mut send = Box::pin(tx.send(3)); + assert_pending(&mut send).await; + + drop(rx); + + let err = guarded(send).await.expect_err("receiver was dropped"); + assert_eq!(err.0, 3, "the undelivered value is returned"); + }); +} + +#[test] +fn mpsc_sender_blocked_on_full_completes_when_capacity_frees() { + block_on(async { + let (tx, mut rx) = mpsc::channel::(capacity(2)); + tx.send(1).await.expect("send first value"); + tx.send(2).await.expect("send second value"); + + let mut send = Box::pin(tx.send(3)); + assert_pending(&mut send).await; + + assert_eq!(rx.recv().await, Some(1)); + guarded(send) + .await + .expect("sender proceeds once a slot frees"); + assert_eq!(rx.recv().await, Some(2)); + assert_eq!(rx.recv().await, Some(3)); + }); +} + +#[test] +fn mpsc_receiver_blocked_on_empty_returns_none_when_senders_dropped() { + block_on(async { + let (tx, mut rx) = mpsc::channel::(capacity(2)); + + let mut recv = Box::pin(rx.recv()); + assert_pending(&mut recv).await; + + drop(tx); + + assert_eq!(guarded(recv).await, None, "channel closes with senders"); + }); +} + +#[test] +fn mpsc_receiver_cancelled_then_resumed_receives_sent_value() { + block_on(async { + let (tx, mut rx) = mpsc::channel::(capacity(2)); + + { + let mut recv = Box::pin(rx.recv()); + assert_pending(&mut recv).await; + // Cancel the blocked receiver; its waker registration goes stale. + } + + tx.send(7).await.expect("send after cancellation"); + assert_eq!(rx.recv().await, Some(7)); + }); +} + +#[test] +fn mpsc_cancelled_blocked_sender_does_not_corrupt_channel() { + block_on(async { + let (tx, mut rx) = mpsc::channel::(capacity(2)); + tx.send(1).await.expect("send first value"); + tx.send(2).await.expect("send second value"); + + { + let mut send = Box::pin(tx.send(3)); + assert_pending(&mut send).await; + // Cancel the blocked sender; the undelivered value drops with it. + } + + assert_eq!(rx.recv().await, Some(1)); + tx.send(4).await.expect("channel still accepts sends"); + assert_eq!(rx.recv().await, Some(2)); + assert_eq!(rx.recv().await, Some(4)); + }); +} + +#[test] +fn oneshot_receiver_pending_completes_when_sent() { + block_on(async { + let (tx, mut rx) = oneshot::channel(); + assert_pending(&mut rx).await; + + tx.send(42).expect("receiver still alive"); + assert_eq!(rx.await.expect("value delivered"), 42); + }); +} + +#[test] +fn oneshot_send_returns_value_when_receiver_dropped() { + let (tx, rx) = oneshot::channel(); + drop(rx); + + let err = tx.send(42).expect_err("receiver was dropped"); + assert_eq!(err, 42, "the undelivered value is returned"); +} + +#[test] +fn watch_receiver_cancelled_then_resumed_sees_new_value() { + block_on(async { + let (tx, mut rx) = watch::channel(0_u32); + + { + let mut changed = rx.changed(); + assert_pending(&mut changed).await; + // Cancel the blocked change future; the observed version is stale. + } + + tx.send(1).expect("receiver still alive"); + assert!(rx.changed().await.is_ok(), "change is reported"); + assert_eq!(rx.borrow(), 1); + }); +} + +#[test] +fn watch_receiver_changed_errors_when_senders_dropped() { + block_on(async { + let (tx, mut rx) = watch::channel(0_u32); + + { + let mut changed = rx.changed(); + assert_pending(&mut changed).await; + } + + drop(tx); + + assert_eq!(rx.changed().await, Err(watch::RecvError)); + }); +} + +#[test] +fn barrier_releases_all_waiters_when_last_arrives() { + block_on(async { + let barrier = sync::Barrier::new(2); + + let mut first = Box::pin(barrier.wait()); + assert_pending(&mut first).await; + + barrier.wait().await; + guarded(first).await; + }); +} + +#[test] +fn barrier_cancelled_waiter_arrival_still_counts_toward_release() { + block_on(async { + let barrier = sync::Barrier::new(2); + + { + let mut first = Box::pin(barrier.wait()); + assert_pending(&mut first).await; + // Cancel after arriving; the arrival is not reclaimed. + } + + // One fresh arrival brings the tally to the release threshold. + guarded(barrier.wait()).await; + }); +} diff --git a/Build/crates/saikuro-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index fbb50b9e..34a06d7e 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["ipc", "cross-language", "saikuro", "storage", "key-value"] [features] default = ["std", "native-storage"] -std = ["saikuro-core/std"] +std = [] custom = ["saikuro-core/custom"] drbg = ["saikuro-core/drbg"] native-storage = [ @@ -18,19 +18,21 @@ native-storage = [ "inmemory", "local-storage", "session-storage", + "saikuro-core/std", "saikuro-exec/tokio-runtime", ] inmemory = ["std", "dashmap"] local-storage = ["inmemory"] session-storage = ["inmemory"] -fs-storage = ["std", "dep:tokio", "saikuro-exec/tokio-runtime"] -sled-storage = ["std", "dep:tokio", "dep:sled", "saikuro-exec/tokio-runtime"] -sqlite-storage = ["std", "dep:tokio", "dep:rusqlite", "saikuro-exec/tokio-runtime"] +fs-storage = ["std", "dep:tokio", "saikuro-core/std", "saikuro-exec/tokio-runtime"] +sled-storage = ["std", "dep:tokio", "dep:sled", "saikuro-core/std", "saikuro-exec/tokio-runtime"] +sqlite-storage = ["std", "dep:tokio", "dep:rusqlite", "saikuro-core/std", "saikuro-exec/tokio-runtime"] wasm-storage = [ "std", "inmemory", "local-storage", "session-storage", + "saikuro-core/std-no-os", "saikuro-exec/wasm-runtime", "dep:wasm-bindgen", "dep:wasm-bindgen-futures", From e5a2fdbdd841ce7ce9c5c29a078a0619fdc16f7c Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Tue, 11 Aug 2026 20:05:37 -0600 Subject: [PATCH 13/43] network --- Build/Cargo.lock | 90 ++++++++++- Build/Cargo.toml | 1 + Build/crates/saikuro-exec/Cargo.toml | 11 ++ .../saikuro-exec/src/embassy_backend.rs | 8 +- Build/crates/saikuro-exec/src/embassy_net.rs | 20 +++ Build/crates/saikuro-exec/src/lib.rs | 3 + .../tests/embassy_net_loopback.rs | 153 ++++++++++++++++++ 7 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 Build/crates/saikuro-exec/src/embassy_net.rs create mode 100644 Build/crates/saikuro-exec/tests/embassy_net_loopback.rs diff --git a/Build/Cargo.lock b/Build/Cargo.lock index c6f582f5..29dbe057 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -398,6 +398,40 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" +[[package]] +name = "embassy-net" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f9f2979069031c153e41075a43074c36a64492e598780b27944a605f829d23" +dependencies = [ + "document-features", + "embassy-net-driver", + "embassy-sync 0.6.2", + "embassy-time", + "embedded-io-async 0.6.1", + "embedded-nal-async", + "heapless", + "managed", + "smoltcp", +] + +[[package]] +name = "embassy-net-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524eb3c489760508f71360112bca70f6e53173e6fe48fc5f0efd0f5ab217751d" + +[[package]] +name = "embassy-net-driver-channel" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7b2739fbcf6cd206ae08779c7d709087b16577d255f2ea4a45bc4bbbf305b3f" +dependencies = [ + "embassy-futures", + "embassy-net-driver", + "embassy-sync 0.7.2", +] + [[package]] name = "embassy-sync" version = "0.6.2" @@ -412,6 +446,20 @@ dependencies = [ "heapless", ] +[[package]] +name = "embassy-sync" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.6.1", + "futures-core", + "futures-sink", + "heapless", +] + [[package]] name = "embassy-time" version = "0.3.2" @@ -500,6 +548,25 @@ dependencies = [ "embedded-io 0.7.1", ] +[[package]] +name = "embedded-nal" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56a28be191a992f28f178ec338a0bf02f63d7803244add736d026a471e6ed77" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-nal-async" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76959917cd2b86f40a98c28dd5624eddd1fa69d746241c8257eac428d83cb211" +dependencies = [ + "embedded-io-async 0.6.1", + "embedded-nal", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -932,6 +999,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "matchers" version = "0.2.0" @@ -1381,7 +1454,9 @@ name = "saikuro-exec" version = "0.1.0" dependencies = [ "embassy-futures", - "embassy-sync", + "embassy-net", + "embassy-net-driver-channel", + "embassy-sync 0.6.2", "embassy-time", "fluvio-wasm-timer", "futures", @@ -1717,6 +1792,19 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smoltcp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "heapless", + "managed", +] + [[package]] name = "socket2" version = "0.6.3" diff --git a/Build/Cargo.toml b/Build/Cargo.toml index 460bf1cb..fa0304fb 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -76,6 +76,7 @@ tracing-subscriber = { version = "0.3.23", features = [ embassy-sync = { version = "0.6", default-features = false } embassy-time = { version = "0.3", default-features = false } embassy-futures = { version = "0.1", default-features = false } +embassy-net = { version = "0.5", default-features = false } # Error handling thiserror = { version = "2.0", default-features = false } diff --git a/Build/crates/saikuro-exec/Cargo.toml b/Build/crates/saikuro-exec/Cargo.toml index 472e7b68..afcf532f 100644 --- a/Build/crates/saikuro-exec/Cargo.toml +++ b/Build/crates/saikuro-exec/Cargo.toml @@ -22,6 +22,10 @@ embassy-test = [ "embassy-time/std", "embassy-time/generic-queue", ] +# TCP/IP networking for the Embassy backend. The application owns the device +# driver, stack resources, and runner (see `saikuro_exec::net`); this feature +# only pulls in the `embassy-net` stack and exposes it. +net = ["dep:embassy-net"] [dependencies] tokio = { version = "1.52.3", default-features = false, features = ["macros"], optional = true } @@ -34,6 +38,13 @@ fluvio-wasm-timer = { version = "0.2.5", optional = true } embassy-sync = { workspace = true, optional = true } embassy-time = { workspace = true, optional = true } embassy-futures = { workspace = true, optional = true } +embassy-net = { workspace = true, optional = true, features = [ + "medium-ip", + "proto-ipv4", + "tcp", + "udp", +] } [dev-dependencies] +embassy-net-driver-channel = "0.3" futures-executor = "0.3" diff --git a/Build/crates/saikuro-exec/src/embassy_backend.rs b/Build/crates/saikuro-exec/src/embassy_backend.rs index f0ad27f8..489c36cb 100644 --- a/Build/crates/saikuro-exec/src/embassy_backend.rs +++ b/Build/crates/saikuro-exec/src/embassy_backend.rs @@ -26,8 +26,9 @@ //! and hands out `Spawner`s. A facade cannot create a global executor without //! clashing with the application's. Tokio-style task and runtime APIs are not //! exported for this backend, so unsupported shared code fails at compile time -//! instead of panicking on device. `net`, `signal`, and `runtime` are absent for -//! the same reason. +//! instead of panicking on device. `runtime` is absent for the same reason; +//! `net` is available behind the `net` feature (the app owns the stack; see +//! the module documentation). use alloc::sync::Arc; use core::cell::RefCell; @@ -44,6 +45,9 @@ use embassy_sync::waitqueue::MultiWakerRegistration; use embassy_time::{Duration as EmbDuration, Timer}; use futures::future::{Fuse, FutureExt}; +#[cfg(feature = "net")] +pub use crate::embassy_net::net; + // Sleep / Timeout / Yield /// Convert a `std::time::Duration` to the embassy representation. diff --git a/Build/crates/saikuro-exec/src/embassy_net.rs b/Build/crates/saikuro-exec/src/embassy_net.rs new file mode 100644 index 00000000..d789290b --- /dev/null +++ b/Build/crates/saikuro-exec/src/embassy_net.rs @@ -0,0 +1,20 @@ +//! Embassy networking facade (`saikuro_exec::net`). +//! +//! This is the `no_std` counterpart of the host `net` module +//! +//! # Ownership model +//! +//! - the application provides the device driver (the [`driver`] module) and +//! the [`StackResources`] memory for sockets; +//! - [`Stack::new`] returns the [`Stack`] handle plus a [`Runner`]; the runner +//! must be driven to completion on a task, otherwise the stack never +//! processes packets or wakes sockets; +//! - [`tcp::TcpSocket`] and [`udp::UdpSocket`] are created from the `Stack` +//! handle with caller-provided send and receive buffers. +//! +//! Unlike the host backend there is no global stack, so address and port +//! binding are explicit and the app controls every resource lifetime. + +pub mod net { + pub use embassy_net::*; +} diff --git a/Build/crates/saikuro-exec/src/lib.rs b/Build/crates/saikuro-exec/src/lib.rs index 3d722bcf..c622cce3 100644 --- a/Build/crates/saikuro-exec/src/lib.rs +++ b/Build/crates/saikuro-exec/src/lib.rs @@ -11,6 +11,9 @@ extern crate alloc; mod capacity; pub use capacity::{ChannelCapacity, InvalidChannelCapacity}; +#[cfg(all(feature = "embassy-runtime", feature = "net"))] +mod embassy_net; + #[cfg(all(feature = "tokio-runtime", feature = "wasm-runtime"))] compile_error!("Features `tokio-runtime` and `wasm-runtime` are mutually exclusive."); diff --git a/Build/crates/saikuro-exec/tests/embassy_net_loopback.rs b/Build/crates/saikuro-exec/tests/embassy_net_loopback.rs new file mode 100644 index 00000000..0983381b --- /dev/null +++ b/Build/crates/saikuro-exec/tests/embassy_net_loopback.rs @@ -0,0 +1,153 @@ +//! Host-run loopback test for the Embassy net facade (`saikuro_exec::net`). +//! +//! Two `embassy-net` stacks are bridged back to back through in-memory +//! `embassy-net-driver-channel` devices. A TCP connection is opened between +//! them and data flows in both directions. This exercises the re-exported +//! surface (config, addresses, sockets) on the host, without any hardware. +//! +//! Run with: `cargo test -p saikuro-exec --no-default-features --features embassy-test,net` + +#![cfg(all(feature = "embassy-test", feature = "net"))] + +use core::time::Duration as CoreDuration; + +use std::future::Future; +use std::pin::Pin; +use std::task::Poll; + +use embassy_net_driver_channel::driver::{HardwareAddress, LinkState}; +use embassy_net_driver_channel::{RxRunner, State, TxRunner}; + +use saikuro_exec::net::{ + tcp, Config, IpAddress, IpEndpoint, Ipv4Address, Ipv4Cidr, StackResources, StaticConfigV4, +}; +use saikuro_exec::timeout; + +const MTU: usize = 1500; +const CHAN_RX: usize = 4; +const CHAN_TX: usize = 4; +const SOCKET_BUFFER: usize = 4096; +const TEST_TIMEOUT: CoreDuration = CoreDuration::from_secs(30); +const PORT: u16 = 4242; + +const ADDR_A: Ipv4Address = Ipv4Address::new(10, 0, 0, 1); +const ADDR_B: Ipv4Address = Ipv4Address::new(10, 0, 0, 2); + +/// Copy every outbound packet of the source stack into the inbound path of the +/// destination stack. Runs forever; dropped when the test body completes. +async fn bridge(src_tx: &mut TxRunner<'_, M>, dst_rx: &mut RxRunner<'_, M>) { + loop { + let len = { + let pkt = src_tx.tx_buf().await; + let len = pkt.len(); + let dst = dst_rx.rx_buf().await; + dst[..len].copy_from_slice(&pkt[..len]); + len + }; + dst_rx.rx_done(len); + src_tx.tx_done(); + } +} + +#[test] +fn tcp_loopback_between_two_stacks() { + futures_executor::block_on(async { + let outcome = timeout(TEST_TIMEOUT, async { + let mut state_a = State::::new(); + let mut state_b = State::::new(); + + let (mut chan_runner_a, device_a) = + embassy_net_driver_channel::new(&mut state_a, HardwareAddress::Ip); + let (mut chan_runner_b, device_b) = + embassy_net_driver_channel::new(&mut state_b, HardwareAddress::Ip); + chan_runner_a.set_link_state(LinkState::Up); + chan_runner_b.set_link_state(LinkState::Up); + + let mut resources_a = StackResources::<2>::new(); + let mut resources_b = StackResources::<2>::new(); + + let config_a = Config::ipv4_static(StaticConfigV4 { + address: Ipv4Cidr::new(ADDR_A, 24), + gateway: None, + dns_servers: Default::default(), + }); + let config_b = Config::ipv4_static(StaticConfigV4 { + address: Ipv4Cidr::new(ADDR_B, 24), + gateway: None, + dns_servers: Default::default(), + }); + + let (stack_a, mut stack_runner_a) = + saikuro_exec::net::new(device_a, config_a, &mut resources_a, 1234); + let (stack_b, mut stack_runner_b) = + saikuro_exec::net::new(device_b, config_b, &mut resources_b, 4321); + + let (_state_runner_a, mut rx_runner_a, mut tx_runner_a) = chan_runner_a.split(); + let (_state_runner_b, mut rx_runner_b, mut tx_runner_b) = chan_runner_b.split(); + + let mut sock_a_rx = [0u8; SOCKET_BUFFER]; + let mut sock_a_tx = [0u8; SOCKET_BUFFER]; + let mut sock_b_rx = [0u8; SOCKET_BUFFER]; + let mut sock_b_tx = [0u8; SOCKET_BUFFER]; + + let body = async { + stack_a.wait_config_up().await; + stack_b.wait_config_up().await; + + let mut sock_a = tcp::TcpSocket::new(stack_a, &mut sock_a_rx, &mut sock_a_tx); + let mut sock_b = tcp::TcpSocket::new(stack_b, &mut sock_b_rx, &mut sock_b_tx); + + // accept() waits for the first connection, so it must be + // driven concurrently with the peer's connect(). + let server = IpEndpoint::new(IpAddress::Ipv4(ADDR_A), PORT); + let (accept_res, connect_res) = + futures::join!(sock_a.accept(PORT), sock_b.connect(server)); + accept_res.expect("bind + listen"); + connect_res.expect("connect"); + + let mut ping = [0u8; 4]; + sock_b.write(b"ping").await.expect("write ping"); + sock_b.flush().await.expect("flush ping"); + let n = sock_a.read(&mut ping).await.expect("read ping"); + assert_eq!(n, 4); + assert_eq!(&ping, b"ping"); + + sock_a.write(&ping).await.expect("write echo"); + sock_a.flush().await.expect("flush echo"); + let mut echo = [0u8; 4]; + let n = sock_b.read(&mut echo).await.expect("read echo"); + assert_eq!(n, 4); + assert_eq!(&echo, b"ping"); + }; + + // Poll the two stack runners, the two bridges, and the test body + // together. The runners and bridges never complete; the future + // resolves once the body finishes. + let mut body = Box::pin(body); + let mut runner_a = Box::pin(stack_runner_a.run()); + let mut runner_b = Box::pin(stack_runner_b.run()); + let mut bridge_ab = Box::pin(bridge(&mut tx_runner_a, &mut rx_runner_b)); + let mut bridge_ba = Box::pin(bridge(&mut tx_runner_b, &mut rx_runner_a)); + + std::future::poll_fn(move |cx| { + let mut finished = false; + if let Poll::Ready(_) = Pin::new(&mut body).poll(cx) { + finished = true; + } + let _ = Pin::new(&mut runner_a).poll(cx); + let _ = Pin::new(&mut runner_b).poll(cx); + let _ = Pin::new(&mut bridge_ab).poll(cx); + let _ = Pin::new(&mut bridge_ba).poll(cx); + if finished { + Poll::Ready(()) + } else { + Poll::Pending + } + }) + .await + }) + .await; + + outcome.expect("net loopback test timed out") + }); +} From 5d378d0739bb97616059c0acd6c2b3e2f1262c79 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Tue, 11 Aug 2026 20:58:15 -0600 Subject: [PATCH 14/43] executor --- Build/Cargo.lock | 73 +++++++++- Build/crates/saikuro-exec/Cargo.toml | 1 + .../tests/embassy_cancellation.rs | 19 --- .../saikuro-exec/tests/embassy_executor.rs | 131 ++++++++++++++++++ 4 files changed, 201 insertions(+), 23 deletions(-) create mode 100644 Build/crates/saikuro-exec/tests/embassy_executor.rs diff --git a/Build/Cargo.lock b/Build/Cargo.lock index 29dbe057..4c7ba0ff 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -303,14 +303,38 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", ] [[package]] @@ -326,13 +350,24 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn", ] @@ -392,6 +427,29 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "embassy-executor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64f84599b0f4296b92a4b6ac2109bc02340094bda47b9766c5f9ec6a318ebf8" +dependencies = [ + "critical-section", + "document-features", + "embassy-executor-macros", +] + +[[package]] +name = "embassy-executor-macros" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3577b1e9446f61381179a330fc5324b01d511624c55f25e3c66c9e3c626dbecf" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "embassy-futures" version = "0.1.2" @@ -616,6 +674,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -1453,6 +1517,7 @@ dependencies = [ name = "saikuro-exec" version = "0.1.0" dependencies = [ + "embassy-executor", "embassy-futures", "embassy-net", "embassy-net-driver-channel", @@ -1722,7 +1787,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn", diff --git a/Build/crates/saikuro-exec/Cargo.toml b/Build/crates/saikuro-exec/Cargo.toml index afcf532f..fb957911 100644 --- a/Build/crates/saikuro-exec/Cargo.toml +++ b/Build/crates/saikuro-exec/Cargo.toml @@ -46,5 +46,6 @@ embassy-net = { workspace = true, optional = true, features = [ ] } [dev-dependencies] +embassy-executor = { version = "0.6", default-features = false } embassy-net-driver-channel = "0.3" futures-executor = "0.3" diff --git a/Build/crates/saikuro-exec/tests/embassy_cancellation.rs b/Build/crates/saikuro-exec/tests/embassy_cancellation.rs index 5ac42993..06d5c1ae 100644 --- a/Build/crates/saikuro-exec/tests/embassy_cancellation.rs +++ b/Build/crates/saikuro-exec/tests/embassy_cancellation.rs @@ -1,22 +1,3 @@ -//! Cancellation and closure tests for the Embassy execution backend. -//! -//! The Embassy backend's channel and barrier contract is normally -//! only exercised on hardware. The `embassy-test` feature runs the -//! same backend on the host std target: it selects `embassy-runtime` -//! and adds `embassy-time/std`, which also links the std -// `critical-section` implementation the raw-mutex channel state requires. -//! -//! Run with: -//! -//! ```text -//! cargo test -p saikuro-exec --no-default-features --features embassy-test -//! ``` -//! -//! The tests are deterministic: no threads, no sleeps, and the executor is the -//! single-threaded `futures-executor` block_on. A future that would block -//! forever is wrapped in `saikuro_exec::timeout` so a regression fails the test -//! instead of hanging the binary. - #![cfg(feature = "embassy-test")] use std::future::Future; diff --git a/Build/crates/saikuro-exec/tests/embassy_executor.rs b/Build/crates/saikuro-exec/tests/embassy_executor.rs new file mode 100644 index 00000000..3ab7f721 --- /dev/null +++ b/Build/crates/saikuro-exec/tests/embassy_executor.rs @@ -0,0 +1,131 @@ +#![cfg(feature = "embassy-test")] + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use embassy_executor::raw; +use embassy_executor::Spawner; + +use saikuro_exec::{mpsc, oneshot, sleep, timeout, watch, ChannelCapacity}; + +const TEST_TIMEOUT: Duration = Duration::from_secs(30); + +type Done = Arc; + +/// Wake hook for the raw executor. +/// +/// The executor is driven by a busy-poll loop in `run_until`, so the pender +/// does not need to wake anything; woken tasks are enqueued by `wake_task` +/// regardless. On host this is the equivalent of an interrupt pender. +#[no_mangle] +fn __pender(_context: *mut ()) {} + +#[embassy_executor::task] +async fn producer(tx: mpsc::Sender, done_tx: oneshot::Sender<()>) { + for i in 0..5 { + tx.send(i).await.expect("consumer dropped the channel"); + } + let _ = done_tx.send(()); +} + +#[embassy_executor::task] +async fn consumer(mut rx: mpsc::Receiver, done_rx: oneshot::Receiver<()>, done: Done) { + let mut got = Vec::new(); + while let Some(value) = rx.recv().await { + got.push(value); + } + assert_eq!(got, vec![0, 1, 2, 3, 4]); + assert!(done_rx.await.is_ok(), "producer did not signal completion"); + done.store(true, Ordering::SeqCst); +} + +#[embassy_executor::task] +async fn timer_task(done: Done) { + let start = Instant::now(); + sleep(Duration::from_millis(50)).await; + assert!( + start.elapsed() >= Duration::from_millis(40), + "timer woke too early: {:?}", + start.elapsed() + ); + + let expired = timeout( + Duration::from_millis(10), + sleep(Duration::from_millis(1000)), + ) + .await; + assert!( + expired.is_err(), + "timeout should have fired before the sleep" + ); + + done.store(true, Ordering::SeqCst); +} + +#[embassy_executor::task] +async fn watch_writer(tx: watch::Sender) { + tx.send(42).expect("reader was dropped"); +} + +#[embassy_executor::task] +async fn watch_reader(mut rx: watch::Receiver, done: Done) { + rx.changed().await.expect("watch closed before the update"); + assert_eq!(rx.borrow(), 42); + done.store(true, Ordering::SeqCst); +} + +/// Drive a raw executor on the current thread until `done` is set, then return. +/// Task panics propagate out of `poll` and fail the test. +fn run_until(done: &Done, init: impl FnOnce(&Spawner)) { + let executor: &'static raw::Executor = + Box::leak(Box::new(raw::Executor::new(core::ptr::null_mut()))); + let spawner = executor.spawner(); + init(&spawner); + + let deadline = Instant::now() + TEST_TIMEOUT; + while Instant::now() < deadline { + unsafe { executor.poll() }; + if done.load(Ordering::SeqCst) { + return; + } + thread::sleep(Duration::from_millis(1)); + } + panic!("executor integration test timed out"); +} + +#[test] +fn mpsc_and_oneshot_between_spawned_tasks() { + let done: Done = Arc::new(AtomicBool::new(false)); + let done_clone = done.clone(); + let (tx, rx) = mpsc::channel::(ChannelCapacity::new(4).expect("valid capacity")); + let (done_tx, done_rx) = oneshot::channel::<()>(); + + run_until(&done, move |spawner| { + spawner.must_spawn(producer(tx, done_tx)); + spawner.must_spawn(consumer(rx, done_rx, done_clone)); + }); +} + +#[test] +fn timers_and_timeout_on_app_executor() { + let done: Done = Arc::new(AtomicBool::new(false)); + let done_clone = done.clone(); + + run_until(&done, move |spawner| { + spawner.must_spawn(timer_task(done_clone)); + }); +} + +#[test] +fn watch_channel_between_spawned_tasks() { + let done: Done = Arc::new(AtomicBool::new(false)); + let done_clone = done.clone(); + let (tx, rx) = watch::channel::(0); + + run_until(&done, move |spawner| { + spawner.must_spawn(watch_writer(tx)); + spawner.must_spawn(watch_reader(rx, done_clone)); + }); +} From ded6c2b3efb16b04ee8d2b79b5b993ed99e6b36b Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Wed, 12 Aug 2026 00:50:41 -0600 Subject: [PATCH 15/43] Flash storage --- Build/Cargo.lock | 17 + Build/Cargo.toml | 2 +- Build/crates/saikuro-storage/Cargo.toml | 10 +- Build/crates/saikuro-storage/src/config.rs | 96 +++ Build/crates/saikuro-storage/src/flash.rs | 717 ++++++++++++++++++++ Build/crates/saikuro-storage/src/lib.rs | 9 + Build/crates/saikuro-storage/tests/flash.rs | 679 ++++++++++++++++++ 7 files changed, 1528 insertions(+), 2 deletions(-) create mode 100644 Build/crates/saikuro-storage/src/flash.rs create mode 100644 Build/crates/saikuro-storage/tests/flash.rs diff --git a/Build/Cargo.lock b/Build/Cargo.lock index 4c7ba0ff..d134f01d 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -625,6 +625,21 @@ dependencies = [ "embedded-nal", ] +[[package]] +name = "embedded-storage" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032" + +[[package]] +name = "embedded-storage-async" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc" +dependencies = [ + "embedded-storage", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1595,7 +1610,9 @@ dependencies = [ "async-trait", "bytes", "dashmap", + "embedded-storage-async", "futures", + "futures-executor", "js-sys", "rusqlite", "saikuro-core", diff --git a/Build/Cargo.toml b/Build/Cargo.toml index fa0304fb..95d20f7c 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -72,11 +72,11 @@ tracing-subscriber = { version = "0.3.23", features = [ "json", ] } -# Embedded async runtime (embassy) for no_std MCU targets embassy-sync = { version = "0.6", default-features = false } embassy-time = { version = "0.3", default-features = false } embassy-futures = { version = "0.1", default-features = false } embassy-net = { version = "0.5", default-features = false } +embedded-storage-async = { version = "0.4", default-features = false } # Error handling thiserror = { version = "2.0", default-features = false } diff --git a/Build/crates/saikuro-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index 34a06d7e..25c62ff4 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -40,6 +40,7 @@ wasm-storage = [ "dep:web-sys", ] fs-access = ["wasm-storage"] +flash-storage = ["dep:embedded-storage-async"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } @@ -56,6 +57,7 @@ dashmap = { workspace = true, optional = true } tokio = { version = "1.52.3", features = ["rt"], optional = true } sled = { version = "0.34.7", optional = true } rusqlite = { version = "0.40.0", features = ["bundled"], optional = true } +embedded-storage-async = { workspace = true, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = { version = "0.2.122", optional = true } @@ -93,5 +95,11 @@ web-sys = { version = "0.3.99", optional = true, features = [ ] } [dev-dependencies] -saikuro-exec = { workspace = true } +saikuro-exec = { workspace = true, features = ["tokio-runtime"] } tracing-subscriber = { workspace = true } +futures-executor = "0.3" +embedded-storage-async = { workspace = true } + +[[test]] +name = "flash" +required-features = ["flash-storage"] diff --git a/Build/crates/saikuro-storage/src/config.rs b/Build/crates/saikuro-storage/src/config.rs index dd67973b..8f843ffa 100644 --- a/Build/crates/saikuro-storage/src/config.rs +++ b/Build/crates/saikuro-storage/src/config.rs @@ -161,3 +161,99 @@ impl StorageConfig { self } } + +/// Bounded-size limits +#[cfg(feature = "flash-storage")] +pub mod limits { + /// Maximum length of a namespace, in bytes. Encoded as `u8` in the + /// on-flash record header. + pub const MAX_NAMESPACE_LEN: usize = 255; + + /// Default maximum key length, in bytes. + pub const DEFAULT_MAX_KEY_LEN: usize = 64; + + /// Default maximum value length, in bytes. + pub const DEFAULT_MAX_VALUE_LEN: usize = 4096; + + /// Default flash sector (erase unit) size in bytes. The store's sectors + /// must be multiples of the device erase size. + pub const DEFAULT_SECTOR_SIZE: usize = 4096; + + /// Default number of sectors in the flash region. With the default sector + /// size this is a 256 KiB region. One sector is reserved as the + /// compaction spare, so usable capacity is + /// `(sector_count - 1) * usable_bytes_per_sector`. + pub const DEFAULT_SECTOR_COUNT: usize = 64; +} + +/// Geometry and size limits for a flash-backed key-value store. +#[cfg(feature = "flash-storage")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FlashConfig { + /// Offset of the store's region inside the flash device. Must be aligned + /// to the device erase size. + pub base_offset: u32, + /// Size of one sector in bytes. Must be a multiple of the device erase + /// size. + pub sector_size: usize, + /// Number of sectors in the region. At least two: one for data, one as + /// the compaction spare. + pub sector_count: usize, + /// Maximum key length in bytes, 1..=65535. + pub max_key_len: usize, + /// Maximum value length in bytes. A record must fit a single sector. + pub max_value_len: usize, +} + +#[cfg(feature = "flash-storage")] +impl FlashConfig { + /// A 256 KiB region using the defaults for every field. + pub const DEFAULT: Self = Self { + base_offset: 0, + sector_size: limits::DEFAULT_SECTOR_SIZE, + sector_count: limits::DEFAULT_SECTOR_COUNT, + max_key_len: limits::DEFAULT_MAX_KEY_LEN, + max_value_len: limits::DEFAULT_MAX_VALUE_LEN, + }; + + /// Validate geometry and size limits. + /// + /// Fails if the region is too small for two sectors, if `sector_size` is + /// not a positive multiple of `erase_size`, or if a size limit is out of + /// its documented range. Device-specific constraints (capacity, record + /// fit against `WRITE_SIZE`) are checked by + /// [`FlashKvStore::new`](crate::flash::FlashKvStore::new). + pub fn new( + base_offset: u32, + sector_size: usize, + sector_count: usize, + max_key_len: usize, + max_value_len: usize, + erase_size: usize, + ) -> Result { + if sector_count < 2 { + return Err("flash region needs at least two sectors"); + } + if sector_size == 0 || erase_size == 0 || !sector_size.is_multiple_of(erase_size) { + return Err("sector size must be a positive multiple of the erase size"); + } + if max_key_len == 0 || max_key_len > u16::MAX as usize { + return Err("max key length must be in 1..=65535"); + } + if max_value_len == 0 { + return Err("max value length must be positive"); + } + Ok(Self { + base_offset, + sector_size, + sector_count, + max_key_len, + max_value_len, + }) + } + + /// Total size of the region in bytes. + pub fn region_size(&self) -> usize { + self.sector_size * self.sector_count + } +} diff --git a/Build/crates/saikuro-storage/src/flash.rs b/Build/crates/saikuro-storage/src/flash.rs new file mode 100644 index 00000000..f190705c --- /dev/null +++ b/Build/crates/saikuro-storage/src/flash.rs @@ -0,0 +1,717 @@ +//! Bounded key-value store over an async NOR-flash device. +//! +//! The store owns a contiguous region of [`NorFlash`] storage and lays it out +//! as a ring of `sector_count` erasable sectors. A sector starts with a u64 +//! sequence number (all-`0xFF` = empty) followed by appended records. Every +//! record carries a CRC-32 so torn writes are detected and truncated on the +//! next [`open`](FlashKvStore::open). +//! +//! Writes are log-structured: `put` and `delete` append a record to the +//! active sector; deletion appends a tombstone. When the active sector fills, +//! the store advances to the next sector in the ring. If that sector still +//! holds data, the store compacts: the live set (materialized in RAM as the +//! running index) is erased and rewritten into a fresh generation, reserving +//! one sector as the spare that guarantees the next roll has room. +//! +//! # Limits +//! +//! The Tier 2 orchestrator bounds every persisted value through +//! [`FlashConfig`]: namespaces are at most 255 bytes, keys at most +//! `max_key_len` (default 64), values at most `max_value_len` (default +//! 4096), and a record must fit inside one sector. Usable capacity is +//! `(sector_count - 1) * (sector_size - sector_header)` bytes; exceeding it +//! returns [`StorageError::QuotaExceeded`]. The running index holds the whole +//! live set in RAM, so RAM usage tracks stored bytes. +//! +//! # Reliability +//! +//! Power loss during a normal append loses at most the torn tail record +//! (CRC-checked on open). Power loss during compaction loses the store: the +//! whole region is erased before the new generation is written. A +//! crash-consistent compaction journal is future work. +//! +//! # Concurrency +//! +//! The store is single-threaded and not reentrant, like the rest of the local +//! storage tier. Flash I/O borrows the internal `RefCell` across `await`, so +//! concurrent access from multiple tasks must be serialized by the +//! application (for example behind an embassy `Mutex`); a reentrant call +//! panics on the borrow instead of corrupting the log. + +use alloc::borrow::ToOwned; +use alloc::collections::BTreeMap; +use alloc::string::String; +use alloc::vec::Vec; +use bytes::Bytes; +use core::cell::RefCell; +use embedded_storage_async::nor_flash::NorFlash; + +use super::{ + config::{limits, FlashConfig, StorageConfig}, + error::{Result, StorageError}, + traits::{LocalKeyValueBackend, LocalStorageBackend}, + util::{apply_prefix, strip_prefix}, +}; + +const RECORD_HEADER_LEN: usize = 12; +const SEQ_LEN: usize = 8; +const EMPTY_SEQ: u64 = u64::MAX; +const TYPE_PUT: u8 = 0; +const TYPE_DELETE: u8 = 1; +const TYPE_CREATE_NAMESPACE: u8 = 2; +const TYPE_DELETE_NAMESPACE: u8 = 3; + +fn align_up(n: usize, align: usize) -> usize { + n.div_ceil(align) * align +} + +fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &byte in data { + crc ^= byte as u32; + for _ in 0..8 { + crc = if crc & 1 != 0 { + (crc >> 1) ^ 0xEDB8_8320 + } else { + crc >> 1 + }; + } + } + !crc +} + +#[derive(Default)] +struct Namespace { + keys: BTreeMap>, +} + +#[derive(Default)] +struct Index { + namespaces: BTreeMap, +} + +#[derive(Clone, Copy)] +struct Active { + idx: usize, + seq: u64, + next_write: usize, +} + +struct Record { + typ: u8, + ns: String, + key: String, + value: Vec, +} + +struct FlashLog { + flash: F, + flash_config: FlashConfig, + index: Index, + active: Active, + opened: bool, +} + +impl FlashLog { + fn header_len(&self) -> usize { + align_up(SEQ_LEN, F::WRITE_SIZE) + } + + fn sector_start(&self, idx: usize) -> usize { + self.flash_config.base_offset as usize + idx * self.flash_config.sector_size + } + + fn sector_capacity(&self) -> usize { + self.flash_config.sector_size - self.header_len() + } + + fn encode_record(&self, typ: u8, ns: &str, key: &str, value: &[u8]) -> Result> { + if ns.len() > limits::MAX_NAMESPACE_LEN { + return Err(StorageError::internal(format!( + "namespace exceeds {} bytes: {ns}", + limits::MAX_NAMESPACE_LEN + ))); + } + if key.len() > self.flash_config.max_key_len { + return Err(StorageError::internal(format!( + "key exceeds {} bytes: {key}", + self.flash_config.max_key_len + ))); + } + if value.len() > self.flash_config.max_value_len { + return Err(StorageError::quota_exceeded(format!( + "value exceeds {} bytes", + self.flash_config.max_value_len + ))); + } + let mut header = Vec::with_capacity(RECORD_HEADER_LEN); + header.push(typ); + header.push(ns.len() as u8); + header.extend_from_slice(&(key.len() as u16).to_le_bytes()); + header.extend_from_slice(&(value.len() as u32).to_le_bytes()); + + let mut crc_buf = Vec::with_capacity(SEQ_LEN + ns.len() + key.len() + value.len()); + crc_buf.extend_from_slice(&header[..8]); + crc_buf.extend_from_slice(ns.as_bytes()); + crc_buf.extend_from_slice(key.as_bytes()); + crc_buf.extend_from_slice(value); + let crc = crc32(&crc_buf); + + let mut out = Vec::with_capacity(RECORD_HEADER_LEN + ns.len() + key.len() + value.len()); + out.extend_from_slice(&header[..]); + out.extend_from_slice(&crc.to_le_bytes()); + out.extend_from_slice(ns.as_bytes()); + out.extend_from_slice(key.as_bytes()); + out.extend_from_slice(value); + Ok(out) + } + + async fn read_exact(&mut self, offset: usize, buf: &mut [u8]) -> Result<()> { + self.flash + .read(offset as u32, buf) + .await + .map_err(|e| StorageError::internal(format!("flash read: {e:?}"))) + } + + async fn write(&mut self, offset: usize, bytes: &[u8]) -> Result<()> { + self.flash + .write(offset as u32, bytes) + .await + .map_err(|e| StorageError::internal(format!("flash write: {e:?}"))) + } + + async fn erase_sector(&mut self, idx: usize) -> Result<()> { + let base = self.sector_start(idx); + self.flash + .erase(base as u32, (base + self.flash_config.sector_size) as u32) + .await + .map_err(|e| StorageError::internal(format!("flash erase: {e:?}"))) + } + + async fn read_seq(&mut self, idx: usize) -> Result { + let mut buf = [0xFFu8; SEQ_LEN]; + self.read_exact(self.sector_start(idx), &mut buf).await?; + Ok(u64::from_le_bytes(buf)) + } + + async fn init_active(&mut self, idx: usize, seq: u64) -> Result<()> { + let mut buf = vec![0xFFu8; self.header_len()]; + buf[..SEQ_LEN].copy_from_slice(&seq.to_le_bytes()); + self.write(self.sector_start(idx), &buf).await?; + self.active = Active { + idx, + seq, + next_write: self.header_len(), + }; + Ok(()) + } + + async fn read_record( + &mut self, + sector: usize, + offset: usize, + ) -> Result> { + let sector_size = self.flash_config.sector_size; + if offset + RECORD_HEADER_LEN > sector_size { + return Ok(None); + } + let mut hdr = [0u8; RECORD_HEADER_LEN]; + self.read_exact(self.sector_start(sector) + offset, &mut hdr) + .await?; + if hdr[0] == 0xFF { + return Ok(None); + } + let ns_len = hdr[1] as usize; + let key_len = u16::from_le_bytes([hdr[2], hdr[3]]) as usize; + let value_len = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]) as usize; + let stored_crc = u32::from_le_bytes([hdr[8], hdr[9], hdr[10], hdr[11]]); + let payload_len = ns_len + key_len + value_len; + + if ns_len > limits::MAX_NAMESPACE_LEN + || key_len > self.flash_config.max_key_len + || value_len > self.flash_config.max_value_len + || offset + RECORD_HEADER_LEN + payload_len > sector_size + { + return Ok(None); + } + + let mut payload = vec![0u8; payload_len]; + self.read_exact( + self.sector_start(sector) + offset + RECORD_HEADER_LEN, + &mut payload, + ) + .await?; + let mut crc_buf = Vec::with_capacity(SEQ_LEN + payload_len); + crc_buf.extend_from_slice(&hdr[..8]); + crc_buf.extend_from_slice(&payload); + if crc32(&crc_buf) != stored_crc { + return Ok(None); + } + + let rec = Record { + typ: hdr[0], + ns: String::from_utf8_lossy(&payload[..ns_len]).into_owned(), + key: String::from_utf8_lossy(&payload[ns_len..ns_len + key_len]).into_owned(), + value: payload[ns_len + key_len..].to_vec(), + }; + let padded = align_up(RECORD_HEADER_LEN + payload_len, F::WRITE_SIZE); + Ok(Some((rec, padded))) + } + + fn apply_record(&mut self, rec: &Record, index: &mut Index) { + match rec.typ { + TYPE_PUT => { + let ns = index.namespaces.entry(rec.ns.clone()).or_default(); + ns.keys.insert(rec.key.clone(), rec.value.clone()); + } + TYPE_DELETE => { + if let Some(ns) = index.namespaces.get_mut(&rec.ns) { + ns.keys.remove(&rec.key); + } + } + TYPE_CREATE_NAMESPACE => { + index.namespaces.entry(rec.ns.clone()).or_default(); + } + TYPE_DELETE_NAMESPACE => { + index.namespaces.remove(&rec.ns); + } + _ => {} + } + } + + async fn scan_sector(&mut self, sector: usize, index: &mut Index) -> Result> { + let seq = self.read_seq(sector).await?; + if seq == EMPTY_SEQ { + return Ok(None); + } + let mut offset = self.header_len(); + while let Some((rec, padded)) = self.read_record(sector, offset).await? { + self.apply_record(&rec, index); + offset += padded; + } + Ok(Some(Active { + idx: sector, + seq, + next_write: offset, + })) + } + + async fn sector_occupied(&mut self, idx: usize) -> Result { + Ok(self.read_seq(idx).await? != EMPTY_SEQ) + } + + async fn write_record(&mut self, enc: &[u8]) -> Result<()> { + let padded = align_up(enc.len(), F::WRITE_SIZE); + let offset = self.sector_start(self.active.idx) + self.active.next_write; + let mut buf = vec![0xFFu8; padded]; + buf[..enc.len()].copy_from_slice(enc); + self.write(offset, &buf).await?; + self.active.next_write += padded; + Ok(()) + } + + /// Advance past the full active sector. Returns `true` when `pending` was + /// already written by a compaction, `false` when the caller must append it + /// into the freshly activated sector. + async fn roll(&mut self, pending: &[u8]) -> Result { + let next = (self.active.idx + 1) % self.flash_config.sector_count; + if self.sector_occupied(next).await? { + self.compact(pending).await?; + Ok(true) + } else { + self.init_active(next, self.active.seq + 1).await?; + Ok(false) + } + } + + async fn append_record(&mut self, enc: &[u8]) -> Result<()> { + let padded = align_up(enc.len(), F::WRITE_SIZE); + if padded > self.sector_capacity() { + return Err(StorageError::quota_exceeded( + "record does not fit in one sector", + )); + } + if self.active.next_write + padded > self.flash_config.sector_size && self.roll(enc).await? + { + return Ok(()); + } + self.write_record(enc).await + } + + /// Erase the region and rewrite the live index as a fresh generation. + /// + /// `pending` is the record that could not be appended to the full active + /// sector. A pending delete or namespace delete is folded into the + /// compaction (the key/namespace is dropped from the output), so a + /// shrinking operation never needs more space than the current live set. + /// Growth operations (`put`, namespace markers) append `pending` after the + /// live records and require the spare sector to survive, so they fail with + /// `QuotaExceeded` instead of wedging the store. + async fn compact(&mut self, pending: &[u8]) -> Result<()> { + let pending_type = pending[0]; + let skip_key: Option<(String, String)> = if pending_type == TYPE_DELETE { + let ns_len = pending[1] as usize; + let key_len = u16::from_le_bytes([pending[2], pending[3]]) as usize; + let ns = String::from_utf8_lossy(&pending[12..12 + ns_len]).into_owned(); + let key = + String::from_utf8_lossy(&pending[12 + ns_len..12 + ns_len + key_len]).into_owned(); + Some((ns, key)) + } else { + None + }; + let skip_ns: Option = if pending_type == TYPE_DELETE_NAMESPACE { + let ns_len = pending[1] as usize; + Some(String::from_utf8_lossy(&pending[12..12 + ns_len]).into_owned()) + } else { + None + }; + let shrinking = skip_key.is_some() || skip_ns.is_some(); + + let mut records: Vec> = Vec::new(); + for (ns, namespace) in &self.index.namespaces { + if let Some(ref skip) = skip_ns { + if ns == skip { + continue; + } + } + records.push(self.encode_record(TYPE_CREATE_NAMESPACE, ns, "", b"")?); + for (key, value) in &namespace.keys { + if let Some((ref skip_ns, ref skip_key)) = skip_key { + if ns == skip_ns && key == skip_key { + continue; + } + } + records.push(self.encode_record(TYPE_PUT, ns, key, value)?); + } + } + if !shrinking { + records.push(pending.to_vec()); + } + + let total: usize = records + .iter() + .map(|r| align_up(r.len(), F::WRITE_SIZE)) + .sum(); + let needed = total.div_ceil(self.sector_capacity()); + let spare = if shrinking { 0 } else { 1 }; + if needed + spare > self.flash_config.sector_count { + return Err(StorageError::quota_exceeded("flash region full")); + } + + for idx in 0..self.flash_config.sector_count { + self.erase_sector(idx).await?; + } + + let base_seq = self.active.seq + 1; + let mut idx = 0usize; + let mut seq = base_seq; + self.init_active(idx, seq).await?; + for record in &records { + let padded = align_up(record.len(), F::WRITE_SIZE); + if self.active.next_write + padded > self.flash_config.sector_size { + idx = (idx + 1) % self.flash_config.sector_count; + seq += 1; + self.init_active(idx, seq).await?; + } + self.write_record(record).await?; + } + Ok(()) + } + + fn ensure_opened(&self) -> Result<()> { + if self.opened { + Ok(()) + } else { + Err(StorageError::internal( + "flash store is not opened; call open() first", + )) + } + } + + async fn open(&mut self) -> Result<()> { + let mut index = Index::default(); + let mut best: Option = None; + for idx in 0..self.flash_config.sector_count { + let scanned = self.scan_sector(idx, &mut index).await?; + if let Some(active) = scanned { + match best { + None => best = Some(active), + Some(b) if active.seq > b.seq => best = Some(active), + Some(_) => {} + } + } + } + self.index = index; + self.active = match best { + Some(active) => active, + None => { + // Fresh region: write sector 0's sequence header so a later + // open recognizes the active sector. + self.init_active(0, 0).await?; + self.active + } + }; + self.opened = true; + Ok(()) + } + + async fn ensure_namespace(&mut self, stored_ns: &str) -> Result<()> { + let enc = self.encode_record(TYPE_CREATE_NAMESPACE, stored_ns, "", b"")?; + self.append_record(&enc).await?; + self.index + .namespaces + .entry(stored_ns.to_owned()) + .or_default(); + Ok(()) + } +} + +/// A bounded, durable key-value store over an async NOR-flash device. +/// +/// See the [module documentation](self) for the on-flash layout, size limits, +/// and reliability guarantees. The store owns the device and a RAM index of +/// the live set; the region is erased and rewritten by compaction, so this +/// backend never allocates beyond `max_value_len` per record plus the live +/// index. +pub struct FlashKvStore { + config: StorageConfig, + flash_config: FlashConfig, + inner: RefCell>, +} + +impl FlashKvStore { + /// Validate the store geometry against the device and construct the + /// store. Call [`open`](Self::open) before using it. + pub fn new(flash: F, config: StorageConfig, flash_config: FlashConfig) -> Result { + let geometry = FlashConfig::new( + flash_config.base_offset, + flash_config.sector_size, + flash_config.sector_count, + flash_config.max_key_len, + flash_config.max_value_len, + F::ERASE_SIZE, + ) + .map_err(|msg| StorageError::internal(format!("invalid flash config: {msg}")))?; + + let header_len = align_up(SEQ_LEN, F::WRITE_SIZE); + let record_max = align_up( + RECORD_HEADER_LEN + + limits::MAX_NAMESPACE_LEN + + geometry.max_key_len + + geometry.max_value_len, + F::WRITE_SIZE, + ); + if !(geometry.base_offset as usize).is_multiple_of(F::ERASE_SIZE) { + return Err(StorageError::internal( + "flash base offset must be aligned to the erase size", + )); + } + if !geometry.sector_size.is_multiple_of(F::WRITE_SIZE) { + return Err(StorageError::internal( + "flash sector size must be a multiple of the write size", + )); + } + if geometry.sector_size < header_len + RECORD_HEADER_LEN { + return Err(StorageError::internal( + "flash sector size too small for the record header", + )); + } + if geometry.base_offset as usize + geometry.region_size() > flash.capacity() { + return Err(StorageError::internal( + "flash region exceeds the device capacity", + )); + } + if record_max > geometry.sector_size - header_len { + return Err(StorageError::internal( + "a maximum-size record does not fit one sector", + )); + } + + Ok(Self { + config, + flash_config: geometry, + inner: RefCell::new(FlashLog { + flash, + flash_config: geometry, + index: Index::default(), + active: Active { + idx: 0, + seq: 0, + next_write: header_len, + }, + opened: false, + }), + }) + } + + /// Scan the region and rebuild the index. Safe to call again to recover + /// from a torn tail left by a power loss during a normal append. + pub async fn open(&mut self) -> Result<()> { + self.inner.get_mut().open().await + } + + /// The generic storage configuration. + pub fn config(&self) -> &StorageConfig { + &self.config + } + + /// The flash geometry and size limits. + pub fn flash_config(&self) -> FlashConfig { + self.flash_config + } + + /// Usable storage capacity in bytes: `(sector_count - 1)` sectors, the + /// last one reserved as the compaction spare. + pub fn capacity(&self) -> usize { + let header_len = align_up(SEQ_LEN, F::WRITE_SIZE); + (self.flash_config.sector_count - 1) * (self.flash_config.sector_size - header_len) + } +} + +// The borrow is held across await on purpose: the store is single-threaded +// and non-reentrant (see the module docs), and a reentrant call panics on the +// borrow instead of interleaving log writes. +#[allow(clippy::await_holding_refcell_ref)] +impl LocalKeyValueBackend for FlashKvStore { + fn config(&self) -> &StorageConfig { + &self.config + } + + async fn exists(&self, namespace: &str, key: &str) -> Result { + let inner = self.inner.borrow_mut(); + inner.ensure_opened()?; + let stored_ns = apply_prefix(&self.config, namespace); + match inner.index.namespaces.get(&stored_ns) { + Some(ns) => Ok(ns.keys.contains_key(key)), + None if self.config.auto_create_namespaces => Ok(false), + None => Err(StorageError::namespace_not_found(namespace)), + } + } + + async fn get(&self, namespace: &str, key: &str) -> Result> { + let inner = self.inner.borrow_mut(); + inner.ensure_opened()?; + let stored_ns = apply_prefix(&self.config, namespace); + match inner.index.namespaces.get(&stored_ns) { + Some(ns) => Ok(ns.keys.get(key).map(|v| Bytes::from(v.clone()))), + None if self.config.auto_create_namespaces => Ok(None), + None => Err(StorageError::namespace_not_found(namespace)), + } + } + + async fn put(&self, namespace: &str, key: &str, value: Bytes) -> Result<()> { + let mut inner = self.inner.borrow_mut(); + inner.ensure_opened()?; + let stored_ns = apply_prefix(&self.config, namespace); + if !inner.index.namespaces.contains_key(&stored_ns) { + if !self.config.auto_create_namespaces { + return Err(StorageError::namespace_not_found(namespace)); + } + inner.ensure_namespace(&stored_ns).await?; + } + let enc = inner.encode_record(TYPE_PUT, &stored_ns, key, &value)?; + inner.append_record(&enc).await?; + let ns = inner + .index + .namespaces + .get_mut(&stored_ns) + .expect("namespace ensured above"); + ns.keys.insert(key.to_owned(), value.to_vec()); + Ok(()) + } + + async fn delete(&self, namespace: &str, key: &str) -> Result<()> { + let mut inner = self.inner.borrow_mut(); + inner.ensure_opened()?; + let stored_ns = apply_prefix(&self.config, namespace); + let present = match inner.index.namespaces.get(&stored_ns) { + None => return Ok(()), + Some(ns) => ns.keys.contains_key(key), + }; + if !present { + return Ok(()); + } + let enc = inner.encode_record(TYPE_DELETE, &stored_ns, key, b"")?; + inner.append_record(&enc).await?; + let ns = inner + .index + .namespaces + .get_mut(&stored_ns) + .expect("namespace present above"); + ns.keys.remove(key); + Ok(()) + } + + async fn list_keys(&self, namespace: &str) -> Result> { + let inner = self.inner.borrow_mut(); + inner.ensure_opened()?; + let stored_ns = apply_prefix(&self.config, namespace); + match inner.index.namespaces.get(&stored_ns) { + Some(ns) => Ok(ns.keys.keys().cloned().collect()), + None if self.config.auto_create_namespaces => Ok(Vec::new()), + None => Err(StorageError::namespace_not_found(namespace)), + } + } + + async fn list_namespaces(&self) -> Result> { + let inner = self.inner.borrow_mut(); + inner.ensure_opened()?; + Ok(inner + .index + .namespaces + .keys() + .map(|ns| strip_prefix(&self.config, ns)) + .collect()) + } + + async fn create_namespace(&self, namespace: &str) -> Result<()> { + let mut inner = self.inner.borrow_mut(); + inner.ensure_opened()?; + let stored_ns = apply_prefix(&self.config, namespace); + if inner.index.namespaces.contains_key(&stored_ns) { + return Err(StorageError::namespace_already_exists(namespace)); + } + inner.ensure_namespace(&stored_ns).await?; + Ok(()) + } + + async fn delete_namespace(&self, namespace: &str) -> Result<()> { + let mut inner = self.inner.borrow_mut(); + inner.ensure_opened()?; + let stored_ns = apply_prefix(&self.config, namespace); + if !inner.index.namespaces.contains_key(&stored_ns) { + return Ok(()); + } + let enc = inner.encode_record(TYPE_DELETE_NAMESPACE, &stored_ns, "", b"")?; + inner.append_record(&enc).await?; + inner.index.namespaces.remove(&stored_ns); + Ok(()) + } + + async fn clear_namespace(&self, namespace: &str) -> Result<()> { + let mut inner = self.inner.borrow_mut(); + inner.ensure_opened()?; + let stored_ns = apply_prefix(&self.config, namespace); + let keys: Vec = match inner.index.namespaces.get(&stored_ns) { + Some(ns) => ns.keys.keys().cloned().collect(), + None => return Ok(()), + }; + for key in keys { + let enc = inner.encode_record(TYPE_DELETE, &stored_ns, &key, b"")?; + inner.append_record(&enc).await?; + let ns = inner + .index + .namespaces + .get_mut(&stored_ns) + .expect("namespace present above"); + ns.keys.remove(&key); + } + Ok(()) + } +} + +impl LocalStorageBackend for FlashKvStore { + fn supports_files(&self) -> bool { + false + } +} diff --git a/Build/crates/saikuro-storage/src/lib.rs b/Build/crates/saikuro-storage/src/lib.rs index f837226d..a4ba9484 100644 --- a/Build/crates/saikuro-storage/src/lib.rs +++ b/Build/crates/saikuro-storage/src/lib.rs @@ -47,6 +47,9 @@ pub mod local_storage; #[cfg(feature = "session-storage")] pub mod session_storage; +#[cfg(feature = "flash-storage")] +pub mod flash; + /// Generates a web-storage-backed key-value backend. /// /// `$name` is the struct name (e.g., `LocalStorage`). @@ -185,6 +188,9 @@ macro_rules! impl_web_storage { } pub use config::{BackendKind, CleanupPolicy, PersistenceMode, StorageConfig}; + +#[cfg(feature = "flash-storage")] +pub use config::FlashConfig; pub use error::{Result, StorageError}; pub use traits::{ FileBackend, KeyValueBackend, KeyValueBackendExt, LocalFileBackend, LocalKeyValueBackend, @@ -226,3 +232,6 @@ pub use sled::SledStorage; #[cfg(feature = "sqlite-storage")] pub use sqlite::SqliteStorage; + +#[cfg(feature = "flash-storage")] +pub use flash::FlashKvStore; diff --git a/Build/crates/saikuro-storage/tests/flash.rs b/Build/crates/saikuro-storage/tests/flash.rs new file mode 100644 index 00000000..09f40f86 --- /dev/null +++ b/Build/crates/saikuro-storage/tests/flash.rs @@ -0,0 +1,679 @@ +//! Integration tests for the flash-backed bounded key-value store. +//! +//! The fake NOR-flash device enforces real NOR semantics: aligned reads and +//! writes, per-word write-once, erase-to-`0xFF`, and `1`-only-to-`0` bit +//! transitions. Tests share the fake behind `Rc>` so a "reboot" +//! is a fresh store opened over the same device contents. + +use std::cell::RefCell; +use std::rc::Rc; + +use bytes::Bytes; +use embedded_storage_async::nor_flash::{ + ErrorType, NorFlash, NorFlashError, NorFlashErrorKind, ReadNorFlash, +}; +use futures_executor::block_on; +use saikuro_storage::{ + FlashConfig, FlashKvStore, LocalKeyValueBackend, StorageConfig, StorageError, +}; + +const WRITE_SIZE: usize = 4; +const ERASE_SIZE: usize = 256; +const REGION_SIZE: usize = 512 * 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FlashTestError { + kind: NorFlashErrorKind, +} + +impl ErrorType for FakeFlash { + type Error = FlashTestError; +} + +impl NorFlashError for FlashTestError { + fn kind(&self) -> NorFlashErrorKind { + self.kind + } +} + +impl From for FlashTestError { + fn from(kind: NorFlashErrorKind) -> Self { + Self { kind } + } +} + +struct FakeFlash { + data: Vec, + written: Vec, +} + +impl FakeFlash { + fn new(region: usize) -> Self { + assert_eq!(region % ERASE_SIZE, 0); + Self { + data: vec![0xFF; region], + written: vec![false; region / WRITE_SIZE], + } + } +} + +impl ReadNorFlash for FakeFlash { + const READ_SIZE: usize = 1; + + async fn read(&mut self, offset: u32, bytes: &mut [u8]) -> Result<(), Self::Error> { + let off = offset as usize; + if off + bytes.len() > self.data.len() { + return Err(NorFlashErrorKind::OutOfBounds.into()); + } + bytes.copy_from_slice(&self.data[off..off + bytes.len()]); + Ok(()) + } + + fn capacity(&self) -> usize { + self.data.len() + } +} + +impl NorFlash for FakeFlash { + const WRITE_SIZE: usize = WRITE_SIZE; + const ERASE_SIZE: usize = ERASE_SIZE; + + async fn erase(&mut self, from: u32, to: u32) -> Result<(), Self::Error> { + let f = from as usize; + let t = to as usize; + if !f.is_multiple_of(ERASE_SIZE) + || !t.is_multiple_of(ERASE_SIZE) + || t <= f + || t > self.data.len() + { + return Err(NorFlashErrorKind::NotAligned.into()); + } + self.data[f..t].fill(0xFF); + for word in self + .written + .iter_mut() + .skip(f / WRITE_SIZE) + .take((t - f) / WRITE_SIZE) + { + *word = false; + } + Ok(()) + } + + async fn write(&mut self, offset: u32, bytes: &[u8]) -> Result<(), Self::Error> { + let off = offset as usize; + if !off.is_multiple_of(WRITE_SIZE) + || !bytes.len().is_multiple_of(WRITE_SIZE) + || off + bytes.len() > self.data.len() + { + return Err(NorFlashErrorKind::NotAligned.into()); + } + for (i, &b) in bytes.iter().enumerate() { + let word = (off + i) / WRITE_SIZE; + if self.written[word] { + return Err(FlashTestError { + kind: NorFlashErrorKind::Other, + }); + } + let old = self.data[off + i]; + if old | b != old { + return Err(FlashTestError { + kind: NorFlashErrorKind::Other, + }); + } + } + for (i, &b) in bytes.iter().enumerate() { + self.written[(off + i) / WRITE_SIZE] = true; + self.data[off + i] = b; + } + Ok(()) + } +} + +#[derive(Clone)] +struct RcFlash(Rc>); + +impl RcFlash { + fn new() -> Self { + Self(Rc::new(RefCell::new(FakeFlash::new(REGION_SIZE)))) + } +} + +impl ErrorType for RcFlash { + type Error = FlashTestError; +} + +// The borrow is held across await so the fake serializes access to the shared +// device state; the tests are single-threaded, so there is no contention. +#[allow(clippy::await_holding_refcell_ref)] +impl ReadNorFlash for RcFlash { + const READ_SIZE: usize = FakeFlash::READ_SIZE; + + async fn read(&mut self, offset: u32, bytes: &mut [u8]) -> Result<(), Self::Error> { + self.0.borrow_mut().read(offset, bytes).await + } + + fn capacity(&self) -> usize { + self.0.borrow().capacity() + } +} + +#[allow(clippy::await_holding_refcell_ref)] +impl NorFlash for RcFlash { + const WRITE_SIZE: usize = FakeFlash::WRITE_SIZE; + const ERASE_SIZE: usize = FakeFlash::ERASE_SIZE; + + async fn erase(&mut self, from: u32, to: u32) -> Result<(), Self::Error> { + self.0.borrow_mut().erase(from, to).await + } + + async fn write(&mut self, offset: u32, bytes: &[u8]) -> Result<(), Self::Error> { + self.0.borrow_mut().write(offset, bytes).await + } +} + +fn flash_config() -> FlashConfig { + FlashConfig::new(0, 512, 8, 32, 128, ERASE_SIZE).expect("valid test config") +} + +fn new_store(flash: RcFlash) -> FlashKvStore { + FlashKvStore::new(flash, StorageConfig::default(), flash_config()).expect("valid store") +} + +fn expect_invalid(store: Result, StorageError>) -> StorageError { + match store { + Err(e) => e, + Ok(_) => panic!("expected store construction to fail"), + } +} + +async fn open(store: &mut FlashKvStore) { + store.open().await.expect("open scans the region"); +} + +// Construction and geometry + +#[test] +fn capacity_reserves_one_spare_sector() { + let store = new_store(RcFlash::new()); + assert_eq!(store.capacity(), 7 * (512 - 8)); +} + +#[test] +fn new_rejects_sector_not_multiple_of_erase_size() { + let err = expect_invalid(FlashKvStore::new( + RcFlash::new(), + StorageConfig::default(), + FlashConfig { + base_offset: 0, + sector_size: 300, + sector_count: 8, + max_key_len: 32, + max_value_len: 128, + }, + )); + assert!(matches!(err, StorageError::Internal(_))); +} + +#[test] +fn new_rejects_region_beyond_capacity() { + let err = expect_invalid(FlashKvStore::new( + RcFlash::new(), + StorageConfig::default(), + FlashConfig::new(0, 512, 9, 32, 128, ERASE_SIZE).unwrap(), + )); + assert!(matches!(err, StorageError::Internal(_))); +} + +#[test] +fn new_rejects_record_larger_than_sector() { + let err = expect_invalid(FlashKvStore::new( + RcFlash::new(), + StorageConfig::default(), + FlashConfig::new(0, 512, 8, 32, 500, ERASE_SIZE).unwrap(), + )); + assert!(matches!(err, StorageError::Internal(_))); +} + +#[test] +fn operations_require_open() { + let store = new_store(RcFlash::new()); + let err = block_on(store.get("ns", "k")).unwrap_err(); + assert!(matches!(err, StorageError::Internal(_))); +} + +// Basic key-value operations + +#[test] +fn put_and_get_roundtrip() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + store.put("ns", "k", Bytes::from("hello")).await.unwrap(); + assert_eq!( + store.get("ns", "k").await.unwrap(), + Some(Bytes::from("hello")) + ); + }); +} + +#[test] +fn put_overwrites_existing() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + store.put("ns", "k", Bytes::from("v1")).await.unwrap(); + store.put("ns", "k", Bytes::from("v2")).await.unwrap(); + assert_eq!(store.get("ns", "k").await.unwrap(), Some(Bytes::from("v2"))); + }); +} + +#[test] +fn get_missing_returns_none() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + assert_eq!(store.get("ns", "missing").await.unwrap(), None); + assert!(!store.exists("ns", "missing").await.unwrap()); + }); +} + +#[test] +fn delete_removes_key() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + store.put("ns", "k", Bytes::from("v")).await.unwrap(); + store.delete("ns", "k").await.unwrap(); + assert_eq!(store.get("ns", "k").await.unwrap(), None); + }); +} + +#[test] +fn delete_missing_key_does_not_error() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + store.delete("ns", "missing").await.unwrap(); + }); +} + +#[test] +fn list_keys_and_namespaces() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + store.put("ns1", "a", Bytes::from("1")).await.unwrap(); + store.put("ns1", "b", Bytes::from("2")).await.unwrap(); + store.put("ns2", "k", Bytes::from("3")).await.unwrap(); + + let mut keys = store.list_keys("ns1").await.unwrap(); + keys.sort(); + assert_eq!(keys, vec!["a", "b"]); + assert_eq!(store.list_keys("ns2").await.unwrap(), vec!["k"]); + + let mut nss = store.list_namespaces().await.unwrap(); + nss.sort(); + assert_eq!(nss, vec!["ns1", "ns2"]); + }); +} + +// Namespace lifecycle + +#[test] +fn namespace_marker_survives_key_deletion() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + store.create_namespace("empty").await.unwrap(); + store.put("empty", "k", Bytes::from("v")).await.unwrap(); + store.delete("empty", "k").await.unwrap(); + assert_eq!(store.list_namespaces().await.unwrap(), vec!["empty"]); + }); +} + +#[test] +fn create_existing_namespace_errors() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + store.create_namespace("ns").await.unwrap(); + let err = store.create_namespace("ns").await.unwrap_err(); + assert!(matches!(err, StorageError::NamespaceAlreadyExists(_))); + }); +} + +#[test] +fn delete_namespace_removes_keys_and_marker() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + store.put("ns", "k", Bytes::from("v")).await.unwrap(); + store.delete_namespace("ns").await.unwrap(); + assert_eq!(store.get("ns", "k").await.unwrap(), None); + assert!(store.list_namespaces().await.unwrap().is_empty()); + }); +} + +#[test] +fn clear_namespace_keeps_namespace() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + store.put("ns", "k", Bytes::from("v")).await.unwrap(); + store.clear_namespace("ns").await.unwrap(); + assert_eq!(store.get("ns", "k").await.unwrap(), None); + assert_eq!(store.list_namespaces().await.unwrap(), vec!["ns"]); + store.put("ns", "k2", Bytes::from("v")).await.unwrap(); + assert!(store.exists("ns", "k2").await.unwrap()); + }); +} + +// Size limits (Tier 2 orchestration bounds) + +#[test] +fn rejects_key_over_limit() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + let long_key = "x".repeat(33); + let err = store + .put("ns", &long_key, Bytes::from("v")) + .await + .unwrap_err(); + assert!(matches!(err, StorageError::Internal(_))); + }); +} + +#[test] +fn rejects_value_over_limit() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + let big = Bytes::from(vec![0u8; 129]); + let err = store.put("ns", "k", big).await.unwrap_err(); + assert!(matches!(err, StorageError::QuotaExceeded(_))); + }); +} + +#[test] +fn rejects_namespace_over_255_bytes() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + let long_ns = "n".repeat(256); + let err = store + .put(&long_ns, "k", Bytes::from("v")) + .await + .unwrap_err(); + assert!(matches!(err, StorageError::Internal(_))); + }); +} + +// auto_create_namespaces = false + +#[test] +fn put_errors_on_missing_namespace_without_auto_create() { + block_on(async { + let mut store = FlashKvStore::new( + RcFlash::new(), + StorageConfig { + auto_create_namespaces: false, + ..Default::default() + }, + flash_config(), + ) + .unwrap(); + open(&mut store).await; + let err = store + .put("manual", "k", Bytes::from("v")) + .await + .unwrap_err(); + assert!(matches!(err, StorageError::NamespaceNotFound(_))); + }); +} + +// Namespace prefix isolation + +#[test] +fn namespace_prefix_isolates_storage() { + block_on(async { + let mut a = FlashKvStore::new( + RcFlash::new(), + StorageConfig::default().with_prefix("tenant_a"), + flash_config(), + ) + .unwrap(); + let mut b = FlashKvStore::new( + RcFlash::new(), + StorageConfig::default().with_prefix("tenant_b"), + flash_config(), + ) + .unwrap(); + open(&mut a).await; + open(&mut b).await; + + a.put("ns", "k", Bytes::from("from_a")).await.unwrap(); + b.put("ns", "k", Bytes::from("from_b")).await.unwrap(); + + assert_eq!(a.get("ns", "k").await.unwrap(), Some(Bytes::from("from_a"))); + assert_eq!(b.get("ns", "k").await.unwrap(), Some(Bytes::from("from_b"))); + assert_eq!(a.list_namespaces().await.unwrap(), vec!["ns"]); + }); +} + +// Durability and rollover + +#[test] +fn data_survives_reboot() { + block_on(async { + let flash = RcFlash::new(); + { + let mut store = new_store(flash.clone()); + open(&mut store).await; + store + .put("ns", "k", Bytes::from("persisted")) + .await + .unwrap(); + } + { + let mut store = new_store(flash.clone()); + open(&mut store).await; + assert_eq!( + store.get("ns", "k").await.unwrap(), + Some(Bytes::from("persisted")) + ); + } + }); +} + +#[test] +fn rolls_across_sectors_and_reads_back() { + block_on(async { + let flash = RcFlash::new(); + { + let mut store = new_store(flash.clone()); + open(&mut store).await; + for i in 0..40 { + store + .put("ns", &format!("k{i}"), Bytes::from(vec![i as u8; 10])) + .await + .unwrap(); + } + } + { + let mut store = new_store(flash.clone()); + open(&mut store).await; + for i in 0..40 { + assert_eq!( + store.get("ns", &format!("k{i}")).await.unwrap(), + Some(Bytes::from(vec![i as u8; 10])), + "key k{i} after reboot" + ); + } + } + }); +} + +#[test] +fn compaction_reclaims_space_under_overwrite() { + block_on(async { + let flash = RcFlash::new(); + let mut store = new_store(flash.clone()); + open(&mut store).await; + for i in 0..8 { + store + .put("ns", &format!("k{i}"), Bytes::from(vec![i as u8; 100])) + .await + .unwrap(); + } + for round in 0..60 { + store + .put("ns", "k0", Bytes::from(vec![round as u8; 100])) + .await + .unwrap(); + } + for i in 1..8 { + assert_eq!( + store.get("ns", &format!("k{i}")).await.unwrap(), + Some(Bytes::from(vec![i as u8; 100])), + "key k{i} after compaction" + ); + } + assert_eq!( + store.get("ns", "k0").await.unwrap(), + Some(Bytes::from(vec![59u8; 100])) + ); + + let mut reopened = new_store(flash.clone()); + open(&mut reopened).await; + for i in 1..8 { + assert_eq!( + reopened.get("ns", &format!("k{i}")).await.unwrap(), + Some(Bytes::from(vec![i as u8; 100])) + ); + } + }); +} + +#[test] +fn compaction_preserves_tombstones_and_markers() { + block_on(async { + let flash = RcFlash::new(); + let mut store = new_store(flash.clone()); + open(&mut store).await; + store + .put("ns", "dead", Bytes::from(vec![1u8; 100])) + .await + .unwrap(); + store + .put("ns", "live", Bytes::from(vec![2u8; 100])) + .await + .unwrap(); + store.delete("ns", "dead").await.unwrap(); + for _ in 0..60 { + store + .put("ns", "churn", Bytes::from(vec![3u8; 100])) + .await + .unwrap(); + } + assert_eq!(store.get("ns", "dead").await.unwrap(), None); + assert_eq!( + store.get("ns", "live").await.unwrap(), + Some(Bytes::from(vec![2u8; 100])) + ); + + let mut reopened = new_store(flash.clone()); + open(&mut reopened).await; + assert_eq!(reopened.get("ns", "dead").await.unwrap(), None); + assert_eq!( + reopened.get("ns", "live").await.unwrap(), + Some(Bytes::from(vec![2u8; 100])) + ); + let mut nss = reopened.list_namespaces().await.unwrap(); + nss.sort(); + assert_eq!(nss, vec!["ns"]); + }); +} + +// Quota + +#[test] +fn quota_exceeded_when_region_full_then_recoverable() { + block_on(async { + let mut store = new_store(RcFlash::new()); + open(&mut store).await; + + let mut err = None; + for i in 0..64 { + if let Err(e) = store + .put("ns", &format!("k{i}"), Bytes::from(vec![i as u8; 100])) + .await + { + err = Some((i, e)); + break; + } + } + let (full_at, err) = err.expect("region must fill before 64 distinct keys"); + assert!(matches!(err, StorageError::QuotaExceeded(_))); + assert!( + full_at >= 29, + "region should hold ~30 keys, filled at {full_at}" + ); + + for i in 0..full_at { + assert_eq!( + store.get("ns", &format!("k{i}")).await.unwrap(), + Some(Bytes::from(vec![i as u8; 100])), + "key k{i} after quota error" + ); + } + + for i in 0..4 { + store.delete("ns", &format!("k{i}")).await.unwrap(); + } + store + .put("ns", &format!("k{full_at}"), Bytes::from(vec![9u8; 100])) + .await + .expect("deleting keys must free compaction space"); + assert_eq!( + store.get("ns", &format!("k{full_at}")).await.unwrap(), + Some(Bytes::from(vec![9u8; 100])) + ); + }); +} + +// Torn-write recovery + +#[test] +fn open_truncates_torn_tail_record() { + block_on(async { + let flash = RcFlash::new(); + { + let mut store = new_store(flash.clone()); + open(&mut store).await; + store + .put("ns", "a", Bytes::from(vec![1u8; 10])) + .await + .unwrap(); + store + .put("ns", "b", Bytes::from(vec![2u8; 10])) + .await + .unwrap(); + } + // Sector 0: 8-byte seq header, namespace marker at [8..24), "a" at + // [24..52), "b" at [52..80). Corrupt "b"'s ns_len byte so its header + // is invalid. + { + let mut fake = flash.0.borrow_mut(); + fake.data[52 + 1] = 0xFF; + } + let mut reopened = new_store(flash.clone()); + open(&mut reopened).await; + assert_eq!( + reopened.get("ns", "a").await.unwrap(), + Some(Bytes::from(vec![1u8; 10])) + ); + assert_eq!(reopened.get("ns", "b").await.unwrap(), None); + }); +} From 726c552f076e070693c8fe9eb029ff36d71eb536 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Wed, 12 Aug 2026 14:32:58 -0600 Subject: [PATCH 16/43] Reorganization part 1 --- Build/Cargo.toml | 58 ++++++++++++++++------- Build/crates/saikuro-codegen/Cargo.toml | 2 +- Build/crates/saikuro-core/Cargo.toml | 9 +--- Build/crates/saikuro-exec/Cargo.toml | 17 +++---- Build/crates/saikuro-random/Cargo.toml | 1 - Build/crates/saikuro-router/Cargo.toml | 4 +- Build/crates/saikuro-runtime/Cargo.toml | 3 +- Build/crates/saikuro-schema/Cargo.toml | 6 +-- Build/crates/saikuro-storage/Cargo.toml | 20 ++++---- Build/crates/saikuro-transport/Cargo.toml | 47 ++++-------------- Build/tests/Cargo.toml | 14 ++---- 11 files changed, 76 insertions(+), 105 deletions(-) diff --git a/Build/Cargo.toml b/Build/Cargo.toml index 95d20f7c..de86e79c 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -25,8 +25,6 @@ rust-version = "1.75" [workspace.dependencies] # Serialization -# no_std-compatible by default; workspace members that need std behaviour -# enable it explicitly via `features = ["std"]` on their serde dependency. serde = { version = "1.0", default-features = false, features = [ "alloc", "derive", @@ -38,18 +36,15 @@ serde_bytes = { version = "0.11", default-features = false, features = [ messagepack-serde = { version = "0.2.4", default-features = false, features = [ "alloc", ] } -# Reference wire-format implementation used only by the adapter simulators -# (tests/tests/common, cross_language_wire, sandbox_dispatch, log_dispatch) and -# the adapters/rust reference adapter rmp-serde = "1.3" -bytes = "1.7" +bytes = "1.12" futures = { version = "0.3", default-features = false, features = ["alloc"] } async-trait = "0.1" pin-project-lite = "0.2" -uuid = { version = "1.23.2", default-features = false } +uuid = { version = "1.23", default-features = false } -getrandom = { version = "0.3.1", default-features = false } +getrandom = { version = "0.3", default-features = false } chacha20 = { version = "0.9", default-features = false } portable-atomic = { version = "1", default-features = false, features = [ "fallback", @@ -58,15 +53,12 @@ portable-atomic = { version = "1", default-features = false, features = [ heapless = { version = "0.8", default-features = false, features = ["serde"] } -# Duration / utility serde helpers serde_with = "3.0" -# Enum string conversion -strum = { version = "0.28.0", features = ["derive"] } +strum = { version = "0.28", features = ["derive"] } -# Logging / tracing tracing = "0.1" -tracing-subscriber = { version = "0.3.23", features = [ +tracing-subscriber = { version = "0.3", features = [ "env-filter", "fmt", "json", @@ -78,12 +70,10 @@ embassy-futures = { version = "0.1", default-features = false } embassy-net = { version = "0.5", default-features = false } embedded-storage-async = { version = "0.4", default-features = false } -# Error handling -thiserror = { version = "2.0", default-features = false } +thiserror = { version = "2", default-features = false } anyhow = "1.0" -# Concurrency -dashmap = "6.1" +dashmap = "7.0.0-rc2" parking_lot = "0.12" spin = { version = "0.12", default-features = false, features = [ "mutex", @@ -92,9 +82,41 @@ spin = { version = "0.12", default-features = false, features = [ "portable-atomic", ] } -# Time chrono = { version = "0.4", features = ["serde", "wasmbind"] } +# Tokio +tokio = { version = "1.53", default-features = false } +tokio-util = { version = "0.7", default-features = false } + +# Embassy executor (dev-dep only) +embassy-executor = { version = "0.6", default-features = false } +embassy-net-driver-channel = "0.3" + +# WASM +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = "0.3" +fluvio-wasm-timer = "0.2" +send_wrapper = "0.6" +wasm-bindgen-test = "0.3" + +# Embedded IO +embedded-io-async = { version = "0.7", default-features = false } + +# WebSocket +tokio-tungstenite = { version = "0.30", default-features = false } + +# Storage backends +sled = "0.34" +rusqlite = { version = "0.40", features = ["bundled"] } + +# Futures +futures-executor = "0.3" + +# CLI +clap = { version = "4", features = ["derive"] } + # Internal crates saikuro-core = { path = "crates/saikuro-core", default-features = false } saikuro-schema = { path = "crates/saikuro-schema", default-features = false } diff --git a/Build/crates/saikuro-codegen/Cargo.toml b/Build/crates/saikuro-codegen/Cargo.toml index 91e81ea3..38074477 100644 --- a/Build/crates/saikuro-codegen/Cargo.toml +++ b/Build/crates/saikuro-codegen/Cargo.toml @@ -25,6 +25,6 @@ serde_json = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } -clap = { version = "4.5", features = ["derive", "env"], optional = true } +clap = { workspace = true, optional = true } [dev-dependencies] diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index c0b6b6ce..be6d85f7 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -8,13 +8,6 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "rpc", "msgpack"] -# The crate is always `no_std` + `alloc`. The default `std` feature adds the -# std-only conveniences (`std::io::Error` variant, stderr log sink) and selects -# the OS entropy backend; `std-no-os` is the same crate-level conveniences -# without the OS entropy backend, for targets that cannot reach it (wasm32); -# `custom` selects the caller-provided getrandom backend and `drbg` the -# deterministic chacha20 DRBG for bare-metal targets (see saikuro-random). -# `std` is mutually exclusive with `custom`/`drbg`. [features] default = ["std"] std = ["saikuro-random/os"] @@ -31,7 +24,7 @@ serde_bytes = { workspace = true } uuid = { workspace = true } saikuro-random = { workspace = true, default-features = false } thiserror = { workspace = true, default-features = false } -strum = { version = "0.28", default-features = false, features = ["derive"] } +strum = { workspace = true } heapless = { workspace = true } spin = { workspace = true } messagepack-serde = { workspace = true } diff --git a/Build/crates/saikuro-exec/Cargo.toml b/Build/crates/saikuro-exec/Cargo.toml index fb957911..0e0a0e72 100644 --- a/Build/crates/saikuro-exec/Cargo.toml +++ b/Build/crates/saikuro-exec/Cargo.toml @@ -22,18 +22,15 @@ embassy-test = [ "embassy-time/std", "embassy-time/generic-queue", ] -# TCP/IP networking for the Embassy backend. The application owns the device -# driver, stack resources, and runner (see `saikuro_exec::net`); this feature -# only pulls in the `embassy-net` stack and exposes it. net = ["dep:embassy-net"] [dependencies] -tokio = { version = "1.52.3", default-features = false, features = ["macros"], optional = true } -tokio-util = { version = "0.7.18", features = ["codec"], optional = true } +tokio = { workspace = true, optional = true } +tokio-util = { workspace = true, optional = true } futures = { workspace = true } -wasm-bindgen-futures = { version = "0.4.71", optional = true } -fluvio-wasm-timer = { version = "0.2.5", optional = true } +wasm-bindgen-futures = { workspace = true, optional = true } +fluvio-wasm-timer = { workspace = true, optional = true } embassy-sync = { workspace = true, optional = true } embassy-time = { workspace = true, optional = true } @@ -46,6 +43,6 @@ embassy-net = { workspace = true, optional = true, features = [ ] } [dev-dependencies] -embassy-executor = { version = "0.6", default-features = false } -embassy-net-driver-channel = "0.3" -futures-executor = "0.3" +embassy-executor = { workspace = true } +embassy-net-driver-channel = { workspace = true } +futures-executor = { workspace = true } diff --git a/Build/crates/saikuro-random/Cargo.toml b/Build/crates/saikuro-random/Cargo.toml index 812e932f..40801212 100644 --- a/Build/crates/saikuro-random/Cargo.toml +++ b/Build/crates/saikuro-random/Cargo.toml @@ -8,7 +8,6 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "random", "rng"] -# The active randomness source is selected by exactly one backend feature. [features] default = ["os"] os = ["dep:getrandom", "getrandom/std", "std"] diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index 800569f4..d6398daf 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -20,9 +20,7 @@ saikuro-exec = { workspace = true, default-features = false } async-trait = { workspace = true } thiserror = { workspace = true } -# tracing is declared directly so the `std` feature stays off on MCU; the -# `attributes` feature is required by `#[instrument]` on `dispatch`. -tracing = { version = "0.1", default-features = false, features = ["attributes"] } +tracing = { workspace = true } [dev-dependencies] saikuro-exec = { workspace = true } diff --git a/Build/crates/saikuro-runtime/Cargo.toml b/Build/crates/saikuro-runtime/Cargo.toml index 164c78ad..a60a439b 100644 --- a/Build/crates/saikuro-runtime/Cargo.toml +++ b/Build/crates/saikuro-runtime/Cargo.toml @@ -43,8 +43,7 @@ dashmap = { workspace = true } parking_lot = { workspace = true } serde_with = { workspace = true } -# CLI and error handling for binary anyhow = { workspace = true } -clap = { version = "4.5", features = ["derive", "env"] } +clap = { workspace = true, features = ["env"] } [dev-dependencies] diff --git a/Build/crates/saikuro-schema/Cargo.toml b/Build/crates/saikuro-schema/Cargo.toml index 8385e3c3..440823a7 100644 --- a/Build/crates/saikuro-schema/Cargo.toml +++ b/Build/crates/saikuro-schema/Cargo.toml @@ -8,7 +8,6 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "schema", "validation"] -# This crate is always `no_std` + `alloc` [features] default = ["std"] std = ["saikuro-core/std"] @@ -18,8 +17,5 @@ drbg = ["saikuro-core/drbg"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } -# thiserror is already default-features-off at the workspace level. thiserror = { workspace = true } -# tracing is declared directly so the `std` feature stays off; only the -# event macros are used (no `#[instrument]`). -tracing = { version = "0.1", default-features = false } +tracing = { workspace = true } diff --git a/Build/crates/saikuro-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index 25c62ff4..86304c46 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -47,23 +47,23 @@ saikuro-core = { path = "../saikuro-core", default-features = false } serde = { workspace = true } serde_json = { workspace = true, features = ["alloc"] } -bytes = { version = "1.7", default-features = false } +bytes = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } thiserror = { workspace = true } -tracing = { version = "0.1", default-features = false } +tracing = { workspace = true } dashmap = { workspace = true, optional = true } -tokio = { version = "1.52.3", features = ["rt"], optional = true } -sled = { version = "0.34.7", optional = true } -rusqlite = { version = "0.40.0", features = ["bundled"], optional = true } +tokio = { workspace = true, optional = true } +sled = { workspace = true, optional = true } +rusqlite = { workspace = true, optional = true } embedded-storage-async = { workspace = true, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen = { version = "0.2.122", optional = true } -wasm-bindgen-futures = { version = "0.4.72", optional = true } -js-sys = { version = "0.3.99", optional = true } -web-sys = { version = "0.3.99", optional = true, features = [ +wasm-bindgen = { workspace = true, optional = true } +wasm-bindgen-futures = { workspace = true, optional = true } +js-sys = { workspace = true, optional = true } +web-sys = { workspace = true, optional = true, features = [ "Blob", "BroadcastChannel", "DomException", @@ -97,7 +97,7 @@ web-sys = { version = "0.3.99", optional = true, features = [ [dev-dependencies] saikuro-exec = { workspace = true, features = ["tokio-runtime"] } tracing-subscriber = { workspace = true } -futures-executor = "0.3" +futures-executor = { workspace = true } embedded-storage-async = { workspace = true } [[test]] diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index 6e39943c..58dad80e 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -8,73 +8,44 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "transport", "async"] -# Feature flags control which transport backends are compiled in. -# -# std: gate for std-only code (the Io error variant and the -# native/WebSocket/wasm backends). The crate is no_std + -# alloc without it. Forwards core's std-no-os so wasm32 -# never selects the OS entropy backend through transport. -# native-transport: Unix socket + TCP (requires std networking; disabled on wasm32) -# ws-transport: WebSocket module (compiled on wasm32, or on native with native-ws) -# native-ws: WebSocket + tokio-tungstenite on native (non-wasm32 only) -# wasm-host-transport: BroadcastChannel transport (wasm32 only) -# embassy: no_std embassy-executor backend for MCU targets; forwards the -# drbg entropy source so the crate is self-contained under -# `--no-default-features --features embassy` -# embedded-io: local, statically-dispatched embedded-io-async transport -# -# The in-memory transport is always compiled; it has zero OS dependencies. [features] default = ["std", "native-transport"] std = [] embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/drbg"] embedded-io = ["dep:embedded-io-async"] -# native features also forward core/std so native builds keep the OS entropy -# backend that core/std selects; only the crate-level lifter (std) switches to -# std-no-os for wasm32. native-transport = ["std", "saikuro-exec/tokio-runtime", "saikuro-core/std"] ws-transport = [] -# wasm32 has no OS entropy source; the js getrandom backend is forwarded here -# (matching saikuro-runtime's own wasm-runtime feature). wasm-runtime = [ "std", "saikuro-core/std-no-os", "saikuro-exec/wasm-runtime", "saikuro-random/wasm", ] - -# native-ws is only available on non-wasm32 (where tokio-tungstenite exists) native-ws = ["ws-transport", "std", "saikuro-exec/tokio-runtime", "saikuro-core/std", "tokio-tungstenite"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } serde = { workspace = true } -# bytes is declared directly so the `std` feature stays off on MCU targets; -# native builds still get it via tokio's own dependency. -bytes = { version = "1.7", default-features = false } +bytes = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } pin-project-lite = { workspace = true } thiserror = { workspace = true } -# tracing is declared directly so the `std` feature stays off on MCU; only the -# event macros are used (no `#[instrument]`). -tracing = { version = "0.1", default-features = false } +tracing = { workspace = true } saikuro-exec = { workspace = true, default-features = false } saikuro-random = { workspace = true, default-features = false } -embedded-io-async = { version = "0.7.0", default-features = false, optional = true } +embedded-io-async = { workspace = true, optional = true } -# WebSocket support on native (tokio-tungstenite uses mio which doesn't compile on wasm32) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect"], optional = true } +tokio-tungstenite = { workspace = true, optional = true } -# WASM host transport + WASM WebSocket [target.'cfg(target_arch = "wasm32")'.dependencies] -send_wrapper = "0.6" -wasm-bindgen = "0.2" -js-sys = "0.3" -wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = [ +send_wrapper = { workspace = true } +wasm-bindgen = { workspace = true } +js-sys = { workspace = true } +wasm-bindgen-futures = { workspace = true } +web-sys = { workspace = true, features = [ "BroadcastChannel", "Crypto", "MessageEvent", diff --git a/Build/tests/Cargo.toml b/Build/tests/Cargo.toml index 08132049..c8fa9158 100644 --- a/Build/tests/Cargo.toml +++ b/Build/tests/Cargo.toml @@ -26,25 +26,21 @@ serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } -# Native-only: enable TCP/Unix transport backends [target.'cfg(not(target_arch = "wasm32"))'.dependencies] saikuro-core = { workspace = true, features = ["std"] } saikuro-transport = { workspace = true, features = ["native-transport"] } saikuro-runtime = { workspace = true, features = ["native-transport"] } -# WASM-only: BroadcastChannel-based host transport, no native-transport [target.'cfg(target_arch = "wasm32")'.dependencies] saikuro-core = { workspace = true, features = ["std-no-os"] } saikuro-transport = { workspace = true, features = ["wasm-runtime"] } saikuro-runtime = { workspace = true, default-features = false } -# WASM has no OS entropy; saikuro-core draws randomness via saikuro-random, -# so the whole graph must use the wasm_js backend on this target. saikuro-random = { workspace = true, features = ["wasm"] } -wasm-bindgen = "0.2" -wasm-bindgen-test = "0.3" -wasm-bindgen-futures = "0.4" -js-sys = "0.3" -web-sys = { version = "0.3", features = [ +wasm-bindgen = { workspace = true } +wasm-bindgen-test = { workspace = true } +wasm-bindgen-futures = { workspace = true } +js-sys = { workspace = true } +web-sys = { workspace = true, features = [ "BroadcastChannel", "MessageEvent", ] } From 3ecdd73164bf0bc2f48f15ef2b5095fd0f3726e7 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Wed, 12 Aug 2026 16:37:10 -0600 Subject: [PATCH 17/43] saikuro-exec reorganization --- Build/Cargo.toml | 2 +- Build/crates/NEW-saikuro-exec/Cargo.toml | 66 ++++++ Build/crates/NEW-saikuro-exec/base/exec.rs | 183 +++++++++++++++ Build/crates/NEW-saikuro-exec/base/mod.rs | 59 +++++ Build/crates/NEW-saikuro-exec/base/mpsc.rs | 211 +++++++++++++++++ Build/crates/NEW-saikuro-exec/base/oneshot.rs | 120 ++++++++++ Build/crates/NEW-saikuro-exec/base/sync.rs | 159 +++++++++++++ Build/crates/NEW-saikuro-exec/base/watch.rs | 130 +++++++++++ .../crates/NEW-saikuro-exec/embedded/exec.rs | 2 + Build/crates/NEW-saikuro-exec/embedded/mod.rs | 7 + Build/crates/NEW-saikuro-exec/lib.rs | 82 +++++++ Build/crates/NEW-saikuro-exec/native/exec.rs | 153 ++++++++++++ Build/crates/NEW-saikuro-exec/native/mod.rs | 7 + Build/crates/NEW-saikuro-exec/native/mpsc.rs | 50 ++++ .../crates/NEW-saikuro-exec/native/oneshot.rs | 37 +++ Build/crates/NEW-saikuro-exec/native/sync.rs | 106 +++++++++ Build/crates/NEW-saikuro-exec/native/watch.rs | 71 ++++++ Build/crates/NEW-saikuro-exec/shared/mod.rs | 219 ++++++++++++++++++ Build/crates/NEW-saikuro-exec/wasm/exec.rs | 1 + Build/crates/NEW-saikuro-exec/wasm/mod.rs | 7 + 20 files changed, 1671 insertions(+), 1 deletion(-) create mode 100644 Build/crates/NEW-saikuro-exec/Cargo.toml create mode 100644 Build/crates/NEW-saikuro-exec/base/exec.rs create mode 100644 Build/crates/NEW-saikuro-exec/base/mod.rs create mode 100644 Build/crates/NEW-saikuro-exec/base/mpsc.rs create mode 100644 Build/crates/NEW-saikuro-exec/base/oneshot.rs create mode 100644 Build/crates/NEW-saikuro-exec/base/sync.rs create mode 100644 Build/crates/NEW-saikuro-exec/base/watch.rs create mode 100644 Build/crates/NEW-saikuro-exec/embedded/exec.rs create mode 100644 Build/crates/NEW-saikuro-exec/embedded/mod.rs create mode 100644 Build/crates/NEW-saikuro-exec/lib.rs create mode 100644 Build/crates/NEW-saikuro-exec/native/exec.rs create mode 100644 Build/crates/NEW-saikuro-exec/native/mod.rs create mode 100644 Build/crates/NEW-saikuro-exec/native/mpsc.rs create mode 100644 Build/crates/NEW-saikuro-exec/native/oneshot.rs create mode 100644 Build/crates/NEW-saikuro-exec/native/sync.rs create mode 100644 Build/crates/NEW-saikuro-exec/native/watch.rs create mode 100644 Build/crates/NEW-saikuro-exec/shared/mod.rs create mode 100644 Build/crates/NEW-saikuro-exec/wasm/exec.rs create mode 100644 Build/crates/NEW-saikuro-exec/wasm/mod.rs diff --git a/Build/Cargo.toml b/Build/Cargo.toml index de86e79c..7ffdea7b 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -88,7 +88,7 @@ chrono = { version = "0.4", features = ["serde", "wasmbind"] } tokio = { version = "1.53", default-features = false } tokio-util = { version = "0.7", default-features = false } -# Embassy executor (dev-dep only) +# Embassy executor embassy-executor = { version = "0.6", default-features = false } embassy-net-driver-channel = "0.3" diff --git a/Build/crates/NEW-saikuro-exec/Cargo.toml b/Build/crates/NEW-saikuro-exec/Cargo.toml new file mode 100644 index 00000000..25ff94df --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "saikuro-exec" +description = "Execution and concurrency facade for Saikuro" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +path = "lib.rs" + +[features] +default = ["std", "native"] +std = [] +native = ["std", "dep:tokio", "tokio/full", "dep:tokio-util", "futures/std"] +no_std = [ + "dep:embassy-executor", + "embassy-executor/alloc", + "embassy-executor/arch-spin", + "dep:embassy-sync", + "dep:embassy-time", + "dep:embassy-futures", + "futures/async-await", +] +wasm = [ + "dep:embassy-executor", + "embassy-executor/alloc", + "embassy-executor/arch-wasm", + "dep:embassy-sync", + "dep:embassy-time", + "dep:embassy-futures", + "futures/async-await", +] +embedded = [ + "dep:embassy-executor", + "embassy-executor/arch-cortex-m", + "embassy-executor/task-arena-size-4096", + "dep:embassy-sync", + "dep:embassy-time", + "dep:embassy-futures", + "futures/async-await", +] +embassy-test = ["embedded", "embassy-time/std", "embassy-time/generic-queue"] +net = ["dep:embassy-net"] + +[dependencies] +tokio = { workspace = true, optional = true } +tokio-util = { workspace = true, optional = true } + +futures = { workspace = true } + +embassy-executor = { workspace = true, optional = true } +embassy-sync = { workspace = true, optional = true } +embassy-time = { workspace = true, optional = true } +embassy-futures = { workspace = true, optional = true } +embassy-net = { workspace = true, optional = true, features = [ + "medium-ip", + "proto-ipv4", + "tcp", + "udp", +] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-futures = { workspace = true } +fluvio-wasm-timer = { workspace = true } diff --git a/Build/crates/NEW-saikuro-exec/base/exec.rs b/Build/crates/NEW-saikuro-exec/base/exec.rs new file mode 100644 index 00000000..b5af9ac2 --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/base/exec.rs @@ -0,0 +1,183 @@ +#![cfg(any(feature = "wasm", feature = "no_std"))] + +use alloc::boxed::Box; +use alloc::sync::Arc; +use core::cell::OnceCell; +use core::cell::RefCell; +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll}; + +use embassy_executor::{Executor, Spawner}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::blocking_mutex::CriticalSectionMutex; +use embassy_sync::waitqueue::MultiWakerRegistration; + +use crate::shared::JoinError; + +pub use crate::base::{fuse_select, sleep, timeout, yield_now}; +pub use crate::base::{mpsc, oneshot, sync, watch}; + +/// Shared result slot between a spawned task and its [`JoinHandle`]. +struct JoinSlot { + value: Option, + closed: bool, + wakers: MultiWakerRegistration<8>, +} + +type JoinResultSlot = CriticalSectionMutex>>; + +static GLOBAL_EXECUTOR: OnceCell<&'static Executor> = OnceCell::new(); +static GLOBAL_SPAWNER: OnceCell<&'static Spawner> = OnceCell::new(); + +fn global_executor() -> &'static Executor { + *GLOBAL_EXECUTOR.get_or_init(|| Box::leak(Box::new(Executor::new()))) +} + +fn global_spawner() -> &'static Spawner { + *GLOBAL_SPAWNER.get_or_init(|| { + let executor = global_executor(); + Box::leak(Box::new(executor.spawner())) + }) +} + +pub fn new_runtime() -> Runtime { + Runtime::new() +} + +pub struct Runtime; + +impl Runtime { + pub fn new() -> Self { + Runtime + } + + pub fn new_multi_thread() -> Self { + Runtime + } + + pub fn new_current_thread() -> Self { + Runtime + } + + pub fn block_on(&self, fut: F) -> F::Output { + block_on(fut) + } +} + +impl Default for Runtime { + fn default() -> Self { + Self::new() + } +} + +pub struct RuntimeBuilder { + _private: (), +} + +impl RuntimeBuilder { + pub fn new_multi_thread() -> Self { + RuntimeBuilder { _private: () } + } + + pub fn new_current_thread() -> Self { + RuntimeBuilder { _private: () } + } + + pub fn enable_all(self) -> Self { + self + } + + pub fn worker_threads(self, _n: usize) -> Self { + self + } + + pub fn build(self) -> Runtime { + Runtime::new() + } +} + +pub fn block_on(fut: F) -> F::Output { + let executor = global_executor(); + let slot: &'static JoinResultSlot> = Box::leak(Box::new( + CriticalSectionMutex::new(RefCell::new(JoinSlot { + value: None, + closed: false, + wakers: MultiWakerRegistration::new(), + })), + )); + let token = executor.spawn(async move { + let result = fut.await; + slot.lock(|s| { + s.borrow_mut().value = Some(result); + s.borrow().wakers.wake(); + }); + }); + global_spawner().spawn(token).ok(); + loop { + unsafe { executor.poll() }; + if let Some(v) = slot.lock(|s| s.borrow_mut().value.take()) { + return v; + } + } +} + +pub fn spawn(fut: F) -> JoinHandle +where + F: Future + 'static, + F::Output: 'static, +{ + let slot: Arc>> = Arc::new(CriticalSectionMutex::new( + RefCell::new(JoinSlot { + value: None, + closed: false, + wakers: MultiWakerRegistration::new(), + }), + )); + let task_slot = slot.clone(); + let token = global_executor().spawn(async move { + let result = fut.await; + task_slot.lock(|s| { + s.borrow_mut().value = Some(result); + s.borrow().wakers.wake(); + }); + }); + global_spawner().spawn(token).ok(); + JoinHandle { slot } +} + +pub struct JoinHandle { + slot: Arc>>, +} + +impl JoinHandle { + pub fn abort(&self) { + self.slot.lock(|s| s.borrow_mut().closed = true); + } + + pub fn is_finished(&self) -> bool { + self.slot.lock(|s| s.borrow().value.is_some()) + } +} + +impl Future for JoinHandle { + type Output = Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let mut result = None; + self.slot.lock(|s| { + let mut state = s.borrow_mut(); + if let Some(v) = state.value.take() { + result = Some(Ok(v)); + } else if state.closed { + result = Some(Err(JoinError::cancelled())); + } else { + state.wakers.register(cx.waker()); + } + }); + match result { + Some(r) => Poll::Ready(r), + None => Poll::Pending, + } + } +} diff --git a/Build/crates/NEW-saikuro-exec/base/mod.rs b/Build/crates/NEW-saikuro-exec/base/mod.rs new file mode 100644 index 00000000..18b52e05 --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/base/mod.rs @@ -0,0 +1,59 @@ +use alloc::sync::Arc; +use core::cell::RefCell; +use core::future::{poll_fn, Future}; +use core::pin::Pin; +use core::task::{Context, Poll, Waker}; +use core::time::Duration; + +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::blocking_mutex::CriticalSectionMutex; +use embassy_sync::channel::Channel as EmbChannel; +use embassy_sync::channel::TrySendError as EmbTrySendError; +use embassy_sync::waitqueue::MultiWakerRegistration; +use embassy_time::{Duration as EmbDuration, Timer}; +use futures::future::{Fuse, FutureExt}; + +pub use embassy_futures::yield_now; + +fn emb_duration(dur: Duration) -> EmbDuration { + EmbDuration::from_micros(dur.as_micros().min(u64::MAX as u128) as u64) +} + +pub async fn sleep(dur: Duration) { + Timer::after(emb_duration(dur)).await; +} + +pub async fn timeout(dur: Duration, fut: F) -> Result +where + F: Future, +{ + match embassy_futures::select::select(fut, Timer::after(emb_duration(dur))).await { + embassy_futures::select::Either::First(res) => Ok(res), + embassy_futures::select::Either::Second(_) => Err(()), + } +} + +#[doc(hidden)] +pub fn fuse_select(fut: F) -> Fuse { + FutureExt::fuse(fut) +} + +pub mod mpsc; +pub mod oneshot; +pub mod sync; +pub mod watch; + +// Non-native engines share a `pending()`-based signal stub: there are no OS +// signals on wasm/embedded/no_std, so a shutdown signal simply never fires. +#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] +pub mod signal { + use core::convert::Infallible; + + pub async fn ctrl_c() -> Result<(), Infallible> { + core::future::pending().await + } +} + +// Heap executor harness +#[cfg(any(feature = "wasm", feature = "no_std"))] +pub mod exec; diff --git a/Build/crates/NEW-saikuro-exec/base/mpsc.rs b/Build/crates/NEW-saikuro-exec/base/mpsc.rs new file mode 100644 index 00000000..737aa658 --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/base/mpsc.rs @@ -0,0 +1,211 @@ +// mpsc + +use super::*; +use crate::ChannelCapacity; +pub use crate::shared::mpsc::{SendError, TrySendError}; + + pub const CHANNEL_CAPACITY: usize = 256; + const MAX_WAITING_SENDERS: usize = 16; + + struct ChannelState { + capacity: usize, + senders: usize, + receivers: usize, + senders_waiting: MultiWakerRegistration, + receivers_waiting: MultiWakerRegistration<1>, + } + + impl ChannelState { + const fn new(capacity: usize) -> Self { + ChannelState { + capacity, + senders: 0, + receivers: 0, + senders_waiting: MultiWakerRegistration::new(), + receivers_waiting: MultiWakerRegistration::new(), + } + } + } + + struct ChannelInner { + state: CriticalSectionMutex>, + channel: EmbChannel, + } + + pub struct Sender { + inner: Arc>, + } + + impl Clone for Sender { + fn clone(&self) -> Self { + self.inner.state.lock(|s| s.borrow_mut().senders += 1); + Sender { + inner: self.inner.clone(), + } + } + } + + impl Drop for Sender { + fn drop(&mut self) { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.senders -= 1; + if state.senders == 0 { + state.receivers_waiting.wake(); + } + }); + } + } + + enum EnqueueOutcome { + Sent, + Full(T), + Disconnected(T), + } + + impl Sender { + pub fn is_closed(&self) -> bool { + self.inner.state.lock(|s| s.borrow().receivers == 0) + } + + fn enqueue(&self, value: T) -> EnqueueOutcome { + self.inner.state.lock(|s| { + let state = s.borrow_mut(); + if state.receivers == 0 { + return EnqueueOutcome::Disconnected(value); + } + if self.inner.channel.len() >= state.capacity { + return EnqueueOutcome::Full(value); + } + match self.inner.channel.try_send(value) { + Ok(()) => EnqueueOutcome::Sent, + Err(EmbTrySendError::Full(value)) => EnqueueOutcome::Full(value), + } + }) + } + + fn has_capacity(&self) -> bool { + self.inner.state.lock(|s| { + let state = s.borrow(); + self.inner.channel.len() < state.capacity + }) + } + + pub fn try_send(&self, value: T) -> Result<(), TrySendError> { + match self.enqueue(value) { + EnqueueOutcome::Sent => Ok(()), + EnqueueOutcome::Full(value) => Err(TrySendError::Full(value)), + EnqueueOutcome::Disconnected(value) => Err(TrySendError::Disconnected(value)), + } + } + + pub async fn send(&self, value: T) -> Result<(), SendError> { + let mut pending = Some(value); + poll_fn(move |cx| { + loop { + if self.is_closed() { + let message = pending.take().expect("mpsc send message restored on Full path"); + return Poll::Ready(Err(SendError(message))); + } + let message = pending.take().expect("mpsc send message restored on Full path"); + match self.enqueue(message) { + EnqueueOutcome::Sent => return Poll::Ready(Ok(())), + EnqueueOutcome::Disconnected(message) => { + return Poll::Ready(Err(SendError(message))) + } + EnqueueOutcome::Full(message) => { + pending = Some(message); + self.inner + .state + .lock(|s| s.borrow_mut().senders_waiting.register(cx.waker())); + if self.is_closed() { + let message = pending.take().expect("mpsc send message restored on Full path"); + return Poll::Ready(Err(SendError(message))); + } + if self.has_capacity() { + continue; + } + return Poll::Pending; + } + } + } + }) + .await + } + } + + pub struct Receiver { + inner: Arc>, + } + + impl Drop for Receiver { + fn drop(&mut self) { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.receivers -= 1; + if state.receivers == 0 { + state.senders_waiting.wake(); + } + }); + } + } + + impl Receiver { + pub async fn recv(&mut self) -> Option { + poll_fn(|cx| self.poll_recv(cx)).await + } + + fn poll_recv(&self, cx: &mut Context<'_>) -> Poll> { + let all_senders_gone = self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.receivers_waiting.register(cx.waker()); + state.senders == 0 + }); + + if let Ok(value) = self.inner.channel.try_receive() { + self.inner.state.lock(|s| s.borrow_mut().senders_waiting.wake()); + return Poll::Ready(Some(value)); + } + + if all_senders_gone { + return Poll::Ready(None); + } + + match self.inner.channel.poll_receive(cx) { + Poll::Ready(value) => { + self.inner.state.lock(|s| s.borrow_mut().senders_waiting.wake()); + Poll::Ready(Some(value)) + } + Poll::Pending => { + if self.inner.state.lock(|s| s.borrow().senders) == 0 { + match self.inner.channel.try_receive() { + Ok(value) => { + self.inner.state.lock(|s| s.borrow_mut().senders_waiting.wake()); + Poll::Ready(Some(value)) + } + Err(_) => Poll::Ready(None), + } + } else { + Poll::Pending + } + } + } + } + } + + pub fn channel(capacity: ChannelCapacity) -> (Sender, Receiver) { + let inner = Arc::new(ChannelInner { + state: CriticalSectionMutex::new(RefCell::new(ChannelState::new(capacity.get()))), + channel: EmbChannel::new(), + }); + inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.senders = 1; + state.receivers = 1; + }); + ( + Sender { inner: inner.clone() }, + Receiver { inner }, + ) + } + diff --git a/Build/crates/NEW-saikuro-exec/base/oneshot.rs b/Build/crates/NEW-saikuro-exec/base/oneshot.rs new file mode 100644 index 00000000..74cd8890 --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/base/oneshot.rs @@ -0,0 +1,120 @@ +// oneshot + +use super::*; +pub use crate::shared::oneshot::RecvError; + + enum State { + Empty, + Waiting(Waker), + Ready(T), + Closed, + } + + struct InnerData { + channel: State, + receiver_alive: bool, + } + + struct Inner { + state: CriticalSectionMutex>>, + } + + pub struct Sender { + inner: Arc>, + } + + impl Sender { + pub fn send(self, value: T) -> Result<(), T> { + self.inner.state.lock(|s| { + let mut data = s.borrow_mut(); + if !data.receiver_alive { + return Err(value); + } + match core::mem::replace(&mut data.channel, State::Empty) { + State::Empty => data.channel = State::Ready(value), + State::Waiting(waker) => { + data.channel = State::Ready(value); + waker.wake(); + } + State::Ready(v) => { + data.channel = State::Ready(v); + core::unreachable!("oneshot sender cannot send twice"); + } + State::Closed => { + data.channel = State::Closed; + core::unreachable!("oneshot sender cannot send on a closed channel"); + } + } + Ok(()) + }) + } + } + + impl Drop for Sender { + fn drop(&mut self) { + self.inner.state.lock(|s| { + let mut data = s.borrow_mut(); + if matches!(data.channel, State::Ready(_)) { + return; + } + let old = core::mem::replace(&mut data.channel, State::Closed); + if let State::Waiting(waker) = old { + waker.wake(); + } + }); + } + } + + pub struct Receiver { + inner: Arc>, + } + + impl Drop for Receiver { + fn drop(&mut self) { + self.inner.state.lock(|s| s.borrow_mut().receiver_alive = false); + } + } + + impl Future for Receiver { + type Output = Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.get_mut().inner.state.lock(|s| { + let mut data = s.borrow_mut(); + match core::mem::replace(&mut data.channel, State::Empty) { + State::Ready(value) => { + data.channel = State::Closed; + Poll::Ready(Ok(value)) + } + State::Closed => Poll::Ready(Err(RecvError)), + State::Empty => { + data.channel = State::Waiting(cx.waker().clone()); + Poll::Pending + } + State::Waiting(w) => { + if w.will_wake(cx.waker()) { + data.channel = State::Waiting(w); + } else { + data.channel = State::Waiting(cx.waker().clone()); + w.wake(); + } + Poll::Pending + } + } + }) + } + } + + pub fn channel() -> (Sender, Receiver) { + let inner = Arc::new(Inner { + state: CriticalSectionMutex::new(RefCell::new(InnerData { + channel: State::Empty, + receiver_alive: true, + })), + }); + ( + Sender { inner: inner.clone() }, + Receiver { inner }, + ) + } + diff --git a/Build/crates/NEW-saikuro-exec/base/sync.rs b/Build/crates/NEW-saikuro-exec/base/sync.rs new file mode 100644 index 00000000..f6f7481f --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/base/sync.rs @@ -0,0 +1,159 @@ +// sync + +use super::*; + + pub struct Mutex { + inner: embassy_sync::mutex::Mutex, + } + + impl Mutex { + pub const fn new(value: T) -> Self { + Mutex { + inner: embassy_sync::mutex::Mutex::new(value), + } + } + + pub async fn lock(&self) -> MutexGuard<'_, T> { + MutexGuard { + inner: self.inner.lock().await, + } + } + } + + pub struct MutexGuard<'a, T> { + inner: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, + } + + impl core::ops::Deref for MutexGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.inner + } + } + + impl core::ops::DerefMut for MutexGuard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.inner + } + } + + pub struct RwLock { + inner: embassy_sync::mutex::Mutex, + } + + impl RwLock { + pub const fn new(value: T) -> Self { + RwLock { + inner: embassy_sync::mutex::Mutex::new(value), + } + } + + pub async fn read(&self) -> RwLockReadGuard<'_, T> { + RwLockReadGuard { + guard: self.inner.lock().await, + } + } + + pub async fn write(&self) -> RwLockWriteGuard<'_, T> { + RwLockWriteGuard { + guard: self.inner.lock().await, + } + } + } + + pub struct RwLockReadGuard<'a, T> { + guard: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, + } + + impl core::ops::Deref for RwLockReadGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.guard + } + } + + pub struct RwLockWriteGuard<'a, T> { + guard: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, + } + + impl core::ops::Deref for RwLockWriteGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.guard + } + } + + impl core::ops::DerefMut for RwLockWriteGuard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.guard + } + } + + const MAX_BARRIER_WAITERS: usize = 16; + + pub struct Barrier { + inner: Arc, + } + + struct BarrierInner { + state: CriticalSectionMutex>, + } + + struct BarrierState { + count: usize, + arrived: usize, + generation: u64, + waiting: MultiWakerRegistration, + } + + impl Barrier { + pub fn new(n: usize) -> Self { + assert!(n > 0, "saikuro-exec: Barrier::new requires n > 0"); + let inner = Arc::new(BarrierInner { + state: CriticalSectionMutex::new(RefCell::new(BarrierState { + count: n, + arrived: 0, + generation: 0, + waiting: MultiWakerRegistration::new(), + })), + }); + Barrier { inner } + } + + pub async fn wait(&self) { + let pre_release_generation = self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.arrived += 1; + if state.arrived == state.count { + state.arrived = 0; + state.generation += 1; + state.waiting.wake(); + None + } else { + Some(state.generation) + } + }); + let Some(mut gen) = pre_release_generation else { + return; + }; + poll_fn(move |cx| { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + if state.generation != gen { + gen = state.generation; + Poll::Ready(()) + } else { + state.waiting.register(cx.waker()); + if state.generation != gen { + gen = state.generation; + Poll::Ready(()) + } else { + Poll::Pending + } + } + }) + }) + .await + } + } + diff --git a/Build/crates/NEW-saikuro-exec/base/watch.rs b/Build/crates/NEW-saikuro-exec/base/watch.rs new file mode 100644 index 00000000..2a237a01 --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/base/watch.rs @@ -0,0 +1,130 @@ +// watch + +use super::*; +pub use crate::shared::watch::{RecvError, SendError}; + + const MAX_WAITING_RECEIVERS: usize = 16; + + struct WatchState { + value: T, + version: u64, + senders: usize, + receivers: usize, + waiting: MultiWakerRegistration, + } + + struct WatchInner { + state: CriticalSectionMutex>>, + } + + pub struct Sender { + inner: Arc>, + } + + impl Sender { + pub fn send(&self, value: T) -> Result<(), SendError> { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + if state.receivers == 0 { + return Err(SendError(value)); + } + state.value = value; + state.version += 1; + state.waiting.wake(); + Ok(()) + }) + } + } + + impl Clone for Sender { + fn clone(&self) -> Self { + self.inner.state.lock(|s| s.borrow_mut().senders += 1); + Sender { inner: self.inner.clone() } + } + } + + impl Drop for Sender { + fn drop(&mut self) { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.senders -= 1; + if state.senders == 0 { + state.waiting.wake(); + } + }); + } + } + + pub struct Receiver { + inner: Arc>, + version: u64, + } + + impl Receiver { + pub fn borrow(&self) -> T { + self.inner.state.lock(|s| s.borrow().value.clone()) + } + + pub fn changed(&mut self) -> ChangedFuture<'_, T> { + ChangedFuture { receiver: self } + } + } + + impl Clone for Receiver { + fn clone(&self) -> Self { + self.inner.state.lock(|s| s.borrow_mut().receivers += 1); + Receiver { + inner: self.inner.clone(), + version: self.version, + } + } + } + + impl Drop for Receiver { + fn drop(&mut self) { + self.inner.state.lock(|s| s.borrow_mut().receivers -= 1); + } + } + + pub struct ChangedFuture<'a, T> { + receiver: &'a mut Receiver, + } + + impl Future for ChangedFuture<'_, T> { + type Output = Result<(), RecvError>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + this.receiver.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.waiting.register(cx.waker()); + let version = state.version; + if this.receiver.version != version { + this.receiver.version = version; + return Poll::Ready(Ok(())); + } + if state.senders == 0 { + return Poll::Ready(Err(RecvError)); + } + Poll::Pending + }) + } + } + + pub fn channel(initial: T) -> (Sender, Receiver) { + let inner = Arc::new(WatchInner { + state: CriticalSectionMutex::new(RefCell::new(WatchState { + value: initial, + version: 0, + senders: 1, + receivers: 1, + waiting: MultiWakerRegistration::new(), + })), + }); + let receiver = Receiver { + inner: inner.clone(), + version: 0, + }; + (Sender { inner }, receiver) + } + diff --git a/Build/crates/NEW-saikuro-exec/embedded/exec.rs b/Build/crates/NEW-saikuro-exec/embedded/exec.rs new file mode 100644 index 00000000..90841d14 --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/embedded/exec.rs @@ -0,0 +1,2 @@ +pub use embassy_executor::Executor; +pub use embassy_executor::Spawner; diff --git a/Build/crates/NEW-saikuro-exec/embedded/mod.rs b/Build/crates/NEW-saikuro-exec/embedded/mod.rs new file mode 100644 index 00000000..4a3b9826 --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/embedded/mod.rs @@ -0,0 +1,7 @@ +pub mod exec; + +pub use crate::base::{fuse_select, sleep, timeout, yield_now}; +pub use crate::base::{mpsc, oneshot, sync, watch}; +pub use crate::base::signal; + +pub use exec::*; diff --git a/Build/crates/NEW-saikuro-exec/lib.rs b/Build/crates/NEW-saikuro-exec/lib.rs new file mode 100644 index 00000000..18b22d0c --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/lib.rs @@ -0,0 +1,82 @@ +//! Saikuro execution and concurrency facade. +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(not(feature = "std"))] +extern crate alloc; + +// Exactly one engine must be selected +#[cfg(any( + all(feature = "native", any(feature = "no_std", feature = "wasm", feature = "embedded")), + all(feature = "no_std", any(feature = "native", feature = "wasm", feature = "embedded")), + all(feature = "wasm", any(feature = "native", feature = "no_std", feature = "embedded")), + all( + feature = "embedded", + any(feature = "native", feature = "no_std", feature = "wasm") + ) +))] +compile_error!("exactly one engine must be enabled: native | no_std | wasm | embedded"); + +#[cfg(all(feature = "std", feature = "no_std"))] +compile_error!("the no_std engine cannot be combined with the std toolchain"); + +mod shared; +pub use shared::{ChannelCapacity, InvalidChannelCapacity}; +pub use shared::JoinError; + +#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] +mod base; +#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] +pub use base::*; + +#[cfg(feature = "native")] +mod native; +#[cfg(feature = "native")] +pub use native::*; + +#[cfg(feature = "wasm")] +mod wasm; +#[cfg(feature = "wasm")] +pub use wasm::*; + +#[cfg(feature = "embedded")] +mod embedded; +#[cfg(feature = "embedded")] +pub use embedded::*; + +#[cfg(feature = "native")] +pub use tokio as _tokio; +#[cfg(not(feature = "native"))] +pub use futures as _futures; + +#[macro_export] +macro_rules! select { + ($($tt:tt)*) => { + $crate::select_impl! { $($tt)* } + }; +} + +#[doc(hidden)] +#[cfg(feature = "native")] +#[macro_export] +macro_rules! select_impl { + ($($tt:tt)*) => { + $crate::_tokio::select! { $($tt)* } + }; +} + +#[doc(hidden)] +#[cfg(not(feature = "native"))] +#[macro_export] +macro_rules! select_impl { + ( + $( + $pattern:pat = $fut:expr => $handler:block $(,)? + )+ + ) => { + $crate::_futures::select_biased! { + $( + $pattern = $crate::fuse_select($fut) => $handler , + )+ + } + }; +} diff --git a/Build/crates/NEW-saikuro-exec/native/exec.rs b/Build/crates/NEW-saikuro-exec/native/exec.rs new file mode 100644 index 00000000..08480f23 --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/native/exec.rs @@ -0,0 +1,153 @@ +use std::future::Future; +use std::time::Duration; + +use tokio::runtime::{Builder, Runtime as TokioRuntime}; +use tokio::task::{JoinError as TokioJoinError, JoinHandle as TokioJoinHandle}; + +use crate::shared::JoinError; + +pub use tokio::signal; + +pub fn new_runtime() -> Runtime { + Runtime::new() +} + +pub struct Runtime { + inner: TokioRuntime, +} + +impl Runtime { + pub fn new() -> Self { + Runtime { + inner: Builder::new_multi_thread() + .enable_all() + .build() + .expect("saikuro-exec: failed to build tokio runtime"), + } + } + + pub fn new_multi_thread() -> Self { + Self::new() + } + + pub fn new_current_thread() -> Self { + Runtime { + inner: Builder::new_current_thread() + .enable_all() + .build() + .expect("saikuro-exec: failed to build tokio runtime"), + } + } + + pub fn block_on(&self, fut: F) -> F::Output { + self.inner.block_on(fut) + } +} + +impl Default for Runtime { + fn default() -> Self { + Self::new() + } +} + +pub struct RuntimeBuilder { + inner: Builder, +} + +impl RuntimeBuilder { + pub fn new_multi_thread() -> Self { + RuntimeBuilder { + inner: Builder::new_multi_thread(), + } + } + + pub fn new_current_thread() -> Self { + RuntimeBuilder { + inner: Builder::new_current_thread(), + } + } + + pub fn worker_threads(mut self, n: usize) -> Self { + self.inner.worker_threads(n); + self + } + + pub fn enable_all(mut self) -> Self { + self.inner.enable_all(); + self + } + + pub fn build(self) -> Runtime { + Runtime { + inner: self + .inner + .build() + .expect("saikuro-exec: failed to build tokio runtime"), + } + } +} + +pub fn spawn(fut: F) -> JoinHandle +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + JoinHandle { + inner: tokio::spawn(fut), + } +} + +impl JoinHandle { + pub async fn abort(&self) { + self.inner.abort(); + } + + pub fn is_finished(&self) -> bool { + self.inner.is_finished() + } +} + +impl Future for JoinHandle { + type Output = Result; + + fn poll( + self: core::pin::Pin<&mut Self>, + cx: &mut core::task::Context<'_>, + ) -> core::task::Poll { + let this = self.get_mut(); + match Pin::new(&mut this.inner).poll(cx) { + core::task::Poll::Ready(Ok(v)) => core::task::Poll::Ready(Ok(v)), + core::task::Poll::Ready(Err(e)) => core::task::Poll::Ready(Err(JoinError::from_tokio(e))), + core::task::Poll::Pending => core::task::Poll::Pending, + } + } +} + +impl JoinError { + fn from_tokio(e: TokioJoinError) -> Self { + if e.is_cancelled() { + JoinError::cancelled() + } else { + JoinError::panic() + } + } +} + +pub async fn sleep(dur: Duration) { + tokio::time::sleep(dur).await; +} + +pub async fn timeout(dur: Duration, fut: F) -> Result +where + F: Future, +{ + match tokio::time::timeout(dur, fut).await { + Ok(v) => Ok(v), + Err(_) => Err(()), + } +} + +pub async fn yield_now() { + tokio::task::yield_now().await; +} + diff --git a/Build/crates/NEW-saikuro-exec/native/mod.rs b/Build/crates/NEW-saikuro-exec/native/mod.rs new file mode 100644 index 00000000..68a01ee3 --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/native/mod.rs @@ -0,0 +1,7 @@ +pub mod exec; +pub mod mpsc; +pub mod oneshot; +pub mod sync; +pub mod watch; + +pub use exec::*; diff --git a/Build/crates/NEW-saikuro-exec/native/mpsc.rs b/Build/crates/NEW-saikuro-exec/native/mpsc.rs new file mode 100644 index 00000000..b8f0a208 --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/native/mpsc.rs @@ -0,0 +1,50 @@ +use crate::ChannelCapacity; +use crate::shared::mpsc::{SendError, TrySendError}; + + pub struct Sender { + inner: tokio::sync::mpsc::Sender, + } + + pub struct Receiver { + inner: tokio::sync::mpsc::Receiver, + } + + impl Clone for Sender { + fn clone(&self) -> Self { + Sender { + inner: self.inner.clone(), + } + } + } + + impl Sender { + pub async fn send(&self, value: T) -> Result<(), SendError> { + self.inner + .send(value) + .await + .map_err(|e| SendError(e.into_inner())) + } + + pub fn try_send(&self, value: T) -> Result<(), TrySendError> { + self.inner.try_send(value).map_err(|e| match e { + tokio::sync::mpsc::TrySendError::Full(v) => TrySendError::Full(v), + tokio::sync::mpsc::TrySendError::Closed(v) => TrySendError::Disconnected(v), + }) + } + + pub fn is_closed(&self) -> bool { + self.inner.is_closed() + } + } + + impl Receiver { + pub async fn recv(&mut self) -> Option { + self.inner.recv().await + } + } + + pub fn channel(capacity: ChannelCapacity) -> (Sender, Receiver) { + let (tx, rx) = tokio::sync::mpsc::channel(capacity.get()); + (Sender { inner: tx }, Receiver { inner: rx }) + } + diff --git a/Build/crates/NEW-saikuro-exec/native/oneshot.rs b/Build/crates/NEW-saikuro-exec/native/oneshot.rs new file mode 100644 index 00000000..2baa6fda --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/native/oneshot.rs @@ -0,0 +1,37 @@ +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll}; + +use crate::shared::oneshot::RecvError; + + pub fn channel() -> (Sender, Receiver) { + let (tx, rx) = tokio::sync::oneshot::channel(); + (Sender { inner: tx }, Receiver { inner: rx }) + } + + pub struct Sender { + inner: tokio::sync::oneshot::Sender, + } + + impl Sender { + pub fn send(self, value: T) -> Result<(), T> { + self.inner.send(value) + } + } + + pub struct Receiver { + inner: tokio::sync::oneshot::Receiver, + } + + impl Future for Receiver { + type Output = Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match self.get_mut().inner.poll_recv(cx) { + Poll::Ready(Ok(v)) => Poll::Ready(Ok(v)), + Poll::Ready(Err(_)) => Poll::Ready(Err(RecvError)), + Poll::Pending => Poll::Pending, + } + } + +} diff --git a/Build/crates/NEW-saikuro-exec/native/sync.rs b/Build/crates/NEW-saikuro-exec/native/sync.rs new file mode 100644 index 00000000..dc68be1f --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/native/sync.rs @@ -0,0 +1,106 @@ +use core::future::Future; +use core::ops::{Deref, DerefMut}; + + pub struct Mutex { + inner: tokio::sync::Mutex, + } + + impl Mutex { + pub fn new(value: T) -> Self { + Mutex { + inner: tokio::sync::Mutex::new(value), + } + } + + pub async fn lock(&self) -> MutexGuard<'_, T> { + MutexGuard { + inner: self.inner.lock().await, + } + } + } + + pub struct MutexGuard<'a, T> { + inner: tokio::sync::MutexGuard<'a, T>, + } + + impl Deref for MutexGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.inner + } + } + + impl DerefMut for MutexGuard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.inner + } + } + + pub struct RwLock { + inner: tokio::sync::RwLock, + } + + impl RwLock { + pub fn new(value: T) -> Self { + RwLock { + inner: tokio::sync::RwLock::new(value), + } + } + + pub async fn read(&self) -> RwLockReadGuard<'_, T> { + RwLockReadGuard { + guard: self.inner.read().await, + } + } + + pub async fn write(&self) -> RwLockWriteGuard<'_, T> { + RwLockWriteGuard { + guard: self.inner.write().await, + } + } + } + + pub struct RwLockReadGuard<'a, T> { + guard: tokio::sync::RwLockReadGuard<'a, T>, + } + + impl Deref for RwLockReadGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.guard + } + } + + pub struct RwLockWriteGuard<'a, T> { + guard: tokio::sync::RwLockWriteGuard<'a, T>, + } + + impl Deref for RwLockWriteGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.guard + } + } + + impl DerefMut for RwLockWriteGuard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.guard + } + } + + pub struct Barrier { + inner: tokio::sync::Barrier, + } + + impl Barrier { + pub fn new(n: usize) -> Self { + Barrier { + inner: tokio::sync::Barrier::new(n), + } + } + + pub async fn wait(&self) { + self.inner.wait().await; + } + } + diff --git a/Build/crates/NEW-saikuro-exec/native/watch.rs b/Build/crates/NEW-saikuro-exec/native/watch.rs new file mode 100644 index 00000000..117cf3b4 --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/native/watch.rs @@ -0,0 +1,71 @@ +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll}; + +use crate::shared::watch::{RecvError, SendError}; + + pub fn channel(initial: T) -> (Sender, Receiver) { + let (tx, rx) = tokio::sync::watch::channel(initial); + (Sender { inner: tx }, Receiver { inner: rx }) + } + + pub struct Sender { + inner: tokio::sync::watch::Sender, + } + + impl Clone for Sender { + fn clone(&self) -> Self { + Sender { + inner: self.inner.clone(), + } + } + } + + impl Sender { + pub fn send(&self, value: T) -> Result<(), SendError> { + self.inner + .send(value) + .map_err(|e| SendError(e.into_inner())) + } + } + + pub struct Receiver { + inner: tokio::sync::watch::Receiver, + } + + impl Clone for Receiver { + fn clone(&self) -> Self { + Receiver { + inner: self.inner.clone(), + } + } + } + + impl Receiver { + pub fn borrow(&self) -> T { + self.inner.borrow() + } + + pub fn changed(&mut self) -> ChangedFuture<'_, T> { + ChangedFuture { receiver: self } + } + } + + pub struct ChangedFuture<'a, T> { + receiver: &'a mut Receiver, + } + + impl Future for ChangedFuture<'_, T> { + type Output = Result<(), RecvError>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + let mut fut = this.receiver.inner.changed(); + match Pin::new(&mut fut).poll(cx) { + Poll::Ready(Ok(())) => Poll::Ready(Ok(())), + Poll::Ready(Err(_)) => Poll::Ready(Err(RecvError)), + Poll::Pending => Poll::Pending, + } + } + } + diff --git a/Build/crates/NEW-saikuro-exec/shared/mod.rs b/Build/crates/NEW-saikuro-exec/shared/mod.rs new file mode 100644 index 00000000..3d87f75d --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/shared/mod.rs @@ -0,0 +1,219 @@ +use core::fmt; + +const MIN_CHANNEL_CAPACITY: usize = 1; +const MAX_CHANNEL_CAPACITY: usize = 256; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct ChannelCapacity(usize); + +impl ChannelCapacity { + pub const DEFAULT: Self = Self(128); + pub const MIN: Self = Self(MIN_CHANNEL_CAPACITY); + pub const MAX: Self = Self(MAX_CHANNEL_CAPACITY); + + pub const fn new(value: usize) -> Result { + if value < MIN_CHANNEL_CAPACITY || value > MAX_CHANNEL_CAPACITY { + Err(InvalidChannelCapacity { value }) + } else { + Ok(Self(value)) + } + } + + pub const fn get(self) -> usize { + self.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InvalidChannelCapacity { + value: usize, +} + +impl InvalidChannelCapacity { + pub const fn value(self) -> usize { + self.value + } +} + +impl fmt::Display for InvalidChannelCapacity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "channel capacity {} is outside the range {}..={}", + self.value, MIN_CHANNEL_CAPACITY, MAX_CHANNEL_CAPACITY + ) + } +} + +#[cfg(feature = "std")] +impl std::error::Error for InvalidChannelCapacity {} + +impl TryFrom for ChannelCapacity { + type Error = InvalidChannelCapacity; + + fn try_from(value: usize) -> Result { + Self::new(value) + } +} + +impl From for usize { + fn from(value: ChannelCapacity) -> Self { + value.get() + } +} + +// Unified error types + +pub mod mpsc { + use core::fmt; + + #[derive(Debug)] + pub struct SendError(pub T); + + impl SendError { + pub fn into_inner(self) -> T { + self.0 + } + } + + impl fmt::Display for SendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("send failed: channel is disconnected") + } + } + + #[cfg(feature = "std")] + impl std::error::Error for SendError {} + + #[derive(Debug)] + pub enum TrySendError { + Full(T), + Disconnected(T), + } + + impl TrySendError { + pub fn into_inner(self) -> T { + match self { + TrySendError::Full(v) => v, + TrySendError::Disconnected(v) => v, + } + } + + pub fn is_full(&self) -> bool { + matches!(self, TrySendError::Full(_)) + } + + pub fn is_disconnected(&self) -> bool { + matches!(self, TrySendError::Disconnected(_)) + } + } + + impl fmt::Display for TrySendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TrySendError::Full(_) => f.write_str("send failed: channel is full"), + TrySendError::Disconnected(_) => { + f.write_str("send failed: channel is disconnected") + } + } + } + } + + #[cfg(feature = "std")] + impl std::error::Error for TrySendError {} +} + +pub mod oneshot { + use core::fmt; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct RecvError; + + impl fmt::Display for RecvError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("oneshot receiver closed") + } + } + + #[cfg(feature = "std")] + impl std::error::Error for RecvError {} +} + +pub mod watch { + use core::fmt; + + #[derive(Debug)] + pub struct SendError(pub T); + + impl SendError { + pub fn into_inner(self) -> T { + self.0 + } + } + + impl fmt::Display for SendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("watch channel has no receivers") + } + } + + #[cfg(feature = "std")] + impl std::error::Error for SendError {} + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct RecvError; + + impl fmt::Display for RecvError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("watch channel closed") + } + } + + #[cfg(feature = "std")] + impl std::error::Error for RecvError {} +} + +#[derive(Debug)] +pub struct JoinError { + kind: JoinErrorKind, +} + +#[derive(Debug)] +enum JoinErrorKind { + Cancelled, + Panic, +} + +impl JoinError { + pub fn is_cancelled(&self) -> bool { + matches!(self.kind, JoinErrorKind::Cancelled) + } + + pub fn is_panic(&self) -> bool { + matches!(self.kind, JoinErrorKind::Panic) + } + + pub fn cancelled() -> Self { + JoinError { + kind: JoinErrorKind::Cancelled, + } + } + + pub fn panic() -> Self { + JoinError { + kind: JoinErrorKind::Panic, + } + } +} + +impl fmt::Display for JoinError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.kind { + JoinErrorKind::Cancelled => f.write_str("task was cancelled"), + JoinErrorKind::Panic => f.write_str("task panicked"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for JoinError {} diff --git a/Build/crates/NEW-saikuro-exec/wasm/exec.rs b/Build/crates/NEW-saikuro-exec/wasm/exec.rs new file mode 100644 index 00000000..f40d7edf --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/wasm/exec.rs @@ -0,0 +1 @@ +pub use crate::base::exec::*; diff --git a/Build/crates/NEW-saikuro-exec/wasm/mod.rs b/Build/crates/NEW-saikuro-exec/wasm/mod.rs new file mode 100644 index 00000000..4a3b9826 --- /dev/null +++ b/Build/crates/NEW-saikuro-exec/wasm/mod.rs @@ -0,0 +1,7 @@ +pub mod exec; + +pub use crate::base::{fuse_select, sleep, timeout, yield_now}; +pub use crate::base::{mpsc, oneshot, sync, watch}; +pub use crate::base::signal; + +pub use exec::*; From 8555a4f6bbd47407185b96bddfaa3caba7b38100 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Wed, 12 Aug 2026 21:14:35 -0600 Subject: [PATCH 18/43] saikuro-net --- Build/Cargo.toml | 1 + Build/crates/NEW-saikuro-exec/base/exec.rs | 35 +++++++++++-------- Build/crates/saikuro-net/Cargo.toml | 34 ++++++++++++++++++ Build/crates/saikuro-net/embedded/io.rs | 1 + Build/crates/saikuro-net/embedded/mod.rs | 2 ++ Build/crates/saikuro-net/embedded/net.rs | 1 + Build/crates/saikuro-net/lib.rs | 30 ++++++++++++++++ Build/crates/saikuro-net/native/io.rs | 1 + Build/crates/saikuro-net/native/mod.rs | 2 ++ Build/crates/saikuro-net/native/net.rs | 1 + Build/crates/saikuro-net/no_std/io.rs | 1 + Build/crates/saikuro-net/no_std/mod.rs | 2 ++ Build/crates/saikuro-net/no_std/net.rs | 1 + Build/crates/saikuro-net/wasm/mod.rs | 10 ++++++ .../saikuro-net}/embassy_net_loopback.rs | 0 15 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 Build/crates/saikuro-net/Cargo.toml create mode 100644 Build/crates/saikuro-net/embedded/io.rs create mode 100644 Build/crates/saikuro-net/embedded/mod.rs create mode 100644 Build/crates/saikuro-net/embedded/net.rs create mode 100644 Build/crates/saikuro-net/lib.rs create mode 100644 Build/crates/saikuro-net/native/io.rs create mode 100644 Build/crates/saikuro-net/native/mod.rs create mode 100644 Build/crates/saikuro-net/native/net.rs create mode 100644 Build/crates/saikuro-net/no_std/io.rs create mode 100644 Build/crates/saikuro-net/no_std/mod.rs create mode 100644 Build/crates/saikuro-net/no_std/net.rs create mode 100644 Build/crates/saikuro-net/wasm/mod.rs rename Build/{crates/saikuro-exec/tests => tests/saikuro-net}/embassy_net_loopback.rs (100%) diff --git a/Build/Cargo.toml b/Build/Cargo.toml index 7ffdea7b..4a297edc 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/saikuro-router", "crates/saikuro-runtime", "crates/saikuro-exec", + "crates/saikuro-net", "crates/saikuro-random", "crates/saikuro-codegen", "adapters/c", diff --git a/Build/crates/NEW-saikuro-exec/base/exec.rs b/Build/crates/NEW-saikuro-exec/base/exec.rs index b5af9ac2..aecbbb22 100644 --- a/Build/crates/NEW-saikuro-exec/base/exec.rs +++ b/Build/crates/NEW-saikuro-exec/base/exec.rs @@ -27,18 +27,25 @@ struct JoinSlot { type JoinResultSlot = CriticalSectionMutex>>; -static GLOBAL_EXECUTOR: OnceCell<&'static Executor> = OnceCell::new(); -static GLOBAL_SPAWNER: OnceCell<&'static Spawner> = OnceCell::new(); +static EXECUTOR: OnceCell = OnceCell::new(); +static SPAWNER: OnceCell = OnceCell::new(); fn global_executor() -> &'static Executor { - *GLOBAL_EXECUTOR.get_or_init(|| Box::leak(Box::new(Executor::new()))) + EXECUTOR.get_or_init(Executor::new) } fn global_spawner() -> &'static Spawner { - *GLOBAL_SPAWNER.get_or_init(|| { - let executor = global_executor(); - Box::leak(Box::new(executor.spawner())) - }) + SPAWNER.get_or_init(|| global_executor().spawner()) +} + +/// Safe wrapper around embassy-executor's `unsafe fn poll()`. The host or +/// `main` calls this in a loop. +pub fn pump() { + let executor = global_executor(); + // SAFETY: `executor` is `&'static` and initialized exactly once via + // `get_or_init`. `poll` is never called reentrantly on this executor, + // and the embassy pender (arch-spin) never calls `poll` directly. + unsafe { executor.poll() }; } pub fn new_runtime() -> Runtime { @@ -98,24 +105,24 @@ impl RuntimeBuilder { } pub fn block_on(fut: F) -> F::Output { - let executor = global_executor(); - let slot: &'static JoinResultSlot> = Box::leak(Box::new( - CriticalSectionMutex::new(RefCell::new(JoinSlot { + let slot: Arc>> = Arc::new(CriticalSectionMutex::new( + RefCell::new(JoinSlot { value: None, closed: false, wakers: MultiWakerRegistration::new(), - })), + }), )); - let token = executor.spawn(async move { + let task_slot = slot.clone(); + let token = global_executor().spawn(async move { let result = fut.await; - slot.lock(|s| { + task_slot.lock(|s| { s.borrow_mut().value = Some(result); s.borrow().wakers.wake(); }); }); global_spawner().spawn(token).ok(); loop { - unsafe { executor.poll() }; + pump(); if let Some(v) = slot.lock(|s| s.borrow_mut().value.take()) { return v; } diff --git a/Build/crates/saikuro-net/Cargo.toml b/Build/crates/saikuro-net/Cargo.toml new file mode 100644 index 00000000..27a0207c --- /dev/null +++ b/Build/crates/saikuro-net/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "saikuro-net" +description = "Networking and IO facade for Saikuro" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +path = "lib.rs" + +[features] +default = ["std", "native"] +std = [] +native = ["std", "dep:tokio", "tokio/full"] +no_std = [ + "dep:embassy-net", + "embassy-net/{medium-ip,proto-ipv4,tcp,udp}", + "dep:embedded-io-async", +] +embedded = [ + "dep:embassy-net", + "embassy-net/{medium-ip,proto-ipv4,tcp,udp}", + "dep:embedded-io-async", +] +wasm = [] +embassy-test = ["embedded", "embassy-time/std", "embassy-time/generic-queue"] + +[dependencies] +tokio = { workspace = true, optional = true } +embassy-net = { workspace = true, optional = true } +embedded-io-async = { workspace = true, optional = true } +futures = { workspace = true } diff --git a/Build/crates/saikuro-net/embedded/io.rs b/Build/crates/saikuro-net/embedded/io.rs new file mode 100644 index 00000000..4138e63f --- /dev/null +++ b/Build/crates/saikuro-net/embedded/io.rs @@ -0,0 +1 @@ +pub use embedded_io_async::{Read as AsyncRead, Write as AsyncWrite}; diff --git a/Build/crates/saikuro-net/embedded/mod.rs b/Build/crates/saikuro-net/embedded/mod.rs new file mode 100644 index 00000000..fe89eccc --- /dev/null +++ b/Build/crates/saikuro-net/embedded/mod.rs @@ -0,0 +1,2 @@ +pub mod net; +pub mod io; diff --git a/Build/crates/saikuro-net/embedded/net.rs b/Build/crates/saikuro-net/embedded/net.rs new file mode 100644 index 00000000..db087bd1 --- /dev/null +++ b/Build/crates/saikuro-net/embedded/net.rs @@ -0,0 +1 @@ +pub use embassy_net::*; diff --git a/Build/crates/saikuro-net/lib.rs b/Build/crates/saikuro-net/lib.rs new file mode 100644 index 00000000..43b024b1 --- /dev/null +++ b/Build/crates/saikuro-net/lib.rs @@ -0,0 +1,30 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] + +//! Networking and IO facade for Saikuro. + +#[cfg(all( + feature = "native", + any(feature = "no_std", feature = "embedded", feature = "wasm") +))] +compile_error!("only one of native/no_std/embedded/wasm may be enabled"); +#[cfg(all(feature = "no_std", any(feature = "embedded", feature = "wasm")))] +compile_error!("only one of native/no_std/embedded/wasm may be enabled"); +#[cfg(all(feature = "embedded", feature = "wasm"))] +compile_error!("only one of native/no_std/embedded/wasm may be enabled"); +#[cfg(not(any( + feature = "native", + feature = "no_std", + feature = "embedded", + feature = "wasm" +)))] +compile_error!("exactly one of native/no_std/embedded/wasm must be enabled"); + +#[cfg(feature = "native")] +pub mod native; +#[cfg(feature = "no_std")] +pub mod no_std; +#[cfg(feature = "embedded")] +pub mod embedded; +#[cfg(feature = "wasm")] +pub mod wasm; diff --git a/Build/crates/saikuro-net/native/io.rs b/Build/crates/saikuro-net/native/io.rs new file mode 100644 index 00000000..c79450b5 --- /dev/null +++ b/Build/crates/saikuro-net/native/io.rs @@ -0,0 +1 @@ +pub use tokio::io::{duplex, split, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; diff --git a/Build/crates/saikuro-net/native/mod.rs b/Build/crates/saikuro-net/native/mod.rs new file mode 100644 index 00000000..fe89eccc --- /dev/null +++ b/Build/crates/saikuro-net/native/mod.rs @@ -0,0 +1,2 @@ +pub mod net; +pub mod io; diff --git a/Build/crates/saikuro-net/native/net.rs b/Build/crates/saikuro-net/native/net.rs new file mode 100644 index 00000000..8a645a25 --- /dev/null +++ b/Build/crates/saikuro-net/native/net.rs @@ -0,0 +1 @@ +pub use tokio::net::*; diff --git a/Build/crates/saikuro-net/no_std/io.rs b/Build/crates/saikuro-net/no_std/io.rs new file mode 100644 index 00000000..4138e63f --- /dev/null +++ b/Build/crates/saikuro-net/no_std/io.rs @@ -0,0 +1 @@ +pub use embedded_io_async::{Read as AsyncRead, Write as AsyncWrite}; diff --git a/Build/crates/saikuro-net/no_std/mod.rs b/Build/crates/saikuro-net/no_std/mod.rs new file mode 100644 index 00000000..fe89eccc --- /dev/null +++ b/Build/crates/saikuro-net/no_std/mod.rs @@ -0,0 +1,2 @@ +pub mod net; +pub mod io; diff --git a/Build/crates/saikuro-net/no_std/net.rs b/Build/crates/saikuro-net/no_std/net.rs new file mode 100644 index 00000000..db087bd1 --- /dev/null +++ b/Build/crates/saikuro-net/no_std/net.rs @@ -0,0 +1 @@ +pub use embassy_net::*; diff --git a/Build/crates/saikuro-net/wasm/mod.rs b/Build/crates/saikuro-net/wasm/mod.rs new file mode 100644 index 00000000..2e7e707c --- /dev/null +++ b/Build/crates/saikuro-net/wasm/mod.rs @@ -0,0 +1,10 @@ +//! Browser (`wasm32-unknown-unknown`) engine. +//! +//! Networking on the browser has no TCP/UDP socket API, so `net`/`io` are not +//! provided here. +//! +//! A WASI (preview2 component) build should select the +//! `no_std` engine instead. + +pub mod net; +pub mod io; diff --git a/Build/crates/saikuro-exec/tests/embassy_net_loopback.rs b/Build/tests/saikuro-net/embassy_net_loopback.rs similarity index 100% rename from Build/crates/saikuro-exec/tests/embassy_net_loopback.rs rename to Build/tests/saikuro-net/embassy_net_loopback.rs From 06b03bf98d06e6aa64c779f780f08a8b3fce622f Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Thu, 13 Aug 2026 01:36:59 -0600 Subject: [PATCH 19/43] Fix --- Build/crates/NEW-saikuro-exec/Cargo.toml | 2 +- Build/crates/saikuro-net/Cargo.toml | 6 +----- Build/crates/saikuro-net/no_std/io.rs | 1 - Build/crates/saikuro-net/no_std/net.rs | 1 - 4 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 Build/crates/saikuro-net/no_std/io.rs delete mode 100644 Build/crates/saikuro-net/no_std/net.rs diff --git a/Build/crates/NEW-saikuro-exec/Cargo.toml b/Build/crates/NEW-saikuro-exec/Cargo.toml index 25ff94df..fe54a909 100644 --- a/Build/crates/NEW-saikuro-exec/Cargo.toml +++ b/Build/crates/NEW-saikuro-exec/Cargo.toml @@ -61,6 +61,6 @@ embassy-net = { workspace = true, optional = true, features = [ "udp", ] } -[target.'cfg(target_arch = "wasm32")'.dependencies] +[target.'cfg(all(target_arch = "wasm32", feature = "wasm"))'.dependencies] wasm-bindgen-futures = { workspace = true } fluvio-wasm-timer = { workspace = true } diff --git a/Build/crates/saikuro-net/Cargo.toml b/Build/crates/saikuro-net/Cargo.toml index 27a0207c..5b82f68b 100644 --- a/Build/crates/saikuro-net/Cargo.toml +++ b/Build/crates/saikuro-net/Cargo.toml @@ -14,11 +14,7 @@ path = "lib.rs" default = ["std", "native"] std = [] native = ["std", "dep:tokio", "tokio/full"] -no_std = [ - "dep:embassy-net", - "embassy-net/{medium-ip,proto-ipv4,tcp,udp}", - "dep:embedded-io-async", -] +no_std = [] embedded = [ "dep:embassy-net", "embassy-net/{medium-ip,proto-ipv4,tcp,udp}", diff --git a/Build/crates/saikuro-net/no_std/io.rs b/Build/crates/saikuro-net/no_std/io.rs deleted file mode 100644 index 4138e63f..00000000 --- a/Build/crates/saikuro-net/no_std/io.rs +++ /dev/null @@ -1 +0,0 @@ -pub use embedded_io_async::{Read as AsyncRead, Write as AsyncWrite}; diff --git a/Build/crates/saikuro-net/no_std/net.rs b/Build/crates/saikuro-net/no_std/net.rs deleted file mode 100644 index db087bd1..00000000 --- a/Build/crates/saikuro-net/no_std/net.rs +++ /dev/null @@ -1 +0,0 @@ -pub use embassy_net::*; From 01f9cb5216e28ff75f3fbf70c1fb1d561aea31ef Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Thu, 13 Aug 2026 12:52:24 -0600 Subject: [PATCH 20/43] saikuro-random --- .cargo/config.toml | 18 +- Build/Cargo.lock | 252 +++++-------- Build/Cargo.toml | 2 + Build/crates/saikuro-core/Cargo.toml | 7 +- Build/crates/saikuro-net/Cargo.toml | 6 +- Build/crates/saikuro-net/lib.rs | 54 +-- Build/crates/saikuro-net/no_std/mod.rs | 2 - Build/crates/saikuro-net/wasm/mod.rs | 11 +- Build/crates/saikuro-random/Cargo.toml | 17 +- Build/crates/saikuro-random/src/base/mod.rs | 28 ++ Build/crates/saikuro-random/src/drbg.rs | 226 ------------ .../crates/saikuro-random/src/embedded/mod.rs | 17 + Build/crates/saikuro-random/src/lib.rs | 225 ++---------- Build/crates/saikuro-random/src/native/mod.rs | 24 ++ Build/crates/saikuro-random/src/shared/mod.rs | 336 ++++++++++++++++++ Build/crates/saikuro-random/src/wasm/mod.rs | 23 ++ Build/crates/saikuro-router/Cargo.toml | 2 +- Build/crates/saikuro-schema/Cargo.toml | 2 +- Build/crates/saikuro-storage/Cargo.toml | 2 +- Build/crates/saikuro-transport/Cargo.toml | 2 +- .../tests => tests/saikuro-random}/drbg.rs | 0 .../saikuro-random}/drbg_unseeded.rs | 0 .../saikuro-random}/os_backend.rs | 0 23 files changed, 627 insertions(+), 629 deletions(-) delete mode 100644 Build/crates/saikuro-net/no_std/mod.rs create mode 100644 Build/crates/saikuro-random/src/base/mod.rs delete mode 100644 Build/crates/saikuro-random/src/drbg.rs create mode 100644 Build/crates/saikuro-random/src/embedded/mod.rs create mode 100644 Build/crates/saikuro-random/src/native/mod.rs create mode 100644 Build/crates/saikuro-random/src/shared/mod.rs create mode 100644 Build/crates/saikuro-random/src/wasm/mod.rs rename Build/{crates/saikuro-random/tests => tests/saikuro-random}/drbg.rs (100%) rename Build/{crates/saikuro-random/tests => tests/saikuro-random}/drbg_unseeded.rs (100%) rename Build/{crates/saikuro-random/tests => tests/saikuro-random}/os_backend.rs (100%) diff --git a/.cargo/config.toml b/.cargo/config.toml index ea1162cc..29ec9436 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -8,22 +8,26 @@ # cfg, so we set it per-target here. # # wasm32-unknown-unknown has no OS entropy, so we pin the `wasm_js` backend on -# every build for it. saikuro-random's `wasm` feature turns on the matching +# every build for it. saikuro-random's `wasm` engine enables the matching # cargo feature (threaded through adapters/rust, saikuro-runtime, and # saikuro-tests' wasm32 deps). # -# Bare-metal MCU targets have no OS and no real wasm host, so they get the -# `custom` backend. Whatever we link has to define `__getrandom_v03_custom` -- -# we deliberately don't stub it, because a no-op would be a silently broken RNG. -# Better to have `fill` blow up than hand back fake randomness. saikuro-random's -# `custom` feature wires up the cargo feature. +# The `no_std` engine (WASI preview1/preview2) relies on getrandom's built-in +# WASI backend, selected automatically by the target triple -- no cfg needed. +# +# Bare-metal MCU targets no longer use getrandom at all: saikuro-random's +# `embedded` engine takes entropy from an application-provided `EntropySource` +# trait via `init_from`, so there is no `__getrandom_v03_custom` to link. The +# `custom` cfgs below are retained only for any direct getrandom usage and are +# inert for the embedded engine. # # Host targets: leave the cfg alone and let getrandom use its normal OS backend. [target.wasm32-unknown-unknown] runner = "wasm-bindgen-test-runner" rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""] -# Bare-metal targets: +# Bare-metal targets (retained for direct getrandom usage; the embedded engine +# uses the EntropySource trait instead): # riscv32imc-unknown-none-elf ESP32-C3 # thumbv6m-none-eabi RP2040 # thumbv8m.main-none-eabihf RP2350 diff --git a/Build/Cargo.lock b/Build/Cargo.lock index d134f01d..be78f8ad 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -111,15 +111,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bs58" version = "0.5.1" @@ -177,7 +168,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -263,6 +265,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -387,10 +398,18 @@ dependencies = [ ] [[package]] -name = "data-encoding" -version = "2.10.0" +name = "dashmap" +version = "7.0.0-rc2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "e4a1e35a65fe0538a60167f0ada6e195ad5d477f6ddae273943596d4a1a5730b" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "equivalent", + "hashbrown 0.15.5", + "lock_api", + "parking_lot_core 0.9.12", +] [[package]] name = "deranged" @@ -402,16 +421,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "document-features" version = "0.2.12" @@ -820,27 +829,28 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.17" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", - "wasi", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", - "r-efi", - "wasip2", - "wasm-bindgen", + "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -864,6 +874,12 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + [[package]] name = "hashbrown" version = "0.16.1" @@ -914,22 +930,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1279,15 +1279,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1312,35 +1303,37 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" -version = "0.8.6" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "libc", - "rand_chacha", - "rand_core", + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "rand_core" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "ppv-lite86", - "rand_core", + "getrandom 0.3.4", ] [[package]] name = "rand_core" -version = "0.6.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "redox_syscall" @@ -1435,7 +1428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1467,7 +1460,7 @@ dependencies = [ "async-trait", "bytes", "clap", - "dashmap", + "dashmap 6.2.1", "futures", "rmp-serde", "saikuro-core", @@ -1478,7 +1471,7 @@ dependencies = [ "serde", "serde_json", "syn", - "thiserror 2.0.18", + "thiserror", "tracing", ] @@ -1495,7 +1488,7 @@ dependencies = [ "saikuro-runtime", "saikuro-transport", "serde_json", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1508,7 +1501,7 @@ dependencies = [ "saikuro-schema", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1524,7 +1517,7 @@ dependencies = [ "serde_json", "spin", "strum", - "thiserror 2.0.18", + "thiserror", "uuid", ] @@ -1546,13 +1539,25 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "saikuro-net" +version = "0.1.0" +dependencies = [ + "embassy-net", + "embassy-time", + "embedded-io-async 0.7.0", + "futures", + "tokio", +] + [[package]] name = "saikuro-random" version = "0.1.0" dependencies = [ - "chacha20", + "chacha20 0.9.1", "getrandom 0.3.4", "portable-atomic", + "rand_core 0.9.5", "uuid", ] @@ -1564,7 +1569,7 @@ dependencies = [ "saikuro-core", "saikuro-exec", "saikuro-schema", - "thiserror 2.0.18", + "thiserror", "tracing", "tracing-subscriber", ] @@ -1577,7 +1582,7 @@ dependencies = [ "async-trait", "bytes", "clap", - "dashmap", + "dashmap 7.0.0-rc2", "futures", "parking_lot 0.12.5", "saikuro-core", @@ -1589,7 +1594,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror", "tracing", "tracing-subscriber", ] @@ -1599,7 +1604,7 @@ name = "saikuro-schema" version = "0.1.0" dependencies = [ "saikuro-core", - "thiserror 2.0.18", + "thiserror", "tracing", ] @@ -1609,7 +1614,7 @@ version = "0.1.0" dependencies = [ "async-trait", "bytes", - "dashmap", + "dashmap 7.0.0-rc2", "embedded-storage-async", "futures", "futures-executor", @@ -1620,7 +1625,7 @@ dependencies = [ "serde", "serde_json", "sled", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "tracing-subscriber", @@ -1671,7 +1676,7 @@ dependencies = [ "saikuro-random", "send_wrapper", "serde", - "thiserror 2.0.18", + "thiserror", "tokio-tungstenite", "tracing", "tracing-subscriber", @@ -1810,17 +1815,6 @@ dependencies = [ "syn", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -1962,33 +1956,13 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -2087,9 +2061,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.24.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", @@ -2186,20 +2160,14 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ - "byteorder", "bytes", - "data-encoding", - "http", - "httparse", "log", "rand", - "sha1", - "thiserror 1.0.69", - "utf-8", + "thiserror", ] [[package]] @@ -2214,12 +2182,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8parse" version = "0.2.2" @@ -2490,26 +2452,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "zerocopy" -version = "0.8.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/Build/Cargo.toml b/Build/Cargo.toml index 4a297edc..41d77701 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -52,6 +52,8 @@ portable-atomic = { version = "1", default-features = false, features = [ "critical-section", ] } +rand_core = { version = "0.9", default-features = false } + heapless = { version = "0.8", default-features = false, features = ["serde"] } serde_with = "3.0" diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index be6d85f7..d78427f5 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -10,10 +10,11 @@ keywords = ["ipc", "cross-language", "saikuro", "rpc", "msgpack"] [features] default = ["std"] -std = ["saikuro-random/os"] +std = ["saikuro-random/native"] std-no-os = ["saikuro-random/wasm"] -custom = ["saikuro-random/custom"] -drbg = ["saikuro-random/drbg"] +custom = ["saikuro-random/embedded"] +embedded = ["saikuro-random/embedded"] +wasi = ["saikuro-random/no_std"] [dependencies] serde = { workspace = true } diff --git a/Build/crates/saikuro-net/Cargo.toml b/Build/crates/saikuro-net/Cargo.toml index 5b82f68b..953455a9 100644 --- a/Build/crates/saikuro-net/Cargo.toml +++ b/Build/crates/saikuro-net/Cargo.toml @@ -17,7 +17,10 @@ native = ["std", "dep:tokio", "tokio/full"] no_std = [] embedded = [ "dep:embassy-net", - "embassy-net/{medium-ip,proto-ipv4,tcp,udp}", + "embassy-net/medium-ip", + "embassy-net/proto-ipv4", + "embassy-net/tcp", + "embassy-net/udp", "dep:embedded-io-async", ] wasm = [] @@ -27,4 +30,5 @@ embassy-test = ["embedded", "embassy-time/std", "embassy-time/generic-queue"] tokio = { workspace = true, optional = true } embassy-net = { workspace = true, optional = true } embedded-io-async = { workspace = true, optional = true } +embassy-time = { workspace = true, optional = true } futures = { workspace = true } diff --git a/Build/crates/saikuro-net/lib.rs b/Build/crates/saikuro-net/lib.rs index 43b024b1..d3c9495d 100644 --- a/Build/crates/saikuro-net/lib.rs +++ b/Build/crates/saikuro-net/lib.rs @@ -3,28 +3,40 @@ //! Networking and IO facade for Saikuro. -#[cfg(all( - feature = "native", - any(feature = "no_std", feature = "embedded", feature = "wasm") +// Exactly one engine must be selected +#[cfg(any( + all(feature = "native", any(feature = "no_std", feature = "wasm", feature = "embedded")), + all(feature = "no_std", any(feature = "native", feature = "wasm", feature = "embedded")), + all(feature = "wasm", any(feature = "native", feature = "no_std", feature = "embedded")), + all( + feature = "embedded", + any(feature = "native", feature = "no_std", feature = "wasm") + ) ))] -compile_error!("only one of native/no_std/embedded/wasm may be enabled"); -#[cfg(all(feature = "no_std", any(feature = "embedded", feature = "wasm")))] -compile_error!("only one of native/no_std/embedded/wasm may be enabled"); -#[cfg(all(feature = "embedded", feature = "wasm"))] -compile_error!("only one of native/no_std/embedded/wasm may be enabled"); -#[cfg(not(any( - feature = "native", - feature = "no_std", - feature = "embedded", - feature = "wasm" -)))] -compile_error!("exactly one of native/no_std/embedded/wasm must be enabled"); +compile_error!("exactly one engine must be enabled: native | no_std | wasm | embedded"); + +#[cfg(all(feature = "std", feature = "no_std"))] +compile_error!("the no_std engine cannot be combined with the std toolchain"); + +mod shared; +pub use shared::*; + +#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] +mod base; +#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] +pub use base::*; #[cfg(feature = "native")] -pub mod native; -#[cfg(feature = "no_std")] -pub mod no_std; -#[cfg(feature = "embedded")] -pub mod embedded; +mod native; +#[cfg(feature = "native")] +pub use native::*; + +#[cfg(feature = "wasm")] +mod wasm; #[cfg(feature = "wasm")] -pub mod wasm; +pub use wasm::*; + +#[cfg(feature = "embedded")] +mod embedded; +#[cfg(feature = "embedded")] +pub use embedded::*; diff --git a/Build/crates/saikuro-net/no_std/mod.rs b/Build/crates/saikuro-net/no_std/mod.rs deleted file mode 100644 index fe89eccc..00000000 --- a/Build/crates/saikuro-net/no_std/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod net; -pub mod io; diff --git a/Build/crates/saikuro-net/wasm/mod.rs b/Build/crates/saikuro-net/wasm/mod.rs index 2e7e707c..ad19d953 100644 --- a/Build/crates/saikuro-net/wasm/mod.rs +++ b/Build/crates/saikuro-net/wasm/mod.rs @@ -1,10 +1,5 @@ //! Browser (`wasm32-unknown-unknown`) engine. //! -//! Networking on the browser has no TCP/UDP socket API, so `net`/`io` are not -//! provided here. -//! -//! A WASI (preview2 component) build should select the -//! `no_std` engine instead. - -pub mod net; -pub mod io; +//! Networking on the browser has no TCP/UDP socket API exposed to Rust, so +//! `net`/`io` are not provided here. A WASI (preview1 or preview2 component) +//! build should select the `no_std` engine instead. diff --git a/Build/crates/saikuro-random/Cargo.toml b/Build/crates/saikuro-random/Cargo.toml index 40801212..8f2cfebb 100644 --- a/Build/crates/saikuro-random/Cargo.toml +++ b/Build/crates/saikuro-random/Cargo.toml @@ -9,17 +9,18 @@ repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "random", "rng"] [features] -default = ["os"] -os = ["dep:getrandom", "getrandom/std", "std"] -wasm = ["dep:getrandom", "getrandom/wasm_js", "std"] -custom = ["dep:getrandom"] -drbg = ["dep:chacha20", "dep:portable-atomic"] -std = [] +default = ["std", "native"] +std = ["rand_core/std"] +native = ["std", "getrandom", "getrandom/std"] +no_std = ["getrandom"] +wasm = ["getrandom", "getrandom/wasm_js"] +embedded = [] [dependencies] getrandom = { workspace = true, optional = true } -chacha20 = { workspace = true, optional = true } -portable-atomic = { workspace = true, optional = true } +chacha20 = { workspace = true } +portable-atomic = { workspace = true } +rand_core = { workspace = true } uuid = { workspace = true } [dev-dependencies] diff --git a/Build/crates/saikuro-random/src/base/mod.rs b/Build/crates/saikuro-random/src/base/mod.rs new file mode 100644 index 00000000..47d7aa84 --- /dev/null +++ b/Build/crates/saikuro-random/src/base/mod.rs @@ -0,0 +1,28 @@ +#[cfg(feature = "no_std")] +use crate::shared::{init, EntropySource, Error}; + +/// WASI entropy source, backed by `getrandom`'s built-in backend. +#[cfg(feature = "no_std")] +pub struct WasiEntropy; + +#[cfg(feature = "no_std")] +impl EntropySource for WasiEntropy { + fn try_fill(&self, dest: &mut [u8]) -> Result<(), Error> { + getrandom::fill(dest).map_err(|e| Error::from(e)) + } +} + +/// Seed the process-wide DRBG from the WASI entropy source. +#[cfg(feature = "no_std")] +pub fn init_default() -> Result<(), Error> { + init(&WasiEntropy) +} + +/// Seed the global DRBG from the WASI source if it hasn't been seeded yet. +/// +/// Called automatically by [`crate::fill`] on first use. +#[cfg(feature = "no_std")] +#[doc(hidden)] +pub fn try_auto_seed() -> Result<(), Error> { + init(&WasiEntropy) +} diff --git a/Build/crates/saikuro-random/src/drbg.rs b/Build/crates/saikuro-random/src/drbg.rs deleted file mode 100644 index e177b401..00000000 --- a/Build/crates/saikuro-random/src/drbg.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Deterministic ChaCha20 DRBG backend. -//! -//! This is a counter-mode DRBG over the RFC 8439 ChaCha20 stream cipher. Block -//! `n` of the keystream is `ChaCha20(key, nonce)` seeked to byte `n * 64`, so -//! the whole stream is just a function of the 56-byte seed (32-byte key + -//! 24-byte XChaCha20 nonce). Same seed in, same bytes out: that's the whole -//! point, since it lets tests be deterministic. -//! -//! On MCUs with no entropy source (RP2040, say) the binary seeds the global -//! state from whatever weak entropy the hardware has lying around (ROSC jitter) -//! and every [`crate::fill`] call pulls from that. - -// We use portable-atomic rather than core::sync::atomic because some of the MCU -// targets don't have the atomics we need: riscv32imc and thumbv6m have no -// native atomics at all, and riscv32imac has no 64-bit ones. portable-atomic -// uses native instructions when they're there and falls back to -// critical-section otherwise, so this module keeps compiling everywhere. -use portable_atomic::{AtomicBool, AtomicU64, Ordering}; - -use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; -use chacha20::XChaCha20; - -/// ChaCha20 operates on 64-byte blocks. -const BLOCK_LEN: usize = 64; -/// ChaCha20 key length in bytes. -const KEY_LEN: usize = 32; -/// XChaCha20 extended nonce length in bytes. -const NONCE_LEN: usize = 24; -/// Total seed length in bytes. -const SEED_LEN: usize = KEY_LEN + NONCE_LEN; -/// Global seed stored as `SEED_LEN / 8` independent `u64` words. -const SEED_WORDS: usize = SEED_LEN / 8; -/// ChaCha20 exposes a 32-bit block counter for each key and nonce. -const MAX_BLOCKS: u64 = 1u64 << 32; - -/// Generate keystream block `index` for the given key and nonce. -/// -/// Returns an error if `index` runs past the cipher's u32 block counter (2^32 -/// blocks, ~256 GiB of stream) -- that's how we signal the DRBG is exhausted. -fn keystream_block( - key: &[u8; KEY_LEN], - nonce: &[u8; NONCE_LEN], - index: u64, -) -> Result<[u8; BLOCK_LEN], crate::Error> { - let mut cipher = - XChaCha20::new_from_slices(key, nonce).map_err(|_| crate::Error::InvalidSeed)?; - // chacha20 seeks by byte offset, not by block index. - let pos = index - .checked_mul(BLOCK_LEN as u64) - .ok_or(crate::Error::DrbgExhausted)?; - cipher - .try_seek(pos) - .map_err(|_| crate::Error::DrbgExhausted)?; - let mut block = [0u8; BLOCK_LEN]; - cipher.apply_keystream(&mut block); - Ok(block) -} - -/// A seedable, deterministic counter-mode ChaCha20 DRBG. -/// -/// Local instances are the easy-to-unit-test form of this backend. The -/// process-wide seeded state ([`seed_from_slice`]) is just a thin wrapper around -/// the same keystream construction. -#[derive(Debug, PartialEq, Eq)] -pub struct Drbg { - key: [u8; KEY_LEN], - nonce: [u8; NONCE_LEN], - counter: u64, -} - -impl Drbg { - /// Construct a DRBG from a seed of at least [`SEED_LEN`] bytes. - /// - /// First 32 bytes are the key, next 24 are the nonce. Anything past that is - /// ignored. - pub fn from_seed(seed: &[u8]) -> Result { - if seed.len() < SEED_LEN { - return Err(crate::Error::InvalidSeed); - } - let mut key = [0u8; KEY_LEN]; - let mut nonce = [0u8; NONCE_LEN]; - key.copy_from_slice(&seed[..KEY_LEN]); - nonce.copy_from_slice(&seed[KEY_LEN..SEED_LEN]); - Ok(Self { - key, - nonce, - counter: 0, - }) - } - - /// Fill `dest` with the next bytes of the keystream. - pub fn fill(&mut self, dest: &mut [u8]) -> Result<(), crate::Error> { - let blocks = dest.len().div_ceil(BLOCK_LEN); - let start = self.counter; - let block_count = blocks as u64; - let end = start - .checked_add(block_count) - .filter(|&end| end <= MAX_BLOCKS) - .ok_or(crate::Error::DrbgExhausted)?; - self.counter = end; - for i in 0..blocks { - let block = keystream_block(&self.key, &self.nonce, start + i as u64)?; - let from = i * BLOCK_LEN; - let to = core::cmp::min(from + BLOCK_LEN, dest.len()); - dest[from..to].copy_from_slice(&block[..to - from]); - } - Ok(()) - } - - /// Fill potentially uninitialized `dest` with keystream bytes. - pub fn fill_uninit( - &mut self, - dest: &mut [core::mem::MaybeUninit], - ) -> Result<(), crate::Error> { - // SAFETY: `MaybeUninit` has no validity constraints, so writing - // initialized bytes through an `&mut [u8]` view is always sound. - let bytes = - unsafe { core::slice::from_raw_parts_mut(dest.as_mut_ptr() as *mut u8, dest.len()) }; - self.fill(bytes) - } -} - -static SEEDED: AtomicBool = AtomicBool::new(false); -static INITIALIZING: AtomicBool = AtomicBool::new(false); -static COUNTER: AtomicU64 = AtomicU64::new(0); -// Written out longhand on purpose: array-repeat of a non-Copy type wants inline -// const blocks, and those need rustc >= 1.79 while our workspace floor is 1.75. -// I'm keeping it that low because I don't want Saikuro to be not compatible -// with older toolchains, and 1.75 is the oldest that is reasonable. -static SEED: [AtomicU64; SEED_WORDS] = [ - AtomicU64::new(0), - AtomicU64::new(0), - AtomicU64::new(0), - AtomicU64::new(0), - AtomicU64::new(0), - AtomicU64::new(0), - AtomicU64::new(0), -]; - -/// Seed the process-wide DRBG from `seed`. -/// -/// Call this once at startup, before any concurrent [`crate::fill`]. Each seed -/// word is stored individually with release ordering, so a reader that sees -/// `SEEDED` will never catch a half-written seed. -pub fn seed_from_slice(seed: &[u8]) -> Result<(), crate::Error> { - if seed.len() < SEED_LEN { - return Err(crate::Error::InvalidSeed); - } - if SEEDED.load(Ordering::Acquire) - || INITIALIZING - .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) - .is_err() - { - return Err(crate::Error::AlreadySeeded); - } - for (i, word) in SEED.iter().enumerate() { - let mut bytes = [0u8; 8]; - bytes.copy_from_slice(&seed[i * 8..i * 8 + 8]); - word.store(u64::from_ne_bytes(bytes), Ordering::Release); - } - COUNTER.store(0, Ordering::Relaxed); - SEEDED.store(true, Ordering::Release); - INITIALIZING.store(false, Ordering::Release); - Ok(()) -} - -/// Whether the process-wide DRBG has been seeded. -pub fn is_seeded() -> bool { - SEEDED.load(Ordering::Acquire) -} - -/// Read the process-wide seed as `(key, nonce)`. -fn read_seed() -> ([u8; KEY_LEN], [u8; NONCE_LEN]) { - let mut seed = [0u8; SEED_LEN]; - for (i, word) in SEED.iter().enumerate() { - seed[i * 8..i * 8 + 8].copy_from_slice(&word.load(Ordering::Acquire).to_ne_bytes()); - } - let mut key = [0u8; KEY_LEN]; - let mut nonce = [0u8; NONCE_LEN]; - key.copy_from_slice(&seed[..KEY_LEN]); - // SEED only starts out zeroed because it's a static. seed_from_slice() writes - // real entropy into it before anyone calls fill(), and fill() is gated on - // is_seeded(), so nobody ever actually reads the zero initializer. - nonce.copy_from_slice(&seed[KEY_LEN..SEED_LEN]); - (key, nonce) -} - -/// Fill `dest` from the process-wide DRBG. -pub fn fill(dest: &mut [u8]) -> Result<(), crate::Error> { - if !is_seeded() { - return Err(crate::Error::DrbgNotSeeded); - } - let (key, nonce) = read_seed(); - let blocks = dest.len().div_ceil(BLOCK_LEN); - let start = reserve_blocks(blocks as u64)?; - for i in 0..blocks { - let block = keystream_block(&key, &nonce, start + i as u64)?; - let from = i * BLOCK_LEN; - let to = core::cmp::min(from + BLOCK_LEN, dest.len()); - dest[from..to].copy_from_slice(&block[..to - from]); - } - Ok(()) -} - -fn reserve_blocks(blocks: u64) -> Result { - let mut current = COUNTER.load(Ordering::Relaxed); - loop { - let next = current - .checked_add(blocks) - .filter(|&next| next <= MAX_BLOCKS) - .ok_or(crate::Error::DrbgExhausted)?; - match COUNTER.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) { - Ok(_) => return Ok(current), - Err(observed) => current = observed, - } - } -} - -/// Fill potentially uninitialized `dest` from the process-wide DRBG. -pub fn fill_uninit(dest: &mut [core::mem::MaybeUninit]) -> Result<(), crate::Error> { - // SAFETY: `MaybeUninit` has no validity constraints, so writing - // initialized bytes through an `&mut [u8]` view is always sound. - let bytes = - unsafe { core::slice::from_raw_parts_mut(dest.as_mut_ptr() as *mut u8, dest.len()) }; - fill(bytes) -} diff --git a/Build/crates/saikuro-random/src/embedded/mod.rs b/Build/crates/saikuro-random/src/embedded/mod.rs new file mode 100644 index 00000000..6f19bbf6 --- /dev/null +++ b/Build/crates/saikuro-random/src/embedded/mod.rs @@ -0,0 +1,17 @@ +use crate::shared::{init, EntropySource, Error}; + +/// Seed the process-wide DRBG from an application-provided [`EntropySource`]. +/// +/// Call this once at startup after constructing the MCU's entropy source, e.g. +/// a hardware RNG peripheral. There is no default source on `embedded`. +pub fn init_from(source: &impl EntropySource) -> Result<(), Error> { + init(source) +} + +/// The `embedded` engine has no default entropy source, so auto-seed is a +/// no-op that reports the DRBG as unseeded until the application calls +/// [`init_from`]. +#[doc(hidden)] +pub fn try_auto_seed() -> Result<(), Error> { + Err(Error::DrbgNotSeeded) +} diff --git a/Build/crates/saikuro-random/src/lib.rs b/Build/crates/saikuro-random/src/lib.rs index e671ac2e..beef1e1a 100644 --- a/Build/crates/saikuro-random/src/lib.rs +++ b/Build/crates/saikuro-random/src/lib.rs @@ -1,204 +1,41 @@ -//! Randomness and entropy facade for Saikuro. -//! -//! Wraps the platform entropy source in a small `no_std` API so protocol types -//! don't have to depend on one specific RNG crate. -//! -//! Backend selection works the same way as saikuro-exec: the binary picks its -//! entropy source through cargo features. -//! -//! # Determinism -//! Turn on `drbg` and every call becomes reproducible for a given seed - -#![no_std] - -#[cfg(feature = "std")] -extern crate std; +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] -use core::mem::MaybeUninit; - -// `drbg` is the deterministic override for MCU targets without an OS entropy -// source. Combining it with a platform backend would compile getrandom for -// nothing and let the drbg implementation win silently, so reject the -// combination at build time and force `--no-default-features --features drbg`. -#[cfg(all( - feature = "drbg", - any(feature = "os", feature = "wasm", feature = "custom") -))] -compile_error!( - "saikuro-random: `drbg` conflicts with the `os`, `wasm`, or `custom` backend; \ - build with `--no-default-features --features drbg`" -); +//! Randomness and entropy facade for Saikuro. #[cfg(any( - all(feature = "os", feature = "wasm"), - all(feature = "os", feature = "custom"), - all(feature = "wasm", feature = "custom") + all(feature = "native", any(feature = "no_std", feature = "wasm", feature = "embedded")), + all(feature = "no_std", any(feature = "native", feature = "wasm", feature = "embedded")), + all(feature = "wasm", any(feature = "native", feature = "no_std", feature = "embedded")), + all( + feature = "embedded", + any(feature = "native", feature = "no_std", feature = "wasm") + ) ))] -compile_error!("saikuro-random: select exactly one of `os`, `wasm`, or `custom`"); - -#[cfg(feature = "drbg")] -mod drbg; - -pub use uuid::Uuid; - -/// Deterministic, seedable ChaCha20 DRBG. -/// -/// Comes with the `drbg` feature. Local instances are fully deterministic: -/// the same seed always gives the same stream, which is what makes them handy -/// for reproducible tests and as the entropy core on MCUs with no hardware RNG. -#[cfg(feature = "drbg")] -pub use drbg::Drbg; - -/// Errors produced by the entropy facade. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Error { - /// The getrandom-based backend couldn't produce entropy. - #[cfg(any(feature = "os", feature = "wasm", feature = "custom"))] - Backend(getrandom::Error), - /// The DRBG was used before anyone seeded it. - #[cfg(feature = "drbg")] - DrbgNotSeeded, - /// The seed handed to the DRBG was too short. - #[cfg(feature = "drbg")] - InvalidSeed, - /// The DRBG keystream for the current seed ran out. - #[cfg(feature = "drbg")] - DrbgExhausted, - /// No entropy backend was selected. - #[cfg(not(any(feature = "os", feature = "wasm", feature = "custom", feature = "drbg")))] - NoBackend, - /// The process-wide DRBG was already initialized. - #[cfg(feature = "drbg")] - AlreadySeeded, -} - -impl core::fmt::Display for Error { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - #[cfg(any(feature = "os", feature = "wasm", feature = "custom"))] - Error::Backend(e) => write!(f, "entropy backend failed: {e}"), - #[cfg(feature = "drbg")] - Error::DrbgNotSeeded => write!(f, "DRBG used before being seeded"), - #[cfg(feature = "drbg")] - Error::InvalidSeed => write!(f, "DRBG seed must be at least 56 bytes"), - #[cfg(feature = "drbg")] - Error::DrbgExhausted => write!(f, "DRBG keystream exhausted; reseed required"), - #[cfg(feature = "drbg")] - Error::AlreadySeeded => write!(f, "DRBG has already been seeded"), - #[cfg(not(any( - feature = "os", - feature = "wasm", - feature = "custom", - feature = "drbg" - )))] - Error::NoBackend => write!(f, "no entropy backend selected"), - } - } -} +compile_error!("exactly one engine must be enabled: native | no_std | wasm | embedded"); -#[cfg(feature = "std")] -impl std::error::Error for Error {} +#[cfg(all(feature = "std", feature = "no_std"))] +compile_error!("the no_std engine cannot be combined with the std toolchain"); -#[cfg(any(feature = "os", feature = "wasm", feature = "custom"))] -impl From for Error { - fn from(err: getrandom::Error) -> Self { - Error::Backend(err) - } -} +mod shared; +pub use shared::*; -/// Fill `dest` with cryptographically secure random bytes. -/// -/// With the `drbg` feature on you get the DRBG backend instead, and you have to -/// seed it first via [`seed_from_slice`]. -pub fn fill(dest: &mut [u8]) -> Result<(), Error> { - fill_impl(dest) -} +#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] +mod base; +#[cfg(feature = "no_std")] +pub use base::*; -/// Fill potentially uninitialized `dest` with random bytes. -/// -/// Same semantics as [`getrandom::fill_uninit`]: on success every byte is -/// initialized, and even on the error path the buffer may be partly written. -pub fn fill_uninit(dest: &mut [MaybeUninit]) -> Result<(), Error> { - fill_uninit_impl(dest) -} - -/// Draw a random `u32` from the active backend. -pub fn u32() -> Result { - let mut bytes = [0u8; 4]; - fill(&mut bytes)?; - Ok(u32::from_ne_bytes(bytes)) -} - -/// Draw a random `u64` from the active backend. -pub fn u64() -> Result { - let mut bytes = [0u8; 8]; - fill(&mut bytes)?; - Ok(u64::from_ne_bytes(bytes)) -} - -/// Generate a random RFC 4122 version 4 UUID. -/// -/// The 16 random bytes come from the active backend; the version and variant -/// bits get set per RFC 9562 section 5.8. -pub fn uuid_v4() -> Result { - let mut bytes = [0u8; 16]; - fill(&mut bytes)?; - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - Ok(Uuid::from_bytes(bytes)) -} - -/// Seed the DRBG backend from `seed`. -/// -/// The seed needs at least 56 bytes: the first 32 are the ChaCha20 key and the -/// next 24 are the XChaCha20 nonce. Call it once at startup, before any -/// concurrent `fill` calls, since the seed is written before tasks spawn, readers -/// never catch a half-written seed. Only there with the `drbg` feature. -#[cfg(feature = "drbg")] -pub fn seed_from_slice(seed: &[u8]) -> Result<(), Error> { - drbg::seed_from_slice(seed) -} - -/// Report whether the DRBG backend has been seeded. -#[cfg(feature = "drbg")] -pub fn is_seeded() -> bool { - drbg::is_seeded() -} - -#[cfg(feature = "drbg")] -fn fill_impl(dest: &mut [u8]) -> Result<(), Error> { - drbg::fill(dest) -} - -#[cfg(feature = "drbg")] -fn fill_uninit_impl(dest: &mut [MaybeUninit]) -> Result<(), Error> { - drbg::fill_uninit(dest) -} - -#[cfg(all( - not(feature = "drbg"), - any(feature = "os", feature = "wasm", feature = "custom") -))] -fn fill_impl(dest: &mut [u8]) -> Result<(), Error> { - getrandom::fill(dest).map_err(Error::from) -} - -#[cfg(all( - not(feature = "drbg"), - any(feature = "os", feature = "wasm", feature = "custom") -))] -fn fill_uninit_impl(dest: &mut [MaybeUninit]) -> Result<(), Error> { - getrandom::fill_uninit(dest) - .map_err(Error::from) - .map(|_| ()) -} +#[cfg(feature = "native")] +mod native; +#[cfg(feature = "native")] +pub use native::*; -#[cfg(not(any(feature = "os", feature = "wasm", feature = "custom", feature = "drbg")))] -fn fill_impl(_dest: &mut [u8]) -> Result<(), Error> { - Err(Error::NoBackend) -} +#[cfg(feature = "wasm")] +mod wasm; +#[cfg(feature = "wasm")] +pub use wasm::*; -#[cfg(not(any(feature = "os", feature = "wasm", feature = "custom", feature = "drbg")))] -fn fill_uninit_impl(_dest: &mut [MaybeUninit]) -> Result<(), Error> { - Err(Error::NoBackend) -} +#[cfg(feature = "embedded")] +mod embedded; +#[cfg(feature = "embedded")] +pub use embedded::*; diff --git a/Build/crates/saikuro-random/src/native/mod.rs b/Build/crates/saikuro-random/src/native/mod.rs new file mode 100644 index 00000000..571bd4fe --- /dev/null +++ b/Build/crates/saikuro-random/src/native/mod.rs @@ -0,0 +1,24 @@ +use crate::shared::{init, EntropySource, Error}; + +/// OS entropy source, backed by `getrandom`/`std`. +pub struct OsEntropy; + +impl EntropySource for OsEntropy { + fn try_fill(&self, dest: &mut [u8]) -> Result<(), Error> { + getrandom::fill(dest).map_err(|e| Error::from(e)) + } +} + +/// Seed the process-wide DRBG from the OS entropy source. +pub fn init_default() -> Result<(), Error> { + init(&OsEntropy) +} + +/// Seed the global DRBG from the OS source if it hasn't been seeded yet. +/// +/// Called automatically by [`crate::fill`] on first use so hosted binaries +/// don't have to seed explicitly. +#[doc(hidden)] +pub fn try_auto_seed() -> Result<(), Error> { + init(&OsEntropy) +} diff --git a/Build/crates/saikuro-random/src/shared/mod.rs b/Build/crates/saikuro-random/src/shared/mod.rs new file mode 100644 index 00000000..cb1673c7 --- /dev/null +++ b/Build/crates/saikuro-random/src/shared/mod.rs @@ -0,0 +1,336 @@ +use core::mem::MaybeUninit; + +use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; +use chacha20::XChaCha20; +use portable_atomic::{AtomicBool, AtomicU64, Ordering}; +use rand_core::{CryptoRng, RngCore, SeedableRng}; + +pub use uuid::Uuid; + +/// ChaCha20 operates on 64-byte blocks. +const BLOCK_LEN: usize = 64; +/// ChaCha20 key length in bytes. +const KEY_LEN: usize = 32; +/// XChaCha20 extended nonce length in bytes. +const NONCE_LEN: usize = 24; +/// Total seed length in bytes. +const SEED_LEN: usize = KEY_LEN + NONCE_LEN; +/// Global seed stored as `SEED_LEN / 8` independent `u64` words. +const SEED_WORDS: usize = SEED_LEN / 8; +/// ChaCha20 exposes a 32-bit block counter for each key and nonce. +const MAX_BLOCKS: u64 = 1u64 << 32; + +/// Entropy source for the process-wide DRBG. +pub trait EntropySource { + /// Fill `dest` with fresh entropy, fully initializing every byte. + fn try_fill(&self, dest: &mut [u8]) -> Result<(), Error>; +} + +/// Errors produced by the entropy facade. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Error { + /// The platform entropy backend couldn't produce bytes. + #[cfg(feature = "getrandom")] + Backend(getrandom::Error), + /// A custom (e.g. embedded hardware) entropy source failed. + Custom(&'static str), + /// The global DRBG was used before anyone seeded it. + DrbgNotSeeded, + /// The seed handed to the DRBG was too short. + InvalidSeed, + /// The DRBG keystream for the current seed ran out. + DrbgExhausted, + /// The process-wide DRBG was already initialized. + AlreadySeeded, +} + +impl core::fmt::Display for Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + #[cfg(feature = "getrandom")] + Error::Backend(e) => write!(f, "entropy backend failed: {e}"), + Error::Custom(s) => write!(f, "entropy source failed: {s}"), + Error::DrbgNotSeeded => write!(f, "DRBG used before being seeded"), + Error::InvalidSeed => write!(f, "DRBG seed must be at least {SEED_LEN} bytes"), + Error::DrbgExhausted => write!(f, "DRBG keystream exhausted; reseed required"), + Error::AlreadySeeded => write!(f, "DRBG has already been seeded"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +#[cfg(feature = "getrandom")] +impl From for Error { + fn from(err: getrandom::Error) -> Self { + Error::Backend(err) + } +} + +/// Generate keystream block `index` for the given key and nonce. +fn keystream_block( + key: &[u8; KEY_LEN], + nonce: &[u8; NONCE_LEN], + index: u64, +) -> Result<[u8; BLOCK_LEN], Error> { + let mut cipher = + XChaCha20::new_from_slices(key, nonce).map_err(|_| Error::InvalidSeed)?; + // chacha20 seeks by byte offset, not by block index. + let pos = index + .checked_mul(BLOCK_LEN as u64) + .ok_or(Error::DrbgExhausted)?; + cipher + .try_seek(pos) + .map_err(|_| Error::DrbgExhausted)?; + let mut block = [0u8; BLOCK_LEN]; + cipher.apply_keystream(&mut block); + Ok(block) +} + +/// A seedable, deterministic counter-mode ChaCha20 DRBG. +#[derive(Debug, PartialEq, Eq)] +pub struct Drbg { + key: [u8; KEY_LEN], + nonce: [u8; NONCE_LEN], + counter: u64, +} + +impl Drbg { + /// Construct a DRBG from a seed of at least [`SEED_LEN`] bytes. + /// + /// The first 32 bytes are the key and the next 24 are the XChaCha20 nonce; + /// anything past that is ignored. + pub fn from_seed(seed: &[u8]) -> Result { + if seed.len() < SEED_LEN { + return Err(Error::InvalidSeed); + } + let mut key = [0u8; KEY_LEN]; + let mut nonce = [0u8; NONCE_LEN]; + key.copy_from_slice(&seed[..KEY_LEN]); + nonce.copy_from_slice(&seed[KEY_LEN..SEED_LEN]); + Ok(Self { + key, + nonce, + counter: 0, + }) + } + + /// Fill `dest` with the next bytes of the keystream. + pub fn fill(&mut self, dest: &mut [u8]) -> Result<(), Error> { + let blocks = dest.len().div_ceil(BLOCK_LEN); + let start = self.counter; + let block_count = blocks as u64; + let end = start + .checked_add(block_count) + .filter(|&end| end <= MAX_BLOCKS) + .ok_or(Error::DrbgExhausted)?; + self.counter = end; + for i in 0..blocks { + let block = keystream_block(&self.key, &self.nonce, start + i as u64)?; + let from = i * BLOCK_LEN; + let to = core::cmp::min(from + BLOCK_LEN, dest.len()); + dest[from..to].copy_from_slice(&block[..to - from]); + } + Ok(()) + } + + /// Fill potentially uninitialized `dest` with keystream bytes. + pub fn fill_uninit(&mut self, dest: &mut [MaybeUninit]) -> Result<(), Error> { + // SAFETY: `MaybeUninit` has no validity constraints, so writing + // initialized bytes through an `&mut [u8]` view is always sound. + let bytes = + unsafe { core::slice::from_raw_parts_mut(dest.as_mut_ptr() as *mut u8, dest.len()) }; + self.fill(bytes) + } +} + +impl RngCore for Drbg { + fn next_u32(&mut self) -> u32 { + let mut bytes = [0u8; 4]; + self.fill(&mut bytes).expect("Drbg keystream exhausted"); + u32::from_ne_bytes(bytes) + } + + fn next_u64(&mut self) -> u64 { + let mut bytes = [0u8; 8]; + self.fill(&mut bytes).expect("Drbg keystream exhausted"); + u64::from_ne_bytes(bytes) + } + + fn fill_bytes(&mut self, dst: &mut [u8]) { + self.fill(dst).expect("Drbg keystream exhausted"); + } +} + +impl CryptoRng for Drbg {} + +/// Seed wrapper for `rand_core::SeedableRng`. +#[derive(Clone)] +pub struct SeedBytes(pub [u8; SEED_LEN]); + +impl Default for SeedBytes { + fn default() -> Self { + SeedBytes([0u8; SEED_LEN]) + } +} + +impl AsRef<[u8]> for SeedBytes { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl AsMut<[u8]> for SeedBytes { + fn as_mut(&mut self) -> &mut [u8] { + &mut self.0 + } +} + +impl SeedableRng for Drbg { + type Seed = SeedBytes; + + fn from_seed(seed: SeedBytes) -> Self { + Drbg::from_seed(&seed.0).expect("SeedBytes is always SEED_LEN long") + } +} + +static SEEDED: AtomicBool = AtomicBool::new(false); +static INITIALIZING: AtomicBool = AtomicBool::new(false); +static COUNTER: AtomicU64 = AtomicU64::new(0); +static SEED: [AtomicU64; SEED_WORDS] = [ + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), +]; + +/// Seed the process-wide DRBG from `seed`. +pub fn seed_from_slice(seed: &[u8]) -> Result<(), Error> { + if seed.len() < SEED_LEN { + return Err(Error::InvalidSeed); + } + if SEEDED.load(Ordering::Acquire) + || INITIALIZING + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + return Err(Error::AlreadySeeded); + } + for (i, word) in SEED.iter().enumerate() { + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&seed[i * 8..i * 8 + 8]); + word.store(u64::from_ne_bytes(bytes), Ordering::Release); + } + COUNTER.store(0, Ordering::Relaxed); + SEEDED.store(true, Ordering::Release); + INITIALIZING.store(false, Ordering::Release); + Ok(()) +} + +/// Seed the process-wide DRBG from an [`EntropySource`]. +/// +/// Convenience over [`seed_from_slice`]: draw a fresh seed from `source` and +/// install it. Engines expose `init_default`/`init_from` which call this. +pub fn init(source: &impl EntropySource) -> Result<(), Error> { + let mut seed = [0u8; SEED_LEN]; + source.try_fill(&mut seed)?; + seed_from_slice(&seed) +} + +/// Whether the process-wide DRBG has been seeded. +pub fn is_seeded() -> bool { + SEEDED.load(Ordering::Acquire) +} + +fn read_seed() -> ([u8; KEY_LEN], [u8; NONCE_LEN]) { + let mut seed = [0u8; SEED_LEN]; + for (i, word) in SEED.iter().enumerate() { + seed[i * 8..i * 8 + 8].copy_from_slice(&word.load(Ordering::Acquire).to_ne_bytes()); + } + let mut key = [0u8; KEY_LEN]; + let mut nonce = [0u8; NONCE_LEN]; + key.copy_from_slice(&seed[..KEY_LEN]); + // SEED is only zeroed because it's a static; seed_from_slice writes real + // entropy before any fill, and fill is gated on is_seeded(), so the zero + // initializer is never read. + nonce.copy_from_slice(&seed[KEY_LEN..SEED_LEN]); + (key, nonce) +} + +/// Fill `dest` with cryptographically secure random bytes from the +/// process-wide DRBG. +/// +/// On first use, entropy-backed engines (`native`, `wasm`, `no_std`) seed the +/// DRBG automatically from their platform source, so hosted binaries can call +/// this without explicit setup. The `embedded` engine has no default source +/// and returns [`Error::DrbgNotSeeded`] until the application calls +/// [`init_from`]. +pub fn fill(dest: &mut [u8]) -> Result<(), Error> { + if !is_seeded() { + crate::try_auto_seed()?; + if !is_seeded() { + return Err(Error::DrbgNotSeeded); + } + } + let (key, nonce) = read_seed(); + let blocks = dest.len().div_ceil(BLOCK_LEN); + let start = reserve_blocks(blocks as u64)?; + for i in 0..blocks { + let block = keystream_block(&key, &nonce, start + i as u64)?; + let from = i * BLOCK_LEN; + let to = core::cmp::min(from + BLOCK_LEN, dest.len()); + dest[from..to].copy_from_slice(&block[..to - from]); + } + Ok(()) +} + +/// Fill potentially uninitialized `dest` with random bytes from the +/// process-wide DRBG. +pub fn fill_uninit(dest: &mut [MaybeUninit]) -> Result<(), Error> { + // SAFETY: `MaybeUninit` has no validity constraints, so writing + // initialized bytes through an `&mut [u8]` view is always sound. + let bytes = + unsafe { core::slice::from_raw_parts_mut(dest.as_mut_ptr() as *mut u8, dest.len()) }; + fill(bytes) +} + +/// Draw a random `u32` from the process-wide DRBG. +pub fn u32() -> Result { + let mut bytes = [0u8; 4]; + fill(&mut bytes)?; + Ok(u32::from_ne_bytes(bytes)) +} + +/// Draw a random `u64` from the process-wide DRBG. +pub fn u64() -> Result { + let mut bytes = [0u8; 8]; + fill(&mut bytes)?; + Ok(u64::from_ne_bytes(bytes)) +} + +/// Generate a random RFC 4122 version 4 UUID from the process-wide DRBG. +pub fn uuid_v4() -> Result { + let mut bytes = [0u8; 16]; + fill(&mut bytes)?; + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Ok(Uuid::from_bytes(bytes)) +} + +fn reserve_blocks(blocks: u64) -> Result { + let mut current = COUNTER.load(Ordering::Relaxed); + loop { + let next = current + .checked_add(blocks) + .filter(|&next| next <= MAX_BLOCKS) + .ok_or(Error::DrbgExhausted)?; + match COUNTER.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => return Ok(current), + Err(observed) => current = observed, + } + } +} diff --git a/Build/crates/saikuro-random/src/wasm/mod.rs b/Build/crates/saikuro-random/src/wasm/mod.rs new file mode 100644 index 00000000..209399be --- /dev/null +++ b/Build/crates/saikuro-random/src/wasm/mod.rs @@ -0,0 +1,23 @@ +use crate::shared::{init, EntropySource, Error}; + +/// Browser entropy source, backed by `getrandom`/`wasm_js`. +pub struct JsEntropy; + +impl EntropySource for JsEntropy { + fn try_fill(&self, dest: &mut [u8]) -> Result<(), Error> { + getrandom::fill(dest).map_err(|e| Error::from(e)) + } +} + +/// Seed the process-wide DRBG from the browser entropy source. +pub fn init_default() -> Result<(), Error> { + init(&JsEntropy) +} + +/// Seed the global DRBG from the browser source if it hasn't been seeded yet. +/// +/// Called automatically by [`crate::fill`] on first use. +#[doc(hidden)] +pub fn try_auto_seed() -> Result<(), Error> { + init(&JsEntropy) +} diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index d6398daf..7286e4e4 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -11,7 +11,7 @@ keywords = ["ipc", "cross-language", "saikuro", "router", "rpc"] [features] default = ["std"] std = ["saikuro-core/std", "saikuro-exec/tokio-runtime"] -embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/drbg"] +embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/embedded"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } diff --git a/Build/crates/saikuro-schema/Cargo.toml b/Build/crates/saikuro-schema/Cargo.toml index 440823a7..a94f7e55 100644 --- a/Build/crates/saikuro-schema/Cargo.toml +++ b/Build/crates/saikuro-schema/Cargo.toml @@ -12,7 +12,7 @@ keywords = ["ipc", "cross-language", "saikuro", "schema", "validation"] default = ["std"] std = ["saikuro-core/std"] custom = ["saikuro-core/custom"] -drbg = ["saikuro-core/drbg"] +drbg = ["saikuro-core/embedded"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } diff --git a/Build/crates/saikuro-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index 86304c46..8d1cc0f1 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -12,7 +12,7 @@ keywords = ["ipc", "cross-language", "saikuro", "storage", "key-value"] default = ["std", "native-storage"] std = [] custom = ["saikuro-core/custom"] -drbg = ["saikuro-core/drbg"] +drbg = ["saikuro-core/embedded"] native-storage = [ "std", "inmemory", diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index 58dad80e..83463f28 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -11,7 +11,7 @@ keywords = ["ipc", "cross-language", "saikuro", "transport", "async"] [features] default = ["std", "native-transport"] std = [] -embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/drbg"] +embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/embedded"] embedded-io = ["dep:embedded-io-async"] native-transport = ["std", "saikuro-exec/tokio-runtime", "saikuro-core/std"] ws-transport = [] diff --git a/Build/crates/saikuro-random/tests/drbg.rs b/Build/tests/saikuro-random/drbg.rs similarity index 100% rename from Build/crates/saikuro-random/tests/drbg.rs rename to Build/tests/saikuro-random/drbg.rs diff --git a/Build/crates/saikuro-random/tests/drbg_unseeded.rs b/Build/tests/saikuro-random/drbg_unseeded.rs similarity index 100% rename from Build/crates/saikuro-random/tests/drbg_unseeded.rs rename to Build/tests/saikuro-random/drbg_unseeded.rs diff --git a/Build/crates/saikuro-random/tests/os_backend.rs b/Build/tests/saikuro-random/os_backend.rs similarity index 100% rename from Build/crates/saikuro-random/tests/os_backend.rs rename to Build/tests/saikuro-random/os_backend.rs From 5f3e05d6c223edb7a5908337325c304a01f75e77 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Thu, 13 Aug 2026 12:57:36 -0600 Subject: [PATCH 21/43] saikuro-exec --- Build/crates/NEW-saikuro-exec/Cargo.toml | 66 -- Build/crates/saikuro-exec/Cargo.toml | 55 +- .../base/exec.rs | 0 .../base/mod.rs | 0 .../base/mpsc.rs | 0 .../base/oneshot.rs | 0 .../base/sync.rs | 0 .../base/watch.rs | 0 .../embedded/exec.rs | 0 .../embedded/mod.rs | 0 .../{NEW-saikuro-exec => saikuro-exec}/lib.rs | 0 .../native/exec.rs | 0 .../native/mod.rs | 0 .../native/mpsc.rs | 0 .../native/oneshot.rs | 0 .../native/sync.rs | 0 .../native/watch.rs | 0 .../shared/mod.rs | 0 Build/crates/saikuro-exec/src/capacity.rs | 73 -- .../saikuro-exec/src/embassy_backend.rs | 928 ------------------ Build/crates/saikuro-exec/src/embassy_net.rs | 20 - Build/crates/saikuro-exec/src/lib.rs | 103 -- .../crates/saikuro-exec/src/tokio_backend.rs | 93 -- Build/crates/saikuro-exec/src/wasm_backend.rs | 436 -------- .../wasm/exec.rs | 0 .../wasm/mod.rs | 0 .../saikuro-exec}/embassy_cancellation.rs | 0 .../saikuro-exec}/embassy_executor.rs | 0 28 files changed, 36 insertions(+), 1738 deletions(-) delete mode 100644 Build/crates/NEW-saikuro-exec/Cargo.toml rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/base/exec.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/base/mod.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/base/mpsc.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/base/oneshot.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/base/sync.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/base/watch.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/embedded/exec.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/embedded/mod.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/lib.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/native/exec.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/native/mod.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/native/mpsc.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/native/oneshot.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/native/sync.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/native/watch.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/shared/mod.rs (100%) delete mode 100644 Build/crates/saikuro-exec/src/capacity.rs delete mode 100644 Build/crates/saikuro-exec/src/embassy_backend.rs delete mode 100644 Build/crates/saikuro-exec/src/embassy_net.rs delete mode 100644 Build/crates/saikuro-exec/src/lib.rs delete mode 100644 Build/crates/saikuro-exec/src/tokio_backend.rs delete mode 100644 Build/crates/saikuro-exec/src/wasm_backend.rs rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/wasm/exec.rs (100%) rename Build/crates/{NEW-saikuro-exec => saikuro-exec}/wasm/mod.rs (100%) rename Build/{crates/saikuro-exec/tests => tests/saikuro-exec}/embassy_cancellation.rs (100%) rename Build/{crates/saikuro-exec/tests => tests/saikuro-exec}/embassy_executor.rs (100%) diff --git a/Build/crates/NEW-saikuro-exec/Cargo.toml b/Build/crates/NEW-saikuro-exec/Cargo.toml deleted file mode 100644 index fe54a909..00000000 --- a/Build/crates/NEW-saikuro-exec/Cargo.toml +++ /dev/null @@ -1,66 +0,0 @@ -[package] -name = "saikuro-exec" -description = "Execution and concurrency facade for Saikuro" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true - -[lib] -path = "lib.rs" - -[features] -default = ["std", "native"] -std = [] -native = ["std", "dep:tokio", "tokio/full", "dep:tokio-util", "futures/std"] -no_std = [ - "dep:embassy-executor", - "embassy-executor/alloc", - "embassy-executor/arch-spin", - "dep:embassy-sync", - "dep:embassy-time", - "dep:embassy-futures", - "futures/async-await", -] -wasm = [ - "dep:embassy-executor", - "embassy-executor/alloc", - "embassy-executor/arch-wasm", - "dep:embassy-sync", - "dep:embassy-time", - "dep:embassy-futures", - "futures/async-await", -] -embedded = [ - "dep:embassy-executor", - "embassy-executor/arch-cortex-m", - "embassy-executor/task-arena-size-4096", - "dep:embassy-sync", - "dep:embassy-time", - "dep:embassy-futures", - "futures/async-await", -] -embassy-test = ["embedded", "embassy-time/std", "embassy-time/generic-queue"] -net = ["dep:embassy-net"] - -[dependencies] -tokio = { workspace = true, optional = true } -tokio-util = { workspace = true, optional = true } - -futures = { workspace = true } - -embassy-executor = { workspace = true, optional = true } -embassy-sync = { workspace = true, optional = true } -embassy-time = { workspace = true, optional = true } -embassy-futures = { workspace = true, optional = true } -embassy-net = { workspace = true, optional = true, features = [ - "medium-ip", - "proto-ipv4", - "tcp", - "udp", -] } - -[target.'cfg(all(target_arch = "wasm32", feature = "wasm"))'.dependencies] -wasm-bindgen-futures = { workspace = true } -fluvio-wasm-timer = { workspace = true } diff --git a/Build/crates/saikuro-exec/Cargo.toml b/Build/crates/saikuro-exec/Cargo.toml index 0e0a0e72..746a84b1 100644 --- a/Build/crates/saikuro-exec/Cargo.toml +++ b/Build/crates/saikuro-exec/Cargo.toml @@ -7,42 +7,59 @@ authors.workspace = true license.workspace = true repository.workspace = true +[lib] +path = "lib.rs" + [features] -default = ["tokio-runtime"] -tokio-runtime = ["dep:tokio", "tokio/full", "dep:tokio-util", "futures/std"] -wasm-runtime = ["dep:tokio", "wasm-bindgen-futures", "fluvio-wasm-timer", "futures/std"] -embassy-runtime = [ +default = ["std", "native"] +std = [] +native = ["std", "dep:tokio", "tokio/full", "dep:tokio-util", "futures/std"] +no_std = [ + "dep:embassy-executor", + "embassy-executor/alloc", + "embassy-executor/arch-spin", + "dep:embassy-sync", + "dep:embassy-time", + "dep:embassy-futures", + "futures/async-await", +] +wasm = [ + "dep:embassy-executor", + "embassy-executor/alloc", + "embassy-executor/arch-wasm", "dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures", "futures/async-await", ] -embassy-test = [ - "embassy-runtime", - "embassy-time/std", - "embassy-time/generic-queue", +embedded = [ + "dep:embassy-executor", + "embassy-executor/arch-cortex-m", + "embassy-executor/task-arena-size-4096", + "dep:embassy-sync", + "dep:embassy-time", + "dep:embassy-futures", + "futures/async-await", ] -net = ["dep:embassy-net"] +embassy-test = ["embedded", "embassy-time/std", "embassy-time/generic-queue"] [dependencies] -tokio = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } tokio-util = { workspace = true, optional = true } futures = { workspace = true } -wasm-bindgen-futures = { workspace = true, optional = true } -fluvio-wasm-timer = { workspace = true, optional = true } -embassy-sync = { workspace = true, optional = true } -embassy-time = { workspace = true, optional = true } +embassy-executor = { workspace = true, optional = true } +embassy-sync = { workspace = true, optional = true } +embassy-time = { workspace = true, optional = true } embassy-futures = { workspace = true, optional = true } -embassy-net = { workspace = true, optional = true, features = [ +embassy-net = { workspace = true, optional = true, features = [ "medium-ip", "proto-ipv4", "tcp", "udp", ] } -[dev-dependencies] -embassy-executor = { workspace = true } -embassy-net-driver-channel = { workspace = true } -futures-executor = { workspace = true } +[target.'cfg(all(target_arch = "wasm32", feature = "wasm"))'.dependencies] +wasm-bindgen-futures = { workspace = true } +fluvio-wasm-timer = { workspace = true } diff --git a/Build/crates/NEW-saikuro-exec/base/exec.rs b/Build/crates/saikuro-exec/base/exec.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/base/exec.rs rename to Build/crates/saikuro-exec/base/exec.rs diff --git a/Build/crates/NEW-saikuro-exec/base/mod.rs b/Build/crates/saikuro-exec/base/mod.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/base/mod.rs rename to Build/crates/saikuro-exec/base/mod.rs diff --git a/Build/crates/NEW-saikuro-exec/base/mpsc.rs b/Build/crates/saikuro-exec/base/mpsc.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/base/mpsc.rs rename to Build/crates/saikuro-exec/base/mpsc.rs diff --git a/Build/crates/NEW-saikuro-exec/base/oneshot.rs b/Build/crates/saikuro-exec/base/oneshot.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/base/oneshot.rs rename to Build/crates/saikuro-exec/base/oneshot.rs diff --git a/Build/crates/NEW-saikuro-exec/base/sync.rs b/Build/crates/saikuro-exec/base/sync.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/base/sync.rs rename to Build/crates/saikuro-exec/base/sync.rs diff --git a/Build/crates/NEW-saikuro-exec/base/watch.rs b/Build/crates/saikuro-exec/base/watch.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/base/watch.rs rename to Build/crates/saikuro-exec/base/watch.rs diff --git a/Build/crates/NEW-saikuro-exec/embedded/exec.rs b/Build/crates/saikuro-exec/embedded/exec.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/embedded/exec.rs rename to Build/crates/saikuro-exec/embedded/exec.rs diff --git a/Build/crates/NEW-saikuro-exec/embedded/mod.rs b/Build/crates/saikuro-exec/embedded/mod.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/embedded/mod.rs rename to Build/crates/saikuro-exec/embedded/mod.rs diff --git a/Build/crates/NEW-saikuro-exec/lib.rs b/Build/crates/saikuro-exec/lib.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/lib.rs rename to Build/crates/saikuro-exec/lib.rs diff --git a/Build/crates/NEW-saikuro-exec/native/exec.rs b/Build/crates/saikuro-exec/native/exec.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/native/exec.rs rename to Build/crates/saikuro-exec/native/exec.rs diff --git a/Build/crates/NEW-saikuro-exec/native/mod.rs b/Build/crates/saikuro-exec/native/mod.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/native/mod.rs rename to Build/crates/saikuro-exec/native/mod.rs diff --git a/Build/crates/NEW-saikuro-exec/native/mpsc.rs b/Build/crates/saikuro-exec/native/mpsc.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/native/mpsc.rs rename to Build/crates/saikuro-exec/native/mpsc.rs diff --git a/Build/crates/NEW-saikuro-exec/native/oneshot.rs b/Build/crates/saikuro-exec/native/oneshot.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/native/oneshot.rs rename to Build/crates/saikuro-exec/native/oneshot.rs diff --git a/Build/crates/NEW-saikuro-exec/native/sync.rs b/Build/crates/saikuro-exec/native/sync.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/native/sync.rs rename to Build/crates/saikuro-exec/native/sync.rs diff --git a/Build/crates/NEW-saikuro-exec/native/watch.rs b/Build/crates/saikuro-exec/native/watch.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/native/watch.rs rename to Build/crates/saikuro-exec/native/watch.rs diff --git a/Build/crates/NEW-saikuro-exec/shared/mod.rs b/Build/crates/saikuro-exec/shared/mod.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/shared/mod.rs rename to Build/crates/saikuro-exec/shared/mod.rs diff --git a/Build/crates/saikuro-exec/src/capacity.rs b/Build/crates/saikuro-exec/src/capacity.rs deleted file mode 100644 index 35a9a4a3..00000000 --- a/Build/crates/saikuro-exec/src/capacity.rs +++ /dev/null @@ -1,73 +0,0 @@ -use core::fmt; - -const MIN_CHANNEL_CAPACITY: usize = 1; -const MAX_CHANNEL_CAPACITY: usize = 256; - -/// A validated capacity for a bounded execution channel. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct ChannelCapacity(usize); - -impl ChannelCapacity { - /// The default channel capacity used by the execution facade. - pub const DEFAULT: Self = Self(128); - - /// The smallest supported channel capacity. - pub const MIN: Self = Self(MIN_CHANNEL_CAPACITY); - - /// The largest supported channel capacity. - pub const MAX: Self = Self(MAX_CHANNEL_CAPACITY); - - /// Construct a capacity after checking the shared backend bounds. - pub const fn new(value: usize) -> Result { - if value < MIN_CHANNEL_CAPACITY || value > MAX_CHANNEL_CAPACITY { - Err(InvalidChannelCapacity { value }) - } else { - Ok(Self(value)) - } - } - - /// Return the validated capacity as a `usize`. - pub const fn get(self) -> usize { - self.0 - } -} - -/// Error returned when a channel capacity is outside the supported bounds. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct InvalidChannelCapacity { - value: usize, -} - -impl InvalidChannelCapacity { - /// Return the rejected capacity. - pub const fn value(self) -> usize { - self.value - } -} - -impl fmt::Display for InvalidChannelCapacity { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "channel capacity {} is outside the range {}..={}", - self.value, MIN_CHANNEL_CAPACITY, MAX_CHANNEL_CAPACITY - ) - } -} - -#[cfg(any(feature = "tokio-runtime", feature = "wasm-runtime"))] -impl std::error::Error for InvalidChannelCapacity {} - -impl TryFrom for ChannelCapacity { - type Error = InvalidChannelCapacity; - - fn try_from(value: usize) -> Result { - Self::new(value) - } -} - -impl From for usize { - fn from(value: ChannelCapacity) -> Self { - value.get() - } -} diff --git a/Build/crates/saikuro-exec/src/embassy_backend.rs b/Build/crates/saikuro-exec/src/embassy_backend.rs deleted file mode 100644 index 489c36cb..00000000 --- a/Build/crates/saikuro-exec/src/embassy_backend.rs +++ /dev/null @@ -1,928 +0,0 @@ -//! Embassy backend for `saikuro-exec` (`no_std`). -//! -//! Embassy-backed implementations of the saikuro-exec API surface. The actual -//! executor comes from the application via `embassy-executor`; all this crate -//! provides is the concurrency facade. -//! -//! # Channels -//! -//! `mpsc`, `oneshot`, and `watch` are real, owned wrappers over embassy-sync -//! primitives. The channel state is shared between sender and receiver through -//! `alloc::sync::Arc`, so the handles are `'static` (same as the tokio facade) -//! and the backing storage is freed once the last handle is dropped. In -//! practice the router creates its facade channels once and keeps them around -//! for the whole life of the process. -//! -//! Channel state is guarded by -//! `embassy_sync::blocking_mutex::CriticalSectionRawMutex`. On single-core MCUs -//! the `critical-section` backend comes from the HAL -//! (`critical-section-single-core`, `cortex-m`, etc.); multicore targets have -//! to supply a critical-section impl that covers the whole core. -//! -//! # Task lifecycle -//! -//! There is no `spawn` or `block_on` here. The embassy executor owns task -//! scheduling: the application stands up a static `embassy_executor::Executor` -//! and hands out `Spawner`s. A facade cannot create a global executor without -//! clashing with the application's. Tokio-style task and runtime APIs are not -//! exported for this backend, so unsupported shared code fails at compile time -//! instead of panicking on device. `runtime` is absent for the same reason; -//! `net` is available behind the `net` feature (the app owns the stack; see -//! the module documentation). - -use alloc::sync::Arc; -use core::cell::RefCell; -use core::future::{poll_fn, Future}; -use core::pin::Pin; -use core::task::{Context, Poll, Waker}; -use core::time::Duration; - -use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use embassy_sync::blocking_mutex::CriticalSectionMutex; -use embassy_sync::channel::Channel as EmbChannel; -use embassy_sync::channel::TrySendError as EmbTrySendError; -use embassy_sync::waitqueue::MultiWakerRegistration; -use embassy_time::{Duration as EmbDuration, Timer}; -use futures::future::{Fuse, FutureExt}; - -#[cfg(feature = "net")] -pub use crate::embassy_net::net; - -// Sleep / Timeout / Yield - -/// Convert a `std::time::Duration` to the embassy representation. -/// -/// Preserves microsecond resolution (embassy timers tick at microseconds) and -/// saturates at the `u64` microsecond range instead of wrapping via an -/// `as` cast. -fn emb_duration(dur: Duration) -> EmbDuration { - EmbDuration::from_micros(dur.as_micros().min(u64::MAX as u128) as u64) -} - -pub async fn sleep(dur: Duration) { - Timer::after(emb_duration(dur)).await; -} - -pub async fn timeout(dur: Duration, fut: F) -> Result -where - F: Future, -{ - match embassy_futures::select::select(fut, Timer::after(emb_duration(dur))).await { - embassy_futures::select::Either::First(res) => Ok(res), - embassy_futures::select::Either::Second(_) => Err(()), - } -} - -pub async fn yield_now() { - embassy_futures::yield_now().await; -} - -/// Fuse a future for use in `saikuro_exec::select!` branches. -/// -/// `futures::select_biased!` requires every branch to implement -/// `FusedFuture`; fusing each branch at the facade boundary lets call sites -/// pass plain futures such as `listener.accept()` or `forward_rx.recv()`. -#[doc(hidden)] -pub fn fuse_select(fut: F) -> Fuse { - FutureExt::fuse(fut) -} - -// mpsc -/// Bounded multi-producer, single-consumer channel. -pub mod mpsc { - use super::*; - use crate::ChannelCapacity; - - /// Fixed backing capacity of an embassy mpsc channel. - /// - /// The fixed queue backing every Embassy channel. - pub const CHANNEL_CAPACITY: usize = 256; - - /// Waker slots for senders blocked on a full channel. - /// - /// `MultiWakerRegistration` falls back to waking every registered waker - /// when this fills up, so this is a performance knob, not a hard limit. - const MAX_WAITING_SENDERS: usize = 16; - - /// Error returned by [`Sender::send`] when the receiver has been dropped. - #[derive(Debug)] - pub struct SendError(pub T); - - impl SendError { - pub fn into_inner(self) -> T { - self.0 - } - } - - impl core::fmt::Display for SendError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str("send failed: channel is disconnected") - } - } - - /// Error returned by [`Sender::try_send`]. - #[derive(Debug)] - pub enum TrySendError { - Full(T), - Disconnected(T), - } - - impl TrySendError { - pub fn into_inner(self) -> T { - match self { - TrySendError::Full(v) => v, - TrySendError::Disconnected(v) => v, - } - } - - pub fn is_full(&self) -> bool { - matches!(self, TrySendError::Full(_)) - } - - pub fn is_disconnected(&self) -> bool { - matches!(self, TrySendError::Disconnected(_)) - } - } - - impl core::fmt::Display for TrySendError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - TrySendError::Full(_) => f.write_str("send failed: channel is full"), - TrySendError::Disconnected(_) => { - f.write_str("send failed: channel is disconnected") - } - } - } - } - - struct ChannelState { - /// Requested capacity. The backing `EmbChannel` is fixed at - /// `CHANNEL_CAPACITY`; this bound is enforced on enqueue. - capacity: usize, - senders: usize, - receivers: usize, - senders_waiting: MultiWakerRegistration, - receivers_waiting: MultiWakerRegistration<1>, - } - - impl ChannelState { - const fn new(capacity: usize) -> Self { - ChannelState { - capacity, - senders: 0, - receivers: 0, - senders_waiting: MultiWakerRegistration::new(), - receivers_waiting: MultiWakerRegistration::new(), - } - } - } - - struct ChannelInner { - state: CriticalSectionMutex>, - channel: EmbChannel, - } - - /// Sending half of a bounded mpsc channel. Cloneable; each clone can send - /// independently, and the channel closes for receivers once every sender is - /// dropped. - pub struct Sender { - inner: Arc>, - } - - impl Clone for Sender { - fn clone(&self) -> Self { - self.inner.state.lock(|s| s.borrow_mut().senders += 1); - Sender { - inner: self.inner.clone(), - } - } - } - - impl Drop for Sender { - fn drop(&mut self) { - self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - state.senders -= 1; - if state.senders == 0 { - // A receiver parked in recv() must observe the closure. - state.receivers_waiting.wake(); - } - }); - } - } - - /// Outcome of an atomic enqueue attempt against the channel state. - enum EnqueueOutcome { - Sent, - Full(T), - Disconnected(T), - } - - impl Sender { - /// Returns true once the receiver has been dropped. - pub fn is_closed(&self) -> bool { - self.inner.state.lock(|s| s.borrow().receivers == 0) - } - - /// Enqueue `value` under the channel-state lock, enforcing the - /// requested capacity. Holding the state lock makes the length check - /// and the push atomic against other senders. - fn enqueue(&self, value: T) -> EnqueueOutcome { - self.inner.state.lock(|s| { - let state = s.borrow_mut(); - if state.receivers == 0 { - return EnqueueOutcome::Disconnected(value); - } - if self.inner.channel.len() >= state.capacity { - return EnqueueOutcome::Full(value); - } - match self.inner.channel.try_send(value) { - Ok(()) => EnqueueOutcome::Sent, - Err(EmbTrySendError::Full(value)) => EnqueueOutcome::Full(value), - } - }) - } - - /// Whether the queue is below its requested capacity right now. - fn has_capacity(&self) -> bool { - self.inner.state.lock(|s| { - let state = s.borrow(); - self.inner.channel.len() < state.capacity - }) - } - - /// Attempt to enqueue `value` without waiting. - pub fn try_send(&self, value: T) -> Result<(), TrySendError> { - match self.enqueue(value) { - EnqueueOutcome::Sent => Ok(()), - EnqueueOutcome::Full(value) => Err(TrySendError::Full(value)), - EnqueueOutcome::Disconnected(value) => Err(TrySendError::Disconnected(value)), - } - } - - /// Send `value`, waiting for capacity when the channel is full. - /// - /// Returns `Err(SendError(value))` once the receiver has been dropped. - pub async fn send(&self, value: T) -> Result<(), SendError> { - // The message lives in an Option so the FnMut poll closure can take - // and restore it without moving out of the captured binding. - let mut pending = Some(value); - poll_fn(move |cx| { - loop { - if self.is_closed() { - // The Full arm always restores the message before the - // loop continues, so `pending` is Some here. - let message = pending - .take() - .expect("mpsc send message is restored on the Full path"); - return Poll::Ready(Err(SendError(message))); - } - let message = pending - .take() - .expect("mpsc send message is restored on the Full path"); - match self.enqueue(message) { - EnqueueOutcome::Sent => return Poll::Ready(Ok(())), - EnqueueOutcome::Disconnected(message) => { - return Poll::Ready(Err(SendError(message))) - } - EnqueueOutcome::Full(message) => { - pending = Some(message); - self.inner - .state - .lock(|s| s.borrow_mut().senders_waiting.register(cx.waker())); - // Re-check after registering so a wake that fired - // between the enqueue attempt and the register is - // not missed. - if self.is_closed() { - let message = pending - .take() - .expect("mpsc send message is restored on the Full path"); - return Poll::Ready(Err(SendError(message))); - } - if self.has_capacity() { - continue; - } - return Poll::Pending; - } - } - } - }) - .await - } - } - - /// Receiving half of a bounded mpsc channel. Not cloneable. - pub struct Receiver { - inner: Arc>, - } - - impl Drop for Receiver { - fn drop(&mut self) { - self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - state.receivers -= 1; - if state.receivers == 0 { - // Senders blocked on a full channel must observe the - // receiver disappearing, otherwise they wait forever. - state.senders_waiting.wake(); - } - }); - } - } - - impl Receiver { - /// Receive the next value, or `None` once every sender has been dropped - /// and the buffered values have been drained. - pub async fn recv(&mut self) -> Option { - poll_fn(|cx| self.poll_recv(cx)).await - } - - fn poll_recv(&self, cx: &mut Context<'_>) -> Poll> { - // Register the closure waker under the same lock as the closure - // check so a sender drop racing with registration is observed. - let all_senders_gone = self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - state.receivers_waiting.register(cx.waker()); - state.senders == 0 - }); - - if let Ok(value) = self.inner.channel.try_receive() { - self.inner - .state - .lock(|s| s.borrow_mut().senders_waiting.wake()); - return Poll::Ready(Some(value)); - } - - if all_senders_gone { - return Poll::Ready(None); - } - - match self.inner.channel.poll_receive(cx) { - Poll::Ready(value) => { - self.inner - .state - .lock(|s| s.borrow_mut().senders_waiting.wake()); - Poll::Ready(Some(value)) - } - Poll::Pending => { - // A sender may have enqueued and dropped between the checks - // above; drain instead of parking on an empty closed queue. - if self.inner.state.lock(|s| s.borrow().senders) == 0 { - match self.inner.channel.try_receive() { - Ok(value) => { - self.inner - .state - .lock(|s| s.borrow_mut().senders_waiting.wake()); - Poll::Ready(Some(value)) - } - Err(_) => Poll::Ready(None), - } - } else { - Poll::Pending - } - } - } - } - } - - /// Create a bounded channel with the given capacity. - /// - /// The Embassy backend stores the queue in a fixed `CHANNEL_CAPACITY` - /// buffer. The validated capacity cannot exceed that backing queue. The - /// channel state is reference-counted and freed once all handles are dropped. - pub fn channel(capacity: ChannelCapacity) -> (Sender, Receiver) { - let inner = Arc::new(ChannelInner { - state: CriticalSectionMutex::new(RefCell::new(ChannelState::new(capacity.get()))), - channel: EmbChannel::new(), - }); - inner.state.lock(|s| { - let mut state = s.borrow_mut(); - state.senders = 1; - state.receivers = 1; - }); - ( - Sender { - inner: inner.clone(), - }, - Receiver { inner }, - ) - } -} - -// oneshot - -/// Single-value channel used to return one response to one caller. -pub mod oneshot { - use super::*; - - enum State { - Empty, - Waiting(Waker), - Ready(T), - Closed, - } - - struct InnerData { - channel: State, - receiver_alive: bool, - } - - struct Inner { - state: CriticalSectionMutex>>, - } - - /// Sending half of a one-shot channel. Not cloneable; `send` consumes it. - pub struct Sender { - inner: Arc>, - } - - impl Sender { - /// Deliver `value`, returning it if the receiver was already dropped. - pub fn send(self, value: T) -> Result<(), T> { - self.inner.state.lock(|s| { - let mut data = s.borrow_mut(); - if !data.receiver_alive { - return Err(value); - } - match core::mem::replace(&mut data.channel, State::Empty) { - State::Empty => data.channel = State::Ready(value), - State::Waiting(waker) => { - data.channel = State::Ready(value); - waker.wake(); - } - State::Ready(v) => { - data.channel = State::Ready(v); - core::unreachable!("oneshot sender cannot send twice"); - } - State::Closed => { - data.channel = State::Closed; - core::unreachable!("oneshot sender cannot send on a closed channel"); - } - } - Ok(()) - }) - } - } - - impl Drop for Sender { - fn drop(&mut self) { - self.inner.state.lock(|s| { - let mut data = s.borrow_mut(); - if matches!(data.channel, State::Ready(_)) { - // The value was delivered; keep it available to the receiver. - return; - } - let old = core::mem::replace(&mut data.channel, State::Closed); - if let State::Waiting(waker) = old { - waker.wake(); - } - }); - } - } - - /// Error returned by the receiver when the sender is dropped without - /// sending a value. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub struct RecvError; - - impl core::fmt::Display for RecvError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str("oneshot receiver closed") - } - } - - /// Receiving half of a one-shot channel. Awaits the single value. - pub struct Receiver { - inner: Arc>, - } - - impl Drop for Receiver { - fn drop(&mut self) { - self.inner - .state - .lock(|s| s.borrow_mut().receiver_alive = false); - } - } - - impl Future for Receiver { - type Output = Result; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - self.get_mut().inner.state.lock(|s| { - let mut data = s.borrow_mut(); - match core::mem::replace(&mut data.channel, State::Empty) { - State::Ready(value) => { - // Terminate the channel so a re-poll (for example by a - // select! that re-checks a completed branch) observes - // the closure instead of parking a fresh waker forever. - data.channel = State::Closed; - Poll::Ready(Ok(value)) - } - State::Closed => Poll::Ready(Err(RecvError)), - State::Empty => { - data.channel = State::Waiting(cx.waker().clone()); - Poll::Pending - } - State::Waiting(w) => { - if w.will_wake(cx.waker()) { - data.channel = State::Waiting(w); - } else { - data.channel = State::Waiting(cx.waker().clone()); - w.wake(); - } - Poll::Pending - } - } - }) - } - } - - /// Create a one-shot channel. The channel state is reference-counted and - /// freed once both handles are dropped. - pub fn channel() -> (Sender, Receiver) { - let inner = Arc::new(Inner { - state: CriticalSectionMutex::new(RefCell::new(InnerData { - channel: State::Empty, - receiver_alive: true, - })), - }); - ( - Sender { - inner: inner.clone(), - }, - Receiver { inner }, - ) - } -} - -// sync -pub mod sync { - use super::*; - - /// Async mutual-exclusion lock. - /// - /// Single-parameter facade matching the tokio and wasm backends. The raw - /// embassy mutex is bound to `CriticalSectionRawMutex`, like [`RwLock`]. - pub struct Mutex { - inner: embassy_sync::mutex::Mutex, - } - - impl Mutex { - pub const fn new(value: T) -> Self { - Mutex { - inner: embassy_sync::mutex::Mutex::new(value), - } - } - - /// Acquire the lock, waiting until it is released by any holder. - pub async fn lock(&self) -> MutexGuard<'_, T> { - MutexGuard { - inner: self.inner.lock().await, - } - } - } - - /// Guard returned by [`Mutex::lock`]. Derefs to the guarded value. - pub struct MutexGuard<'a, T> { - inner: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, - } - - impl core::ops::Deref for MutexGuard<'_, T> { - type Target = T; - fn deref(&self) -> &T { - &self.inner - } - } - - impl core::ops::DerefMut for MutexGuard<'_, T> { - fn deref_mut(&mut self) -> &mut T { - &mut self.inner - } - } - - /// Read/write lock. - /// - /// Backed by a single async `embassy_sync::mutex::Mutex`. Readers are - /// serialized with writers rather than running concurrently, so only one - /// task holds the lock at a time regardless of kind. A task must not hold - /// one read guard while awaiting another read guard on the same lock: the - /// second acquire would deadlock because the first guard is still held. - /// This differs from tokio's `RwLock`, where concurrent reads are allowed - /// and read guards are reentrant. Guards deref to the guarded value. - pub struct RwLock { - inner: embassy_sync::mutex::Mutex, - } - - impl RwLock { - pub const fn new(value: T) -> Self { - RwLock { - inner: embassy_sync::mutex::Mutex::new(value), - } - } - - /// Acquire a shared read guard. - pub async fn read(&self) -> RwLockReadGuard<'_, T> { - RwLockReadGuard { - guard: self.inner.lock().await, - } - } - - /// Acquire an exclusive write guard. - pub async fn write(&self) -> RwLockWriteGuard<'_, T> { - RwLockWriteGuard { - guard: self.inner.lock().await, - } - } - } - - pub struct RwLockReadGuard<'a, T> { - guard: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, - } - - impl core::ops::Deref for RwLockReadGuard<'_, T> { - type Target = T; - fn deref(&self) -> &T { - &self.guard - } - } - - pub struct RwLockWriteGuard<'a, T> { - guard: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, - } - - impl core::ops::Deref for RwLockWriteGuard<'_, T> { - type Target = T; - fn deref(&self) -> &T { - &self.guard - } - } - - impl core::ops::DerefMut for RwLockWriteGuard<'_, T> { - fn deref_mut(&mut self) -> &mut T { - &mut self.guard - } - } - - /// Waker slots for tasks parked at a barrier. - const MAX_BARRIER_WAITERS: usize = 16; - - /// Synchronization barrier that releases `n` tasks together. - pub struct Barrier { - inner: Arc, - } - - struct BarrierInner { - state: CriticalSectionMutex>, - } - - struct BarrierState { - count: usize, - arrived: usize, - generation: u64, - waiting: MultiWakerRegistration, - } - - impl Barrier { - pub fn new(n: usize) -> Self { - assert!(n > 0, "saikuro-exec: Barrier::new requires n > 0"); - let inner = Arc::new(BarrierInner { - state: CriticalSectionMutex::new(RefCell::new(BarrierState { - count: n, - arrived: 0, - generation: 0, - waiting: MultiWakerRegistration::new(), - })), - }); - Barrier { inner } - } - - /// Wait until all `n` tasks have called `wait`. Returns immediately for - /// the task that releases the barrier. - pub async fn wait(&self) { - // Capture the pre-arrival generation in the same critical section - // that increments `arrived` so a release completing between the - // arrival and the wait loop cannot be missed. - let pre_release_generation = self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - state.arrived += 1; - if state.arrived == state.count { - state.arrived = 0; - state.generation += 1; - state.waiting.wake(); - None - } else { - Some(state.generation) - } - }); - let Some(mut gen) = pre_release_generation else { - return; - }; - poll_fn(move |cx| { - self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - if state.generation != gen { - gen = state.generation; - Poll::Ready(()) - } else { - state.waiting.register(cx.waker()); - if state.generation != gen { - gen = state.generation; - Poll::Ready(()) - } else { - Poll::Pending - } - } - }) - }) - .await - } - } -} - -// signal / watch / net / runtime -pub mod signal { - pub async fn ctrl_c() -> Result<(), core::convert::Infallible> { - core::future::pending().await - } -} - -/// Watch channel: a shared value with change notification. -pub mod watch { - use super::*; - - /// Waker slots for receivers blocked in [`Receiver::changed`]. - /// - /// `MultiWakerRegistration` falls back to waking every registered waker - /// when this fills up, so this is a performance knob, not a hard limit. - const MAX_WAITING_RECEIVERS: usize = 16; - - /// Error returned by [`Sender::send`] when there are no receivers left. - #[derive(Debug)] - pub struct SendError(pub T); - - impl SendError { - pub fn into_inner(self) -> T { - self.0 - } - } - - impl core::fmt::Display for SendError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str("watch channel has no receivers") - } - } - - /// Error returned by `changed` once all senders have been dropped. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub struct RecvError; - - impl core::fmt::Display for RecvError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str("watch channel closed") - } - } - - struct WatchState { - value: T, - version: u64, - senders: usize, - receivers: usize, - waiting: MultiWakerRegistration, - } - - struct WatchInner { - state: CriticalSectionMutex>>, - } - - /// Sending half of a watch channel. Cloneable; the channel closes for - /// receivers when the last sender is dropped. - pub struct Sender { - inner: Arc>, - } - - impl Sender { - /// Publish `value`, returning it if there are no receivers left. - pub fn send(&self, value: T) -> Result<(), SendError> { - self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - if state.receivers == 0 { - return Err(SendError(value)); - } - state.value = value; - state.version += 1; - state.waiting.wake(); - Ok(()) - }) - } - } - - impl Clone for Sender { - fn clone(&self) -> Self { - self.inner.state.lock(|s| s.borrow_mut().senders += 1); - Sender { - inner: self.inner.clone(), - } - } - } - - impl Drop for Sender { - fn drop(&mut self) { - self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - state.senders -= 1; - if state.senders == 0 { - // Receivers parked in changed() observe the closure. - state.waiting.wake(); - } - }); - } - } - - /// Receiving half of a watch channel. Cloneable; each clone tracks its own - /// observed version. - pub struct Receiver { - inner: Arc>, - version: u64, - } - - impl Receiver { - /// Snapshot of the latest value. - /// - /// The value is cloned rather than borrowed, matching the wasm backend; - /// this avoids holding a critical section across the returned borrow. - pub fn borrow(&self) -> T { - self.inner.state.lock(|s| s.borrow().value.clone()) - } - - /// Future that completes when a new value is sent, or with `Err` once - /// all senders are dropped. - pub fn changed(&mut self) -> ChangedFuture<'_, T> { - ChangedFuture { receiver: self } - } - } - - impl Clone for Receiver { - fn clone(&self) -> Self { - self.inner.state.lock(|s| s.borrow_mut().receivers += 1); - Receiver { - inner: self.inner.clone(), - version: self.version, - } - } - } - - impl Drop for Receiver { - fn drop(&mut self) { - self.inner.state.lock(|s| s.borrow_mut().receivers -= 1); - } - } - - /// Future returned by [`Receiver::changed`]. - pub struct ChangedFuture<'a, T> { - receiver: &'a mut Receiver, - } - - impl Future for ChangedFuture<'_, T> { - type Output = Result<(), RecvError>; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.get_mut(); - this.receiver.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - // Register before checking so a send that races with the - // registration is not missed. - state.waiting.register(cx.waker()); - let version = state.version; - // Deliver a pending change before reporting closure: a value - // sent before the last sender dropped must still be observed. - if this.receiver.version != version { - this.receiver.version = version; - return Poll::Ready(Ok(())); - } - if state.senders == 0 { - return Poll::Ready(Err(RecvError)); - } - Poll::Pending - }) - } - } - - /// Create a watch channel seeded with `initial`. The channel state is - /// reference-counted and freed once all handles are dropped. - pub fn channel(initial: T) -> (Sender, Receiver) { - let inner = Arc::new(WatchInner { - state: CriticalSectionMutex::new(RefCell::new(WatchState { - value: initial, - version: 0, - senders: 1, - receivers: 1, - waiting: MultiWakerRegistration::new(), - })), - }); - let receiver = Receiver { - inner: inner.clone(), - version: 0, - }; - (Sender { inner }, receiver) - } -} diff --git a/Build/crates/saikuro-exec/src/embassy_net.rs b/Build/crates/saikuro-exec/src/embassy_net.rs deleted file mode 100644 index d789290b..00000000 --- a/Build/crates/saikuro-exec/src/embassy_net.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Embassy networking facade (`saikuro_exec::net`). -//! -//! This is the `no_std` counterpart of the host `net` module -//! -//! # Ownership model -//! -//! - the application provides the device driver (the [`driver`] module) and -//! the [`StackResources`] memory for sockets; -//! - [`Stack::new`] returns the [`Stack`] handle plus a [`Runner`]; the runner -//! must be driven to completion on a task, otherwise the stack never -//! processes packets or wakes sockets; -//! - [`tcp::TcpSocket`] and [`udp::UdpSocket`] are created from the `Stack` -//! handle with caller-provided send and receive buffers. -//! -//! Unlike the host backend there is no global stack, so address and port -//! binding are explicit and the app controls every resource lifetime. - -pub mod net { - pub use embassy_net::*; -} diff --git a/Build/crates/saikuro-exec/src/lib.rs b/Build/crates/saikuro-exec/src/lib.rs deleted file mode 100644 index c622cce3..00000000 --- a/Build/crates/saikuro-exec/src/lib.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Lightweight execution facade used by Saikuro. -//! -//! Re-exports one backend implementation depending on enabled cargo features. -//! Supported backends: `tokio-runtime` (default), `wasm-runtime`, `embassy-runtime`. - -#![cfg_attr(feature = "embassy-runtime", no_std)] - -#[cfg(feature = "embassy-runtime")] -extern crate alloc; - -mod capacity; -pub use capacity::{ChannelCapacity, InvalidChannelCapacity}; - -#[cfg(all(feature = "embassy-runtime", feature = "net"))] -mod embassy_net; - -#[cfg(all(feature = "tokio-runtime", feature = "wasm-runtime"))] -compile_error!("Features `tokio-runtime` and `wasm-runtime` are mutually exclusive."); - -#[cfg(all(feature = "tokio-runtime", feature = "embassy-runtime"))] -compile_error!("Features `tokio-runtime` and `embassy-runtime` are mutually exclusive."); - -#[cfg(all(feature = "wasm-runtime", feature = "embassy-runtime"))] -compile_error!("Features `wasm-runtime` and `embassy-runtime` are mutually exclusive."); - -#[cfg(not(any( - feature = "tokio-runtime", - feature = "wasm-runtime", - feature = "embassy-runtime" -)))] -compile_error!( - "saikuro-exec: no runtime backend selected. \ - Enable one of `tokio-runtime`, `wasm-runtime`, or `embassy-runtime`." -); - -#[cfg(any(feature = "tokio-runtime", feature = "wasm-runtime"))] -pub use tokio as _tokio; - -#[cfg(feature = "tokio-runtime")] -mod tokio_backend; -#[cfg(feature = "tokio-runtime")] -pub use tokio_backend::*; - -#[cfg(feature = "wasm-runtime")] -mod wasm_backend; -#[cfg(feature = "wasm-runtime")] -pub use wasm_backend::*; - -#[cfg(feature = "embassy-runtime")] -mod embassy_backend; -#[cfg(feature = "embassy-runtime")] -pub use embassy_backend::*; - -#[cfg(feature = "embassy-runtime")] -pub use futures as _futures; - -/// Branch on the first future to complete. -/// -/// The tokio and wasm backends delegate to `tokio::select!` and accept its -/// full syntax. The embassy backend only supports `pattern = future => { ... }` -/// branches (see `select_impl!`); it rejects `else`, `biased;`, guards, and -/// expression handlers. Cross-backend code must stay within the shared subset -/// so it compiles on every backend. -#[macro_export] -macro_rules! select { - ($($tt:tt)*) => { - $crate::select_impl! { $($tt)* } - }; -} - -#[doc(hidden)] -#[cfg(any(feature = "tokio-runtime", feature = "wasm-runtime"))] -#[macro_export] -macro_rules! select_impl { - ($($tt:tt)*) => { - $crate::_tokio::select! { $($tt)* } - }; -} - -/// Embassy-compatible `select!`. -/// -/// Delegates to `futures::select_biased!`, which requires every branch future -/// to implement `FusedFuture`. Each branch is fused at the facade boundary so -/// call sites pass plain futures (`listener.accept()`, `forward_rx.recv()`, -/// and friends). `select_biased!` is used rather than `futures::select!` -/// because the latter is gated behind the `std` feature and cannot resolve on -/// `no_std` MCU targets. -#[doc(hidden)] -#[cfg(feature = "embassy-runtime")] -#[macro_export] -macro_rules! select_impl { - ( - $( - $pattern:pat = $fut:expr => $handler:block $(,)? - )+ - ) => { - $crate::_futures::select_biased! { - $( - $pattern = $crate::fuse_select($fut) => $handler , - )+ - } - }; -} diff --git a/Build/crates/saikuro-exec/src/tokio_backend.rs b/Build/crates/saikuro-exec/src/tokio_backend.rs deleted file mode 100644 index 88b5aa08..00000000 --- a/Build/crates/saikuro-exec/src/tokio_backend.rs +++ /dev/null @@ -1,93 +0,0 @@ -use std::future::Future; -use std::sync::OnceLock; -use std::time::Duration; - -pub type JoinHandle = tokio::task::JoinHandle; -pub type Runtime = tokio::runtime::Runtime; -pub type RuntimeBuilder = tokio::runtime::Builder; - -pub fn new_runtime() -> RuntimeBuilder { - tokio::runtime::Builder::new_multi_thread() -} - -pub fn spawn(fut: F) -> JoinHandle -where - F: Future + Send + 'static, - T: Send + 'static, -{ - tokio::spawn(fut) -} - -pub mod mpsc { - use crate::ChannelCapacity; - pub use tokio::sync::mpsc::{Receiver, Sender}; - - /// Create a bounded channel with a validated capacity. - pub fn channel(capacity: ChannelCapacity) -> (Sender, Receiver) { - tokio::sync::mpsc::channel(capacity.get()) - } -} - -pub mod oneshot { - pub use tokio::sync::oneshot::{channel, Receiver, Sender}; -} - -pub mod sync { - pub use tokio::sync::{Barrier, Mutex, RwLock}; -} - -pub mod net { - pub use tokio::net::*; -} - -pub mod io { - pub use tokio::io::{ - duplex, split, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf, - }; -} - -pub mod signal { - pub use tokio::signal::*; -} - -pub mod watch { - pub use tokio::sync::watch::{channel, Receiver, Sender}; -} - -pub mod runtime { - pub use tokio::runtime::{Builder, Runtime}; -} - -static RUNTIME: OnceLock = OnceLock::new(); - -pub fn block_on(future: F) -> F::Output -where - F: Future, -{ - let rt = RUNTIME.get_or_init(|| { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .unwrap_or_else(|e| panic!("failed to build tokio runtime: {e}")) - }); - rt.block_on(future) -} - -pub async fn sleep(dur: Duration) { - tokio::time::sleep(dur).await -} - -pub async fn timeout(dur: Duration, fut: F) -> Result -where - F: Future, -{ - tokio::time::timeout(dur, fut).await -} - -pub async fn yield_now() { - tokio::task::yield_now().await -} - -pub use tokio_util; - -// Re-export select macro via a thin wrapper macro in crate root if needed. diff --git a/Build/crates/saikuro-exec/src/wasm_backend.rs b/Build/crates/saikuro-exec/src/wasm_backend.rs deleted file mode 100644 index fcc84262..00000000 --- a/Build/crates/saikuro-exec/src/wasm_backend.rs +++ /dev/null @@ -1,436 +0,0 @@ -//! WASM backend for saikuro-exec. -//! -//! Backed by `futures` channels and `wasm_bindgen_futures` for single-threaded -//! WASM execution. This is the default backend when compiling to WASM. - -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; -use std::time::Duration; - -use futures::channel::oneshot as inner_oneshot; - -/// Spawn a future on the JS/WASM executor. -pub fn spawn(fut: F) -> JoinHandle -where - F: Future + 'static, - T: 'static, -{ - let (tx, rx) = inner_oneshot::channel(); - wasm_bindgen_futures::spawn_local(async move { - let _ = tx.send(fut.await); - }); - JoinHandle { rx } -} - -pub mod mpsc { - use futures::channel::mpsc as inner; - use futures::lock::Mutex; - use futures::stream::StreamExt; - use std::sync::Arc; - - #[derive(Debug)] - pub struct SendError(pub T); - - impl SendError { - pub fn into_inner(self) -> T { - self.0 - } - pub fn is_disconnected(&self) -> bool { - true - } - } - - impl std::fmt::Display for SendError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "send failed: channel is disconnected") - } - } - - impl std::error::Error for SendError {} - - pub enum TrySendError { - Full(T), - Disconnected(T), - } - - impl TrySendError { - pub fn into_inner(self) -> T { - match self { - TrySendError::Full(v) => v, - TrySendError::Disconnected(v) => v, - } - } - pub fn is_full(&self) -> bool { - matches!(self, TrySendError::Full(_)) - } - pub fn is_disconnected(&self) -> bool { - matches!(self, TrySendError::Disconnected(_)) - } - } - - impl std::fmt::Debug for TrySendError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TrySendError::Full(_) => write!(f, "TrySendError::Full(..)"), - TrySendError::Disconnected(_) => write!(f, "TrySendError::Disconnected(..)"), - } - } - } - - impl std::fmt::Display for TrySendError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TrySendError::Full(_) => write!(f, "send failed: channel is full"), - TrySendError::Disconnected(_) => write!(f, "send failed: channel is disconnected"), - } - } - } - - impl std::error::Error for TrySendError {} - - pub struct Sender { - inner: Arc>>, - } - - impl Clone for Sender { - fn clone(&self) -> Self { - Sender { - inner: self.inner.clone(), - } - } - } - - pub struct Receiver { - inner: inner::Receiver, - } - - /// Create a bounded channel with a validated capacity. - pub fn channel(buffer: crate::ChannelCapacity) -> (Sender, Receiver) { - let (tx, rx) = inner::channel(buffer.get()); - ( - Sender { - inner: Arc::new(Mutex::new(tx)), - }, - Receiver { inner: rx }, - ) - } - - impl Sender { - pub async fn send(&self, value: T) -> Result<(), SendError> { - let mut value = value; - loop { - let mut guard = self.inner.lock().await; - match guard.try_send(value) { - Ok(()) => return Ok(()), - Err(e) if e.is_full() => { - value = e.into_inner(); - drop(guard); - super::yield_now().await; - } - Err(e) => { - return Err(SendError(e.into_inner())); - } - } - } - } - - pub fn is_closed(&self) -> bool { - if let Some(guard) = self.inner.try_lock() { - guard.is_closed() - } else { - false - } - } - - pub fn try_send(&self, value: T) -> Result<(), TrySendError> { - if let Some(mut guard) = self.inner.try_lock() { - guard.try_send(value).map_err(|e| { - if e.is_full() { - TrySendError::Full(e.into_inner()) - } else { - TrySendError::Disconnected(e.into_inner()) - } - }) - } else { - Err(TrySendError::Full(value)) - } - } - } - - impl Receiver { - pub fn recv(&mut self) -> impl futures::future::FusedFuture> + '_ { - self.inner.next() - } - } -} - -pub mod oneshot { - pub use futures::channel::oneshot::{channel, Receiver, Sender}; -} - -pub mod sync { - pub use futures::lock::Mutex; -} - -/// Signal handling for WASM. -/// -/// WASM has no OS signals, so `ctrl_c` never completes: it awaits -/// `std::future::pending()` so callers awaiting a shutdown signal simply -/// stay parked instead of triggering an immediate (spurious) shutdown. -pub mod signal { - pub async fn ctrl_c() -> Result<(), ()> { - std::future::pending().await - } -} - -// Watch channel -pub mod watch { - use std::fmt; - use std::future::Future; - use std::pin::Pin; - use std::sync::{Arc, Mutex}; - use std::task::{Context, Poll, Waker}; - - pub fn channel(initial: T) -> (Sender, Receiver) { - let inner = Arc::new(Mutex::new(Inner { - value: initial, - changed: false, - closed: false, - senders: 1, - wakers: Vec::new(), - })); - ( - Sender { - inner: inner.clone(), - }, - Receiver { inner }, - ) - } - - struct Inner { - value: T, - changed: bool, - closed: bool, - senders: usize, - wakers: Vec, - } - - pub struct Sender { - inner: Arc>>, - } - - impl Sender { - pub fn send(&self, val: T) { - let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); - inner.value = val; - inner.changed = true; - for w in inner.wakers.drain(..) { - w.wake(); - } - } - } - - impl Clone for Sender { - fn clone(&self) -> Self { - let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); - guard.senders += 1; - drop(guard); - Sender { - inner: self.inner.clone(), - } - } - } - - impl Drop for Sender { - fn drop(&mut self) { - let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); - guard.senders -= 1; - if guard.senders == 0 { - guard.closed = true; - for w in guard.wakers.drain(..) { - w.wake(); - } - } - } - } - - pub struct Receiver { - inner: Arc>>, - } - - impl Receiver { - pub fn borrow(&self) -> T { - self.inner - .lock() - .unwrap_or_else(|e| e.into_inner()) - .value - .clone() - } - - pub fn changed(&mut self) -> ChangedFuture<'_, T> { - ChangedFuture { receiver: self } - } - } - - pub struct ChangedFuture<'a, T> { - receiver: &'a Receiver, - } - - impl Future for ChangedFuture<'_, T> { - type Output = Result<(), ()>; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let mut inner = self - .receiver - .inner - .lock() - .unwrap_or_else(|e| e.into_inner()); - if inner.changed { - inner.changed = false; - Poll::Ready(Ok(())) - } else if inner.closed { - Poll::Ready(Err(())) - } else { - let waker = cx.waker(); - if !inner.wakers.iter().any(|w| w.will_wake(waker)) { - inner.wakers.push(waker.clone()); - } - if inner.closed { - Poll::Ready(Err(())) - } else if inner.changed { - inner.changed = false; - Poll::Ready(Ok(())) - } else { - Poll::Pending - } - } - } - } - - impl fmt::Debug for ChangedFuture<'_, T> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ChangedFuture").finish_non_exhaustive() - } - } -} - -// JoinHandle - -/// Error returned when a spawned task is cancelled (sender dropped). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct JoinError; - -impl std::fmt::Display for JoinError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "task was cancelled") - } -} - -impl std::error::Error for JoinError {} - -pub struct JoinHandle { - rx: inner_oneshot::Receiver, -} - -impl Future for JoinHandle { - type Output = Result; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match Pin::new(&mut self.get_mut().rx).poll(cx) { - Poll::Ready(Ok(v)) => Poll::Ready(Ok(v)), - Poll::Ready(Err(_)) => Poll::Ready(Err(JoinError)), - Poll::Pending => Poll::Pending, - } - } -} - -// Time utilities -pub async fn sleep(dur: Duration) { - let _ = fluvio_wasm_timer::Delay::new(dur).await; -} - -pub async fn timeout(dur: Duration, fut: F) -> Result -where - F: Future, -{ - use futures::future::{Either, FutureExt}; - futures::pin_mut!(fut); - let delay = fluvio_wasm_timer::Delay::new(dur).fuse(); - futures::pin_mut!(delay); - match futures::future::select(fut, delay).await { - Either::Left((res, _)) => Ok(res), - Either::Right(_) => Err(()), - } -} - -pub async fn yield_now() { - let mut yielded = false; - std::future::poll_fn(|cx| { - if !yielded { - yielded = true; - cx.waker().wake_by_ref(); - Poll::Pending - } else { - Poll::Ready(()) - } - }) - .await -} - -/// Run a future to completion on the current WASM thread. -/// -/// This uses `futures::executor::block_on` which polls the future in a loop. -/// -/// ## Important -/// -/// On single-threaded WASM targets (no atomics) this **cannot** yield to the -/// JavaScript event loop, so futures that depend on JS I/O (timers, -/// `fetch`, `BroadcastChannel`, …) will never complete. For those futures -/// use `spawn_local` with an async entrypoint instead. -/// -/// On WASM targets with atomics enabled (`RUSTFLAGS="--cfg -/// target_feature=atomics"`), `block_on` uses `Atomics.wait()` for proper -/// blocking, which allows the JS event loop to make progress. -pub fn block_on(future: F) -> F::Output -where - F: Future + 'static, -{ - futures::executor::block_on(future) -} - -/// Stub Runtime for wasm that mirrors tokio's runtime API so -/// `saikuro-c` (and other consumers) can use the same code -/// path on wasm32-unknown-unknown. -pub struct Runtime { - _private: (), -} - -/// Stub builder that always produces a Runtime successfully. -pub struct RuntimeBuilder { - _private: (), -} - -impl RuntimeBuilder { - /// No-op: everything is already enabled on wasm. - pub fn enable_all(self) -> Self { - self - } - - /// Always returns Ok(Runtime). - pub fn build(self) -> Result> { - Ok(Runtime { _private: () }) - } -} - -/// Create a new runtime builder for the wasm backend. -pub fn new_runtime() -> RuntimeBuilder { - RuntimeBuilder { _private: () } -} - -impl Runtime { - /// Block on a future using the single-threaded wasm executor. - pub fn block_on(&self, future: F) -> F::Output { - futures::executor::block_on(future) - } -} diff --git a/Build/crates/NEW-saikuro-exec/wasm/exec.rs b/Build/crates/saikuro-exec/wasm/exec.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/wasm/exec.rs rename to Build/crates/saikuro-exec/wasm/exec.rs diff --git a/Build/crates/NEW-saikuro-exec/wasm/mod.rs b/Build/crates/saikuro-exec/wasm/mod.rs similarity index 100% rename from Build/crates/NEW-saikuro-exec/wasm/mod.rs rename to Build/crates/saikuro-exec/wasm/mod.rs diff --git a/Build/crates/saikuro-exec/tests/embassy_cancellation.rs b/Build/tests/saikuro-exec/embassy_cancellation.rs similarity index 100% rename from Build/crates/saikuro-exec/tests/embassy_cancellation.rs rename to Build/tests/saikuro-exec/embassy_cancellation.rs diff --git a/Build/crates/saikuro-exec/tests/embassy_executor.rs b/Build/tests/saikuro-exec/embassy_executor.rs similarity index 100% rename from Build/crates/saikuro-exec/tests/embassy_executor.rs rename to Build/tests/saikuro-exec/embassy_executor.rs From 526c583a3ba52f213aa828afa384f84a8d72b847 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Thu, 13 Aug 2026 13:06:54 -0600 Subject: [PATCH 22/43] Move tests --- Build/crates/saikuro-random/Cargo.toml | 3 +++ Build/crates/saikuro-random/{src => }/base/mod.rs | 0 Build/crates/saikuro-random/{src => }/embedded/mod.rs | 0 Build/crates/saikuro-random/{src => }/lib.rs | 0 Build/crates/saikuro-random/{src => }/native/mod.rs | 0 Build/crates/saikuro-random/{src => }/shared/mod.rs | 0 Build/crates/saikuro-random/{src => }/wasm/mod.rs | 0 Build/tests/{tests => }/common/mod.rs | 0 Build/tests/{src => }/lib.rs | 0 .../tests => tests/saikuro-codegen}/c_cpp_codegen.rs | 0 Build/tests/{tests => saikuro-codegen}/codegen_output.rs | 0 Build/tests/{tests => saikuro-core}/cross_language_wire.rs | 0 Build/tests/{tests => saikuro-core}/envelope_roundtrip.rs | 0 Build/tests/{tests => saikuro-core}/error_propagation.rs | 0 .../saikuro-core/tests => tests/saikuro-core}/invocation.rs | 0 .../saikuro-core/tests => tests/saikuro-core}/resource.rs | 0 .../{crates/saikuro-core/tests => tests/saikuro-core}/value.rs | 0 Build/tests/{tests => saikuro-exec}/exec_channels.rs | 0 Build/tests/{tests => saikuro-exec}/exec_concurrency.rs | 0 Build/tests/{tests => saikuro-exec}/exec_select.rs | 0 Build/tests/{tests => saikuro-router}/announce_dispatch.rs | 0 Build/tests/{tests => saikuro-router}/batch_dispatch.rs | 0 Build/tests/{tests => saikuro-router}/call_dispatch.rs | 0 Build/tests/{tests => saikuro-router}/channel_dispatch.rs | 0 Build/tests/{tests => saikuro-router}/log_dispatch.rs | 0 .../tests => tests/saikuro-router}/provider_registry.rs | 0 Build/tests/{tests => saikuro-router}/resource_dispatch.rs | 0 Build/tests/{tests => saikuro-router}/sandbox_dispatch.rs | 0 Build/tests/{tests => saikuro-router}/stream_dispatch.rs | 0 .../tests => tests/saikuro-runtime}/config_capacity.rs | 0 .../tests => tests/saikuro-runtime}/schema_registration.rs | 0 .../tests/{tests => saikuro-schema}/capability_enforcement.rs | 0 .../saikuro-schema/tests => tests/saikuro-schema}/registry.rs | 0 Build/tests/{tests => saikuro-schema}/schema_validation.rs | 0 .../saikuro-schema/tests => tests/saikuro-schema}/validator.rs | 0 .../saikuro-storage/tests => tests/saikuro-storage}/flash.rs | 0 .../tests => tests/saikuro-storage}/inmemory.rs | 0 .../saikuro-storage/tests => tests/saikuro-storage}/util.rs | 0 .../tests => tests/saikuro-transport}/embedded_io.rs | 0 .../tests/{tests => saikuro-transport}/transport_compliance.rs | 0 Build/tests/{tests => saikuro-transport}/transport_framing.rs | 0 .../{tests => saikuro-transport}/transport_memory_stress.rs | 0 .../tests/{tests => saikuro-transport}/transport_wasm_host.rs | 0 43 files changed, 3 insertions(+) rename Build/crates/saikuro-random/{src => }/base/mod.rs (100%) rename Build/crates/saikuro-random/{src => }/embedded/mod.rs (100%) rename Build/crates/saikuro-random/{src => }/lib.rs (100%) rename Build/crates/saikuro-random/{src => }/native/mod.rs (100%) rename Build/crates/saikuro-random/{src => }/shared/mod.rs (100%) rename Build/crates/saikuro-random/{src => }/wasm/mod.rs (100%) rename Build/tests/{tests => }/common/mod.rs (100%) rename Build/tests/{src => }/lib.rs (100%) rename Build/{crates/saikuro-codegen/tests => tests/saikuro-codegen}/c_cpp_codegen.rs (100%) rename Build/tests/{tests => saikuro-codegen}/codegen_output.rs (100%) rename Build/tests/{tests => saikuro-core}/cross_language_wire.rs (100%) rename Build/tests/{tests => saikuro-core}/envelope_roundtrip.rs (100%) rename Build/tests/{tests => saikuro-core}/error_propagation.rs (100%) rename Build/{crates/saikuro-core/tests => tests/saikuro-core}/invocation.rs (100%) rename Build/{crates/saikuro-core/tests => tests/saikuro-core}/resource.rs (100%) rename Build/{crates/saikuro-core/tests => tests/saikuro-core}/value.rs (100%) rename Build/tests/{tests => saikuro-exec}/exec_channels.rs (100%) rename Build/tests/{tests => saikuro-exec}/exec_concurrency.rs (100%) rename Build/tests/{tests => saikuro-exec}/exec_select.rs (100%) rename Build/tests/{tests => saikuro-router}/announce_dispatch.rs (100%) rename Build/tests/{tests => saikuro-router}/batch_dispatch.rs (100%) rename Build/tests/{tests => saikuro-router}/call_dispatch.rs (100%) rename Build/tests/{tests => saikuro-router}/channel_dispatch.rs (100%) rename Build/tests/{tests => saikuro-router}/log_dispatch.rs (100%) rename Build/{crates/saikuro-router/tests => tests/saikuro-router}/provider_registry.rs (100%) rename Build/tests/{tests => saikuro-router}/resource_dispatch.rs (100%) rename Build/tests/{tests => saikuro-router}/sandbox_dispatch.rs (100%) rename Build/tests/{tests => saikuro-router}/stream_dispatch.rs (100%) rename Build/{crates/saikuro-runtime/tests => tests/saikuro-runtime}/config_capacity.rs (100%) rename Build/{crates/saikuro-runtime/tests => tests/saikuro-runtime}/schema_registration.rs (100%) rename Build/tests/{tests => saikuro-schema}/capability_enforcement.rs (100%) rename Build/{crates/saikuro-schema/tests => tests/saikuro-schema}/registry.rs (100%) rename Build/tests/{tests => saikuro-schema}/schema_validation.rs (100%) rename Build/{crates/saikuro-schema/tests => tests/saikuro-schema}/validator.rs (100%) rename Build/{crates/saikuro-storage/tests => tests/saikuro-storage}/flash.rs (100%) rename Build/{crates/saikuro-storage/tests => tests/saikuro-storage}/inmemory.rs (100%) rename Build/{crates/saikuro-storage/tests => tests/saikuro-storage}/util.rs (100%) rename Build/{crates/saikuro-transport/tests => tests/saikuro-transport}/embedded_io.rs (100%) rename Build/tests/{tests => saikuro-transport}/transport_compliance.rs (100%) rename Build/tests/{tests => saikuro-transport}/transport_framing.rs (100%) rename Build/tests/{tests => saikuro-transport}/transport_memory_stress.rs (100%) rename Build/tests/{tests => saikuro-transport}/transport_wasm_host.rs (100%) diff --git a/Build/crates/saikuro-random/Cargo.toml b/Build/crates/saikuro-random/Cargo.toml index 8f2cfebb..58110d8c 100644 --- a/Build/crates/saikuro-random/Cargo.toml +++ b/Build/crates/saikuro-random/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "random", "rng"] +[lib] +path = "lib.rs" + [features] default = ["std", "native"] std = ["rand_core/std"] diff --git a/Build/crates/saikuro-random/src/base/mod.rs b/Build/crates/saikuro-random/base/mod.rs similarity index 100% rename from Build/crates/saikuro-random/src/base/mod.rs rename to Build/crates/saikuro-random/base/mod.rs diff --git a/Build/crates/saikuro-random/src/embedded/mod.rs b/Build/crates/saikuro-random/embedded/mod.rs similarity index 100% rename from Build/crates/saikuro-random/src/embedded/mod.rs rename to Build/crates/saikuro-random/embedded/mod.rs diff --git a/Build/crates/saikuro-random/src/lib.rs b/Build/crates/saikuro-random/lib.rs similarity index 100% rename from Build/crates/saikuro-random/src/lib.rs rename to Build/crates/saikuro-random/lib.rs diff --git a/Build/crates/saikuro-random/src/native/mod.rs b/Build/crates/saikuro-random/native/mod.rs similarity index 100% rename from Build/crates/saikuro-random/src/native/mod.rs rename to Build/crates/saikuro-random/native/mod.rs diff --git a/Build/crates/saikuro-random/src/shared/mod.rs b/Build/crates/saikuro-random/shared/mod.rs similarity index 100% rename from Build/crates/saikuro-random/src/shared/mod.rs rename to Build/crates/saikuro-random/shared/mod.rs diff --git a/Build/crates/saikuro-random/src/wasm/mod.rs b/Build/crates/saikuro-random/wasm/mod.rs similarity index 100% rename from Build/crates/saikuro-random/src/wasm/mod.rs rename to Build/crates/saikuro-random/wasm/mod.rs diff --git a/Build/tests/tests/common/mod.rs b/Build/tests/common/mod.rs similarity index 100% rename from Build/tests/tests/common/mod.rs rename to Build/tests/common/mod.rs diff --git a/Build/tests/src/lib.rs b/Build/tests/lib.rs similarity index 100% rename from Build/tests/src/lib.rs rename to Build/tests/lib.rs diff --git a/Build/crates/saikuro-codegen/tests/c_cpp_codegen.rs b/Build/tests/saikuro-codegen/c_cpp_codegen.rs similarity index 100% rename from Build/crates/saikuro-codegen/tests/c_cpp_codegen.rs rename to Build/tests/saikuro-codegen/c_cpp_codegen.rs diff --git a/Build/tests/tests/codegen_output.rs b/Build/tests/saikuro-codegen/codegen_output.rs similarity index 100% rename from Build/tests/tests/codegen_output.rs rename to Build/tests/saikuro-codegen/codegen_output.rs diff --git a/Build/tests/tests/cross_language_wire.rs b/Build/tests/saikuro-core/cross_language_wire.rs similarity index 100% rename from Build/tests/tests/cross_language_wire.rs rename to Build/tests/saikuro-core/cross_language_wire.rs diff --git a/Build/tests/tests/envelope_roundtrip.rs b/Build/tests/saikuro-core/envelope_roundtrip.rs similarity index 100% rename from Build/tests/tests/envelope_roundtrip.rs rename to Build/tests/saikuro-core/envelope_roundtrip.rs diff --git a/Build/tests/tests/error_propagation.rs b/Build/tests/saikuro-core/error_propagation.rs similarity index 100% rename from Build/tests/tests/error_propagation.rs rename to Build/tests/saikuro-core/error_propagation.rs diff --git a/Build/crates/saikuro-core/tests/invocation.rs b/Build/tests/saikuro-core/invocation.rs similarity index 100% rename from Build/crates/saikuro-core/tests/invocation.rs rename to Build/tests/saikuro-core/invocation.rs diff --git a/Build/crates/saikuro-core/tests/resource.rs b/Build/tests/saikuro-core/resource.rs similarity index 100% rename from Build/crates/saikuro-core/tests/resource.rs rename to Build/tests/saikuro-core/resource.rs diff --git a/Build/crates/saikuro-core/tests/value.rs b/Build/tests/saikuro-core/value.rs similarity index 100% rename from Build/crates/saikuro-core/tests/value.rs rename to Build/tests/saikuro-core/value.rs diff --git a/Build/tests/tests/exec_channels.rs b/Build/tests/saikuro-exec/exec_channels.rs similarity index 100% rename from Build/tests/tests/exec_channels.rs rename to Build/tests/saikuro-exec/exec_channels.rs diff --git a/Build/tests/tests/exec_concurrency.rs b/Build/tests/saikuro-exec/exec_concurrency.rs similarity index 100% rename from Build/tests/tests/exec_concurrency.rs rename to Build/tests/saikuro-exec/exec_concurrency.rs diff --git a/Build/tests/tests/exec_select.rs b/Build/tests/saikuro-exec/exec_select.rs similarity index 100% rename from Build/tests/tests/exec_select.rs rename to Build/tests/saikuro-exec/exec_select.rs diff --git a/Build/tests/tests/announce_dispatch.rs b/Build/tests/saikuro-router/announce_dispatch.rs similarity index 100% rename from Build/tests/tests/announce_dispatch.rs rename to Build/tests/saikuro-router/announce_dispatch.rs diff --git a/Build/tests/tests/batch_dispatch.rs b/Build/tests/saikuro-router/batch_dispatch.rs similarity index 100% rename from Build/tests/tests/batch_dispatch.rs rename to Build/tests/saikuro-router/batch_dispatch.rs diff --git a/Build/tests/tests/call_dispatch.rs b/Build/tests/saikuro-router/call_dispatch.rs similarity index 100% rename from Build/tests/tests/call_dispatch.rs rename to Build/tests/saikuro-router/call_dispatch.rs diff --git a/Build/tests/tests/channel_dispatch.rs b/Build/tests/saikuro-router/channel_dispatch.rs similarity index 100% rename from Build/tests/tests/channel_dispatch.rs rename to Build/tests/saikuro-router/channel_dispatch.rs diff --git a/Build/tests/tests/log_dispatch.rs b/Build/tests/saikuro-router/log_dispatch.rs similarity index 100% rename from Build/tests/tests/log_dispatch.rs rename to Build/tests/saikuro-router/log_dispatch.rs diff --git a/Build/crates/saikuro-router/tests/provider_registry.rs b/Build/tests/saikuro-router/provider_registry.rs similarity index 100% rename from Build/crates/saikuro-router/tests/provider_registry.rs rename to Build/tests/saikuro-router/provider_registry.rs diff --git a/Build/tests/tests/resource_dispatch.rs b/Build/tests/saikuro-router/resource_dispatch.rs similarity index 100% rename from Build/tests/tests/resource_dispatch.rs rename to Build/tests/saikuro-router/resource_dispatch.rs diff --git a/Build/tests/tests/sandbox_dispatch.rs b/Build/tests/saikuro-router/sandbox_dispatch.rs similarity index 100% rename from Build/tests/tests/sandbox_dispatch.rs rename to Build/tests/saikuro-router/sandbox_dispatch.rs diff --git a/Build/tests/tests/stream_dispatch.rs b/Build/tests/saikuro-router/stream_dispatch.rs similarity index 100% rename from Build/tests/tests/stream_dispatch.rs rename to Build/tests/saikuro-router/stream_dispatch.rs diff --git a/Build/crates/saikuro-runtime/tests/config_capacity.rs b/Build/tests/saikuro-runtime/config_capacity.rs similarity index 100% rename from Build/crates/saikuro-runtime/tests/config_capacity.rs rename to Build/tests/saikuro-runtime/config_capacity.rs diff --git a/Build/crates/saikuro-runtime/tests/schema_registration.rs b/Build/tests/saikuro-runtime/schema_registration.rs similarity index 100% rename from Build/crates/saikuro-runtime/tests/schema_registration.rs rename to Build/tests/saikuro-runtime/schema_registration.rs diff --git a/Build/tests/tests/capability_enforcement.rs b/Build/tests/saikuro-schema/capability_enforcement.rs similarity index 100% rename from Build/tests/tests/capability_enforcement.rs rename to Build/tests/saikuro-schema/capability_enforcement.rs diff --git a/Build/crates/saikuro-schema/tests/registry.rs b/Build/tests/saikuro-schema/registry.rs similarity index 100% rename from Build/crates/saikuro-schema/tests/registry.rs rename to Build/tests/saikuro-schema/registry.rs diff --git a/Build/tests/tests/schema_validation.rs b/Build/tests/saikuro-schema/schema_validation.rs similarity index 100% rename from Build/tests/tests/schema_validation.rs rename to Build/tests/saikuro-schema/schema_validation.rs diff --git a/Build/crates/saikuro-schema/tests/validator.rs b/Build/tests/saikuro-schema/validator.rs similarity index 100% rename from Build/crates/saikuro-schema/tests/validator.rs rename to Build/tests/saikuro-schema/validator.rs diff --git a/Build/crates/saikuro-storage/tests/flash.rs b/Build/tests/saikuro-storage/flash.rs similarity index 100% rename from Build/crates/saikuro-storage/tests/flash.rs rename to Build/tests/saikuro-storage/flash.rs diff --git a/Build/crates/saikuro-storage/tests/inmemory.rs b/Build/tests/saikuro-storage/inmemory.rs similarity index 100% rename from Build/crates/saikuro-storage/tests/inmemory.rs rename to Build/tests/saikuro-storage/inmemory.rs diff --git a/Build/crates/saikuro-storage/tests/util.rs b/Build/tests/saikuro-storage/util.rs similarity index 100% rename from Build/crates/saikuro-storage/tests/util.rs rename to Build/tests/saikuro-storage/util.rs diff --git a/Build/crates/saikuro-transport/tests/embedded_io.rs b/Build/tests/saikuro-transport/embedded_io.rs similarity index 100% rename from Build/crates/saikuro-transport/tests/embedded_io.rs rename to Build/tests/saikuro-transport/embedded_io.rs diff --git a/Build/tests/tests/transport_compliance.rs b/Build/tests/saikuro-transport/transport_compliance.rs similarity index 100% rename from Build/tests/tests/transport_compliance.rs rename to Build/tests/saikuro-transport/transport_compliance.rs diff --git a/Build/tests/tests/transport_framing.rs b/Build/tests/saikuro-transport/transport_framing.rs similarity index 100% rename from Build/tests/tests/transport_framing.rs rename to Build/tests/saikuro-transport/transport_framing.rs diff --git a/Build/tests/tests/transport_memory_stress.rs b/Build/tests/saikuro-transport/transport_memory_stress.rs similarity index 100% rename from Build/tests/tests/transport_memory_stress.rs rename to Build/tests/saikuro-transport/transport_memory_stress.rs diff --git a/Build/tests/tests/transport_wasm_host.rs b/Build/tests/saikuro-transport/transport_wasm_host.rs similarity index 100% rename from Build/tests/tests/transport_wasm_host.rs rename to Build/tests/saikuro-transport/transport_wasm_host.rs From 30e232af76c7fc2173ac40eaa5addb2819066975 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Thu, 13 Aug 2026 13:59:07 -0600 Subject: [PATCH 23/43] So that's the base that won't compile so far --- .cargo/config.toml | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 29ec9436..9b8db2d8 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,33 +1,32 @@ # Saikuro Cargo config # -# WASM tests need wasm-bindgen-cli (cargo install wasm-bindgen-cli). +# WASM tests need wasm-bindgen-cli (cargo install wasm-bindgen-cli): # cargo test -p saikuro-tests --target wasm32-unknown-unknown # wasm-pack test --headless --chrome Build/tests -# getrandom 0.3 picks its backend at compile time with the `getrandom_backend` -# cfg, so we set it per-target here. +# getrandom 0.3 picks its backend at compile time via the `getrandom_backend` +# cfg, so it's set per-target below. # -# wasm32-unknown-unknown has no OS entropy, so we pin the `wasm_js` backend on -# every build for it. saikuro-random's `wasm` engine enables the matching -# cargo feature (threaded through adapters/rust, saikuro-runtime, and -# saikuro-tests' wasm32 deps). +# wasm32-unknown-unknown: no OS entropy, so pin the `wasm_js` backend. +# saikuro-random's `wasm` engine enables the matching cargo feature (wired +# through adapters/rust, saikuro-runtime, and saikuro-tests' wasm32 deps). # -# The `no_std` engine (WASI preview1/preview2) relies on getrandom's built-in -# WASI backend, selected automatically by the target triple -- no cfg needed. +# WASI (preview1/preview2): the `no_std` engine uses getrandom's built-in WASI +# backend, picked automatically from the target triple. No cfg needed. # -# Bare-metal MCU targets no longer use getrandom at all: saikuro-random's -# `embedded` engine takes entropy from an application-provided `EntropySource` -# trait via `init_from`, so there is no `__getrandom_v03_custom` to link. The -# `custom` cfgs below are retained only for any direct getrandom usage and are -# inert for the embedded engine. +# Bare-metal MCU targets don't use getrandom at all. saikuro-random's +# `embedded` engine pulls entropy from an application-provided `EntropySource` +# via `init_from`, so there's no `__getrandom_v03_custom` symbol to link. The +# `custom` cfgs below only matter for direct getrandom usage and are inert for +# the embedded engine. # -# Host targets: leave the cfg alone and let getrandom use its normal OS backend. +# Host targets: leave the cfg alone; getrandom uses the OS backend. [target.wasm32-unknown-unknown] runner = "wasm-bindgen-test-runner" rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""] -# Bare-metal targets (retained for direct getrandom usage; the embedded engine -# uses the EntropySource trait instead): +# Bare-metal targets (kept for direct getrandom usage; the embedded engine uses +# EntropySource instead): # riscv32imc-unknown-none-elf ESP32-C3 # thumbv6m-none-eabi RP2040 # thumbv8m.main-none-eabihf RP2350 From 6b746537ec1b1cbe10c4250869df300f582f4561 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Thu, 13 Aug 2026 16:34:15 -0600 Subject: [PATCH 24/43] saikuro-log --- Build/Cargo.toml | 2 + Build/crates/saikuro-core/src/envelope.rs | 2 +- Build/crates/saikuro-core/src/lib.rs | 4 - Build/crates/saikuro-log/Cargo.toml | 38 ++++++++ Build/crates/saikuro-log/embedded/mod.rs | 1 + Build/crates/saikuro-log/embedded/serial.rs | 51 +++++++++++ Build/crates/saikuro-log/lib.rs | 46 ++++++++++ Build/crates/saikuro-log/native/mod.rs | 6 ++ Build/crates/saikuro-log/native/stderr.rs | 20 ++++ Build/crates/saikuro-log/native/tracing.rs | 25 +++++ Build/crates/saikuro-log/shared/level.rs | 23 +++++ Build/crates/saikuro-log/shared/mod.rs | 6 ++ .../log.rs => saikuro-log/shared/record.rs} | 91 +++---------------- Build/crates/saikuro-log/shared/ring.rs | 41 +++++++++ Build/crates/saikuro-log/shared/sink.rs | 41 +++++++++ Build/crates/saikuro-log/wasm/console.rs | 21 +++++ Build/crates/saikuro-log/wasm/mod.rs | 1 + Build/crates/saikuro-router/Cargo.toml | 1 + Build/crates/saikuro-router/src/lib.rs | 2 +- Build/crates/saikuro-router/src/router.rs | 68 +++++++------- 20 files changed, 370 insertions(+), 120 deletions(-) create mode 100644 Build/crates/saikuro-log/Cargo.toml create mode 100644 Build/crates/saikuro-log/embedded/mod.rs create mode 100644 Build/crates/saikuro-log/embedded/serial.rs create mode 100644 Build/crates/saikuro-log/lib.rs create mode 100644 Build/crates/saikuro-log/native/mod.rs create mode 100644 Build/crates/saikuro-log/native/stderr.rs create mode 100644 Build/crates/saikuro-log/native/tracing.rs create mode 100644 Build/crates/saikuro-log/shared/level.rs create mode 100644 Build/crates/saikuro-log/shared/mod.rs rename Build/crates/{saikuro-core/src/log.rs => saikuro-log/shared/record.rs} (55%) create mode 100644 Build/crates/saikuro-log/shared/ring.rs create mode 100644 Build/crates/saikuro-log/shared/sink.rs create mode 100644 Build/crates/saikuro-log/wasm/console.rs create mode 100644 Build/crates/saikuro-log/wasm/mod.rs diff --git a/Build/Cargo.toml b/Build/Cargo.toml index 41d77701..b2c92d12 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/saikuro-runtime", "crates/saikuro-exec", "crates/saikuro-net", + "crates/saikuro-log", "crates/saikuro-random", "crates/saikuro-codegen", "adapters/c", @@ -130,4 +131,5 @@ saikuro-runtime = { path = "crates/saikuro-runtime", default-features = false } saikuro-codegen = { path = "crates/saikuro-codegen" } saikuro-exec = { path = "crates/saikuro-exec", default-features = false } saikuro-random = { path = "crates/saikuro-random", default-features = false } +saikuro-log = { path = "crates/saikuro-log", default-features = false } saikuro = { path = "adapters/rust", default-features = false } diff --git a/Build/crates/saikuro-core/src/envelope.rs b/Build/crates/saikuro-core/src/envelope.rs index 06b772e0..ef35402a 100644 --- a/Build/crates/saikuro-core/src/envelope.rs +++ b/Build/crates/saikuro-core/src/envelope.rs @@ -63,7 +63,7 @@ pub enum InvocationType { /// Structured log record forwarded from an adapter to the runtime log sink. /// /// Log envelopes are never routed to a provider. The runtime extracts the - /// [`LogRecord`](crate::log::LogRecord) from `args[0]` and passes it to the + /// [`LogRecord`](saikuro_log::LogRecord) from `args[0]` and passes it to the /// configured log sink. No response envelope is sent. Log, /// Schema announcement sent by a provider immediately after connecting. diff --git a/Build/crates/saikuro-core/src/lib.rs b/Build/crates/saikuro-core/src/lib.rs index a07d2638..d1fb1a72 100644 --- a/Build/crates/saikuro-core/src/lib.rs +++ b/Build/crates/saikuro-core/src/lib.rs @@ -22,7 +22,6 @@ pub mod capability; pub mod envelope; pub mod error; pub mod invocation; -pub mod log; pub mod msgpack; pub mod registration; pub mod resource; @@ -34,9 +33,6 @@ pub use capability::{CapabilitySet, CapabilityToken}; pub use envelope::{split_target, Envelope, InvocationType, ResponseEnvelope}; pub use error::{ErrorCode, ErrorDetail, SaikuroError}; pub use invocation::InvocationId; -#[cfg(any(feature = "std", feature = "std-no-os"))] -pub use log::stderr_log_sink; -pub use log::{LogLevel, LogRecord, LogSink}; pub use registration::RegistrationToken; pub use resource::ResourceHandle; pub use value::Value; diff --git a/Build/crates/saikuro-log/Cargo.toml b/Build/crates/saikuro-log/Cargo.toml new file mode 100644 index 00000000..23e361ab --- /dev/null +++ b/Build/crates/saikuro-log/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "saikuro-log" +description = "Logging types and sinks for Saikuro" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords = ["ipc", "cross-language", "saikuro", "logging", "log"] + +[lib] +path = "lib.rs" + +[features] +default = ["std", "native", "stderr", "null", "filter"] +std = [] +native = ["std"] +no_std = [] +wasm = [] +embedded = ["dep:embedded-io-async", "dep:spin"] +stderr = ["native"] +tracing = ["native", "dep:tracing"] +console = ["wasm", "dep:web-sys"] +serial = ["embedded"] +collector = ["dep:spin"] +null = [] +filter = [] + +[dependencies] +saikuro-core = { workspace = true, default-features = false } +serde = { workspace = true } +serde_json = { workspace = true, default-features = false, features = ["alloc"] } +heapless = { workspace = true } +strum = { workspace = true } +tracing = { workspace = true, optional = true } +web-sys = { workspace = true, optional = true, features = ["console"] } +embedded-io-async = { workspace = true, optional = true } +spin = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-log/embedded/mod.rs b/Build/crates/saikuro-log/embedded/mod.rs new file mode 100644 index 00000000..b1fc0cf1 --- /dev/null +++ b/Build/crates/saikuro-log/embedded/mod.rs @@ -0,0 +1 @@ +pub mod serial; diff --git a/Build/crates/saikuro-log/embedded/serial.rs b/Build/crates/saikuro-log/embedded/serial.rs new file mode 100644 index 00000000..d7ed7633 --- /dev/null +++ b/Build/crates/saikuro-log/embedded/serial.rs @@ -0,0 +1,51 @@ +use core::fmt::Write as _; +use embedded_io_async::Write; +use heapless::String as HString; +use spin::Mutex; + +use crate::record::LogRecord; +use crate::sink::LogSink; + +/// A sink emitting [`LogRecord`]s as text lines on a serial writer. +/// +/// `W` is any `embedded-io-async` writer (a UART/USART driver). Records are +/// formatted into a fixed-capacity stack buffer and written asynchronously. +pub struct SerialSink { + writer: Mutex, +} + +impl SerialSink { + /// Wrap a serial writer. + pub fn new(writer: W) -> Self { + Self { + writer: Mutex::new(writer), + } + } +} + +impl LogSink for SerialSink { + async fn emit(&self, record: &LogRecord) { + let mut buf = HString::<512>::new(); + if core::fmt::write( + &mut buf, + format_args!("[{}] {} {}", record.ts, record.level, record.msg), + ) + .is_ok() + { + let bytes = buf.as_bytes(); + let mut offset = 0; + while offset < bytes.len() { + match self.writer.lock().write(&bytes[offset..]).await { + Ok(0) => break, + Ok(n) => offset += n, + Err(_) => break, + } + } + } + } +} + +/// Construct a [`SerialSink`] wrapping `writer`. +pub fn serial_log_sink(writer: W) -> SerialSink { + SerialSink::new(writer) +} diff --git a/Build/crates/saikuro-log/lib.rs b/Build/crates/saikuro-log/lib.rs new file mode 100644 index 00000000..d7a5d671 --- /dev/null +++ b/Build/crates/saikuro-log/lib.rs @@ -0,0 +1,46 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] + +//! Logging types and sinks for Saikuro. + +#[cfg(not(feature = "std"))] +extern crate alloc; +#[cfg(feature = "std")] +extern crate std; + +#[cfg(any( + all(feature = "native", any(feature = "no_std", feature = "wasm", feature = "embedded")), + all(feature = "no_std", any(feature = "native", feature = "wasm", feature = "embedded")), + all(feature = "wasm", any(feature = "native", feature = "no_std", feature = "embedded")), + all(feature = "embedded", any(feature = "native", feature = "no_std", feature = "wasm")) +))] +compile_error!("exactly one engine must be enabled: native | no_std | wasm | embedded"); + +#[cfg(all(feature = "std", feature = "no_std"))] +compile_error!("the no_std engine cannot be combined with the std toolchain"); + +#[cfg(not(any(feature = "native", feature = "no_std", feature = "wasm", feature = "embedded")))] +compile_error!("exactly one engine must be selected: native | no_std | wasm | embedded"); + +mod shared; +pub use shared::*; + +#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] +mod base; +#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] +pub use base::*; + +#[cfg(feature = "native")] +mod native; +#[cfg(feature = "native")] +pub use native::*; + +#[cfg(feature = "wasm")] +mod wasm; +#[cfg(feature = "wasm")] +pub use wasm::*; + +#[cfg(feature = "embedded")] +mod embedded; +#[cfg(feature = "embedded")] +pub use embedded::*; diff --git a/Build/crates/saikuro-log/native/mod.rs b/Build/crates/saikuro-log/native/mod.rs new file mode 100644 index 00000000..a527564c --- /dev/null +++ b/Build/crates/saikuro-log/native/mod.rs @@ -0,0 +1,6 @@ +//! Host (OS) logging sinks. + +pub mod stderr; + +#[cfg(feature = "tracing")] +pub mod tracing; diff --git a/Build/crates/saikuro-log/native/stderr.rs b/Build/crates/saikuro-log/native/stderr.rs new file mode 100644 index 00000000..5b5d5dd0 --- /dev/null +++ b/Build/crates/saikuro-log/native/stderr.rs @@ -0,0 +1,20 @@ +use serde_json; + +use crate::record::LogRecord; +use crate::sink::LogSink; + +/// A sink emitting [`LogRecord`]s as JSON lines on stderr. +pub struct StderrSink; + +impl LogSink for StderrSink { + async fn emit(&self, record: &LogRecord) { + if let Ok(json) = serde_json::to_string(record) { + std::eprintln!("{}", json); + } + } +} + +/// Construct a [`StderrSink`]. +pub fn stderr_log_sink() -> StderrSink { + StderrSink +} diff --git a/Build/crates/saikuro-log/native/tracing.rs b/Build/crates/saikuro-log/native/tracing.rs new file mode 100644 index 00000000..c79ab9bf --- /dev/null +++ b/Build/crates/saikuro-log/native/tracing.rs @@ -0,0 +1,25 @@ +use crate::level::LogLevel; +use crate::record::LogRecord; +use crate::sink::LogSink; + +/// A sink emitting [`LogRecord`]s through the `tracing` crate at the matching +/// level. +pub struct TracingSink; + +impl LogSink for TracingSink { + async fn emit(&self, record: &LogRecord) { + let line = format!("[{}] {}", record.name, record.msg); + match record.level { + LogLevel::Trace => tracing::trace!("{}", line), + LogLevel::Debug => tracing::debug!("{}", line), + LogLevel::Info => tracing::info!("{}", line), + LogLevel::Warn => tracing::warn!("{}", line), + LogLevel::Error => tracing::error!("{}", line), + } + } +} + +/// Construct a [`TracingSink`]. +pub fn tracing_log_sink() -> TracingSink { + TracingSink +} diff --git a/Build/crates/saikuro-log/shared/level.rs b/Build/crates/saikuro-log/shared/level.rs new file mode 100644 index 00000000..2d825a21 --- /dev/null +++ b/Build/crates/saikuro-log/shared/level.rs @@ -0,0 +1,23 @@ +//! Severity levels for log records. + +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +/// Severity level of a log record, ordered from least to most severe. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Display, EnumString +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum LogLevel { + /// Most verbose: fine-grained debugging trace. + Trace, + /// Debugging information. + Debug, + /// Informational messages. + Info, + /// Warnings: recoverable anomalies. + Warn, + /// Errors: an operation failed. + Error, +} diff --git a/Build/crates/saikuro-log/shared/mod.rs b/Build/crates/saikuro-log/shared/mod.rs new file mode 100644 index 00000000..66167517 --- /dev/null +++ b/Build/crates/saikuro-log/shared/mod.rs @@ -0,0 +1,6 @@ +pub mod level; +pub mod record; +pub mod sink; + +#[cfg(feature = "collector")] +pub mod ring; diff --git a/Build/crates/saikuro-core/src/log.rs b/Build/crates/saikuro-log/shared/record.rs similarity index 55% rename from Build/crates/saikuro-core/src/log.rs rename to Build/crates/saikuro-log/shared/record.rs index 1aa0cf1f..4830d949 100644 --- a/Build/crates/saikuro-core/src/log.rs +++ b/Build/crates/saikuro-log/shared/record.rs @@ -1,19 +1,12 @@ -//! Structured log record types for the Saikuro log-transport protocol. -//! -//! When an adapter wants to forward structured logs to the runtime (rather than -//! writing directly to its own stderr), it wraps a [`LogRecord`] in a standard -//! [`Envelope`](crate::envelope::Envelope) with -//! `invocation_type = InvocationType::Log` and places the serialised -//! `LogRecord` as the first element of `args`. -//! -//! The runtime's router intercepts `Log` envelopes before they reach a -//! provider and dispatches them to the configured [`LogSink`]. - -use alloc::{boxed::Box, string::String}; +use alloc::string::String; use core::fmt; +use core::str::FromStr; use serde::{Deserialize, Serialize}; -use crate::value::Value; +use saikuro_core::error::SaikuroError; +use saikuro_core::value::{Value, ValueMap}; + +use crate::level::LogLevel; /// Maximum number of structured context fields a [`LogRecord`] can carry. pub const LOG_FIELDS_CAPACITY: usize = 16; @@ -21,35 +14,6 @@ pub const LOG_FIELDS_CAPACITY: usize = 16; /// Fixed-capacity map of structured context fields on [`LogRecord`]. pub type LogFieldMap = heapless::FnvIndexMap; -// Log level - -/// Severity level of a log record, ordered from least to most severe. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - Serialize, - Deserialize, - strum::Display, - strum::EnumString, -)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum LogLevel { - Trace, - Debug, - Info, - Warn, - Error, -} - -// Log record - /// A structured log record forwarded from an adapter to the runtime log sink. /// /// The `fields` map holds any additional key/value context the emitting logger @@ -92,25 +56,23 @@ impl LogRecord { /// Add a structured field and return `self` for chaining. /// - /// Fails with [`crate::error::SaikuroError::CapacityExceeded`] if the - /// record is already at [`LOG_FIELDS_CAPACITY`] fields. + /// Fails with [`SaikuroError::CapacityExceeded`] if the record is already at + /// [`LOG_FIELDS_CAPACITY`] fields. pub fn with_field( mut self, key: impl Into, value: impl Into, - ) -> Result { + ) -> Result { let key = key.into(); self.fields.insert(key.clone(), value.into()).map_err(|_| { - crate::error::SaikuroError::CapacityExceeded(format!( - "log field bag full at key '{key}'" - )) + SaikuroError::CapacityExceeded(format!("log field bag full at key '{key}'")) })?; Ok(self) } } -/// Helper: extract a `Value::String` from a [`ValueMap`](crate::value::ValueMap) by key. -fn take_string(map: &mut crate::value::ValueMap, key: &str) -> Option { +/// Helper: extract a `Value::String` from a [`ValueMap`] by key. +fn take_string(map: &mut ValueMap, key: &str) -> Option { match map.remove(key) { Some(Value::String(s)) => Some(s), _ => None, @@ -127,7 +89,7 @@ impl TryFrom for LogRecord { let level = map .remove("level") .and_then(|v| match v { - Value::String(s) => LogLevel::try_from(s.as_str()).ok(), + Value::String(s) => LogLevel::from_str(s.as_str()).ok(), _ => None, }) .unwrap_or(LogLevel::Info); @@ -154,31 +116,6 @@ impl TryFrom for LogRecord { impl fmt::Display for LogRecord { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "[{}] {} {} : {}", - self.ts, self.level, self.name, self.msg - ) + write!(f, "[{}] {} {} : {}", self.ts, self.level, self.name, self.msg) } } - -// Log sink - -/// A callable that receives log records forwarded by adapters. -/// -/// Construct a concrete sink with [`stderr_log_sink`] (writes JSON lines to -/// stderr) or build your own by implementing the same signature. -/// -/// Higher-level crates (`saikuro-runtime`) provide a `tracing`-backed default. -pub type LogSink = Box; - -/// A simple log sink that serialises each [`LogRecord`] as a JSON line and -/// writes it to stderr. Used when no richer sink is configured. -#[cfg(any(feature = "std", feature = "std-no-os"))] -pub fn stderr_log_sink() -> LogSink { - Box::new(|record: LogRecord| { - if let Ok(json) = serde_json::to_string(&record) { - std::eprintln!("{}", json); - } - }) -} diff --git a/Build/crates/saikuro-log/shared/ring.rs b/Build/crates/saikuro-log/shared/ring.rs new file mode 100644 index 00000000..6cfd0dbe --- /dev/null +++ b/Build/crates/saikuro-log/shared/ring.rs @@ -0,0 +1,41 @@ +//! A bounded in-memory collector sink. + +use alloc::vec::Vec; +use spin::Mutex; + +use crate::record::LogRecord; +use crate::sink::LogSink; + +/// A sink that retains the most recent records in a ring buffer, for later +/// inspection (e.g. a host draining buffered logs from a constrained target). +/// +/// Bounded by `capacity`; once full, the oldest record is evicted. +pub struct RingSink { + buffer: Mutex>, + capacity: usize, +} + +impl RingSink { + /// Create a collector that retains up to `capacity` records. + pub fn new(capacity: usize) -> Self { + Self { + buffer: Mutex::new(Vec::new()), + capacity, + } + } + + /// Remove and return all buffered records. + pub fn drain(&self) -> Vec { + self.buffer.lock().drain(..).collect() + } +} + +impl LogSink for RingSink { + async fn emit(&self, record: &LogRecord) { + let mut buf = self.buffer.lock(); + if buf.len() >= self.capacity { + buf.remove(0); + } + buf.push(record.clone()); + } +} diff --git a/Build/crates/saikuro-log/shared/sink.rs b/Build/crates/saikuro-log/shared/sink.rs new file mode 100644 index 00000000..e88eef2f --- /dev/null +++ b/Build/crates/saikuro-log/shared/sink.rs @@ -0,0 +1,41 @@ +use crate::level::LogLevel; +use crate::record::LogRecord; + +/// A destination for [`LogRecord`]s. +pub trait LogSink { + /// Emit a single log record. + async fn emit(&self, record: &LogRecord); +} + +/// A sink that discards every record. +/// +/// Useful for benchmarks, silent embedded builds, and tests. +pub struct NullSink; + +impl LogSink for NullSink { + async fn emit(&self, _record: &LogRecord) {} +} + +/// Wraps another sink, forwarding only records at or above `min_level`. +/// +/// This is the simplest example of a *composable* sink: sinks can be layered +/// without knowing each other's internals. +pub struct LevelFilterSink { + inner: S, + min_level: LogLevel, +} + +impl LevelFilterSink { + /// Wrap `inner`, dropping records below `min_level`. + pub fn new(inner: S, min_level: LogLevel) -> Self { + Self { inner, min_level } + } +} + +impl LogSink for LevelFilterSink { + async fn emit(&self, record: &LogRecord) { + if record.level >= self.min_level { + self.inner.emit(record).await; + } + } +} diff --git a/Build/crates/saikuro-log/wasm/console.rs b/Build/crates/saikuro-log/wasm/console.rs new file mode 100644 index 00000000..1c2065d4 --- /dev/null +++ b/Build/crates/saikuro-log/wasm/console.rs @@ -0,0 +1,21 @@ +use serde_json; +use wasm_bindgen::JsValue; + +use crate::record::LogRecord; +use crate::sink::LogSink; + +/// A sink emitting [`LogRecord`]s as JSON lines on the browser console. +pub struct ConsoleSink; + +impl LogSink for ConsoleSink { + async fn emit(&self, record: &LogRecord) { + if let Ok(json) = serde_json::to_string(record) { + web_sys::console::log_1(&JsValue::from_str(&json)); + } + } +} + +/// Construct a [`ConsoleSink`]. +pub fn console_log_sink() -> ConsoleSink { + ConsoleSink +} diff --git a/Build/crates/saikuro-log/wasm/mod.rs b/Build/crates/saikuro-log/wasm/mod.rs new file mode 100644 index 00000000..5b9849fd --- /dev/null +++ b/Build/crates/saikuro-log/wasm/mod.rs @@ -0,0 +1 @@ +pub mod console; diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index 7286e4e4..dd01fe8b 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -17,6 +17,7 @@ embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/embedded"] saikuro-core = { path = "../saikuro-core", default-features = false } saikuro-schema = { workspace = true, default-features = false } saikuro-exec = { workspace = true, default-features = false } +saikuro-log = { workspace = true, default-features = false, features = ["tracing"] } async-trait = { workspace = true } thiserror = { workspace = true } diff --git a/Build/crates/saikuro-router/src/lib.rs b/Build/crates/saikuro-router/src/lib.rs index 6c732597..e2ef13ab 100644 --- a/Build/crates/saikuro-router/src/lib.rs +++ b/Build/crates/saikuro-router/src/lib.rs @@ -17,5 +17,5 @@ pub mod stream_state; pub use error::RouterError; pub use provider::{Provider, ProviderHandle, ProviderRegistry}; -pub use router::{tracing_log_sink, InvocationRouter, RouterConfig}; +pub use router::{InvocationRouter, RouterConfig}; pub use stream_state::{ChannelState, StreamState, StreamStateStore}; diff --git a/Build/crates/saikuro-router/src/router.rs b/Build/crates/saikuro-router/src/router.rs index 6b4cab5e..2c1ab896 100644 --- a/Build/crates/saikuro-router/src/router.rs +++ b/Build/crates/saikuro-router/src/router.rs @@ -19,9 +19,9 @@ use saikuro_core::{ envelope::{Envelope, InvocationType}, error::{ErrorDetail, SaikuroError}, invocation::InvocationId, - log::{LogLevel, LogRecord, LogSink}, ResponseEnvelope, }; +use saikuro_log::{LogLevel, LogRecord, LogSink, TracingSink, tracing_log_sink}; use saikuro_exec::{mpsc, oneshot, timeout, ChannelCapacity}; use tracing::{debug, instrument, warn}; @@ -57,51 +57,50 @@ impl Default for RouterConfig { } } -// Tracing-backed default log sink - -/// Construct a log sink that forwards [`LogRecord`]s into the `tracing` -/// infrastructure at the matching level. -/// -/// The logger `name` and `msg` are concatenated in the tracing event message -/// since `tracing` macros require a literal `target:`. -pub fn tracing_log_sink() -> LogSink { - Box::new(|record: LogRecord| { - // tracing macros need a string-literal target; we embed the name in - // the message instead so callers can still filter by it in log output. - let line = format!("[{}] {}", record.name, record.msg); - match record.level { - LogLevel::Trace => tracing::trace!("{}", line), - LogLevel::Debug => tracing::debug!("{}", line), - LogLevel::Info => tracing::info!("{}", line), - LogLevel::Warn => tracing::warn!("{}", line), - LogLevel::Error => tracing::error!("{}", line), - } - }) -} - // Router /// The central dispatch hub. /// /// `InvocationRouter` is cheap to clone : all state is `Arc`-wrapped inside /// the registries it references. -#[derive(Clone)] -pub struct InvocationRouter { +pub struct InvocationRouter { providers: ProviderRegistry, streams: StreamStateStore, config: RouterConfig, /// Sink for `Log`-type envelopes. Wrapped in `Arc` so `Clone` works. - log_sink: Arc, + log_sink: Arc, +} + +impl Clone for InvocationRouter { + fn clone(&self) -> Self { + Self { + providers: self.providers.clone(), + streams: self.streams.clone(), + config: self.config.clone(), + log_sink: self.log_sink.clone(), + } + } } -impl InvocationRouter { +impl InvocationRouter { pub fn new(providers: ProviderRegistry, config: RouterConfig) -> Self { Self::with_log_sink(providers, config, tracing_log_sink()) } + /// Create a router with the given providers and default config. + pub fn with_providers(providers: ProviderRegistry) -> Self { + Self::new(providers, RouterConfig::default()) + } +} + +impl InvocationRouter { /// Create a router with a custom log sink. - pub fn with_log_sink(providers: ProviderRegistry, config: RouterConfig, sink: LogSink) -> Self { - Self { + pub fn with_log_sink( + providers: ProviderRegistry, + config: RouterConfig, + sink: S2, + ) -> InvocationRouter { + InvocationRouter { providers, streams: StreamStateStore::new(), config, @@ -109,11 +108,6 @@ impl InvocationRouter { } } - /// Create a router with default config. - pub fn with_providers(providers: ProviderRegistry) -> Self { - Self::new(providers, RouterConfig::default()) - } - // State store access /// Access the shared [`StreamStateStore`] directly. @@ -150,7 +144,7 @@ impl InvocationRouter { // as a call and let the provider interpret the args. self.dispatch_call(envelope).await } - InvocationType::Log => self.dispatch_log(envelope), + InvocationType::Log => self.dispatch_log(envelope).await, InvocationType::Announce => { // Announce envelopes are handled by the connection layer before // reaching the router. If one leaks through here it is a no-op @@ -343,7 +337,7 @@ impl InvocationRouter { /// /// Extracts the [`LogRecord`] from `args[0]`, forwards it to the log sink, /// and returns `ok_empty`. Never touches a provider. - fn dispatch_log(&self, envelope: Envelope) -> ResponseEnvelope { + async fn dispatch_log(&self, envelope: Envelope) -> ResponseEnvelope { let id = envelope.id; // args[0] is the LogRecord as a Value::Map. @@ -361,7 +355,7 @@ impl InvocationRouter { match record { Some(r) => { - (self.log_sink)(r); + self.log_sink.emit(&r).await; } None => { warn!(%id, "log envelope has no valid LogRecord in args[0]; dropping"); From 98dc08b9c238138531ca132271e26826ba9b27bf Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Thu, 13 Aug 2026 21:24:43 -0600 Subject: [PATCH 25/43] saikuro-core --- Build/crates/saikuro-core/Cargo.toml | 15 +- Build/crates/saikuro-core/codec/mod.rs | 3 + .../saikuro-core/{src => codec}/msgpack.rs | 15 -- .../saikuro-core/{src => error}/error.rs | 39 ++--- Build/crates/saikuro-core/error/io.rs | 133 ++++++++++++++++++ Build/crates/saikuro-core/error/mod.rs | 5 + Build/crates/saikuro-core/lib.rs | 53 +++++++ .../{src => protocol}/envelope.rs | 16 --- .../{src => protocol}/invocation.rs | 9 +- Build/crates/saikuro-core/protocol/mod.rs | 9 ++ .../{src => protocol}/registration.rs | 2 - .../saikuro-core/{src => protocol}/schema.rs | 7 - Build/crates/saikuro-core/src/lib.rs | 42 ------ Build/crates/saikuro-core/{src => }/sync.rs | 51 +------ .../saikuro-core/{src => value}/capability.rs | 16 +-- Build/crates/saikuro-core/value/mod.rs | 7 + .../saikuro-core/{src => value}/resource.rs | 30 ---- .../saikuro-core/{src => value}/value.rs | 9 -- 18 files changed, 251 insertions(+), 210 deletions(-) create mode 100644 Build/crates/saikuro-core/codec/mod.rs rename Build/crates/saikuro-core/{src => codec}/msgpack.rs (79%) rename Build/crates/saikuro-core/{src => error}/error.rs (91%) create mode 100644 Build/crates/saikuro-core/error/io.rs create mode 100644 Build/crates/saikuro-core/error/mod.rs create mode 100644 Build/crates/saikuro-core/lib.rs rename Build/crates/saikuro-core/{src => protocol}/envelope.rs (94%) rename Build/crates/saikuro-core/{src => protocol}/invocation.rs (89%) create mode 100644 Build/crates/saikuro-core/protocol/mod.rs rename Build/crates/saikuro-core/{src => protocol}/registration.rs (95%) rename Build/crates/saikuro-core/{src => protocol}/schema.rs (96%) delete mode 100644 Build/crates/saikuro-core/src/lib.rs rename Build/crates/saikuro-core/{src => }/sync.rs (65%) rename Build/crates/saikuro-core/{src => value}/capability.rs (85%) create mode 100644 Build/crates/saikuro-core/value/mod.rs rename Build/crates/saikuro-core/{src => value}/resource.rs (80%) rename Build/crates/saikuro-core/{src => value}/value.rs (94%) diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index d78427f5..d7f61d7d 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -9,12 +9,17 @@ repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "rpc", "msgpack"] [features] -default = ["std"] -std = ["saikuro-random/native"] -std-no-os = ["saikuro-random/wasm"] -custom = ["saikuro-random/embedded"] +default = ["std", "native"] +std = [] +native = ["std", "saikuro-random/native"] +no_std = ["saikuro-random/no_std"] +wasm = ["saikuro-random/wasm"] embedded = ["saikuro-random/embedded"] -wasi = ["saikuro-random/no_std"] +custom = ["embedded"] +drbg = ["embedded"] + +[lib] +path = "lib.rs" [dependencies] serde = { workspace = true } diff --git a/Build/crates/saikuro-core/codec/mod.rs b/Build/crates/saikuro-core/codec/mod.rs new file mode 100644 index 00000000..b09576b8 --- /dev/null +++ b/Build/crates/saikuro-core/codec/mod.rs @@ -0,0 +1,3 @@ +pub mod msgpack; + +pub use msgpack::*; diff --git a/Build/crates/saikuro-core/src/msgpack.rs b/Build/crates/saikuro-core/codec/msgpack.rs similarity index 79% rename from Build/crates/saikuro-core/src/msgpack.rs rename to Build/crates/saikuro-core/codec/msgpack.rs index b4f5f2d4..643dda25 100644 --- a/Build/crates/saikuro-core/src/msgpack.rs +++ b/Build/crates/saikuro-core/codec/msgpack.rs @@ -1,18 +1,3 @@ -//! MessagePack codec -//! -//! All Saikuro wire encoding goes through this module so host, wasm, and MCU -//! targets emit identical bytes. The underlying encoder is `messagepack-serde`, -//! a `no_std` + alloc MessagePack serializer, so these helpers are available on -//! every build target (the previous rmp-serde codec was std-only). -//! -//! Encoding always uses [`RmpCompatible`], which reproduces the reference -//! rmp-serde byte format exactly: integers are minimized to the smallest -//! representation that holds them and floats keep their native width. -//! `messagepack-serde`'s default `LosslessMinimize` config downcasts `f64` -//! values that fit exactly in `f32`, which would silently change the wire -//! format for `Value::Float`; `RmpCompatible` restores rmp-serde's behavior. -//! Tests in the workspace use rmp-serde as a reference implementation - use alloc::vec::Vec; use core::convert::Infallible; use messagepack_serde::{ diff --git a/Build/crates/saikuro-core/src/error.rs b/Build/crates/saikuro-core/error/error.rs similarity index 91% rename from Build/crates/saikuro-core/src/error.rs rename to Build/crates/saikuro-core/error/error.rs index 35fff910..48009c44 100644 --- a/Build/crates/saikuro-core/src/error.rs +++ b/Build/crates/saikuro-core/error/error.rs @@ -1,18 +1,9 @@ -//! Error types for the Saikuro system. -//! -//! Errors are modelled at two levels: -//! -//! 1. **[`SaikuroError`]** : the Rust `Error`-implementing type -//! used throughout the runtime for fallible operations. -//! 2. **[`ErrorDetail`]** : the wire representation serialised into -//! [`ResponseEnvelope`] when an invocation fails. This is what remote -//! adapters receive and surface to their callers. - use alloc::string::{String, ToString}; use core::fmt; use serde::{Deserialize, Serialize}; use thiserror::Error; +use crate::io::{IoError, IoErrorKind}; use crate::value::Value; /// Maximum number of structured context entries an [`ErrorDetail`] can carry. @@ -21,10 +12,7 @@ pub const ERROR_DETAIL_CAPACITY: usize = 16; /// Fixed-capacity map of structured context entries on [`ErrorDetail`]. pub type DetailMap = heapless::FnvIndexMap; -/// Machine-readable error codes transmitted on the wire. -/// -/// Each variant maps to a distinct failure category so that adapters can -/// handle them appropriately without string parsing. +/// All error codes transmitted on the wire. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum ErrorCode { @@ -217,9 +205,8 @@ pub enum SaikuroError { MsgpackDecode(#[from] crate::msgpack::DecodeError), // I/O - #[cfg(any(feature = "std", feature = "std-no-os"))] #[error("I/O error: {0}")] - Io(#[from] std::io::Error), + Io(IoError), /// A fixed-capacity map reached its compile-time limit /// (e.g. [`crate::value::VALUE_MAP_CAPACITY`]). @@ -254,8 +241,13 @@ impl From for ErrorDetail { SaikuroError::ChannelClosed => ErrorCode::ChannelClosed, SaikuroError::OutOfOrder { .. } => ErrorCode::OutOfOrder, SaikuroError::MsgpackEncode(_) | SaikuroError::MsgpackDecode(_) => ErrorCode::Internal, - #[cfg(any(feature = "std", feature = "std-no-os"))] - SaikuroError::Io(_) => ErrorCode::Internal, + SaikuroError::Io(e) => match e.kind { + IoErrorKind::TimedOut => ErrorCode::Timeout, + IoErrorKind::ConnectionReset + | IoErrorKind::ConnectionAborted + | IoErrorKind::ConnectionRefused => ErrorCode::ConnectionLost, + _ => ErrorCode::Internal, + }, SaikuroError::CapacityExceeded(_) | SaikuroError::Internal(_) => ErrorCode::Internal, }; @@ -263,5 +255,16 @@ impl From for ErrorDetail { } } +/// Convert a host `std::io::Error` into the unified error type. +/// +/// Only available when the `std` toolchain is present; on no_std targets +/// I/O failures are constructed directly from [`IoError`]. +#[cfg(feature = "std")] +impl From for SaikuroError { + fn from(err: std::io::Error) -> Self { + SaikuroError::Io(err.into()) + } +} + /// Convenience alias for `Result`. pub type Result = core::result::Result; diff --git a/Build/crates/saikuro-core/error/io.rs b/Build/crates/saikuro-core/error/io.rs new file mode 100644 index 00000000..20713c0a --- /dev/null +++ b/Build/crates/saikuro-core/error/io.rs @@ -0,0 +1,133 @@ +use alloc::string::{String, ToString}; +use core::fmt; + +use serde::{Deserialize, Serialize}; + +/// Classification of an I/O failure +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum IoErrorKind { + /// An entity was not found. + NotFound, + /// The operation lacked the necessary permissions. + PermissionDenied, + /// An entity already exists. + AlreadyExists, + /// The connection was refused. + ConnectionRefused, + /// The connection was reset by the remote side. + ConnectionReset, + /// The connection was aborted by the remote side. + ConnectionAborted, + /// The endpoint was not connected. + NotConnected, + /// A network address was already in use. + AddrInUse, + /// A network address was not available. + AddrNotAvailable, + /// The operating-system pipe was closed. + BrokenPipe, + /// The operation would block, but the caller asked for non-blocking. + WouldBlock, + /// Invalid input argument. + InvalidInput, + /// Invalid data encountered. + InvalidData, + /// The operation timed out. + TimedOut, + /// A write to a closed pipe or socket returned zero bytes. + WriteZero, + /// The operation was interrupted before completion. + Interrupted, + /// An unexpected end of file was encountered. + UnexpectedEof, + /// A memory allocation failed. + OutOfMemory, + /// The operation is not supported on this target. + Unsupported, + /// A category not covered by the variants above. + Other, +} + +impl fmt::Display for IoErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + IoErrorKind::NotFound => "not found", + IoErrorKind::PermissionDenied => "permission denied", + IoErrorKind::AlreadyExists => "entity already exists", + IoErrorKind::ConnectionRefused => "connection refused", + IoErrorKind::ConnectionReset => "connection reset", + IoErrorKind::ConnectionAborted => "connection aborted", + IoErrorKind::NotConnected => "not connected", + IoErrorKind::AddrInUse => "address in use", + IoErrorKind::AddrNotAvailable => "address not available", + IoErrorKind::BrokenPipe => "broken pipe", + IoErrorKind::WouldBlock => "operation would block", + IoErrorKind::InvalidInput => "invalid input", + IoErrorKind::InvalidData => "invalid data", + IoErrorKind::TimedOut => "timed out", + IoErrorKind::WriteZero => "write zero", + IoErrorKind::Interrupted => "operation interrupted", + IoErrorKind::UnexpectedEof => "unexpected end of file", + IoErrorKind::OutOfMemory => "out of memory", + IoErrorKind::Unsupported => "unsupported", + IoErrorKind::Other => "other I/O error", + }; + f.write_str(name) + } +} + +/// A portable I/O error: an [`IoErrorKind`] plus an optional human message. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IoError { + /// The failure category. + pub kind: IoErrorKind, + /// Optional free-form description, when the platform can supply one. + pub message: Option, +} + +impl fmt::Display for IoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.message { + Some(msg) => write!(f, "{}: {}", self.kind, msg), + None => write!(f, "{}", self.kind), + } + } +} + +#[cfg(feature = "std")] +impl From for IoErrorKind { + fn from(kind: std::io::ErrorKind) -> Self { + match kind { + std::io::ErrorKind::NotFound => IoErrorKind::NotFound, + std::io::ErrorKind::PermissionDenied => IoErrorKind::PermissionDenied, + std::io::ErrorKind::AlreadyExists => IoErrorKind::AlreadyExists, + std::io::ErrorKind::ConnectionRefused => IoErrorKind::ConnectionRefused, + std::io::ErrorKind::ConnectionReset => IoErrorKind::ConnectionReset, + std::io::ErrorKind::ConnectionAborted => IoErrorKind::ConnectionAborted, + std::io::ErrorKind::NotConnected => IoErrorKind::NotConnected, + std::io::ErrorKind::AddrInUse => IoErrorKind::AddrInUse, + std::io::ErrorKind::AddrNotAvailable => IoErrorKind::AddrNotAvailable, + std::io::ErrorKind::BrokenPipe => IoErrorKind::BrokenPipe, + std::io::ErrorKind::WouldBlock => IoErrorKind::WouldBlock, + std::io::ErrorKind::InvalidInput => IoErrorKind::InvalidInput, + std::io::ErrorKind::InvalidData => IoErrorKind::InvalidData, + std::io::ErrorKind::TimedOut => IoErrorKind::TimedOut, + std::io::ErrorKind::WriteZero => IoErrorKind::WriteZero, + std::io::ErrorKind::Interrupted => IoErrorKind::Interrupted, + std::io::ErrorKind::UnexpectedEof => IoErrorKind::UnexpectedEof, + std::io::ErrorKind::OutOfMemory => IoErrorKind::OutOfMemory, + std::io::ErrorKind::Unsupported => IoErrorKind::Unsupported, + _ => IoErrorKind::Other, + } + } +} + +#[cfg(feature = "std")] +impl From for IoError { + fn from(err: std::io::Error) -> Self { + IoError { + kind: err.kind().into(), + message: Some(err.to_string()), + } + } +} diff --git a/Build/crates/saikuro-core/error/mod.rs b/Build/crates/saikuro-core/error/mod.rs new file mode 100644 index 00000000..16460402 --- /dev/null +++ b/Build/crates/saikuro-core/error/mod.rs @@ -0,0 +1,5 @@ +pub mod error; +pub mod io; + +pub use error::*; +pub use io::*; diff --git a/Build/crates/saikuro-core/lib.rs b/Build/crates/saikuro-core/lib.rs new file mode 100644 index 00000000..1d4d667e --- /dev/null +++ b/Build/crates/saikuro-core/lib.rs @@ -0,0 +1,53 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +#[macro_use] +extern crate alloc; + +mod error; +pub use error::*; + +mod protocol; +pub use protocol::*; + +mod value; +pub use value::*; + +mod codec; +pub use codec::*; + +mod sync; +pub use sync::*; + +// Engine selection guard +// Exactly one engine feature must be enabled. The engine selects the +// platform's entropy backend (via `saikuro-random`) and, for `native`, the +// `std` toolchain. `std` is orthogonal and may only be combined with `native`. +#[cfg(all( + feature = "native", + any(feature = "wasm", feature = "embedded", feature = "no_std") +))] +compile_error!("saikuro-core: only one engine feature (native/wasm/embedded/no_std) may be enabled"); + +#[cfg(all( + feature = "wasm", + any(feature = "embedded", feature = "no_std") +))] +compile_error!("saikuro-core: only one engine feature (native/wasm/embedded/no_std) may be enabled"); + +#[cfg(all(feature = "embedded", feature = "no_std"))] +compile_error!("saikuro-core: only one engine feature (native/wasm/embedded/no_std) may be enabled"); + +#[cfg(not(any( + feature = "native", + feature = "wasm", + feature = "embedded", + feature = "no_std" +)))] +compile_error!("saikuro-core: an engine feature (native/wasm/embedded/no_std) must be enabled"); + +#[cfg(all(feature = "std", feature = "no_std"))] +compile_error!("saikuro-core: the `std` toolchain flag is incompatible with the `no_std` engine"); + +/// Wire-level protocol version. All envelopes carry this; the runtime +/// rejects messages with an incompatible version. +pub const PROTOCOL_VERSION: u32 = 1; diff --git a/Build/crates/saikuro-core/src/envelope.rs b/Build/crates/saikuro-core/protocol/envelope.rs similarity index 94% rename from Build/crates/saikuro-core/src/envelope.rs rename to Build/crates/saikuro-core/protocol/envelope.rs index ef35402a..3488c19e 100644 --- a/Build/crates/saikuro-core/src/envelope.rs +++ b/Build/crates/saikuro-core/protocol/envelope.rs @@ -1,10 +1,3 @@ -//! Wire-level envelope types. -//! -//! Every message exchanged between a language adapter and the Saikuro runtime -//! is wrapped in an [`Envelope`] or [`ResponseEnvelope`]. Envelopes are -//! serialised to binary using MessagePack via `crate::msgpack` before transit; -//! the types here are the canonical in-memory representation. - use alloc::{string::String, vec::Vec}; use serde::{ ser::{SerializeMap, Serializer}, @@ -23,10 +16,6 @@ pub type MetaMap = heapless::FnvIndexMap; /// Serialize the metadata map with keys sorted, so equivalent metadata always /// produces identical bytes regardless of the caller's insertion order. -/// -/// `MetaMap` is an insertion-ordered `FnvIndexMap`, so serde would otherwise -/// emit keys in insertion order and two semantically-equal envelopes could -/// differ on the wire. fn serialize_meta(meta: &MetaMap, serializer: S) -> Result where S: Serializer, @@ -41,9 +30,6 @@ where } /// The type of an outgoing invocation. -/// -/// This is the primary discriminator that tells the runtime and the -/// recipient adapter how to handle a message. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] @@ -92,8 +78,6 @@ pub enum StreamControl { /// The outbound envelope carrying a single invocation from an adapter to /// the runtime, or from the runtime to a provider adapter. -/// -/// Fields follow the spec exactly. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Envelope { /// Protocol version : must equal [`PROTOCOL_VERSION`]. diff --git a/Build/crates/saikuro-core/src/invocation.rs b/Build/crates/saikuro-core/protocol/invocation.rs similarity index 89% rename from Build/crates/saikuro-core/src/invocation.rs rename to Build/crates/saikuro-core/protocol/invocation.rs index 09f7f582..b52dc223 100644 --- a/Build/crates/saikuro-core/src/invocation.rs +++ b/Build/crates/saikuro-core/protocol/invocation.rs @@ -1,10 +1,3 @@ -//! Globally-unique invocation identifiers. -//! -//! Every invocation : whether a call, cast, stream open, or channel open : -//! carries an [`InvocationId`]. Responses are correlated back to their -//! originating invocation using this identifier. UUIDs v4 are used to ensure -//! global uniqueness without coordination. - use alloc::{ string::{String, ToString}, vec::Vec, @@ -18,7 +11,7 @@ use uuid::Uuid; /// /// Internally this is a UUID v4 represented as a compact 16-byte array for /// efficient wire encoding via MessagePack. The `Display` and `Debug` -/// implementations render it as the canonical hyphenated UUID string. +/// implementations render it as a hyphenated UUID string. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct InvocationId(Uuid); diff --git a/Build/crates/saikuro-core/protocol/mod.rs b/Build/crates/saikuro-core/protocol/mod.rs new file mode 100644 index 00000000..4e3319d6 --- /dev/null +++ b/Build/crates/saikuro-core/protocol/mod.rs @@ -0,0 +1,9 @@ +pub mod envelope; +pub mod invocation; +pub mod registration; +pub mod schema; + +pub use envelope::*; +pub use invocation::*; +pub use registration::*; +pub use schema::*; diff --git a/Build/crates/saikuro-core/src/registration.rs b/Build/crates/saikuro-core/protocol/registration.rs similarity index 95% rename from Build/crates/saikuro-core/src/registration.rs rename to Build/crates/saikuro-core/protocol/registration.rs index 5064688f..3d82a0be 100644 --- a/Build/crates/saikuro-core/src/registration.rs +++ b/Build/crates/saikuro-core/protocol/registration.rs @@ -1,5 +1,3 @@ -//! Process-unique provider registration identity. - use portable_atomic::{AtomicU64, Ordering}; static NEXT_REGISTRATION_TOKEN: AtomicU64 = AtomicU64::new(1); diff --git a/Build/crates/saikuro-core/src/schema.rs b/Build/crates/saikuro-core/protocol/schema.rs similarity index 96% rename from Build/crates/saikuro-core/src/schema.rs rename to Build/crates/saikuro-core/protocol/schema.rs index 2ad96601..e838733c 100644 --- a/Build/crates/saikuro-core/src/schema.rs +++ b/Build/crates/saikuro-core/protocol/schema.rs @@ -1,10 +1,3 @@ -//! Schema definition types. -//! -//! These are the *data* types that describe the contract between providers -//! and callers. They are kept in `saikuro-core` so that any crate in the -//! workspace can read schemas without depending on the heavier validation -//! and registry machinery in `saikuro-schema`. - use alloc::{boxed::Box, string::String, vec::Vec}; use serde::{Deserialize, Serialize}; diff --git a/Build/crates/saikuro-core/src/lib.rs b/Build/crates/saikuro-core/src/lib.rs deleted file mode 100644 index d1fb1a72..00000000 --- a/Build/crates/saikuro-core/src/lib.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Saikuro Core -//! -//! Foundational protocol types, envelope structures, and error definitions -//! for the Saikuro cross-language invocation fabric. Every other crate -//! in the workspace depends on this one; it purposely has minimal dependencies -//! and zero async code so it can be embedded anywhere. -//! -//! The crate is always `#![no_std]` + `alloc`: strings and vectors come from -//! `alloc`, and all maps/sets are fixed-capacity `heapless` collections. The -//! msgpack codec is available on every target; the default `std` feature adds -//! the `Io` error variant, a stderr log sink, and std-backed sync primitives. - -#![no_std] - -#[macro_use] -extern crate alloc; - -#[cfg(any(feature = "std", feature = "std-no-os"))] -extern crate std; - -pub mod capability; -pub mod envelope; -pub mod error; -pub mod invocation; -pub mod msgpack; -pub mod registration; -pub mod resource; -pub mod schema; -pub mod sync; -pub mod value; - -pub use capability::{CapabilitySet, CapabilityToken}; -pub use envelope::{split_target, Envelope, InvocationType, ResponseEnvelope}; -pub use error::{ErrorCode, ErrorDetail, SaikuroError}; -pub use invocation::InvocationId; -pub use registration::RegistrationToken; -pub use resource::ResourceHandle; -pub use value::Value; - -/// Wire-level protocol version. All envelopes carry this; the runtime -/// rejects messages with an incompatible version. -pub const PROTOCOL_VERSION: u32 = 1; diff --git a/Build/crates/saikuro-core/src/sync.rs b/Build/crates/saikuro-core/sync.rs similarity index 65% rename from Build/crates/saikuro-core/src/sync.rs rename to Build/crates/saikuro-core/sync.rs index 5b62fea5..b7bad819 100644 --- a/Build/crates/saikuro-core/src/sync.rs +++ b/Build/crates/saikuro-core/sync.rs @@ -1,49 +1,10 @@ -//! Blocking synchronization primitives for the no_std tiers. -//! -//! `RwLock` and `Mutex` are thin wrappers over two backends: -//! -//! - `std` builds use `std::sync` locks, which park the OS thread while -//! contended; -//! - `no_std` builds use spinlocks from the `spin` crate. -//! -//! Both backends expose the same guard-based API, so downstream crates -//! (`saikuro-schema`, `saikuro-router`) can share one code path between host -//! and MCU targets. The guards are only ever held for short map mutations; -//! they are never held across an `await`. -//! -//! # Task-context restriction (no_std) -//! -//! On `no_std` builds these locks are spinlocks: acquisition busy-waits and -//! never yields or sleeps. They are only safe when all three conditions hold: -//! -//! - the guard is never held across an `.await`. A task that yields while -//! holding a spinlock stalls any other task that spins on it, and on a -//! single-core cooperative executor such as Embassy that is a deadlock; -//! - the critical section is short and does not re-enter the same lock or call -//! anything that could yield or run another task; -//! - no interrupt context acquires a lock that a task can hold while interrupts -//! are enabled: a preempting ISR spinning on a held lock never makes -//! progress. Data shared with interrupts must use a -//! `critical-section`-backed primitive instead. -//! -//! `saikuro-router` uses these locks only for short synchronous map mutations -//! that never await, which satisfies the restriction. Async locks belong in -//! `saikuro-exec`, whose Embassy backend binds them to -//! `CriticalSectionRawMutex` so awaiters park instead of spinning. -//! -//! Poisoning behaviour is backend-specific. `std::sync::Mutex` and -//! `std::sync::RwLock` write guards become poisoned if a panic unwinds while -//! they are held, and the next acquisition then panics, surfacing the bug -//! immediately. `std::sync::RwLock` read guards never poison a lock, and the -//! `spin` guards used on no_std builds expose no poison state at all. - use core::fmt; use core::ops::{Deref, DerefMut}; -#[cfg(any(feature = "std", feature = "std-no-os"))] +#[cfg(feature = "std")] use std::sync as imp; -#[cfg(not(any(feature = "std", feature = "std-no-os")))] +#[cfg(not(feature = "std"))] use spin as imp; /// A reader-writer lock. `read`/`write` return guards that deref to the @@ -86,7 +47,7 @@ trait MutexAccess { fn lock_guard(&self) -> imp::MutexGuard<'_, T>; } -#[cfg(any(feature = "std", feature = "std-no-os"))] +#[cfg(feature = "std")] impl RwLockAccess for imp::RwLock { fn read_guard(&self) -> imp::RwLockReadGuard<'_, T> { self.read() @@ -99,7 +60,7 @@ impl RwLockAccess for imp::RwLock { } } -#[cfg(any(feature = "std", feature = "std-no-os"))] +#[cfg(feature = "std")] impl MutexAccess for imp::Mutex { fn lock_guard(&self) -> imp::MutexGuard<'_, T> { self.lock() @@ -107,7 +68,7 @@ impl MutexAccess for imp::Mutex { } } -#[cfg(not(any(feature = "std", feature = "std-no-os")))] +#[cfg(not(feature = "std"))] impl RwLockAccess for imp::RwLock { fn read_guard(&self) -> imp::RwLockReadGuard<'_, T> { self.read() @@ -118,7 +79,7 @@ impl RwLockAccess for imp::RwLock { } } -#[cfg(not(any(feature = "std", feature = "std-no-os")))] +#[cfg(not(feature = "std"))] impl MutexAccess for imp::Mutex { fn lock_guard(&self) -> imp::MutexGuard<'_, T> { self.lock() diff --git a/Build/crates/saikuro-core/src/capability.rs b/Build/crates/saikuro-core/value/capability.rs similarity index 85% rename from Build/crates/saikuro-core/src/capability.rs rename to Build/crates/saikuro-core/value/capability.rs index 2b24fb2b..209fb4b9 100644 --- a/Build/crates/saikuro-core/src/capability.rs +++ b/Build/crates/saikuro-core/value/capability.rs @@ -1,14 +1,3 @@ -//! Capability tokens and sets. -//! -//! The Saikuro security system is built around capabilities: named, opaque -//! tokens that function declarations require and callers must present. The -//! runtime validates tokens at invocation time; no token matching a required -//! capability means the call is rejected with [`ErrorCode::CapabilityDenied`]. -//! -//! A [`CapabilityToken`] is a string like `"math.basic"` or `"admin.write"`. -//! A [`CapabilitySet`] is the collection of tokens held by a connected peer, -//! issued during the handshake phase. - use alloc::string::String; use core::fmt; use serde::{Deserialize, Serialize}; @@ -22,11 +11,12 @@ pub const CAPABILITY_SET_CAPACITY: usize = 256; /// Fixed-capacity set of capability tokens held by a peer. pub type TokenSet = heapless::FnvIndexSet; -/// A single capability token : a namespaced, human-readable permission string. +/// A single capability token: a namespaced, human-readable permission string. /// /// By convention tokens are dot-separated: `"."`. /// The runtime treats them as opaque strings; no hierarchical wildcard -/// expansion is performed in v1 (exact match only). +/// expansion is performed yet (exact match only). +/// TODO: Implement hierarchical wildcard expansion. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct CapabilityToken(pub String); diff --git a/Build/crates/saikuro-core/value/mod.rs b/Build/crates/saikuro-core/value/mod.rs new file mode 100644 index 00000000..ccf969fd --- /dev/null +++ b/Build/crates/saikuro-core/value/mod.rs @@ -0,0 +1,7 @@ +pub mod value; +pub mod capability; +pub mod resource; + +pub use value::*; +pub use capability::*; +pub use resource::*; diff --git a/Build/crates/saikuro-core/src/resource.rs b/Build/crates/saikuro-core/value/resource.rs similarity index 80% rename from Build/crates/saikuro-core/src/resource.rs rename to Build/crates/saikuro-core/value/resource.rs index e275626a..f10248c2 100644 --- a/Build/crates/saikuro-core/src/resource.rs +++ b/Build/crates/saikuro-core/value/resource.rs @@ -1,33 +1,3 @@ -//! Resource handle type. -//! -//! A [`ResourceHandle`] is an opaque reference to large or external data that -//! is too expensive to inline in a regular response: a file on disk, a blob -//! in object storage, a database cursor, etc. -//! -//! The handle carries enough metadata for the recipient to: -//! - Identify the resource uniquely (`id`) -//! - Know how large it is without fetching it (`size`) -//! - Know its content type (`mime_type`) -//! - Optionally open it via a well-known URI scheme (`uri`) -//! -//! Handles are opaque to the Saikuro runtime: the runtime routes the `Resource` -//! envelope to the provider and returns whatever the provider placed in the -//! response `result` field. The adapter is responsible for presenting a typed -//! [`ResourceHandle`] to its callers. -//! -//! # Wire format -//! -//! A `ResourceHandle` is serialised as a flat MessagePack map: -//! -//! ```json -//! { -//! "id": "", -//! "mime_type": "application/octet-stream", // optional -//! "size": 12345, // optional, bytes -//! "uri": "saikuro://res/" // optional -//! } -//! ``` - use alloc::{borrow::ToOwned, boxed::Box, string::String}; use core::fmt; use serde::{Deserialize, Serialize}; diff --git a/Build/crates/saikuro-core/src/value.rs b/Build/crates/saikuro-core/value/value.rs similarity index 94% rename from Build/crates/saikuro-core/src/value.rs rename to Build/crates/saikuro-core/value/value.rs index 18adf924..a4e1ccdd 100644 --- a/Build/crates/saikuro-core/src/value.rs +++ b/Build/crates/saikuro-core/value/value.rs @@ -1,12 +1,3 @@ -//! Dynamic value type used across the wire. -//! -//! Saikuro carries typed arguments on the wire, but the runtime must be able -//! to handle values whose exact Rust type is not known at compile time. -//! [`Value`] is the universal representation that can model every type in the -//! Saikuro type system, round-trip through MessagePack without loss (bounded -//! by [`VALUE_MAP_CAPACITY`] for map values), and be validated against a -//! schema field descriptor. - use alloc::{borrow::ToOwned, boxed::Box, string::String, vec::Vec}; use serde::{ser::SerializeMap, Deserialize, Serialize, Serializer}; From dbcddc193bcf33f51f7b72fb918cbd74f7c467c3 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Thu, 13 Aug 2026 23:03:48 -0600 Subject: [PATCH 26/43] cleanup --- Build/adapters/c/src/lib.rs | 2 +- Build/crates/saikuro-core/Cargo.toml | 2 - Build/crates/saikuro-core/codec/msgpack.rs | 3 +- Build/crates/saikuro-core/error/error.rs | 119 +++++++++--------- Build/crates/saikuro-core/lib.rs | 3 - .../crates/saikuro-core/protocol/envelope.rs | 36 +----- .../saikuro-core/protocol/registration.rs | 3 +- Build/crates/saikuro-core/protocol/schema.rs | 24 ++-- Build/crates/saikuro-core/sync/mod.rs | 3 + Build/crates/saikuro-core/{ => sync}/sync.rs | 92 ++++---------- Build/crates/saikuro-core/value/capability.rs | 13 +- Build/crates/saikuro-core/value/resource.rs | 14 +-- Build/crates/saikuro-core/value/value.rs | 35 +----- Build/crates/saikuro-log/Cargo.toml | 9 +- 14 files changed, 109 insertions(+), 249 deletions(-) create mode 100644 Build/crates/saikuro-core/sync/mod.rs rename Build/crates/saikuro-core/{ => sync}/sync.rs (61%) diff --git a/Build/adapters/c/src/lib.rs b/Build/adapters/c/src/lib.rs index 8dcb6536..a70be509 100644 --- a/Build/adapters/c/src/lib.rs +++ b/Build/adapters/c/src/lib.rs @@ -115,7 +115,7 @@ fn parse_json_object_arg( } } -// C API helpers factor out the null-check / cast / error pattern +// C API helpers factor out the null-check / cast / error pattern macro_rules! ok_or_ptr { ($expr:expr) => { diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index d7f61d7d..911867f3 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -15,8 +15,6 @@ native = ["std", "saikuro-random/native"] no_std = ["saikuro-random/no_std"] wasm = ["saikuro-random/wasm"] embedded = ["saikuro-random/embedded"] -custom = ["embedded"] -drbg = ["embedded"] [lib] path = "lib.rs" diff --git a/Build/crates/saikuro-core/codec/msgpack.rs b/Build/crates/saikuro-core/codec/msgpack.rs index 643dda25..fe3d15b2 100644 --- a/Build/crates/saikuro-core/codec/msgpack.rs +++ b/Build/crates/saikuro-core/codec/msgpack.rs @@ -101,8 +101,7 @@ pub type EncodeError = messagepack_serde::ser::Error; /// Decoding error produced by [`from_slice`]. pub type DecodeError = messagepack_serde::de::Error; -/// Serialize a value to MessagePack bytes using the rmp-serde-compatible -/// encoding. +/// Serialize a value to MessagePack bytes. pub fn to_vec(value: &T) -> Result, EncodeError> { messagepack_serde::ser::to_vec_with_config(value, RmpCompatible) } diff --git a/Build/crates/saikuro-core/error/error.rs b/Build/crates/saikuro-core/error/error.rs index 48009c44..21d3e570 100644 --- a/Build/crates/saikuro-core/error/error.rs +++ b/Build/crates/saikuro-core/error/error.rs @@ -1,9 +1,9 @@ -use alloc::string::{String, ToString}; +use alloc::string::{ String, ToString }; use core::fmt; -use serde::{Deserialize, Serialize}; +use serde::{ Deserialize, Serialize }; use thiserror::Error; -use crate::io::{IoError, IoErrorKind}; +use crate::io::{ IoError, IoErrorKind }; use crate::value::Value; /// Maximum number of structured context entries an [`ErrorDetail`] can carry. @@ -103,13 +103,12 @@ impl ErrorDetail { } /// Add a detail entry and return `self` for chaining. - /// /// Fails with [`SaikuroError::CapacityExceeded`] if the detail bag is /// already at [`ERROR_DETAIL_CAPACITY`] entries. pub fn with_detail( mut self, key: impl Into, - value: impl Into, + value: impl Into ) -> core::result::Result { let key = key.into(); self.details @@ -128,94 +127,90 @@ impl fmt::Display for ErrorDetail { } /// The main Rust error type for all fallible Saikuro operations. -/// -/// This is used internally by the runtime and its component crates. -/// When an error crosses the wire it is first converted to an [`ErrorDetail`] -/// via the [`From`] implementations below. #[derive(Debug, Error)] pub enum SaikuroError { - // Schema - #[error("namespace not found: {0}")] - NamespaceNotFound(String), + // Schema + #[error("namespace not found: {0}")] NamespaceNotFound(String), - #[error("function not found: {0}")] - FunctionNotFound(String), + #[error("function not found: {0}")] FunctionNotFound(String), - #[error("invalid arguments for {target}: {reason}")] - InvalidArguments { target: String, reason: String }, + #[error("invalid arguments for {target}: {reason}")] InvalidArguments { + target: String, + reason: String, + }, - #[error("incompatible protocol version: expected {expected}, got {received}")] - IncompatibleVersion { expected: u32, received: u32 }, + #[error( + "incompatible protocol version: expected {expected}, got {received}" + )] IncompatibleVersion { + expected: u32, + received: u32, + }, - #[error("malformed envelope: {0}")] - MalformedEnvelope(String), + #[error("malformed envelope: {0}")] MalformedEnvelope(String), - // Routing - #[error("no provider registered for namespace: {0}")] - NoProvider(String), + // Routing + #[error("no provider registered for namespace: {0}")] NoProvider(String), - #[error("provider unavailable for namespace: {0}")] - ProviderUnavailable(String), + #[error("provider unavailable for namespace: {0}")] ProviderUnavailable(String), - #[error("batch routing conflict: {0}")] - BatchRoutingConflict(String), + #[error("batch routing conflict: {0}")] BatchRoutingConflict(String), - // Capability - #[error("capability denied: caller lacks '{required}' for '{target}'")] - CapabilityDenied { target: String, required: String }, + // Capability + #[error("capability denied: caller lacks '{required}' for '{target}'")] CapabilityDenied { + target: String, + required: String, + }, #[error("capability token invalid or expired")] CapabilityInvalid, - // Transport - #[error("transport connection lost: {0}")] - ConnectionLost(String), + // Transport + #[error("transport connection lost: {0}")] ConnectionLost(String), - #[error("message too large: {size} bytes exceeds limit {limit}")] - MessageTooLarge { size: usize, limit: usize }, + #[error("message too large: {size} bytes exceeds limit {limit}")] MessageTooLarge { + size: usize, + limit: usize, + }, - #[error("operation timed out after {millis}ms")] - Timeout { millis: u64 }, + #[error("operation timed out after {millis}ms")] Timeout { + millis: u64, + }, #[error("buffer overflow on stream/channel")] BufferOverflow, - // Provider - #[error("provider returned error: {0}")] - ProviderError(String), + // Provider + #[error("provider returned error: {0}")] ProviderError(String), #[error("provider panicked while handling invocation")] ProviderPanic, - // Stream / channel + // Stream / channel #[error("stream already closed")] StreamClosed, #[error("channel closed by remote side")] ChannelClosed, - #[error("out-of-order sequence: expected {expected}, got {received}")] - OutOfOrder { expected: u64, received: u64 }, + #[error("out-of-order sequence: expected {expected}, got {received}")] OutOfOrder { + expected: u64, + received: u64, + }, // Serialisation - #[error("msgpack encode error: {0}")] - MsgpackEncode(#[from] crate::msgpack::EncodeError), + #[error("msgpack encode error: {0}")] MsgpackEncode(#[from] crate::msgpack::EncodeError), - #[error("msgpack decode error: {0}")] - MsgpackDecode(#[from] crate::msgpack::DecodeError), + #[error("msgpack decode error: {0}")] MsgpackDecode(#[from] crate::msgpack::DecodeError), // I/O - #[error("I/O error: {0}")] - Io(IoError), + #[error("I/O error: {0}")] Io(IoError), - /// A fixed-capacity map reached its compile-time limit - /// (e.g. [`crate::value::VALUE_MAP_CAPACITY`]). + /// A fixed-capacity map reached its compile-time limit. #[error("capacity exceeded: {0}")] CapacityExceeded(String), // Catch-all - #[error("internal error: {0}")] - Internal(String), + #[error("internal error: {0}")] Internal(String), } impl From for ErrorDetail { @@ -241,13 +236,14 @@ impl From for ErrorDetail { SaikuroError::ChannelClosed => ErrorCode::ChannelClosed, SaikuroError::OutOfOrder { .. } => ErrorCode::OutOfOrder, SaikuroError::MsgpackEncode(_) | SaikuroError::MsgpackDecode(_) => ErrorCode::Internal, - SaikuroError::Io(e) => match e.kind { - IoErrorKind::TimedOut => ErrorCode::Timeout, - IoErrorKind::ConnectionReset - | IoErrorKind::ConnectionAborted - | IoErrorKind::ConnectionRefused => ErrorCode::ConnectionLost, - _ => ErrorCode::Internal, - }, + SaikuroError::Io(e) => + match e.kind { + IoErrorKind::TimedOut => ErrorCode::Timeout, + | IoErrorKind::ConnectionReset + | IoErrorKind::ConnectionAborted + | IoErrorKind::ConnectionRefused => ErrorCode::ConnectionLost, + _ => ErrorCode::Internal, + } SaikuroError::CapacityExceeded(_) | SaikuroError::Internal(_) => ErrorCode::Internal, }; @@ -256,9 +252,6 @@ impl From for ErrorDetail { } /// Convert a host `std::io::Error` into the unified error type. -/// -/// Only available when the `std` toolchain is present; on no_std targets -/// I/O failures are constructed directly from [`IoError`]. #[cfg(feature = "std")] impl From for SaikuroError { fn from(err: std::io::Error) -> Self { diff --git a/Build/crates/saikuro-core/lib.rs b/Build/crates/saikuro-core/lib.rs index 1d4d667e..e561b115 100644 --- a/Build/crates/saikuro-core/lib.rs +++ b/Build/crates/saikuro-core/lib.rs @@ -19,9 +19,6 @@ mod sync; pub use sync::*; // Engine selection guard -// Exactly one engine feature must be enabled. The engine selects the -// platform's entropy backend (via `saikuro-random`) and, for `native`, the -// `std` toolchain. `std` is orthogonal and may only be combined with `native`. #[cfg(all( feature = "native", any(feature = "wasm", feature = "embedded", feature = "no_std") diff --git a/Build/crates/saikuro-core/protocol/envelope.rs b/Build/crates/saikuro-core/protocol/envelope.rs index 3488c19e..66e5381d 100644 --- a/Build/crates/saikuro-core/protocol/envelope.rs +++ b/Build/crates/saikuro-core/protocol/envelope.rs @@ -47,17 +47,8 @@ pub enum InvocationType { /// Reference to an opaque external resource (large payload, file handle, …). Resource, /// Structured log record forwarded from an adapter to the runtime log sink. - /// - /// Log envelopes are never routed to a provider. The runtime extracts the - /// [`LogRecord`](saikuro_log::LogRecord) from `args[0]` and passes it to the - /// configured log sink. No response envelope is sent. Log, /// Schema announcement sent by a provider immediately after connecting. - /// - /// The serialised [`Schema`](crate::schema::Schema) is packed as a - /// MessagePack map in `args[0]`. The runtime deserialises it and merges - /// the namespaces into the live schema registry, then returns `ok_empty`. - /// No provider is involved and no capability check is required. Announce, } @@ -80,7 +71,7 @@ pub enum StreamControl { /// the runtime, or from the runtime to a provider adapter. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Envelope { - /// Protocol version : must equal [`PROTOCOL_VERSION`]. + /// Protocol version: must equal [`PROTOCOL_VERSION`]. pub version: u32, /// What kind of invocation this is. @@ -88,16 +79,12 @@ pub struct Envelope { pub invocation_type: InvocationType, /// Unique identifier for this invocation. - /// - /// Callers generate this; casts still carry an ID so they can be - /// correlated in distributed traces even though no reply is sent. pub id: InvocationId, /// Fully-qualified target: `"."`. pub target: String, - /// Positional arguments. Type checking happens in the runtime against - /// the schema; adapters simply forward whatever the caller provided. + /// Positional arguments. #[serde(default)] pub args: Vec, @@ -109,7 +96,7 @@ pub struct Envelope { )] pub meta: MetaMap, - /// Capability token presented by the caller. Required when the target + /// Capability token presented by the caller. Required when the target /// function declares one or more `capabilities`. #[serde(skip_serializing_if = "Option::is_none")] pub capability: Option, @@ -129,8 +116,7 @@ pub struct Envelope { pub seq: Option, } -// Shared MessagePack serialization for wire types. The codec is no_std + alloc, -// so these helpers exist on every build target. +// Shared MessagePack serialization for wire types. macro_rules! impl_msgpack { ($ty:ty) => { impl $ty { @@ -201,9 +187,6 @@ impl Envelope { } /// Construct a schema-announcement envelope. - /// - /// `schema_bytes` is the MessagePack-encoded [`Schema`](crate::schema::Schema) - /// stored as a raw `Bytes` value in `args[0]`. pub fn announce(schema_value: Value) -> Result { let mut envelope = Self::call("$saikuro.announce", vec![schema_value])?; envelope.invocation_type = InvocationType::Announce; @@ -211,10 +194,6 @@ impl Envelope { } /// Construct a resource-access envelope. - /// - /// `target` is the provider function that manages the resource. - /// `args` are provider-specific arguments that identify or parameterise - /// the resource request (e.g. a resource ID, byte range, or query). pub fn resource( target: impl Into, args: Vec, @@ -236,9 +215,6 @@ impl Envelope { } /// Split a `"namespace.function"` target string into its two components. -/// -/// Returns `None` when `target` contains no dot separator or when either -/// component would be empty (e.g. `".fn"` or `"ns."`). pub fn split_target(target: &str) -> Option<(&str, &str)> { let dot = target.rfind('.')?; if dot == 0 || dot == target.len() - 1 { @@ -248,8 +224,6 @@ pub fn split_target(target: &str) -> Option<(&str, &str)> { } /// The envelope carrying a response back to a caller. -/// -/// Fields follow the spec exactly. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResponseEnvelope { /// The ID from the originating [`Envelope`]. @@ -258,7 +232,7 @@ pub struct ResponseEnvelope { /// `true` if the invocation succeeded; `false` otherwise. pub ok: bool, - /// Successful return value. `None` when `ok` is `false` or the function + /// Successful return value. `None` when `ok` is `false` or the function /// returns nothing meaningful (e.g. casts, pure side-effects). #[serde(skip_serializing_if = "Option::is_none")] pub result: Option, diff --git a/Build/crates/saikuro-core/protocol/registration.rs b/Build/crates/saikuro-core/protocol/registration.rs index 3d82a0be..7e0e4b10 100644 --- a/Build/crates/saikuro-core/protocol/registration.rs +++ b/Build/crates/saikuro-core/protocol/registration.rs @@ -12,8 +12,7 @@ pub struct RegistrationToken(u64); impl RegistrationToken { /// Allocate the next process-unique registration token. /// - /// Panics if all `u64` token values have been exhausted. The counter does - /// not wrap, so a token is never reused within a process. + /// Panics if all `u64` token values have been exhausted. pub fn new() -> Self { let value = NEXT_REGISTRATION_TOKEN .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { diff --git a/Build/crates/saikuro-core/protocol/schema.rs b/Build/crates/saikuro-core/protocol/schema.rs index e838733c..33ea5f5d 100644 --- a/Build/crates/saikuro-core/protocol/schema.rs +++ b/Build/crates/saikuro-core/protocol/schema.rs @@ -24,11 +24,9 @@ pub type TypeMap = heapless::FnvIndexMap; -// Primitive types +// Primitive types /// A scalar type name used in function argument and return-type declarations. -/// -/// Extended types (user-defined structs) are represented as `TypeRef`. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PrimitiveType { @@ -46,7 +44,7 @@ pub enum PrimitiveType { String, Bytes, /// Dynamic / untyped: the runtime will pass the value through without - /// checking its shape. Use sparingly. + /// checking its shape. Use sparingly. Any, /// The function returns nothing (or the caller doesn't care about the value). Unit, @@ -75,7 +73,7 @@ impl core::fmt::Display for PrimitiveType { } } -// Type descriptors +// Type descriptors /// A type descriptor that can appear anywhere a type is needed in the schema. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -126,7 +124,7 @@ impl TypeDescriptor { } } -// Function schema +// Function schema /// Visibility of a function to external callers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -191,9 +189,7 @@ fn default_unit() -> TypeDescriptor { TypeDescriptor::primitive(PrimitiveType::Unit) } -// Type definitions - -/// A named field within a user-defined record type. +// Type definitions #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FieldDescriptor { /// The type of this field. @@ -218,8 +214,7 @@ pub enum TypeDefinition { Alias { inner: TypeDescriptor }, } -// Namespace schema - +// Namespace schema /// Schema for a single namespace: a logical grouping of related functions. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NamespaceSchema { @@ -230,10 +225,7 @@ pub struct NamespaceSchema { pub doc: Option, } -// Top-level schema - -/// The root schema document: a versioned description of all namespaces and -/// types available in a Saikuro deployment. +// Top-level schema #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Schema { /// Must equal [`SCHEMA_VERSION`]. @@ -258,8 +250,6 @@ impl Schema { } /// Look up a function descriptor given a fully-qualified target string. - /// - /// Returns `None` if either the namespace or the function does not exist. pub fn lookup_function(&self, target: &str) -> Option<&FunctionSchema> { let dot = target.rfind('.')?; let ns = &target[..dot]; diff --git a/Build/crates/saikuro-core/sync/mod.rs b/Build/crates/saikuro-core/sync/mod.rs new file mode 100644 index 00000000..2ff84677 --- /dev/null +++ b/Build/crates/saikuro-core/sync/mod.rs @@ -0,0 +1,3 @@ +mod sync; + +pub use sync::*; diff --git a/Build/crates/saikuro-core/sync.rs b/Build/crates/saikuro-core/sync/sync.rs similarity index 61% rename from Build/crates/saikuro-core/sync.rs rename to Build/crates/saikuro-core/sync/sync.rs index b7bad819..a764997d 100644 --- a/Build/crates/saikuro-core/sync.rs +++ b/Build/crates/saikuro-core/sync/sync.rs @@ -7,7 +7,7 @@ use std::sync as imp; #[cfg(not(feature = "std"))] use spin as imp; -/// A reader-writer lock. `read`/`write` return guards that deref to the +/// A reader-writer lock. `read`/`write` return guards that deref to the /// protected value. pub struct RwLock { inner: imp::RwLock, @@ -23,7 +23,7 @@ pub struct RwLockWriteGuard<'a, T: ?Sized> { inner: imp::RwLockWriteGuard<'a, T>, } -/// A mutual-exclusion lock. `lock` returns a guard that derefs to the +/// A mutual-exclusion lock. `lock` returns a guard that derefs to the /// protected value. pub struct Mutex { inner: imp::Mutex, @@ -34,58 +34,6 @@ pub struct MutexGuard<'a, T: ?Sized> { inner: imp::MutexGuard<'a, T>, } -// The std and spin backends disagree on whether lock acquisition can fail -// (std returns `LockResult`, spin returns a guard directly). These traits -// normalize the two behind a single guard-returning API. - -trait RwLockAccess { - fn read_guard(&self) -> imp::RwLockReadGuard<'_, T>; - fn write_guard(&self) -> imp::RwLockWriteGuard<'_, T>; -} - -trait MutexAccess { - fn lock_guard(&self) -> imp::MutexGuard<'_, T>; -} - -#[cfg(feature = "std")] -impl RwLockAccess for imp::RwLock { - fn read_guard(&self) -> imp::RwLockReadGuard<'_, T> { - self.read() - .expect("RwLock poisoned by a panicking guard holder") - } - - fn write_guard(&self) -> imp::RwLockWriteGuard<'_, T> { - self.write() - .expect("RwLock poisoned by a panicking guard holder") - } -} - -#[cfg(feature = "std")] -impl MutexAccess for imp::Mutex { - fn lock_guard(&self) -> imp::MutexGuard<'_, T> { - self.lock() - .expect("Mutex poisoned by a panicking guard holder") - } -} - -#[cfg(not(feature = "std"))] -impl RwLockAccess for imp::RwLock { - fn read_guard(&self) -> imp::RwLockReadGuard<'_, T> { - self.read() - } - - fn write_guard(&self) -> imp::RwLockWriteGuard<'_, T> { - self.write() - } -} - -#[cfg(not(feature = "std"))] -impl MutexAccess for imp::Mutex { - fn lock_guard(&self) -> imp::MutexGuard<'_, T> { - self.lock() - } -} - impl RwLock { /// Create a new lock guarding `value`. pub const fn new(value: T) -> Self { @@ -104,16 +52,26 @@ impl Default for RwLock { impl RwLock { /// Acquire the read guard. pub fn read(&self) -> RwLockReadGuard<'_, T> { - RwLockReadGuard { - inner: self.inner.read_guard(), - } + #[cfg(feature = "std")] + let inner = self + .inner + .read() + .expect("RwLock poisoned by a panicking guard holder"); + #[cfg(not(feature = "std"))] + let inner = self.inner.read(); + RwLockReadGuard { inner } } /// Acquire the write guard. pub fn write(&self) -> RwLockWriteGuard<'_, T> { - RwLockWriteGuard { - inner: self.inner.write_guard(), - } + #[cfg(feature = "std")] + let inner = self + .inner + .write() + .expect("RwLock poisoned by a panicking guard holder"); + #[cfg(not(feature = "std"))] + let inner = self.inner.write(); + RwLockWriteGuard { inner } } } @@ -157,9 +115,14 @@ impl Default for Mutex { impl Mutex { /// Acquire the guard. pub fn lock(&self) -> MutexGuard<'_, T> { - MutexGuard { - inner: self.inner.lock_guard(), - } + #[cfg(feature = "std")] + let inner = self + .inner + .lock() + .expect("Mutex poisoned by a panicking guard holder"); + #[cfg(not(feature = "std"))] + let inner = self.inner.lock(); + MutexGuard { inner } } } @@ -194,6 +157,3 @@ impl fmt::Debug for Mutex { .finish() } } - -// The wrappers inherit `Send`/`Sync` from their inner locks: std locks are -// `Send + Sync` when `T: Send`, spin locks likewise. No manual impls needed. diff --git a/Build/crates/saikuro-core/value/capability.rs b/Build/crates/saikuro-core/value/capability.rs index 209fb4b9..1ade4c97 100644 --- a/Build/crates/saikuro-core/value/capability.rs +++ b/Build/crates/saikuro-core/value/capability.rs @@ -11,7 +11,7 @@ pub const CAPABILITY_SET_CAPACITY: usize = 256; /// Fixed-capacity set of capability tokens held by a peer. pub type TokenSet = heapless::FnvIndexSet; -/// A single capability token: a namespaced, human-readable permission string. +/// A single capability token: a namespaced, human-readable permission string. /// /// By convention tokens are dot-separated: `"."`. /// The runtime treats them as opaque strings; no hierarchical wildcard @@ -52,10 +52,6 @@ impl From for CapabilityToken { } /// The full set of capability tokens granted to a peer. -/// -/// During the connection handshake a peer presents its `CapabilitySet`. -/// The runtime stores this and checks it against per-function requirements -/// on every invocation. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CapabilitySet { tokens: TokenSet, @@ -68,9 +64,6 @@ impl CapabilitySet { } /// Construct a set from an iterator of tokens. - /// - /// Fails if the iterator yields more than [`CAPABILITY_SET_CAPACITY`] - /// distinct tokens. pub fn from_tokens( iter: impl IntoIterator, ) -> Result { @@ -93,7 +86,6 @@ impl CapabilitySet { } /// Return `true` if this set grants the given capability. - /// /// The wildcard token `"*"` grants every capability. pub fn grants(&self, required: &CapabilityToken) -> bool { self.tokens.contains(&CapabilityToken::new(WILDCARD_TOKEN)) @@ -106,9 +98,6 @@ impl CapabilitySet { } /// Add a token to the set. - /// - /// Fails (returning the token) if the set is already at - /// [`CAPABILITY_SET_CAPACITY`] distinct tokens. pub fn insert(&mut self, token: CapabilityToken) -> Result { self.tokens.insert(token) } diff --git a/Build/crates/saikuro-core/value/resource.rs b/Build/crates/saikuro-core/value/resource.rs index f10248c2..63e462a3 100644 --- a/Build/crates/saikuro-core/value/resource.rs +++ b/Build/crates/saikuro-core/value/resource.rs @@ -5,36 +5,26 @@ use serde::{Deserialize, Serialize}; use crate::value::{Value, ValueMap}; // ResourceHandle - /// An opaque, serialisable reference to large or external data. -/// -/// Created by a provider and returned to callers as the `result` of a -/// `Resource`-type invocation. Callers use the handle to retrieve, -/// stream, or otherwise interact with the referenced data without -/// transferring it inline. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResourceHandle { /// Unique identifier for this resource instance. - /// - /// Typically a UUID v4. Two handles with the same `id` refer to the + /// Two handles with the same `id` refer to the /// same underlying resource. pub id: String, /// MIME type of the resource content, if known. - /// /// Examples: `"application/octet-stream"`, `"image/png"`, `"text/csv"`. #[serde(skip_serializing_if = "Option::is_none")] pub mime_type: Option, /// Total size of the resource in bytes, if known. - /// /// `None` means the size is unknown or unbounded (e.g. a live stream). #[serde(skip_serializing_if = "Option::is_none")] pub size: Option, /// An optional URI that can be used to access the resource directly. - /// - /// The URI scheme is provider-defined. Common examples: + /// The URI scheme is provider-defined. Common examples: /// - `saikuro://res/`: Saikuro-internal reference /// - `https://storage.example.com/blobs/`: direct object-storage URL /// - `file:///var/data/`: local filesystem path diff --git a/Build/crates/saikuro-core/value/value.rs b/Build/crates/saikuro-core/value/value.rs index a4e1ccdd..d7f6534e 100644 --- a/Build/crates/saikuro-core/value/value.rs +++ b/Build/crates/saikuro-core/value/value.rs @@ -2,26 +2,13 @@ use alloc::{borrow::ToOwned, boxed::Box, string::String, vec::Vec}; use serde::{ser::SerializeMap, Deserialize, Serialize, Serializer}; /// Maximum number of entries a [`Value::Map`] can hold. -/// -/// Saikuro's wire format is schema-driven: argument lists, error detail bags, -/// and log fields are all small by construction. This bound keeps `Value` -/// embeddable without a heap-based map. Deserialising a map larger than this -/// fails cleanly with a serde error rather than truncating. pub const VALUE_MAP_CAPACITY: usize = 64; /// Fixed-capacity map backing [`Value::Map`]. -/// -/// The map retains insertion order internally, but [`Value`] serializes map -/// entries in key order and compares them by key so construction order does -/// not affect protocol bytes or semantic equality. pub type ValueMap = heapless::FnvIndexMap; /// A dynamically-typed value that can appear in an invocation argument list, /// a return value, an error detail bag, or a schema default. -/// -/// The set of variants is deliberately minimal: it mirrors the MessagePack -/// type system so serialisation is lossless: while still providing the -/// richness needed to express the full Saikuro type system. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(untagged)] pub enum Value { @@ -45,28 +32,13 @@ pub enum Value { String(String), /// Ordered sequence of values. - /// - /// **Must come before `Bytes`** in the enum so that during untagged - /// deserialisation a msgpack array is matched as `Array` before the - /// `serde_bytes`-annotated `Bytes` variant, which would otherwise - /// greedily consume any byte-sequence (including integer arrays). Array(Vec), /// Raw binary blob (resource handles, opaque payloads, …). - /// - /// The `serde_bytes` annotation ensures that msgpack `bin` wire type is - /// used instead of the default array-of-u8 encoding. The variant is - /// placed *after* `Array` so that an integer-element array is matched by - /// `Array` first (correct), while a genuine `bin` blob fails the - /// `Vec` check and falls through to this variant (also correct). #[serde(with = "serde_bytes")] Bytes(Vec), - /// String-keyed mapping of values. A `Box` breaks the recursive - /// `Value -> ValueMap -> Value` cycle: heapless maps are stored inline, so - /// without indirection `Value` would have infinite size. The `ValueMap` is - /// a fixed-capacity map. Serialization sorts entries by key to preserve - /// the canonical ordering previously provided by `BTreeMap`. + /// String-keyed mapping of values. Map(#[serde(serialize_with = "serialize_value_map")] Box), } @@ -84,11 +56,6 @@ where } /// Equality for [`Value`]. -/// -/// Implemented manually because the fixed-capacity map backing `Map` only -/// implements `PartialEq` when the value type is `Eq`, which `Value` cannot be -/// (it contains `f64`). Map equality is key-based and independent of insertion -/// order. impl PartialEq for Value { fn eq(&self, other: &Self) -> bool { match (self, other) { diff --git a/Build/crates/saikuro-log/Cargo.toml b/Build/crates/saikuro-log/Cargo.toml index 23e361ab..5a8084d9 100644 --- a/Build/crates/saikuro-log/Cargo.toml +++ b/Build/crates/saikuro-log/Cargo.toml @@ -14,10 +14,11 @@ path = "lib.rs" [features] default = ["std", "native", "stderr", "null", "filter"] std = [] -native = ["std"] -no_std = [] -wasm = [] -embedded = ["dep:embedded-io-async", "dep:spin"] +native = ["std", "saikuro-core/native"] +no_std = ["saikuro-core/no_std"] +wasm = ["saikuro-core/wasm"] +embedded = ["dep:embedded-io-async", "dep:spin", "saikuro-core/embedded"] + stderr = ["native"] tracing = ["native", "dep:tracing"] console = ["wasm", "dep:web-sys"] From 98e01b559a3ea7e9e2a893f4d9b052711b073653 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Thu, 13 Aug 2026 23:05:02 -0600 Subject: [PATCH 27/43] saikuro-router --- Build/crates/saikuro-router/Cargo.toml | 42 +++- .../saikuro-router/{src => error}/error.rs | 2 - Build/crates/saikuro-router/error/mod.rs | 2 + Build/crates/saikuro-router/lib.rs | 33 +++ Build/crates/saikuro-router/provider/mod.rs | 2 + .../{src => provider}/provider.rs | 45 +--- Build/crates/saikuro-router/router/mod.rs | 2 + .../saikuro-router/{src => router}/router.rs | 197 +++++++++++------- Build/crates/saikuro-router/src/lib.rs | 21 -- .../crates/saikuro-router/stream_state/mod.rs | 2 + .../{src => stream_state}/stream_state.rs | 2 - .../tests/saikuro-router/sandbox_dispatch.rs | 162 +++++++------- Build/tests/saikuro-router/stream_dispatch.rs | 56 +++-- 13 files changed, 306 insertions(+), 262 deletions(-) rename Build/crates/saikuro-router/{src => error}/error.rs (97%) create mode 100644 Build/crates/saikuro-router/error/mod.rs create mode 100644 Build/crates/saikuro-router/lib.rs create mode 100644 Build/crates/saikuro-router/provider/mod.rs rename Build/crates/saikuro-router/{src => provider}/provider.rs (74%) create mode 100644 Build/crates/saikuro-router/router/mod.rs rename Build/crates/saikuro-router/{src => router}/router.rs (76%) delete mode 100644 Build/crates/saikuro-router/src/lib.rs create mode 100644 Build/crates/saikuro-router/stream_state/mod.rs rename Build/crates/saikuro-router/{src => stream_state}/stream_state.rs (99%) diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index dd01fe8b..77af21bc 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -8,20 +8,52 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "router", "rpc"] +[lib] +path = "lib.rs" + [features] -default = ["std"] -std = ["saikuro-core/std", "saikuro-exec/tokio-runtime"] -embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/embedded"] +default = ["std", "native"] +std = [] +native = [ + "std", + "saikuro-core/native", + "saikuro-exec/native", + "saikuro-schema/native", + "saikuro-log/native", + "saikuro-log/tracing", +] +no_std = [ + "saikuro-core/no_std", + "saikuro-exec/no_std", + "saikuro-schema/no_std", + "saikuro-log/no_std", + "saikuro-log/null", +] +wasm = [ + "saikuro-core/wasm", + "saikuro-exec/wasm", + "saikuro-schema/wasm", + "saikuro-log/wasm", + "saikuro-log/console", +] +embedded = [ + "saikuro-core/embedded", + "saikuro-exec/embedded", + "saikuro-schema/embedded", + "saikuro-log/embedded", + "saikuro-log/null", +] +custom = ["embedded"] +drbg = ["embedded"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } saikuro-schema = { workspace = true, default-features = false } saikuro-exec = { workspace = true, default-features = false } -saikuro-log = { workspace = true, default-features = false, features = ["tracing"] } +saikuro-log = { workspace = true, default-features = false } async-trait = { workspace = true } thiserror = { workspace = true } -tracing = { workspace = true } [dev-dependencies] saikuro-exec = { workspace = true } diff --git a/Build/crates/saikuro-router/src/error.rs b/Build/crates/saikuro-router/error/error.rs similarity index 97% rename from Build/crates/saikuro-router/src/error.rs rename to Build/crates/saikuro-router/error/error.rs index ec852fa5..7af1ac5f 100644 --- a/Build/crates/saikuro-router/src/error.rs +++ b/Build/crates/saikuro-router/error/error.rs @@ -1,5 +1,3 @@ -//! Router error type. - use alloc::string::String; use thiserror::Error; diff --git a/Build/crates/saikuro-router/error/mod.rs b/Build/crates/saikuro-router/error/mod.rs new file mode 100644 index 00000000..7edf3bf2 --- /dev/null +++ b/Build/crates/saikuro-router/error/mod.rs @@ -0,0 +1,2 @@ +mod error; +pub use error::*; diff --git a/Build/crates/saikuro-router/lib.rs b/Build/crates/saikuro-router/lib.rs new file mode 100644 index 00000000..9d85c382 --- /dev/null +++ b/Build/crates/saikuro-router/lib.rs @@ -0,0 +1,33 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![deny(missing_docs)] + +extern crate alloc; + +pub mod error; +pub mod provider; +pub mod router; +pub mod stream_state; + +pub use error::RouterError; +pub use provider::{Provider, ProviderHandle, ProviderRegistry}; +pub use router::{InvocationRouter, RouterConfig}; +pub use stream_state::{ChannelState, StreamState, StreamStateStore}; + +// Default log sink per engine. +#[cfg(feature = "native")] +pub type DefaultRouterSink = saikuro_log::TracingSink; +#[cfg(feature = "wasm")] +pub type DefaultRouterSink = saikuro_log::ConsoleSink; +#[cfg(any(feature = "no_std", feature = "embedded"))] +pub type DefaultRouterSink = saikuro_log::NullSink; + +// Compilation guard: exactly one engine backend must be selected. +#[cfg(not(any( + feature = "native", + feature = "no_std", + feature = "wasm", + feature = "embedded" +)))] +compile_error!( + "saikuro-router: enable exactly one engine feature: native, no_std, wasm, or embedded" +); diff --git a/Build/crates/saikuro-router/provider/mod.rs b/Build/crates/saikuro-router/provider/mod.rs new file mode 100644 index 00000000..ca0c1ad4 --- /dev/null +++ b/Build/crates/saikuro-router/provider/mod.rs @@ -0,0 +1,2 @@ +mod provider; +pub use provider::*; diff --git a/Build/crates/saikuro-router/src/provider.rs b/Build/crates/saikuro-router/provider/provider.rs similarity index 74% rename from Build/crates/saikuro-router/src/provider.rs rename to Build/crates/saikuro-router/provider/provider.rs index 8c40b6e8..317dd1d1 100644 --- a/Build/crates/saikuro-router/src/provider.rs +++ b/Build/crates/saikuro-router/provider/provider.rs @@ -1,36 +1,19 @@ -//! Provider abstraction and registry. -//! -//! A **provider** is any entity that can handle invocations for one or more -//! namespaces. In practice it is a connected language adapter (Python, -//! TypeScript, …) that has registered its schema and is listening for work. -//! -//! The [`ProviderRegistry`] maps namespace names to [`ProviderHandle`]s. -//! Each handle wraps a MPSC sender so the router can dispatch work -//! without blocking. - use alloc::{ borrow::ToOwned, boxed::Box, collections::BTreeMap, string::String, sync::Arc, vec::Vec, }; use async_trait::async_trait; use saikuro_core::{envelope::Envelope, sync::RwLock, RegistrationToken, ResponseEnvelope}; use saikuro_exec::{mpsc, oneshot}; -use tracing::{debug, warn}; use crate::error::{Result, RouterError}; // Pending call tracker - /// A one-shot channel waiting for the response to a single Call invocation. pub type PendingCallSender = oneshot::Sender; pub type PendingCallReceiver = oneshot::Receiver; // Provider trait - /// An abstract provider that can receive invocations. -/// -/// The `send_invocation` method is the only interface the router uses; concrete -/// provider implementations may queue, dispatch, or transform the envelope in -/// any way they choose. #[async_trait] pub trait Provider: Send + Sync + 'static { /// The unique identifier for this provider connection. @@ -63,9 +46,6 @@ pub struct ProviderWorkItem { } /// A cheap, cloneable handle to a connected provider. -/// -/// Internally holds a bounded MPSC sender; backpressure naturally propagates -/// from here back to the caller when the provider's work queue is full. #[derive(Clone)] pub struct ProviderHandle { id: String, @@ -134,13 +114,7 @@ impl Provider for ProviderHandle { } // ProviderRegistry - /// Thread-safe registry mapping namespace names to provider handles. -/// -/// Both indexes live behind a single [`RwLock`] so `register` and `deregister` -/// keep them consistent atomically. A namespace taken over by a new provider -/// is removed from the old provider's record, and deregistration never removes -/// a namespace that a later provider now owns. #[derive(Clone, Default)] pub struct ProviderRegistry { inner: Arc>, @@ -160,12 +134,6 @@ impl ProviderRegistry { } /// Register a provider handle for the given namespaces. - /// - /// If a namespace already has a provider, the old one is replaced. The - /// namespace is then removed from the old provider's record so a later - /// deregistration of the old provider cannot reclaim the new provider's - /// namespace. Re-registering a provider with fewer namespaces releases - /// the routes it no longer owns (unless a newer provider took them over). pub fn register(&self, handle: ProviderHandle) { let provider_id = handle.id().to_owned(); let registration_token = handle.registration_token(); @@ -174,10 +142,7 @@ impl ProviderRegistry { let mut state = self.inner.write(); - // A re-registering provider that dropped a namespace must release its - // route. Remove each previously-owned namespace that is absent from - // the new list, but only while it still points at this provider (a - // newer provider may have taken it over). + // A re-registering provider that dropped a namespace must release its route. let dropped: Vec = state .by_provider .get(&provider_key) @@ -197,14 +162,12 @@ impl ProviderRegistry { .unwrap_or(false) { state.by_namespace.remove(ns); - debug!(namespace = %ns, provider = %provider_id, "released dropped namespace route"); } } for ns in &namespaces { match state.by_namespace.insert(ns.clone(), handle.clone()) { Some(old) => { - warn!(namespace = %ns, provider = %provider_id, "replacing existing namespace provider"); if old.id() != provider_id || old.registration_token() != registration_token { let old_key = (old.id().to_owned(), old.registration_token()); if let Some(old_ns_list) = state.by_provider.get_mut(&old_key) { @@ -213,7 +176,6 @@ impl ProviderRegistry { } } None => { - debug!(namespace = %ns, provider = %provider_id, "registering provider for namespace") } } } @@ -221,10 +183,6 @@ impl ProviderRegistry { } /// Remove all namespaces owned by one specific provider registration. - /// - /// A namespace is removed from the lookup index only while it still points - /// at this registration; namespaces taken over by a newer registration are - /// left alone even when it uses the same provider ID. pub fn deregister(&self, provider_id: &str, registration_token: RegistrationToken) { let mut state = self.inner.write(); let provider_key = (provider_id.to_owned(), registration_token); @@ -238,7 +196,6 @@ impl ProviderRegistry { { state.by_namespace.remove(&ns); } - debug!(namespace = %ns, provider = %provider_id, "deregistered namespace provider"); } } } diff --git a/Build/crates/saikuro-router/router/mod.rs b/Build/crates/saikuro-router/router/mod.rs new file mode 100644 index 00000000..791f2479 --- /dev/null +++ b/Build/crates/saikuro-router/router/mod.rs @@ -0,0 +1,2 @@ +mod router; +pub use router::*; diff --git a/Build/crates/saikuro-router/src/router.rs b/Build/crates/saikuro-router/router/router.rs similarity index 76% rename from Build/crates/saikuro-router/src/router.rs rename to Build/crates/saikuro-router/router/router.rs index 2c1ab896..e039f91c 100644 --- a/Build/crates/saikuro-router/src/router.rs +++ b/Build/crates/saikuro-router/router/router.rs @@ -1,19 +1,5 @@ -//! Invocation router. -//! -//! The router is the central dispatch component. After the validator has -//! confirmed an envelope is well-formed and permitted, the router: -//! -//! 1. Resolves the target namespace to a provider handle. -//! 2. For `Call`: allocates a one-shot channel, sends work to the provider, -//! and returns a future that completes when the response arrives. -//! 3. For `Cast`: sends work to the provider and returns immediately. -//! 4. For `Stream`/`Channel`: sets up the state tracking entry, sends the -//! open request to the provider, and returns the appropriate receiver. -//! 5. For `Batch`: dispatches each item and collects all results. -//! 6. For `Log`: extracts a [`LogRecord`] from `args[0]` and forwards it to -//! the configured log sink without routing to any provider. - -use alloc::{borrow::ToOwned, boxed::Box, string::ToString, sync::Arc, vec::Vec}; +//! Invocation router +use alloc::{borrow::ToOwned, boxed::Box, format, string::ToString, sync::Arc, vec::Vec}; use core::time::Duration; use saikuro_core::{ envelope::{Envelope, InvocationType}, @@ -21,14 +7,14 @@ use saikuro_core::{ invocation::InvocationId, ResponseEnvelope, }; -use saikuro_log::{LogLevel, LogRecord, LogSink, TracingSink, tracing_log_sink}; +use saikuro_log::{LogLevel, LogRecord, LogSink}; use saikuro_exec::{mpsc, oneshot, timeout, ChannelCapacity}; -use tracing::{debug, instrument, warn}; use crate::{ error::{Result, RouterError}, provider::{Provider, ProviderRegistry}, stream_state::{ChannelState, DeliveryOutcome, StreamState, StreamStateStore}, + DefaultRouterSink, }; // Config @@ -63,7 +49,7 @@ impl Default for RouterConfig { /// /// `InvocationRouter` is cheap to clone : all state is `Arc`-wrapped inside /// the registries it references. -pub struct InvocationRouter { +pub struct InvocationRouter { providers: ProviderRegistry, streams: StreamStateStore, config: RouterConfig, @@ -82,9 +68,9 @@ impl Clone for InvocationRouter { } } -impl InvocationRouter { +impl InvocationRouter { pub fn new(providers: ProviderRegistry, config: RouterConfig) -> Self { - Self::with_log_sink(providers, config, tracing_log_sink()) + Self::with_log_sink(providers, config, default_sink()) } /// Create a router with the given providers and default config. @@ -109,29 +95,11 @@ impl InvocationRouter { } // State store access - - /// Access the shared [`StreamStateStore`] directly. - /// - /// Primarily useful in tests and the runtime server loop when it needs to - /// take receivers to forward stream/channel items to the connected adapter. pub fn streams(&self) -> &StreamStateStore { &self.streams } - // Public dispatch API - - /// Dispatch an envelope and return the response. - /// - /// For `Cast` the response is always `ResponseEnvelope::ok_empty`. - /// For `Stream` / `Channel` the response carries the stream ID; items - /// arrive on the returned channel. - /// For `Log` the log record is forwarded to the sink and - /// `ResponseEnvelope::ok_empty` is returned (no provider is involved). - #[instrument(skip(self, envelope), fields( - id = %envelope.id, - target = %envelope.target, - invocation_type = %envelope.invocation_type, - ))] + /// Dispatch an envelope and return the response pub async fn dispatch(&self, envelope: Envelope) -> ResponseEnvelope { match envelope.invocation_type { InvocationType::Call => self.dispatch_call(envelope).await, @@ -149,14 +117,23 @@ impl InvocationRouter { // Announce envelopes are handled by the connection layer before // reaching the router. If one leaks through here it is a no-op // so we don't panic but we do warn. - warn!(id = %envelope.id, "announce envelope reached router : should be handled by ConnectionHandler"); + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!( + "announce envelope reached router (id={}): should be handled by ConnectionHandler", + envelope.id + ), + )) + .await; ResponseEnvelope::ok_empty(envelope.id) } } } // Call - async fn dispatch_call(&self, envelope: Envelope) -> ResponseEnvelope { let id = envelope.id; @@ -174,14 +151,32 @@ impl InvocationRouter { match timeout(self.config.call_timeout, resp_rx).await { Ok(Ok(response)) => response, Ok(Err(_)) => { - warn!(%id, "provider dropped response sender without replying"); + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!("provider dropped response sender without replying (id={})", id), + )) + .await; error_response( id, SaikuroError::ProviderUnavailable("response channel dropped".into()).into(), ) } Err(_) => { - warn!(%id, timeout_ms = self.config.call_timeout.as_millis(), "call timed out"); + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!( + "call timed out (id={}, timeout_ms={})", + id, + self.config.call_timeout.as_millis() + ), + )) + .await; error_response( id, SaikuroError::Timeout { @@ -194,7 +189,6 @@ impl InvocationRouter { } // Cast - async fn dispatch_cast(&self, envelope: Envelope) -> ResponseEnvelope { let id = envelope.id; @@ -205,7 +199,14 @@ impl InvocationRouter { // Fire-and-forget: we don't wait for any response. if let Err(e) = provider.send_invocation(envelope, None).await { - warn!(%id, "cast dispatch failed: {e}"); + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!("cast dispatch failed (id={}): {e}", id), + )) + .await; // Still return ok_empty : the caller opted out of responses. } @@ -213,12 +214,6 @@ impl InvocationRouter { } // Stream - - /// Open a server-to-client stream. - /// - /// Returns an `ok_empty` response immediately; items arrive on the - /// `mpsc::Receiver` that callers subscribe to via the - /// runtime's stream subscription API. async fn dispatch_stream_open(&self, envelope: Envelope) -> ResponseEnvelope { let id = envelope.id; @@ -237,12 +232,18 @@ impl InvocationRouter { return error_response(id, e.into()); } - debug!(%id, "stream opened"); + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Debug, + "saikuro.router", + format!("stream opened (id={})", id), + )) + .await; ResponseEnvelope::ok_empty(id) } // Channel - async fn dispatch_channel_open(&self, envelope: Envelope) -> ResponseEnvelope { let id = envelope.id; @@ -267,7 +268,14 @@ impl InvocationRouter { return error_response(id, RouterError::ChannelClosed(id.to_string()).into()); } DeliveryOutcome::OutOfOrder => { - warn!(%id, "out-of-order channel item dropped"); + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!("out-of-order channel item dropped (id={})", id), + )) + .await; } DeliveryOutcome::Delivered => {} } @@ -299,12 +307,18 @@ impl InvocationRouter { return error_response(id, e.into()); } - debug!(%id, "channel opened"); + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Debug, + "saikuro.router", + format!("channel opened (id={})", id), + )) + .await; ResponseEnvelope::ok_empty(id) } // Batch - async fn dispatch_batch(&self, envelope: Envelope) -> ResponseEnvelope { let id = envelope.id; let items = match envelope.batch_items { @@ -332,11 +346,6 @@ impl InvocationRouter { } // Log - - /// Handle a `Log`-type envelope. - /// - /// Extracts the [`LogRecord`] from `args[0]`, forwards it to the log sink, - /// and returns `ok_empty`. Never touches a provider. async fn dispatch_log(&self, envelope: Envelope) -> ResponseEnvelope { let id = envelope.id; @@ -348,7 +357,14 @@ impl InvocationRouter { .and_then(|v| match LogRecord::try_from(v) { Ok(r) => Some(r), Err(e) => { - warn!(%id, error = %e, "failed to parse LogRecord from log envelope"); + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!("failed to parse LogRecord from log envelope (id={}): {e}", id), + )) + .await; None } }); @@ -358,7 +374,14 @@ impl InvocationRouter { self.log_sink.emit(&r).await; } None => { - warn!(%id, "log envelope has no valid LogRecord in args[0]; dropping"); + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!("log envelope has no valid LogRecord in args[0]; dropping (id={})", id), + )) + .await; } } @@ -366,14 +389,6 @@ impl InvocationRouter { } // Stream item routing - - /// Route an inbound channel item (client -> provider direction) to the - /// appropriate open channel's inbound queue. - /// - /// This is called when the client sends a follow-up message on an already- - /// opened channel (i.e. a `Channel`-type envelope whose ID matches an - /// existing channel state entry). - /// Route a channel item in the given direction. async fn route_channel_item(&self, response: ResponseEnvelope, inbound: bool) -> Result<()> { let id = response.id; let state = self @@ -387,7 +402,14 @@ impl InvocationRouter { Err(RouterError::ChannelClosed(id.to_string())) } DeliveryOutcome::OutOfOrder => { - warn!(%id, "out-of-order channel item dropped"); + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!("out-of-order channel item dropped (id={})", id), + )) + .await; Ok(()) } DeliveryOutcome::Terminal => { @@ -404,9 +426,6 @@ impl InvocationRouter { /// Route an outbound channel item (provider -> client direction) to the /// appropriate open channel's outbound queue. - /// - /// Called by the provider adapter when it wants to push a message to the - /// client side of an open channel. pub async fn route_channel_outbound(&self, response: ResponseEnvelope) -> Result<()> { self.route_channel_item(response, false).await } @@ -425,7 +444,14 @@ impl InvocationRouter { Err(RouterError::StreamClosed(id.to_string())) } DeliveryOutcome::OutOfOrder => { - warn!(%id, "out-of-order stream item dropped"); + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!("out-of-order stream item dropped (id={})", id), + )) + .await; Ok(()) } DeliveryOutcome::Terminal => { @@ -437,7 +463,6 @@ impl InvocationRouter { } // Helpers - fn resolve_namespace(&self, target: &str) -> Result { let ns = namespace_of(target).ok_or_else(|| RouterError::MalformedTarget(target.to_owned()))?; @@ -456,7 +481,6 @@ impl InvocationRouter { } // Helpers - fn namespace_of(target: &str) -> Option<&str> { saikuro_core::envelope::split_target(target).map(|(ns, _)| ns) } @@ -485,3 +509,18 @@ impl From for ErrorDetail { ErrorDetail::new(code, err.to_string()) } } + +fn default_sink() -> DefaultRouterSink { + #[cfg(feature = "native")] + { + saikuro_log::TracingSink + } + #[cfg(feature = "wasm")] + { + saikuro_log::ConsoleSink + } + #[cfg(any(feature = "no_std", feature = "embedded"))] + { + saikuro_log::NullSink + } +} diff --git a/Build/crates/saikuro-router/src/lib.rs b/Build/crates/saikuro-router/src/lib.rs deleted file mode 100644 index e2ef13ab..00000000 --- a/Build/crates/saikuro-router/src/lib.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Saikuro Router -//! -//! This crate owns the invocation router and provider registry. It maps -//! namespace names to provider handles and dispatches incoming envelopes. -//! -//! The crate is `no_std` + `alloc` - -#![no_std] - -#[macro_use] -extern crate alloc; - -pub mod error; -pub mod provider; -pub mod router; -pub mod stream_state; - -pub use error::RouterError; -pub use provider::{Provider, ProviderHandle, ProviderRegistry}; -pub use router::{InvocationRouter, RouterConfig}; -pub use stream_state::{ChannelState, StreamState, StreamStateStore}; diff --git a/Build/crates/saikuro-router/stream_state/mod.rs b/Build/crates/saikuro-router/stream_state/mod.rs new file mode 100644 index 00000000..d5fb8840 --- /dev/null +++ b/Build/crates/saikuro-router/stream_state/mod.rs @@ -0,0 +1,2 @@ +mod stream_state; +pub use stream_state::*; diff --git a/Build/crates/saikuro-router/src/stream_state.rs b/Build/crates/saikuro-router/stream_state/stream_state.rs similarity index 99% rename from Build/crates/saikuro-router/src/stream_state.rs rename to Build/crates/saikuro-router/stream_state/stream_state.rs index 79804abb..12195ba1 100644 --- a/Build/crates/saikuro-router/src/stream_state.rs +++ b/Build/crates/saikuro-router/stream_state/stream_state.rs @@ -1,5 +1,3 @@ -//! Per-stream and per-channel lifecycle state. - use alloc::{collections::BTreeMap, sync::Arc}; use saikuro_core::invocation::InvocationId; use saikuro_core::sync::RwLock; diff --git a/Build/tests/saikuro-router/sandbox_dispatch.rs b/Build/tests/saikuro-router/sandbox_dispatch.rs index 4451c697..72c9e9a8 100644 --- a/Build/tests/saikuro-router/sandbox_dispatch.rs +++ b/Build/tests/saikuro-router/sandbox_dispatch.rs @@ -2,93 +2,86 @@ use bytes::Bytes; use saikuro_core::{ - capability::{CapabilitySet, CapabilityToken}, - envelope::{Envelope, InvocationType}, + capability::{ CapabilitySet, CapabilityToken }, + envelope::{ Envelope, InvocationType }, schema::{ - FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, - TypeDescriptor, TypeMap, Visibility, + FunctionMap, + FunctionSchema, + NamespaceMap, + NamespaceSchema, + PrimitiveType, + Schema, + TypeDescriptor, + TypeMap, + Visibility, }, value::Value, - InvocationId, ResponseEnvelope, PROTOCOL_VERSION, -}; -use saikuro_router::{ - provider::ProviderRegistry, - router::{InvocationRouter, RouterConfig}, + InvocationId, + ResponseEnvelope, + PROTOCOL_VERSION, }; +use saikuro_router::{ provider::ProviderRegistry, router::{ InvocationRouter, RouterConfig } }; use saikuro_runtime::connection::ConnectionHandler; use saikuro_schema::{ - capability_engine::CapabilityEngine, registry::SchemaRegistry, validator::InvocationValidator, + capability_engine::CapabilityEngine, + registry::SchemaRegistry, + validator::InvocationValidator, }; use saikuro_transport::{ memory::MemoryTransport, - traits::{Transport, TransportReceiver, TransportSender}, + traits::{ Transport, TransportReceiver, TransportSender }, }; -// Helpers +// Helpers fn build_schema() -> Schema { let mut functions = FunctionMap::new(); functions - .insert( - "public_fn".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ) + .insert("public_fn".to_owned(), FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }) .ok(); functions - .insert( - "internal_fn".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Internal, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ) + .insert("internal_fn".to_owned(), FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Internal, + capabilities: vec![], + idempotent: false, + doc: None, + }) .ok(); functions - .insert( - "private_fn".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Private, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ) + .insert("private_fn".to_owned(), FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Private, + capabilities: vec![], + idempotent: false, + doc: None, + }) .ok(); functions - .insert( - "guarded_fn".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Public, - capabilities: vec![CapabilityToken::new("special.cap")], - idempotent: false, - doc: None, - }, - ) + .insert("guarded_fn".to_owned(), FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Public, + capabilities: vec![CapabilityToken::new("special.cap")], + idempotent: false, + doc: None, + }) .ok(); let mut namespaces = NamespaceMap::new(); namespaces - .insert( - "svc".to_owned(), - NamespaceSchema { - functions: Box::new(functions), - doc: None, - }, - ) + .insert("svc".to_owned(), NamespaceSchema { + functions: Box::new(functions), + doc: None, + }) .ok(); Schema { version: 1, @@ -115,7 +108,7 @@ async fn run_and_collect( schema_registry: SchemaRegistry, peer_capabilities: CapabilitySet, sandbox: bool, - envelope: Envelope, + envelope: Envelope ) -> Vec { let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler"); let (handler_sender, handler_receiver) = handler_transport.split(); @@ -157,7 +150,7 @@ async fn run_and_collect( frames } -// Tests +// Tests /// In sandbox mode, announcing a schema causes the handler to push back a /// second frame: an Announce envelope with the capability-filtered schema. @@ -201,13 +194,11 @@ fn sandbox_filtered_schema_excludes_internal_functions() { let push: Envelope = rmp_serde::from_slice(&frames[1]).expect("decode pushed announce"); let schema_value = push.args.into_iter().next().expect("args[0] must exist"); let schema_bytes = rmp_serde::to_vec_named(&schema_value).expect("re-encode"); - let filtered: Schema = - rmp_serde::from_slice(&schema_bytes).expect("decode filtered schema"); + let filtered: Schema = rmp_serde + ::from_slice(&schema_bytes) + .expect("decode filtered schema"); - let svc = filtered - .namespaces - .get("svc") - .expect("svc namespace must be present"); + let svc = filtered.namespaces.get("svc").expect("svc namespace must be present"); assert!( !svc.functions.contains_key("internal_fn"), "Internal functions must be excluded from sandbox schema" @@ -229,8 +220,9 @@ fn sandbox_filtered_schema_excludes_private_functions() { let push: Envelope = rmp_serde::from_slice(&frames[1]).expect("decode pushed announce"); let schema_value = push.args.into_iter().next().expect("args[0]"); let schema_bytes = rmp_serde::to_vec_named(&schema_value).expect("re-encode"); - let filtered: Schema = - rmp_serde::from_slice(&schema_bytes).expect("decode filtered schema"); + let filtered: Schema = rmp_serde + ::from_slice(&schema_bytes) + .expect("decode filtered schema"); let svc = filtered.namespaces.get("svc").expect("svc namespace"); assert!( @@ -254,8 +246,9 @@ fn sandbox_filtered_schema_includes_public_no_cap_functions() { let push: Envelope = rmp_serde::from_slice(&frames[1]).expect("decode pushed announce"); let schema_value = push.args.into_iter().next().expect("args[0]"); let schema_bytes = rmp_serde::to_vec_named(&schema_value).expect("re-encode"); - let filtered: Schema = - rmp_serde::from_slice(&schema_bytes).expect("decode filtered schema"); + let filtered: Schema = rmp_serde + ::from_slice(&schema_bytes) + .expect("decode filtered schema"); let svc = filtered.namespaces.get("svc").expect("svc namespace"); assert!( @@ -280,8 +273,9 @@ fn sandbox_filtered_schema_excludes_functions_peer_lacks_caps_for() { let push: Envelope = rmp_serde::from_slice(&frames[1]).expect("decode pushed announce"); let schema_value = push.args.into_iter().next().expect("args[0]"); let schema_bytes = rmp_serde::to_vec_named(&schema_value).expect("re-encode"); - let filtered: Schema = - rmp_serde::from_slice(&schema_bytes).expect("decode filtered schema"); + let filtered: Schema = rmp_serde + ::from_slice(&schema_bytes) + .expect("decode filtered schema"); let svc = filtered.namespaces.get("svc").expect("svc namespace"); assert!( @@ -306,8 +300,9 @@ fn sandbox_filtered_schema_includes_functions_peer_has_caps_for() { let push: Envelope = rmp_serde::from_slice(&frames[1]).expect("decode pushed announce"); let schema_value = push.args.into_iter().next().expect("args[0]"); let schema_bytes = rmp_serde::to_vec_named(&schema_value).expect("re-encode"); - let filtered: Schema = - rmp_serde::from_slice(&schema_bytes).expect("decode filtered schema"); + let filtered: Schema = rmp_serde + ::from_slice(&schema_bytes) + .expect("decode filtered schema"); let svc = filtered.namespaces.get("svc").expect("svc namespace"); assert!( @@ -345,9 +340,7 @@ fn sandbox_handler_denies_internal_function_invocation() { let schema = build_schema(); // Pre-register the schema so the validator can find it. - registry - .merge_schema(schema.clone(), "test-provider") - .expect("merge schema"); + registry.merge_schema(schema.clone(), "test-provider").expect("merge schema"); // Build the Invoke envelope for the internal function. let invoke_env = Envelope { @@ -367,10 +360,7 @@ fn sandbox_handler_denies_internal_function_invocation() { assert_eq!(frames.len(), 1); let resp = ResponseEnvelope::from_msgpack(&frames[0]).expect("decode response"); - assert!( - !resp.ok, - "internal function invocation must be denied in sandbox mode" - ); + assert!(!resp.ok, "internal function invocation must be denied in sandbox mode"); let err = resp.error.expect("error detail must be present"); assert_eq!( err.code, diff --git a/Build/tests/saikuro-router/stream_dispatch.rs b/Build/tests/saikuro-router/stream_dispatch.rs index 34d6d763..575ea0a1 100644 --- a/Build/tests/saikuro-router/stream_dispatch.rs +++ b/Build/tests/saikuro-router/stream_dispatch.rs @@ -1,8 +1,8 @@ //! Stream dispatch tests. -use futures::{pin_mut, poll}; +use futures::{ pin_mut, poll }; use saikuro_core::{ - envelope::{Envelope, StreamControl}, + envelope::{ Envelope, StreamControl }, error::ErrorCode, invocation::InvocationId, value::Value, @@ -10,12 +10,12 @@ use saikuro_core::{ }; use saikuro_router::provider::ProviderRegistry; use saikuro_router::router::InvocationRouter; -use saikuro_router::stream_state::{DeliveryOutcome, StreamState}; +use saikuro_router::stream_state::{ DeliveryOutcome, StreamState }; use std::task::Poll; mod common; -// Tests +// Tests #[test] fn stream_open_returns_ok_empty() { @@ -23,11 +23,15 @@ fn stream_open_returns_ok_empty() { let (registry, mut work_rx) = common::make_provider("events"); // Consume work items (provider side). - saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); + saikuro_exec::spawn(async move { + while work_rx.recv().await.is_some() {} + }); let router = InvocationRouter::with_providers(registry); - let env = Envelope::stream_open("events.subscribe", vec![Value::String("topic".into())]) - .expect("entropy available"); + let env = Envelope::stream_open( + "events.subscribe", + vec![Value::String("topic".into())] + ).expect("entropy available"); let resp = router.dispatch(env).await; assert!(resp.ok, "stream open should return ok"); @@ -48,7 +52,9 @@ fn route_stream_item_delivers_to_state() { let open_env = Envelope::stream_open("data.feed", vec![]).expect("entropy available"); let stream_id = open_env.id; - saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); + saikuro_exec::spawn(async move { + while work_rx.recv().await.is_some() {} + }); let open_resp = router.dispatch(open_env).await; assert!(open_resp.ok); @@ -69,7 +75,9 @@ fn route_stream_end_removes_state() { let open_env = Envelope::stream_open("fin.feed", vec![]).expect("entropy available"); let stream_id = open_env.id; - saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); + saikuro_exec::spawn(async move { + while work_rx.recv().await.is_some() {} + }); router.dispatch(open_env).await; @@ -119,7 +127,9 @@ fn multiple_streams_are_independent() { let (registry, mut work_rx) = common::make_provider("multi"); let router = InvocationRouter::with_providers(registry); - saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); + saikuro_exec::spawn(async move { + while work_rx.recv().await.is_some() {} + }); // Open two streams. let env1 = Envelope::stream_open("multi.s1", vec![]).expect("entropy available"); @@ -156,7 +166,9 @@ fn out_of_order_item_is_dropped_not_panicked() { let (registry, mut work_rx) = common::make_provider("ooo"); let router = InvocationRouter::with_providers(registry); - saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); + saikuro_exec::spawn(async move { + while work_rx.recv().await.is_some() {} + }); let env = Envelope::stream_open("ooo.feed", vec![]).expect("entropy available"); let id = env.id; @@ -179,7 +191,9 @@ fn stream_abort_control_removes_state() { let (registry, mut work_rx) = common::make_provider("abort"); let router = InvocationRouter::with_providers(registry); - saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); + saikuro_exec::spawn(async move { + while work_rx.recv().await.is_some() {} + }); let env = Envelope::stream_open("abort.feed", vec![]).expect("entropy available"); let id = env.id; @@ -209,9 +223,7 @@ fn concurrent_stream_delivery_preserves_order_and_terminal_closure() { saikuro_exec::block_on(async { let id = InvocationId::new().expect("entropy available"); let (tx, mut rx) = saikuro_exec::mpsc::channel(saikuro_exec::ChannelCapacity::MIN); - tx.send(ResponseEnvelope::ok_empty(id)) - .await - .expect("receiver remains open"); + tx.send(ResponseEnvelope::ok_empty(id)).await.expect("receiver remains open"); let state = StreamState::new(tx); let first = state.deliver(ResponseEnvelope::stream_item(id, 0, Value::Int(0))); @@ -224,21 +236,19 @@ fn concurrent_stream_delivery_preserves_order_and_terminal_closure() { assert!(rx.recv().await.is_some()); assert_eq!(first.await, DeliveryOutcome::Delivered); - assert_eq!(rx.recv().await.and_then(|response| response.seq), Some(0)); + assert_eq!( + rx.recv().await.and_then(|response| response.seq), + Some(0) + ); assert_eq!(terminal.await, DeliveryOutcome::Terminal); let end = rx.recv().await.expect("terminal frame is delivered"); assert_eq!(end.seq, Some(1)); assert_eq!(end.stream_control, Some(StreamControl::End)); assert_eq!( - state - .deliver(ResponseEnvelope::stream_item(id, 2, Value::Int(2))) - .await, + state.deliver(ResponseEnvelope::stream_item(id, 2, Value::Int(2))).await, DeliveryOutcome::Closed ); - assert!( - rx.try_recv().is_err(), - "post-terminal frame was not delivered" - ); + assert!(rx.try_recv().is_err(), "post-terminal frame was not delivered"); }) } From 7fc793765014790fc9697c0f5453fea53aa0e05a Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Thu, 13 Aug 2026 23:07:27 -0600 Subject: [PATCH 28/43] saikuro-schema --- Build/crates/saikuro-router/Cargo.toml | 2 - Build/crates/saikuro-schema/Cargo.toml | 14 ++-- .../engine.rs} | 26 +------ Build/crates/saikuro-schema/capability/mod.rs | 2 + Build/crates/saikuro-schema/lib.rs | 18 +++++ Build/crates/saikuro-schema/registry/mod.rs | 2 + .../{src => registry}/registry.rs | 67 +------------------ Build/crates/saikuro-schema/src/lib.rs | 20 ------ Build/crates/saikuro-schema/validator/mod.rs | 2 + .../{src => validator}/validator.rs | 49 ++------------ Build/crates/saikuro-storage/Cargo.toml | 2 - 11 files changed, 42 insertions(+), 162 deletions(-) rename Build/crates/saikuro-schema/{src/capability_engine.rs => capability/engine.rs} (74%) create mode 100644 Build/crates/saikuro-schema/capability/mod.rs create mode 100644 Build/crates/saikuro-schema/lib.rs create mode 100644 Build/crates/saikuro-schema/registry/mod.rs rename Build/crates/saikuro-schema/{src => registry}/registry.rs (80%) delete mode 100644 Build/crates/saikuro-schema/src/lib.rs create mode 100644 Build/crates/saikuro-schema/validator/mod.rs rename Build/crates/saikuro-schema/{src => validator}/validator.rs (86%) diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index 77af21bc..2203c7c7 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -43,8 +43,6 @@ embedded = [ "saikuro-log/embedded", "saikuro-log/null", ] -custom = ["embedded"] -drbg = ["embedded"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } diff --git a/Build/crates/saikuro-schema/Cargo.toml b/Build/crates/saikuro-schema/Cargo.toml index a94f7e55..6cf11cfa 100644 --- a/Build/crates/saikuro-schema/Cargo.toml +++ b/Build/crates/saikuro-schema/Cargo.toml @@ -8,14 +8,18 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "schema", "validation"] +[lib] +path = "lib.rs" + [features] -default = ["std"] -std = ["saikuro-core/std"] -custom = ["saikuro-core/custom"] -drbg = ["saikuro-core/embedded"] +default = ["std", "native"] +std = [] +native = ["std", "saikuro-core/native"] +no_std = ["saikuro-core/no_std"] +wasm = ["saikuro-core/wasm"] +embedded = ["saikuro-core/embedded"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } thiserror = { workspace = true } -tracing = { workspace = true } diff --git a/Build/crates/saikuro-schema/src/capability_engine.rs b/Build/crates/saikuro-schema/capability/engine.rs similarity index 74% rename from Build/crates/saikuro-schema/src/capability_engine.rs rename to Build/crates/saikuro-schema/capability/engine.rs index 18038315..29bdabc2 100644 --- a/Build/crates/saikuro-schema/src/capability_engine.rs +++ b/Build/crates/saikuro-schema/capability/engine.rs @@ -1,25 +1,8 @@ -//! Capability enforcement engine. -//! -//! The capability engine is responsible for one thing: answering the question -//! "does this peer hold all the capabilities required to invoke this function?". -//! -//! It is intentionally kept stateless and pure: all state lives in the -//! [`CapabilitySet`] that the caller presents. The engine never issues tokens; -//! that is the responsibility of the handshake layer (not yet in v1 scope). -//! -//! Sandbox mode: -//! When `sandbox_mode` is enabled, even requests that pass capability checks -//! are further restricted: the engine only exposes the subset of namespaces -//! declared in the peer's sandbox schema. Additionally, functions with -//! [`Visibility::Internal`] visibility are treated as inaccessible: only -//! `Public` functions are reachable by sandboxed peers. - use alloc::{borrow::ToOwned, string::String, vec::Vec}; use saikuro_core::{ capability::{CapabilitySet, CapabilityToken}, schema::{FunctionSchema, Visibility}, }; -use tracing::debug; use crate::registry::FunctionRef; @@ -65,7 +48,7 @@ impl CapabilityEngine { /// `function_schema`. /// /// In sandbox mode, [`Visibility::Internal`] functions are always denied - /// regardless of capabilities: they are not accessible to untrusted peers. + /// regardless of capabilities: they are not accessible to untrusted peers. /// /// Returns [`CapabilityOutcome::Granted`] if all requirements are met, or /// [`CapabilityOutcome::Denied`] with the first missing token otherwise. @@ -76,7 +59,6 @@ impl CapabilityEngine { ) -> CapabilityOutcome { // In sandbox mode, Internal-visibility functions are inaccessible. if self.sandbox_mode && function_schema.visibility == Visibility::Internal { - debug!("sandbox: denying access to Internal function"); return CapabilityOutcome::Denied { missing: CapabilityToken::new("$sandbox.public_only"), }; @@ -84,10 +66,6 @@ impl CapabilityEngine { for required in &function_schema.capabilities { if !caller_caps.grants(required) { - debug!( - missing = %required, - "capability check failed" - ); return CapabilityOutcome::Denied { missing: required.clone(), }; @@ -106,7 +84,7 @@ impl CapabilityEngine { } /// Filter a list of function names down to only those visible and callable - /// with the given capability set. Used to generate sandbox-restricted schemas. + /// with the given capability set. /// /// In sandbox mode this additionally excludes `Internal` functions. /// `Private` functions are always excluded. diff --git a/Build/crates/saikuro-schema/capability/mod.rs b/Build/crates/saikuro-schema/capability/mod.rs new file mode 100644 index 00000000..807da8f2 --- /dev/null +++ b/Build/crates/saikuro-schema/capability/mod.rs @@ -0,0 +1,2 @@ +mod engine; +pub use engine::*; diff --git a/Build/crates/saikuro-schema/lib.rs b/Build/crates/saikuro-schema/lib.rs new file mode 100644 index 00000000..8e151b24 --- /dev/null +++ b/Build/crates/saikuro-schema/lib.rs @@ -0,0 +1,18 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![deny(missing_docs)] + +extern crate alloc; + +pub mod engine; +pub mod registry; +pub mod validator; + +pub use engine::CapabilityEngine; +pub use registry::{ NamespaceRegistration, SchemaRegistry }; +pub use validator::{ InvocationValidator, ValidationReport }; + +// Compilation guard: exactly one engine backend must be selected. +#[cfg(not(any(feature = "native", feature = "no_std", feature = "wasm", feature = "embedded")))] +compile_error!( + "saikuro-schema: enable exactly one engine feature: native, no_std, wasm, or embedded" +); diff --git a/Build/crates/saikuro-schema/registry/mod.rs b/Build/crates/saikuro-schema/registry/mod.rs new file mode 100644 index 00000000..f61c4754 --- /dev/null +++ b/Build/crates/saikuro-schema/registry/mod.rs @@ -0,0 +1,2 @@ +mod registry; +pub use registry::*; diff --git a/Build/crates/saikuro-schema/src/registry.rs b/Build/crates/saikuro-schema/registry/registry.rs similarity index 80% rename from Build/crates/saikuro-schema/src/registry.rs rename to Build/crates/saikuro-schema/registry/registry.rs index 72a7fb20..118bfc81 100644 --- a/Build/crates/saikuro-schema/src/registry.rs +++ b/Build/crates/saikuro-schema/registry/registry.rs @@ -1,20 +1,3 @@ -//! Schema registry: the live, thread-safe store of all namespace schemas. -//! -//! The registry is the single source of truth for "what functions exist and -//! how are they typed?". It is shared (via `Arc` (not the browser)) across the runtime's -//! components and updated atomically when new providers register or schemas -//! are hot-reloaded. -//! -//! In **development mode** providers announce their schemas at connection time -//! and the registry merges them in. In **production mode** schemas are loaded -//! from a frozen file at startup and providers cannot alter them. -//! -//! All state lives in a single `RwLock` from `saikuro-core::sync` so that the -//! mode check and the mutations it guards are atomic (a registered namespace -//! can never be half-applied against a changing mode). The lock is held only -//! for short map operations and never across an `await`. Keys are ordered -//! `BTreeMap`s for deterministic iteration on both host and MCU targets. - use alloc::{borrow::ToOwned, collections::BTreeMap, string::String, sync::Arc, vec::Vec}; use saikuro_core::schema::{ FunctionSchema, NamespaceSchema, Schema, TypeDefinition, SCHEMA_NAMESPACES_CAPACITY, @@ -22,11 +5,9 @@ use saikuro_core::schema::{ }; use saikuro_core::sync::RwLock; use saikuro_core::RegistrationToken; -use tracing::{debug, info, warn}; use crate::validator::ValidationError; -// Modes /// Whether the registry accepts dynamic schema updates. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -37,8 +18,7 @@ pub enum RegistryMode { Production, } -// Registration descriptor - +// Registration descriptor /// All information a provider submits when it registers a namespace. #[derive(Debug, Clone)] pub struct NamespaceRegistration { @@ -52,8 +32,7 @@ pub struct NamespaceRegistration { pub registration_token: RegistrationToken, } -// Registry - +// Registry /// All registry state, guarded as a unit by [`SchemaRegistry`]'s lock. struct Schemata { /// Per-namespace schemas and their owning provider ID. @@ -72,9 +51,6 @@ struct NamespaceEntry { } /// The live schema registry. -/// -/// Reads are shared-lock `BTreeMap` lookups; writes (registrations, merges) -/// go through the exclusive lock and are infrequent. #[derive(Clone)] pub struct SchemaRegistry { inner: Arc>, @@ -114,17 +90,12 @@ impl SchemaRegistry { for (type_name, type_def) in (*schema.types).into_iter() { schemata.types.insert(type_name, type_def); } - info!( - "schema registry frozen with {} namespace(s)", - schemata.namespaces.len() - ); Self { inner: Arc::new(RwLock::new(schemata)), } } /// Register (or replace) a namespace. - /// /// In production mode this returns an error rather than mutating state. pub fn register(&self, registration: NamespaceRegistration) -> Result<(), RegistryError> { let mut schemata = self.inner.write(); @@ -138,12 +109,6 @@ impl SchemaRegistry { { return Err(RegistryError::SchemaCapacity); } - if schemata.namespaces.contains_key(&ns) { - warn!(namespace = %ns, "overwriting existing namespace schema"); - } else { - debug!(namespace = %ns, provider = %registration.provider_id, "registering namespace"); - } - schemata.namespaces.insert( ns, NamespaceEntry { @@ -156,9 +121,6 @@ impl SchemaRegistry { } /// Merge an entire [`Schema`] document into the registry. - /// - /// Types are added to the shared type library; namespaces are registered - /// under `provider_id`. pub fn merge_schema( &self, schema: Schema, @@ -207,11 +169,6 @@ impl SchemaRegistry { } for (ns_name, ns_schema) in (*schema.namespaces).into_iter() { let ns = ns_name.clone(); - if schemata.namespaces.contains_key(&ns) { - warn!(namespace = %ns, "overwriting existing namespace schema"); - } else { - debug!(namespace = %ns, provider = %provider_id, "registering namespace"); - } schemata.namespaces.insert( ns, NamespaceEntry { @@ -225,9 +182,6 @@ impl SchemaRegistry { } /// Remove namespaces owned by one specific provider registration. - /// - /// A stale disconnect cannot remove schemas from a newer registration that - /// reused the same provider ID. pub fn deregister_provider(&self, provider_id: &str, registration_token: RegistrationToken) { let mut schemata = self.inner.write(); if schemata.mode == RegistryMode::Production { @@ -236,15 +190,11 @@ impl SchemaRegistry { schemata.namespaces.retain(|_ns, entry| { let keep = entry.provider_id != provider_id || entry.registration_token != registration_token; - if !keep { - debug!(provider = %provider_id, "deregistered namespace on disconnect"); - } keep }); } /// Look up the schema for a single function. - /// /// `target` must be in `"namespace.function"` format. pub fn lookup_function(&self, target: &str) -> Result { let (ns_name, fn_name) = split_target(target)?; @@ -290,11 +240,6 @@ impl SchemaRegistry { } /// Export a snapshot of the full schema at this instant. - /// - /// The registry stores its maps in unbounded `BTreeMap`s while the - /// exported [`Schema`] uses fixed-capacity heapless maps, so a registry - /// larger than the schema's capacity fails rather than truncating the - /// snapshot silently. pub fn snapshot(&self) -> Result { let mut schema = Schema::new(); let schemata = self.inner.read(); @@ -316,7 +261,6 @@ impl SchemaRegistry { /// Freeze the registry, preventing any further schema changes. pub fn freeze(&self) { self.inner.write().mode = RegistryMode::Production; - info!("schema registry frozen"); } /// Return the current operating mode. @@ -331,8 +275,6 @@ impl Default for SchemaRegistry { } } -// Resolved reference - /// A fully-resolved reference to a function schema plus its owning provider. #[derive(Debug, Clone)] pub struct FunctionRef { @@ -342,8 +284,7 @@ pub struct FunctionRef { pub provider_id: String, } -// Registry error - +// Registry error #[derive(Debug, thiserror::Error)] pub enum RegistryError { #[error("namespace not found: {0}")] @@ -365,8 +306,6 @@ pub enum RegistryError { SchemaCapacity, } -// Helpers - /// Split a `"namespace.function"` target into its two components. fn split_target(target: &str) -> Result<(&str, &str), RegistryError> { saikuro_core::split_target(target) diff --git a/Build/crates/saikuro-schema/src/lib.rs b/Build/crates/saikuro-schema/src/lib.rs deleted file mode 100644 index 50d10964..00000000 --- a/Build/crates/saikuro-schema/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Saikuro Schema -//! -//! This crate owns the runtime schema registry, invocation validator, and -//! capability enforcement engine. It is the source of truth for "is this -//! invocation well-formed and permitted?". -//! -//! The crate is always `no_std` + `alloc` - -#![no_std] - -#[macro_use] -extern crate alloc; - -pub mod capability_engine; -pub mod registry; -pub mod validator; - -pub use capability_engine::CapabilityEngine; -pub use registry::{NamespaceRegistration, SchemaRegistry}; -pub use validator::{InvocationValidator, ValidationReport}; diff --git a/Build/crates/saikuro-schema/validator/mod.rs b/Build/crates/saikuro-schema/validator/mod.rs new file mode 100644 index 00000000..e727ec61 --- /dev/null +++ b/Build/crates/saikuro-schema/validator/mod.rs @@ -0,0 +1,2 @@ +mod validator; +pub use validator::*; diff --git a/Build/crates/saikuro-schema/src/validator.rs b/Build/crates/saikuro-schema/validator/validator.rs similarity index 86% rename from Build/crates/saikuro-schema/src/validator.rs rename to Build/crates/saikuro-schema/validator/validator.rs index cddf20d6..b615475c 100644 --- a/Build/crates/saikuro-schema/src/validator.rs +++ b/Build/crates/saikuro-schema/validator/validator.rs @@ -1,20 +1,3 @@ -//! Invocation validator. -//! -//! The validator sits between the transport layer and the router. Every -//! inbound [`Envelope`] passes through here before being dispatched: -//! -//! 1. Protocol version check. -//! 2. Envelope structural integrity (required fields present, well-formed -//! target, batch items non-empty when type is Batch, …). -//! 3. Schema lookup (does the target function exist?). -//! 4. Argument arity and type checking. -//! 5. Visibility enforcement (private/internal functions are not callable -//! from external peers). -//! 6. Capability checking is delegated to [`CapabilityEngine`]. -//! -//! All errors are returned as typed [`ValidationError`] values so the -//! runtime can produce the right [`ErrorCode`] on the wire. - use alloc::{ borrow::ToOwned, boxed::Box, @@ -31,7 +14,7 @@ use thiserror::Error; use crate::registry::{FunctionRef, RegistryError, SchemaRegistry}; -// Errors +// Errors /// A validation failure. #[derive(Debug, Error)] @@ -104,21 +87,14 @@ impl ValidationError { } } -// Report - -/// The result of a successful validation pass. Carries the resolved function -/// reference so the router doesn't need to look it up again. +/// The result of a successful validation pass. #[derive(Debug)] pub struct ValidationReport { /// The fully resolved function and its owning provider. pub function_ref: FunctionRef, } -// Validator - /// Stateless invocation validator. -/// -/// This is `Clone`-cheap because the [`SchemaRegistry`] behind it is `Arc`-shared. #[derive(Clone)] pub struct InvocationValidator { registry: SchemaRegistry, @@ -144,8 +120,6 @@ impl InvocationValidator { } /// Validate a single envelope. - /// - /// For [`InvocationType::Batch`] each item is validated recursively. pub fn validate(&self, envelope: &Envelope) -> Result { // 1. Protocol version. if envelope.version != PROTOCOL_VERSION { @@ -155,14 +129,11 @@ impl InvocationValidator { }); } - // 2. Envelope structure. + // Envelope structure. self.check_structural(envelope)?; match envelope.invocation_type { InvocationType::Batch => self.validate_batch(envelope), - // Log and Announce are system envelopes handled before schema lookup; - // they bypass function-level validation entirely. Return a synthetic - // report that won't be used for capability checking. InvocationType::Log | InvocationType::Announce => Ok(ValidationReport { function_ref: crate::registry::FunctionRef { namespace: String::new(), @@ -185,10 +156,7 @@ impl InvocationValidator { } // Structural checks - fn check_structural(&self, envelope: &Envelope) -> Result<(), ValidationError> { - // Target must be "namespace.function": except for system envelope types - // (Log, Announce, Batch) that use special targets or no target at all. let skip_target_check = matches!( envelope.invocation_type, InvocationType::Batch | InvocationType::Log | InvocationType::Announce @@ -213,7 +181,6 @@ impl InvocationValidator { } // Single-invocation validation - fn validate_single(&self, envelope: &Envelope) -> Result { // Schema lookup. let func_ref = self.registry.lookup_function(&envelope.target)?; @@ -230,7 +197,6 @@ impl InvocationValidator { } // Batch validation - fn validate_batch(&self, envelope: &Envelope) -> Result { let items = envelope.batch_items.as_ref().ok_or_else(|| { ValidationError::MalformedEnvelope("batch envelope missing batch_items".into()) @@ -245,9 +211,8 @@ impl InvocationValidator { })?; } - // For batch we return a synthetic report. The router will dispatch each + // For batch we return a synthetic report. The router will dispatch each // item individually and collect results. - // We use the first item's function ref as the representative report. let first_ref = self.registry.lookup_function(&items[0].target)?; Ok(ValidationReport { @@ -256,7 +221,6 @@ impl InvocationValidator { } // Helpers - fn check_visibility( &self, target: &str, @@ -319,9 +283,6 @@ impl InvocationValidator { } /// Recursively check that `value` is compatible with `descriptor`. - /// - /// We apply structural subtype checking rather than exact nominal checking: - /// e.g. an `i32` value is accepted where `i64` is declared. fn check_value_type( &self, target: &str, @@ -344,8 +305,6 @@ impl InvocationValidator { TypeDescriptor::Named { .. } => { // Named types must be maps (record) or strings (enum variants). - // Full structural validation against the type definition is a - // future enhancement; for now we accept maps and strings. match value { Value::Map(_) | Value::String(_) => Ok(()), Value::Null => Ok(()), // null is always acceptable for named types diff --git a/Build/crates/saikuro-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index 8d1cc0f1..06505877 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -11,8 +11,6 @@ keywords = ["ipc", "cross-language", "saikuro", "storage", "key-value"] [features] default = ["std", "native-storage"] std = [] -custom = ["saikuro-core/custom"] -drbg = ["saikuro-core/embedded"] native-storage = [ "std", "inmemory", From 27d1d2e26c0cf6b7395e144a9f7f77875c635651 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Fri, 14 Aug 2026 00:04:45 -0600 Subject: [PATCH 29/43] saikuro-core not scope-creeping --- Build/crates/saikuro-core/Cargo.toml | 1 - Build/crates/saikuro-core/lib.rs | 3 - Build/crates/saikuro-core/sync/mod.rs | 3 - Build/crates/saikuro-core/sync/sync.rs | 159 ------------------ .../saikuro-router/provider/provider.rs | 17 +- Build/crates/saikuro-router/router/router.rs | 38 ++--- .../stream_state/stream_state.rs | 33 ++-- Build/crates/saikuro-schema/Cargo.toml | 9 +- .../saikuro-schema/registry/registry.rs | 44 ++--- .../saikuro-schema/validator/validator.rs | 16 +- 10 files changed, 79 insertions(+), 244 deletions(-) delete mode 100644 Build/crates/saikuro-core/sync/mod.rs delete mode 100644 Build/crates/saikuro-core/sync/sync.rs diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index 911867f3..db22c3cb 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -30,6 +30,5 @@ saikuro-random = { workspace = true, default-features = false } thiserror = { workspace = true, default-features = false } strum = { workspace = true } heapless = { workspace = true } -spin = { workspace = true } messagepack-serde = { workspace = true } portable-atomic = { workspace = true } diff --git a/Build/crates/saikuro-core/lib.rs b/Build/crates/saikuro-core/lib.rs index e561b115..c1726788 100644 --- a/Build/crates/saikuro-core/lib.rs +++ b/Build/crates/saikuro-core/lib.rs @@ -15,9 +15,6 @@ pub use value::*; mod codec; pub use codec::*; -mod sync; -pub use sync::*; - // Engine selection guard #[cfg(all( feature = "native", diff --git a/Build/crates/saikuro-core/sync/mod.rs b/Build/crates/saikuro-core/sync/mod.rs deleted file mode 100644 index 2ff84677..00000000 --- a/Build/crates/saikuro-core/sync/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod sync; - -pub use sync::*; diff --git a/Build/crates/saikuro-core/sync/sync.rs b/Build/crates/saikuro-core/sync/sync.rs deleted file mode 100644 index a764997d..00000000 --- a/Build/crates/saikuro-core/sync/sync.rs +++ /dev/null @@ -1,159 +0,0 @@ -use core::fmt; -use core::ops::{Deref, DerefMut}; - -#[cfg(feature = "std")] -use std::sync as imp; - -#[cfg(not(feature = "std"))] -use spin as imp; - -/// A reader-writer lock. `read`/`write` return guards that deref to the -/// protected value. -pub struct RwLock { - inner: imp::RwLock, -} - -/// Guard acquired by [`RwLock::read`]. -pub struct RwLockReadGuard<'a, T: ?Sized> { - inner: imp::RwLockReadGuard<'a, T>, -} - -/// Guard acquired by [`RwLock::write`]. -pub struct RwLockWriteGuard<'a, T: ?Sized> { - inner: imp::RwLockWriteGuard<'a, T>, -} - -/// A mutual-exclusion lock. `lock` returns a guard that derefs to the -/// protected value. -pub struct Mutex { - inner: imp::Mutex, -} - -/// Guard acquired by [`Mutex::lock`]. -pub struct MutexGuard<'a, T: ?Sized> { - inner: imp::MutexGuard<'a, T>, -} - -impl RwLock { - /// Create a new lock guarding `value`. - pub const fn new(value: T) -> Self { - Self { - inner: imp::RwLock::new(value), - } - } -} - -impl Default for RwLock { - fn default() -> Self { - Self::new(T::default()) - } -} - -impl RwLock { - /// Acquire the read guard. - pub fn read(&self) -> RwLockReadGuard<'_, T> { - #[cfg(feature = "std")] - let inner = self - .inner - .read() - .expect("RwLock poisoned by a panicking guard holder"); - #[cfg(not(feature = "std"))] - let inner = self.inner.read(); - RwLockReadGuard { inner } - } - - /// Acquire the write guard. - pub fn write(&self) -> RwLockWriteGuard<'_, T> { - #[cfg(feature = "std")] - let inner = self - .inner - .write() - .expect("RwLock poisoned by a panicking guard holder"); - #[cfg(not(feature = "std"))] - let inner = self.inner.write(); - RwLockWriteGuard { inner } - } -} - -impl Deref for RwLockReadGuard<'_, T> { - type Target = T; - - fn deref(&self) -> &T { - &self.inner - } -} - -impl Deref for RwLockWriteGuard<'_, T> { - type Target = T; - - fn deref(&self) -> &T { - &self.inner - } -} - -impl DerefMut for RwLockWriteGuard<'_, T> { - fn deref_mut(&mut self) -> &mut T { - &mut self.inner - } -} - -impl Mutex { - /// Create a new mutex guarding `value`. - pub const fn new(value: T) -> Self { - Self { - inner: imp::Mutex::new(value), - } - } -} - -impl Default for Mutex { - fn default() -> Self { - Self::new(T::default()) - } -} - -impl Mutex { - /// Acquire the guard. - pub fn lock(&self) -> MutexGuard<'_, T> { - #[cfg(feature = "std")] - let inner = self - .inner - .lock() - .expect("Mutex poisoned by a panicking guard holder"); - #[cfg(not(feature = "std"))] - let inner = self.inner.lock(); - MutexGuard { inner } - } -} - -impl Deref for MutexGuard<'_, T> { - type Target = T; - - fn deref(&self) -> &T { - &self.inner - } -} - -impl DerefMut for MutexGuard<'_, T> { - fn deref_mut(&mut self) -> &mut T { - &mut self.inner - } -} - -// Debug - -impl fmt::Debug for RwLock { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("RwLock") - .field("inner", &&self.inner) - .finish() - } -} - -impl fmt::Debug for Mutex { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Mutex") - .field("inner", &&self.inner) - .finish() - } -} diff --git a/Build/crates/saikuro-router/provider/provider.rs b/Build/crates/saikuro-router/provider/provider.rs index 317dd1d1..f0af0bfe 100644 --- a/Build/crates/saikuro-router/provider/provider.rs +++ b/Build/crates/saikuro-router/provider/provider.rs @@ -2,7 +2,8 @@ use alloc::{ borrow::ToOwned, boxed::Box, collections::BTreeMap, string::String, sync::Arc, vec::Vec, }; use async_trait::async_trait; -use saikuro_core::{envelope::Envelope, sync::RwLock, RegistrationToken, ResponseEnvelope}; +use saikuro_core::{envelope::Envelope, RegistrationToken, ResponseEnvelope}; +use saikuro_exec::sync::RwLock; use saikuro_exec::{mpsc, oneshot}; use crate::error::{Result, RouterError}; @@ -134,13 +135,13 @@ impl ProviderRegistry { } /// Register a provider handle for the given namespaces. - pub fn register(&self, handle: ProviderHandle) { + pub async fn register(&self, handle: ProviderHandle) { let provider_id = handle.id().to_owned(); let registration_token = handle.registration_token(); let provider_key = (provider_id.clone(), registration_token); let namespaces = handle.namespaces().to_vec(); - let mut state = self.inner.write(); + let mut state = self.inner.write().await; // A re-registering provider that dropped a namespace must release its route. let dropped: Vec = state @@ -183,8 +184,8 @@ impl ProviderRegistry { } /// Remove all namespaces owned by one specific provider registration. - pub fn deregister(&self, provider_id: &str, registration_token: RegistrationToken) { - let mut state = self.inner.write(); + pub async fn deregister(&self, provider_id: &str, registration_token: RegistrationToken) { + let mut state = self.inner.write().await; let provider_key = (provider_id.to_owned(), registration_token); if let Some(namespaces) = state.by_provider.remove(&provider_key) { for ns in namespaces { @@ -201,12 +202,12 @@ impl ProviderRegistry { } /// Look up the provider for a namespace. - pub fn get(&self, namespace: &str) -> Option { - self.inner.read().by_namespace.get(namespace).cloned() + pub async fn get(&self, namespace: &str) -> Option { + self.inner.read().await.by_namespace.get(namespace).cloned() } /// Return `true` if a live provider exists for the namespace. - pub fn has_live_provider(&self, namespace: &str) -> bool { + pub async fn has_live_provider(&self, namespace: &str) -> bool { self.inner .read() .by_namespace diff --git a/Build/crates/saikuro-router/router/router.rs b/Build/crates/saikuro-router/router/router.rs index e039f91c..d4f70fac 100644 --- a/Build/crates/saikuro-router/router/router.rs +++ b/Build/crates/saikuro-router/router/router.rs @@ -137,7 +137,7 @@ impl InvocationRouter { async fn dispatch_call(&self, envelope: Envelope) -> ResponseEnvelope { let id = envelope.id; - let provider = match self.resolve_namespace(&envelope.target) { + let provider = match self.resolve_namespace(&envelope.target).await { Ok(p) => p, Err(e) => return error_response(id, e.into()), }; @@ -192,7 +192,7 @@ impl InvocationRouter { async fn dispatch_cast(&self, envelope: Envelope) -> ResponseEnvelope { let id = envelope.id; - let provider = match self.resolve_namespace(&envelope.target) { + let provider = match self.resolve_namespace(&envelope.target).await { Ok(p) => p, Err(e) => return error_response(id, e.into()), }; @@ -217,18 +217,18 @@ impl InvocationRouter { async fn dispatch_stream_open(&self, envelope: Envelope) -> ResponseEnvelope { let id = envelope.id; - let provider = match self.resolve_namespace(&envelope.target) { + let provider = match self.resolve_namespace(&envelope.target).await { Ok(p) => p, Err(e) => return error_response(id, e.into()), }; let (item_tx, item_rx) = mpsc::channel(self.config.stream_channel_capacity); let state = StreamState::new(item_tx); - self.streams.insert_stream(id, state, item_rx); + self.streams.insert_stream(id, state, item_rx).await; // Send the open request; the provider will start sending items. if let Err(e) = provider.send_invocation(envelope, None).await { - self.streams.remove_stream(&id); + self.streams.remove_stream(&id).await; return error_response(id, e.into()); } @@ -248,7 +248,7 @@ impl InvocationRouter { let id = envelope.id; // If a channel with this id already exists, treat as data frame - if let Some(channel) = self.streams.get_channel(&id) { + if let Some(channel) = self.streams.get_channel(&id).await { // Map the Envelope to a ResponseEnvelope for channel data delivery let resp = ResponseEnvelope { id, @@ -260,11 +260,11 @@ impl InvocationRouter { }; match channel.deliver(resp, true).await { DeliveryOutcome::Terminal => { - self.streams.remove_channel_if(&id, &channel); + self.streams.remove_channel_if(&id, &channel).await; return ResponseEnvelope::ok_empty(id); } DeliveryOutcome::Closed => { - self.streams.remove_channel_if(&id, &channel); + self.streams.remove_channel_if(&id, &channel).await; return error_response(id, RouterError::ChannelClosed(id.to_string()).into()); } DeliveryOutcome::OutOfOrder => { @@ -291,7 +291,7 @@ impl InvocationRouter { } // Otherwise, open a new channel as before - let provider = match self.resolve_namespace(&envelope.target) { + let provider = match self.resolve_namespace(&envelope.target).await { Ok(p) => p, Err(e) => return error_response(id, e.into()), }; @@ -300,10 +300,10 @@ impl InvocationRouter { let (outbound_tx, outbound_rx) = mpsc::channel(self.config.channel_capacity); let state = ChannelState::new(inbound_tx, outbound_tx); self.streams - .insert_channel(id, state, inbound_rx, outbound_rx); + .insert_channel(id, state, inbound_rx, outbound_rx).await; if let Err(e) = provider.send_invocation(envelope, None).await { - self.streams.remove_channel(&id); + self.streams.remove_channel(&id).await; return error_response(id, e.into()); } @@ -393,12 +393,12 @@ impl InvocationRouter { let id = response.id; let state = self .streams - .get_channel(&id) + .get_channel(&id).await .ok_or_else(|| RouterError::ChannelNotFound(id.to_string()))?; match state.deliver(response, inbound).await { DeliveryOutcome::Closed => { - self.streams.remove_channel_if(&id, &state); + self.streams.remove_channel_if(&id, &state).await; Err(RouterError::ChannelClosed(id.to_string())) } DeliveryOutcome::OutOfOrder => { @@ -413,7 +413,7 @@ impl InvocationRouter { Ok(()) } DeliveryOutcome::Terminal => { - self.streams.remove_channel_if(&id, &state); + self.streams.remove_channel_if(&id, &state).await; Ok(()) } DeliveryOutcome::Delivered => Ok(()), @@ -435,12 +435,12 @@ impl InvocationRouter { let id = response.id; let state = self .streams - .get_stream(&id) + .get_stream(&id).await .ok_or_else(|| RouterError::StreamNotFound(id.to_string()))?; match state.deliver(response).await { DeliveryOutcome::Closed => { - self.streams.remove_stream_if(&id, &state); + self.streams.remove_stream_if(&id, &state).await; Err(RouterError::StreamClosed(id.to_string())) } DeliveryOutcome::OutOfOrder => { @@ -455,7 +455,7 @@ impl InvocationRouter { Ok(()) } DeliveryOutcome::Terminal => { - self.streams.remove_stream_if(&id, &state); + self.streams.remove_stream_if(&id, &state).await; Ok(()) } DeliveryOutcome::Delivered => Ok(()), @@ -463,13 +463,13 @@ impl InvocationRouter { } // Helpers - fn resolve_namespace(&self, target: &str) -> Result { + async fn resolve_namespace(&self, target: &str) -> Result { let ns = namespace_of(target).ok_or_else(|| RouterError::MalformedTarget(target.to_owned()))?; let handle = self .providers - .get(ns) + .get(ns).await .ok_or_else(|| RouterError::NoProvider(ns.to_owned()))?; if !handle.is_alive() { diff --git a/Build/crates/saikuro-router/stream_state/stream_state.rs b/Build/crates/saikuro-router/stream_state/stream_state.rs index 12195ba1..46ffb955 100644 --- a/Build/crates/saikuro-router/stream_state/stream_state.rs +++ b/Build/crates/saikuro-router/stream_state/stream_state.rs @@ -1,8 +1,7 @@ use alloc::{collections::BTreeMap, sync::Arc}; use saikuro_core::invocation::InvocationId; -use saikuro_core::sync::RwLock; use saikuro_core::ResponseEnvelope; -use saikuro_exec::{mpsc, sync::Mutex}; +use saikuro_exec::{mpsc, sync::{Mutex, RwLock}}; /// Result of attempting to deliver one frame. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -150,13 +149,13 @@ impl StreamStateStore { Self::default() } - pub fn insert_stream( + pub async fn insert_stream( &self, id: InvocationId, state: Arc, receiver: mpsc::Receiver, ) { - self.streams.write().insert( + self.streams.write().await.insert( id, StreamEntry { state, @@ -165,16 +164,16 @@ impl StreamStateStore { ); } - pub fn get_stream(&self, id: &InvocationId) -> Option> { - self.streams.read().get(id).map(|entry| entry.state.clone()) + pub async fn get_stream(&self, id: &InvocationId) -> Option> { + self.streams.read().await.get(id).map(|entry| entry.state.clone()) } - pub fn remove_stream(&self, id: &InvocationId) -> Option> { - self.streams.write().remove(id).map(|entry| entry.state) + pub async fn remove_stream(&self, id: &InvocationId) -> Option> { + self.streams.write().await.remove(id).map(|entry| entry.state) } - pub fn remove_stream_if(&self, id: &InvocationId, state: &Arc) -> bool { - let mut streams = self.streams.write(); + pub async fn remove_stream_if(&self, id: &InvocationId, state: &Arc) -> bool { + let mut streams = self.streams.write().await; if streams .get(id) .is_some_and(|entry| Arc::ptr_eq(&entry.state, state)) @@ -186,7 +185,7 @@ impl StreamStateStore { } } - pub fn take_stream_receiver( + pub async fn take_stream_receiver( &self, id: &InvocationId, ) -> Option> { @@ -196,7 +195,7 @@ impl StreamStateStore { .and_then(|entry| entry.receiver.take()) } - pub fn insert_channel( + pub async fn insert_channel( &self, id: InvocationId, state: Arc, @@ -213,18 +212,18 @@ impl StreamStateStore { ); } - pub fn get_channel(&self, id: &InvocationId) -> Option> { + pub async fn get_channel(&self, id: &InvocationId) -> Option> { self.channels .read() .get(id) .map(|entry| entry.state.clone()) } - pub fn remove_channel(&self, id: &InvocationId) -> Option> { + pub async fn remove_channel(&self, id: &InvocationId) -> Option> { self.channels.write().remove(id).map(|entry| entry.state) } - pub fn remove_channel_if(&self, id: &InvocationId, state: &Arc) -> bool { + pub async fn remove_channel_if(&self, id: &InvocationId, state: &Arc) -> bool { let mut channels = self.channels.write(); if channels .get(id) @@ -237,7 +236,7 @@ impl StreamStateStore { } } - pub fn take_channel_inbound_receiver( + pub async fn take_channel_inbound_receiver( &self, id: &InvocationId, ) -> Option> { @@ -247,7 +246,7 @@ impl StreamStateStore { .and_then(|entry| entry.inbound_receiver.take()) } - pub fn take_channel_outbound_receiver( + pub async fn take_channel_outbound_receiver( &self, id: &InvocationId, ) -> Option> { diff --git a/Build/crates/saikuro-schema/Cargo.toml b/Build/crates/saikuro-schema/Cargo.toml index 6cf11cfa..bdaa73c7 100644 --- a/Build/crates/saikuro-schema/Cargo.toml +++ b/Build/crates/saikuro-schema/Cargo.toml @@ -14,12 +14,13 @@ path = "lib.rs" [features] default = ["std", "native"] std = [] -native = ["std", "saikuro-core/native"] -no_std = ["saikuro-core/no_std"] -wasm = ["saikuro-core/wasm"] -embedded = ["saikuro-core/embedded"] +native = ["std", "saikuro-core/native", "saikuro-exec/native"] +no_std = ["saikuro-core/no_std", "saikuro-exec/no_std"] +wasm = ["saikuro-core/wasm", "saikuro-exec/wasm"] +embedded = ["saikuro-core/embedded", "saikuro-exec/embedded"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } +saikuro-exec = { path = "../saikuro-exec", default-features = false } thiserror = { workspace = true } diff --git a/Build/crates/saikuro-schema/registry/registry.rs b/Build/crates/saikuro-schema/registry/registry.rs index 118bfc81..3bc63a36 100644 --- a/Build/crates/saikuro-schema/registry/registry.rs +++ b/Build/crates/saikuro-schema/registry/registry.rs @@ -3,7 +3,7 @@ use saikuro_core::schema::{ FunctionSchema, NamespaceSchema, Schema, TypeDefinition, SCHEMA_NAMESPACES_CAPACITY, SCHEMA_TYPES_CAPACITY, }; -use saikuro_core::sync::RwLock; +use saikuro_exec::sync::RwLock; use saikuro_core::RegistrationToken; use crate::validator::ValidationError; @@ -97,8 +97,8 @@ impl SchemaRegistry { /// Register (or replace) a namespace. /// In production mode this returns an error rather than mutating state. - pub fn register(&self, registration: NamespaceRegistration) -> Result<(), RegistryError> { - let mut schemata = self.inner.write(); + pub async fn register(&self, registration: NamespaceRegistration) -> Result<(), RegistryError> { + let mut schemata = self.inner.write().await; if schemata.mode == RegistryMode::Production { return Err(RegistryError::FrozenSchema(registration.namespace)); } @@ -121,16 +121,16 @@ impl SchemaRegistry { } /// Merge an entire [`Schema`] document into the registry. - pub fn merge_schema( + pub async fn merge_schema( &self, schema: Schema, provider_id: impl Into, ) -> Result<(), RegistryError> { - self.merge_schema_with_token(schema, provider_id, RegistrationToken::new()) + self.merge_schema_with_token(schema, provider_id, RegistrationToken::new()).await } /// Merge a schema document under an existing provider registration. - pub fn merge_schema_with_token( + pub async fn merge_schema_with_token( &self, schema: Schema, provider_id: impl Into, @@ -140,7 +140,7 @@ impl SchemaRegistry { // The whole merge happens under one write guard so a concurrent // `freeze()` cannot interleave between the type and namespace phases. - let mut schemata = self.inner.write(); + let mut schemata = self.inner.write().await; if schemata.mode == RegistryMode::Production { let ns = schema.namespaces.keys().next().cloned().unwrap_or_default(); @@ -182,8 +182,8 @@ impl SchemaRegistry { } /// Remove namespaces owned by one specific provider registration. - pub fn deregister_provider(&self, provider_id: &str, registration_token: RegistrationToken) { - let mut schemata = self.inner.write(); + pub async fn deregister_provider(&self, provider_id: &str, registration_token: RegistrationToken) { + let mut schemata = self.inner.write().await; if schemata.mode == RegistryMode::Production { return; } @@ -196,10 +196,10 @@ impl SchemaRegistry { /// Look up the schema for a single function. /// `target` must be in `"namespace.function"` format. - pub fn lookup_function(&self, target: &str) -> Result { + pub async fn lookup_function(&self, target: &str) -> Result { let (ns_name, fn_name) = split_target(target)?; - let schemata = self.inner.read(); + let schemata = self.inner.read().await; let entry = schemata .namespaces .get(ns_name) @@ -221,7 +221,7 @@ impl SchemaRegistry { } /// Return the provider ID for the given namespace. - pub fn provider_for_namespace(&self, namespace: &str) -> Option { + pub async fn provider_for_namespace(&self, namespace: &str) -> Option { self.inner .read() .namespaces @@ -230,19 +230,19 @@ impl SchemaRegistry { } /// Return `true` if the given namespace is registered. - pub fn has_namespace(&self, namespace: &str) -> bool { - self.inner.read().namespaces.contains_key(namespace) + pub async fn has_namespace(&self, namespace: &str) -> bool { + self.inner.read().await.namespaces.contains_key(namespace) } /// Return all registered namespace names (in key order). - pub fn namespace_names(&self) -> Vec { - self.inner.read().namespaces.keys().cloned().collect() + pub async fn namespace_names(&self) -> Vec { + self.inner.read().await.namespaces.keys().cloned().collect() } /// Export a snapshot of the full schema at this instant. - pub fn snapshot(&self) -> Result { + pub async fn snapshot(&self) -> Result { let mut schema = Schema::new(); - let schemata = self.inner.read(); + let schemata = self.inner.read().await; for (name, entry) in schemata.namespaces.iter() { schema .namespaces @@ -259,13 +259,13 @@ impl SchemaRegistry { } /// Freeze the registry, preventing any further schema changes. - pub fn freeze(&self) { - self.inner.write().mode = RegistryMode::Production; + pub async fn freeze(&self) { + self.inner.write().await.mode = RegistryMode::Production; } /// Return the current operating mode. - pub fn mode(&self) -> RegistryMode { - self.inner.read().mode + pub async fn mode(&self) -> RegistryMode { + self.inner.read().await.mode } } diff --git a/Build/crates/saikuro-schema/validator/validator.rs b/Build/crates/saikuro-schema/validator/validator.rs index b615475c..0d91c4bd 100644 --- a/Build/crates/saikuro-schema/validator/validator.rs +++ b/Build/crates/saikuro-schema/validator/validator.rs @@ -120,7 +120,7 @@ impl InvocationValidator { } /// Validate a single envelope. - pub fn validate(&self, envelope: &Envelope) -> Result { + pub async fn validate(&self, envelope: &Envelope) -> Result { // 1. Protocol version. if envelope.version != PROTOCOL_VERSION { return Err(ValidationError::IncompatibleVersion { @@ -133,7 +133,7 @@ impl InvocationValidator { self.check_structural(envelope)?; match envelope.invocation_type { - InvocationType::Batch => self.validate_batch(envelope), + InvocationType::Batch => self.validate_batch(envelope).await, InvocationType::Log | InvocationType::Announce => Ok(ValidationReport { function_ref: crate::registry::FunctionRef { namespace: String::new(), @@ -151,7 +151,7 @@ impl InvocationValidator { provider_id: String::new(), }, }), - _ => self.validate_single(envelope), + _ => self.validate_single(envelope).await, } } @@ -181,9 +181,9 @@ impl InvocationValidator { } // Single-invocation validation - fn validate_single(&self, envelope: &Envelope) -> Result { + async fn validate_single(&self, envelope: &Envelope) -> Result { // Schema lookup. - let func_ref = self.registry.lookup_function(&envelope.target)?; + let func_ref = self.registry.lookup_function(&envelope.target).await?; // Visibility. self.check_visibility(&envelope.target, &func_ref.schema.visibility)?; @@ -197,14 +197,14 @@ impl InvocationValidator { } // Batch validation - fn validate_batch(&self, envelope: &Envelope) -> Result { + async fn validate_batch(&self, envelope: &Envelope) -> Result { let items = envelope.batch_items.as_ref().ok_or_else(|| { ValidationError::MalformedEnvelope("batch envelope missing batch_items".into()) })?; // Validate each item; collect the first error with its index. for (index, item) in items.iter().enumerate() { - self.validate(item) + self.validate(item).await .map_err(|source| ValidationError::BatchItem { index, source: Box::new(source), @@ -213,7 +213,7 @@ impl InvocationValidator { // For batch we return a synthetic report. The router will dispatch each // item individually and collect results. - let first_ref = self.registry.lookup_function(&items[0].target)?; + let first_ref = self.registry.lookup_function(&items[0].target).await?; Ok(ValidationReport { function_ref: first_ref, From 8b2c3e23043347ca014acaec116cd0cc731b06b5 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Fri, 14 Aug 2026 14:47:40 -0600 Subject: [PATCH 30/43] saikuro-codegen i mean we moved it but we need to use a thing for this Askama --- Build/crates/saikuro-codegen/Cargo.toml | 5 ++- .../cli/saikuro-codegen.rs => cli/main.rs} | 7 ++-- .../saikuro-codegen/{src => language}/c.rs | 4 +-- .../saikuro-codegen/{src => language}/cpp.rs | 4 +-- .../{src => language}/csharp.rs | 4 +-- Build/crates/saikuro-codegen/language/mod.rs | 6 ++++ .../{src => language}/python.rs | 4 +-- .../saikuro-codegen/{src => language}/rust.rs | 4 +-- .../{src => language}/typescript.rs | 4 +-- Build/crates/saikuro-codegen/lib.rs | 9 ++++++ .../saikuro-codegen/{src => shared}/error.rs | 2 -- .../{src => shared}/generator.rs | 27 +--------------- Build/crates/saikuro-codegen/shared/mod.rs | 2 ++ Build/crates/saikuro-codegen/src/lib.rs | 32 ------------------- 14 files changed, 39 insertions(+), 75 deletions(-) rename Build/crates/saikuro-codegen/{src/cli/saikuro-codegen.rs => cli/main.rs} (94%) rename Build/crates/saikuro-codegen/{src => language}/c.rs (98%) rename Build/crates/saikuro-codegen/{src => language}/cpp.rs (98%) rename Build/crates/saikuro-codegen/{src => language}/csharp.rs (98%) create mode 100644 Build/crates/saikuro-codegen/language/mod.rs rename Build/crates/saikuro-codegen/{src => language}/python.rs (98%) rename Build/crates/saikuro-codegen/{src => language}/rust.rs (99%) rename Build/crates/saikuro-codegen/{src => language}/typescript.rs (98%) create mode 100644 Build/crates/saikuro-codegen/lib.rs rename Build/crates/saikuro-codegen/{src => shared}/error.rs (94%) rename Build/crates/saikuro-codegen/{src => shared}/generator.rs (83%) create mode 100644 Build/crates/saikuro-codegen/shared/mod.rs delete mode 100644 Build/crates/saikuro-codegen/src/lib.rs diff --git a/Build/crates/saikuro-codegen/Cargo.toml b/Build/crates/saikuro-codegen/Cargo.toml index 38074477..b8208aca 100644 --- a/Build/crates/saikuro-codegen/Cargo.toml +++ b/Build/crates/saikuro-codegen/Cargo.toml @@ -8,9 +8,12 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "codegen", "bindings"] +[lib] +path = "lib.rs" + [[bin]] name = "saikuro-codegen" -path = "src/cli/saikuro-codegen.rs" +path = "cli/main.rs" required-features = ["cli"] [features] diff --git a/Build/crates/saikuro-codegen/src/cli/saikuro-codegen.rs b/Build/crates/saikuro-codegen/cli/main.rs similarity index 94% rename from Build/crates/saikuro-codegen/src/cli/saikuro-codegen.rs rename to Build/crates/saikuro-codegen/cli/main.rs index 4e15617d..1dc8ef7a 100644 --- a/Build/crates/saikuro-codegen/src/cli/saikuro-codegen.rs +++ b/Build/crates/saikuro-codegen/cli/main.rs @@ -6,8 +6,11 @@ use std::{fs, path::PathBuf}; use clap::Parser; use saikuro_codegen::{ - c::CGenerator, cpp::CppGenerator, csharp::CSharpGenerator, generator::BindingGenerator, - python::PythonGenerator, rust::RustGenerator, typescript::TypeScriptGenerator, + language::{ + c::CGenerator, cpp::CppGenerator, csharp::CSharpGenerator, python::PythonGenerator, + rust::RustGenerator, typescript::TypeScriptGenerator, + }, + shared::generator::BindingGenerator, }; use saikuro_core::schema::Schema; diff --git a/Build/crates/saikuro-codegen/src/c.rs b/Build/crates/saikuro-codegen/language/c.rs similarity index 98% rename from Build/crates/saikuro-codegen/src/c.rs rename to Build/crates/saikuro-codegen/language/c.rs index ff3e32a1..ca39890d 100644 --- a/Build/crates/saikuro-codegen/src/c.rs +++ b/Build/crates/saikuro-codegen/language/c.rs @@ -8,7 +8,7 @@ use saikuro_core::schema::{NamespaceSchema, Schema}; use std::collections::HashMap; -use crate::{ +use crate::shared::{ error::{CodegenError, Result}, generator::{BindingGenerator, GeneratorOutput}, }; @@ -118,7 +118,7 @@ impl CGenerator { ]; let mut seen_names: HashMap = HashMap::new(); - for (fn_name, fn_schema) in crate::generator::namespace_public_functions(ns) { + for (fn_name, fn_schema) in crate::shared::generator::namespace_public_functions(ns) { let c_fn_name = format!("{}_{}", safe, sanitize_ident(fn_name)); if let Some(previous_raw) = seen_names.get(&c_fn_name) { return Err(CodegenError::Schema(format!( diff --git a/Build/crates/saikuro-codegen/src/cpp.rs b/Build/crates/saikuro-codegen/language/cpp.rs similarity index 98% rename from Build/crates/saikuro-codegen/src/cpp.rs rename to Build/crates/saikuro-codegen/language/cpp.rs index 8f8f4f87..199f45e2 100644 --- a/Build/crates/saikuro-codegen/src/cpp.rs +++ b/Build/crates/saikuro-codegen/language/cpp.rs @@ -7,7 +7,7 @@ use saikuro_core::schema::{NamespaceSchema, Schema}; use std::collections::{HashMap, HashSet}; -use crate::{ +use crate::shared::{ error::{CodegenError, Result}, generator::{BindingGenerator, GeneratorOutput}, to_pascal_case, @@ -86,7 +86,7 @@ impl CppGenerator { let mut seen_methods: HashSet = HashSet::new(); seen_methods.insert(sanitize_ident(class_name)); seen_methods.insert("client_".to_owned()); - for (fn_name, fn_schema) in crate::generator::namespace_public_functions(ns) { + for (fn_name, fn_schema) in crate::shared::generator::namespace_public_functions(ns) { let method_name = sanitize_ident(fn_name); if !seen_methods.insert(method_name.clone()) { return Err(CodegenError::Schema(format!( diff --git a/Build/crates/saikuro-codegen/src/csharp.rs b/Build/crates/saikuro-codegen/language/csharp.rs similarity index 98% rename from Build/crates/saikuro-codegen/src/csharp.rs rename to Build/crates/saikuro-codegen/language/csharp.rs index bb6aba3a..7de469b0 100644 --- a/Build/crates/saikuro-codegen/src/csharp.rs +++ b/Build/crates/saikuro-codegen/language/csharp.rs @@ -9,7 +9,7 @@ use saikuro_core::schema::{ FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDefinition, TypeDescriptor, }; -use crate::{ +use crate::shared::{ error::Result, generator::{ convert_type, generate_types_and_namespace_clients, generate_types_from_schema, @@ -148,7 +148,7 @@ impl CSharpGenerator { )); lines.push("".to_owned()); - for (fn_name, fn_schema) in crate::generator::namespace_public_functions(ns) { + for (fn_name, fn_schema) in crate::shared::generator::namespace_public_functions(ns) { let method = self.generate_method(ns_name, fn_name, fn_schema)?; lines.push(method); } diff --git a/Build/crates/saikuro-codegen/language/mod.rs b/Build/crates/saikuro-codegen/language/mod.rs new file mode 100644 index 00000000..2c6e8a1a --- /dev/null +++ b/Build/crates/saikuro-codegen/language/mod.rs @@ -0,0 +1,6 @@ +pub mod c; +pub mod cpp; +pub mod csharp; +pub mod python; +pub mod rust; +pub mod typescript; diff --git a/Build/crates/saikuro-codegen/src/python.rs b/Build/crates/saikuro-codegen/language/python.rs similarity index 98% rename from Build/crates/saikuro-codegen/src/python.rs rename to Build/crates/saikuro-codegen/language/python.rs index 0fe87af4..3fa8af3b 100644 --- a/Build/crates/saikuro-codegen/src/python.rs +++ b/Build/crates/saikuro-codegen/language/python.rs @@ -9,7 +9,7 @@ use saikuro_core::schema::{ FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, }; -use crate::{ +use crate::shared::{ error::Result, generator::{ convert_type, generate_types_and_namespace_clients, generate_types_from_schema, @@ -164,7 +164,7 @@ impl PythonGenerator { lines.push(" self._client = client".to_owned()); lines.push(String::new()); - for (fn_name, fn_schema) in crate::generator::namespace_public_functions(ns) { + for (fn_name, fn_schema) in crate::shared::generator::namespace_public_functions(ns) { let method = self.generate_method(ns_name, fn_name, fn_schema)?; lines.push(method); } diff --git a/Build/crates/saikuro-codegen/src/rust.rs b/Build/crates/saikuro-codegen/language/rust.rs similarity index 99% rename from Build/crates/saikuro-codegen/src/rust.rs rename to Build/crates/saikuro-codegen/language/rust.rs index 482bc517..75b01152 100644 --- a/Build/crates/saikuro-codegen/src/rust.rs +++ b/Build/crates/saikuro-codegen/language/rust.rs @@ -9,7 +9,7 @@ use saikuro_core::schema::{ FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDefinition, TypeDescriptor, }; -use crate::{ +use crate::shared::{ error::{CodegenError, Result}, generator::{convert_type, BindingGenerator, GeneratorOutput, TypeConverter}, to_pascal_case, @@ -174,7 +174,7 @@ impl RustGenerator { ]; let mut method_names = HashMap::new(); - for (fn_name, fn_schema) in crate::generator::namespace_public_functions(ns) { + for (fn_name, fn_schema) in crate::shared::generator::namespace_public_functions(ns) { let method_name = ensure_unique_name( &format!("method in namespace {ns_name}"), fn_name, diff --git a/Build/crates/saikuro-codegen/src/typescript.rs b/Build/crates/saikuro-codegen/language/typescript.rs similarity index 98% rename from Build/crates/saikuro-codegen/src/typescript.rs rename to Build/crates/saikuro-codegen/language/typescript.rs index 042588fd..89a02320 100644 --- a/Build/crates/saikuro-codegen/src/typescript.rs +++ b/Build/crates/saikuro-codegen/language/typescript.rs @@ -9,7 +9,7 @@ use saikuro_core::schema::{ FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, }; -use crate::{ +use crate::shared::{ error::Result, generator::{ convert_type, generate_types_and_namespace_clients, generate_types_from_schema, @@ -114,7 +114,7 @@ impl TypeScriptGenerator { lines.push(" constructor(private readonly client: SaikuroClient) {}".to_owned()); lines.push(String::new()); - for (fn_name, fn_schema) in crate::generator::namespace_public_functions(ns) { + for (fn_name, fn_schema) in crate::shared::generator::namespace_public_functions(ns) { let method = self.generate_method(ns_name, fn_name, fn_schema)?; lines.push(method); } diff --git a/Build/crates/saikuro-codegen/lib.rs b/Build/crates/saikuro-codegen/lib.rs new file mode 100644 index 00000000..40b50fa0 --- /dev/null +++ b/Build/crates/saikuro-codegen/lib.rs @@ -0,0 +1,9 @@ +pub mod shared; +pub mod language; + +pub use shared::error::CodegenError; +pub use shared::generator::{ + convert_type, generate_types_and_namespace_clients, generate_types_from_schema, + namespace_public_functions, to_camel_case, to_pascal_case, BindingGenerator, GeneratedFile, + GeneratorOutput, TypeConverter, +}; diff --git a/Build/crates/saikuro-codegen/src/error.rs b/Build/crates/saikuro-codegen/shared/error.rs similarity index 94% rename from Build/crates/saikuro-codegen/src/error.rs rename to Build/crates/saikuro-codegen/shared/error.rs index 9c5973ce..6fe0535b 100644 --- a/Build/crates/saikuro-codegen/src/error.rs +++ b/Build/crates/saikuro-codegen/shared/error.rs @@ -1,5 +1,3 @@ -//! Codegen error type. - use thiserror::Error; #[derive(Debug, Error)] diff --git a/Build/crates/saikuro-codegen/src/generator.rs b/Build/crates/saikuro-codegen/shared/generator.rs similarity index 83% rename from Build/crates/saikuro-codegen/src/generator.rs rename to Build/crates/saikuro-codegen/shared/generator.rs index 5da03efb..e6049bea 100644 --- a/Build/crates/saikuro-codegen/src/generator.rs +++ b/Build/crates/saikuro-codegen/shared/generator.rs @@ -1,11 +1,9 @@ -//! Common generator traits and output types. - use saikuro_core::schema::{ FieldMap, FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDefinition, TypeDescriptor, Visibility, }; -use crate::error::Result; +use crate::shared::error::Result; /// A single generated source file. #[derive(Debug, Clone)] @@ -76,9 +74,6 @@ pub fn to_camel_case(s: &str) -> String { } /// Iterate over namespace functions sorted by name, filtering out private ones. -/// -/// Every codegen backend needs this same loop. Using this helper -/// eliminates the duplicated iteration + filter pattern. pub fn namespace_public_functions(ns: &NamespaceSchema) -> Vec<(&str, &FunctionSchema)> { let mut fn_keys: Vec<_> = ns.functions.keys().collect(); fn_keys.sort(); @@ -96,11 +91,6 @@ pub fn namespace_public_functions(ns: &NamespaceSchema) -> Vec<(&str, &FunctionS } /// Language-specific type name conversion. -/// -/// Every codegen backend has a match over `TypeDescriptor` variants that -/// produces a target-language type string. This trait + [`convert_type`] -/// eliminate that duplicated dispatcher; each backend only provides the -/// per-variant mappings. pub trait TypeConverter { /// Map a Saikuro primitive type to the target-language type name. fn primitive_name(&self, t: &PrimitiveType) -> &'static str; @@ -119,10 +109,6 @@ pub trait TypeConverter { } /// Convert a [`TypeDescriptor`] to a target-language type string. -/// -/// This is the shared dispatcher that all backends use instead of -/// writing their own `match` over the same variants. Each backend -/// implements [`TypeConverter`] to supply the language-specific mappings. pub fn convert_type(desc: &TypeDescriptor, conv: &impl TypeConverter) -> String { match desc { TypeDescriptor::Primitive { r#type } => conv.primitive_name(r#type).to_owned(), @@ -138,11 +124,6 @@ pub fn convert_type(desc: &TypeDescriptor, conv: &impl TypeConverter) -> String } /// Shared iteration + match over all schema types. -/// -/// Every codegen backend iterates `schema.types` and dispatches on -/// `TypeDefinition::{Record, Enum, Alias}`. This function captures that -/// common skeleton; each backend provides language-specific generation -/// for each variant via the three closures. pub fn generate_types_from_schema( schema: &Schema, header: Vec, @@ -171,12 +152,6 @@ pub fn generate_types_from_schema( } /// Shared namespace client file generation. -/// -/// C#, Python, and TypeScript backends all follow the same pattern: -/// 1. Add a types file to the output. -/// 2. Iterate over schema namespaces, generating a client file per namespace. -/// 3. Return a list of `(namespace_name, class_name)` pairs so the caller can -/// build an umbrella/index file with the correct names. pub fn generate_types_and_namespace_clients( schema: &Schema, output: &mut GeneratorOutput, diff --git a/Build/crates/saikuro-codegen/shared/mod.rs b/Build/crates/saikuro-codegen/shared/mod.rs new file mode 100644 index 00000000..07820df1 --- /dev/null +++ b/Build/crates/saikuro-codegen/shared/mod.rs @@ -0,0 +1,2 @@ +pub mod error; +pub mod generator; diff --git a/Build/crates/saikuro-codegen/src/lib.rs b/Build/crates/saikuro-codegen/src/lib.rs deleted file mode 100644 index 0fb98969..00000000 --- a/Build/crates/saikuro-codegen/src/lib.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Saikuro Codegen -//! -//! Generates typed language bindings from a frozen [`Schema`]. -//! -//! Currently supported targets: -//! - [`python`] : Python 3 dataclasses + async client stubs -//! - [`typescript`] : TypeScript interfaces + async client stubs -//! - [`csharp`] : C# records + async client stubs -//! - [`c`] : C headers with namespace client helpers over the C adapter ABI -//! - [`cpp`] : C++ wrappers with typed class stubs over the C adapter ABI -//! - [`rust`] : Rust bindings with async client and type-safe wrappers -//! -//! The codegen pipeline is: -//! 1. Load a [`Schema`] (from JSON file or in-process snapshot). -//! 2. Pass it through a [`BindingGenerator`] for the target language. -//! 3. Write the output files. - -pub mod c; -pub mod cpp; -pub mod csharp; -pub mod error; -pub mod generator; -pub mod python; -pub mod rust; -pub mod typescript; - -pub use error::CodegenError; -pub use generator::{ - convert_type, generate_types_and_namespace_clients, generate_types_from_schema, - namespace_public_functions, to_camel_case, to_pascal_case, BindingGenerator, GeneratedFile, - GeneratorOutput, TypeConverter, -}; From 83961bc70dd53c0c2475d2da71f545ba55f6a054 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Fri, 14 Aug 2026 21:39:52 -0600 Subject: [PATCH 31/43] saikuro-event --- Build/Cargo.toml | 7 +- Build/adapters/rust/src/error.rs | 2 +- Build/adapters/rust/src/provider.rs | 2 +- Build/adapters/rust/src/value.rs | 12 +- Build/adapters/rust/tests/integration.rs | 14 +- Build/crates/saikuro-core/codec/msgpack.rs | 10 +- Build/crates/saikuro-core/error/mod.rs | 5 - Build/crates/saikuro-core/lib.rs | 3 - .../crates/saikuro-core/protocol/envelope.rs | 25 ++- .../saikuro-core/protocol/invocation.rs | 2 +- Build/crates/saikuro-core/protocol/schema.rs | 10 +- Build/crates/saikuro-core/value/mod.rs | 2 - Build/crates/saikuro-core/value/resource.rs | 2 +- .../{saikuro-log => saikuro-event}/Cargo.toml | 26 +-- .../crates/saikuro-event/core_events/codec.rs | 5 + .../core_events/event.rs} | 195 ++++++++++++++++-- .../error => saikuro-event/core_events}/io.rs | 0 Build/crates/saikuro-event/core_events/mod.rs | 7 + Build/crates/saikuro-event/lib.rs | 18 ++ .../log}/embedded/mod.rs | 0 .../log}/embedded/serial.rs | 0 .../shared => saikuro-event/log}/level.rs | 2 - .../lib.rs => saikuro-event/log/mod.rs} | 25 +-- .../log}/native/mod.rs | 2 - .../log}/native/stderr.rs | 0 .../log}/native/tracing.rs | 0 .../shared => saikuro-event/log}/record.rs | 27 +-- .../shared => saikuro-event/log}/ring.rs | 2 - .../shared => saikuro-event/log}/sink.rs | 0 .../log}/wasm/console.rs | 0 .../log}/wasm/mod.rs | 0 Build/crates/saikuro-event/value/mod.rs | 3 + .../value/value.rs | 0 Build/crates/saikuro-log/shared/mod.rs | 6 - Build/crates/saikuro-random/Cargo.toml | 7 +- Build/crates/saikuro-random/base/mod.rs | 12 +- Build/crates/saikuro-random/embedded/mod.rs | 9 +- Build/crates/saikuro-random/native/mod.rs | 11 +- Build/crates/saikuro-random/shared/mod.rs | 88 +++----- Build/crates/saikuro-random/wasm/mod.rs | 11 +- Build/crates/saikuro-router/Cargo.toml | 18 +- Build/crates/saikuro-router/error/error.rs | 34 --- Build/crates/saikuro-router/error/mod.rs | 2 - Build/crates/saikuro-router/lib.rs | 8 +- .../saikuro-router/provider/provider.rs | 4 +- Build/crates/saikuro-router/router/router.rs | 52 ++--- Build/crates/saikuro-runtime/Cargo.toml | 1 - .../crates/saikuro-runtime/src/connection.rs | 15 +- Build/crates/saikuro-runtime/src/error.rs | 56 ----- Build/crates/saikuro-runtime/src/handle.rs | 9 +- Build/crates/saikuro-runtime/src/lib.rs | 2 - Build/crates/saikuro-schema/Cargo.toml | 9 +- .../saikuro-schema/registry/registry.rs | 54 ++--- .../saikuro-schema/validator/validator.rs | 126 +++-------- Build/crates/saikuro-storage/Cargo.toml | 4 - Build/tests/Cargo.toml | 1 + Build/tests/saikuro-core/value.rs | 2 +- .../tests/saikuro-router/announce_dispatch.rs | 6 +- .../tests/saikuro-router/resource_dispatch.rs | 2 +- .../tests/saikuro-router/sandbox_dispatch.rs | 2 +- Build/tests/saikuro-schema/registry.rs | 5 +- .../tests/saikuro-schema/schema_validation.rs | 21 +- Build/tests/saikuro-schema/validator.rs | 5 +- 63 files changed, 445 insertions(+), 545 deletions(-) delete mode 100644 Build/crates/saikuro-core/error/mod.rs rename Build/crates/{saikuro-log => saikuro-event}/Cargo.toml (64%) create mode 100644 Build/crates/saikuro-event/core_events/codec.rs rename Build/crates/{saikuro-core/error/error.rs => saikuro-event/core_events/event.rs} (55%) rename Build/crates/{saikuro-core/error => saikuro-event/core_events}/io.rs (100%) create mode 100644 Build/crates/saikuro-event/core_events/mod.rs create mode 100644 Build/crates/saikuro-event/lib.rs rename Build/crates/{saikuro-log => saikuro-event/log}/embedded/mod.rs (100%) rename Build/crates/{saikuro-log => saikuro-event/log}/embedded/serial.rs (100%) rename Build/crates/{saikuro-log/shared => saikuro-event/log}/level.rs (94%) rename Build/crates/{saikuro-log/lib.rs => saikuro-event/log/mod.rs} (72%) rename Build/crates/{saikuro-log => saikuro-event/log}/native/mod.rs (67%) rename Build/crates/{saikuro-log => saikuro-event/log}/native/stderr.rs (100%) rename Build/crates/{saikuro-log => saikuro-event/log}/native/tracing.rs (100%) rename Build/crates/{saikuro-log/shared => saikuro-event/log}/record.rs (80%) rename Build/crates/{saikuro-log/shared => saikuro-event/log}/ring.rs (96%) rename Build/crates/{saikuro-log/shared => saikuro-event/log}/sink.rs (100%) rename Build/crates/{saikuro-log => saikuro-event/log}/wasm/console.rs (100%) rename Build/crates/{saikuro-log => saikuro-event/log}/wasm/mod.rs (100%) create mode 100644 Build/crates/saikuro-event/value/mod.rs rename Build/crates/{saikuro-core => saikuro-event}/value/value.rs (100%) delete mode 100644 Build/crates/saikuro-log/shared/mod.rs delete mode 100644 Build/crates/saikuro-router/error/error.rs delete mode 100644 Build/crates/saikuro-router/error/mod.rs delete mode 100644 Build/crates/saikuro-runtime/src/error.rs diff --git a/Build/Cargo.toml b/Build/Cargo.toml index b2c92d12..873296b3 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -2,19 +2,18 @@ resolver = "2" members = [ "crates/saikuro-core", + "crates/saikuro-event", "crates/saikuro-schema", - "crates/saikuro-storage", "crates/saikuro-transport", + "crates/saikuro-storage", "crates/saikuro-router", "crates/saikuro-runtime", "crates/saikuro-exec", "crates/saikuro-net", - "crates/saikuro-log", "crates/saikuro-random", "crates/saikuro-codegen", "adapters/c", "adapters/rust", - "tests", ] [workspace.package] @@ -131,5 +130,5 @@ saikuro-runtime = { path = "crates/saikuro-runtime", default-features = false } saikuro-codegen = { path = "crates/saikuro-codegen" } saikuro-exec = { path = "crates/saikuro-exec", default-features = false } saikuro-random = { path = "crates/saikuro-random", default-features = false } -saikuro-log = { path = "crates/saikuro-log", default-features = false } +saikuro-event = { path = "crates/saikuro-event", default-features = false } saikuro = { path = "adapters/rust", default-features = false } diff --git a/Build/adapters/rust/src/error.rs b/Build/adapters/rust/src/error.rs index 11ac8f5d..ab8d37f3 100644 --- a/Build/adapters/rust/src/error.rs +++ b/Build/adapters/rust/src/error.rs @@ -49,7 +49,7 @@ pub enum Error { /// The configured entropy source could not generate an invocation ID. #[error("entropy error: {0}")] - Entropy(#[from] saikuro_random::Error), + Entropy(#[from] saikuro_event::SaikuroError), } impl Error { diff --git a/Build/adapters/rust/src/provider.rs b/Build/adapters/rust/src/provider.rs index 4bccd74e..f2e39063 100644 --- a/Build/adapters/rust/src/provider.rs +++ b/Build/adapters/rust/src/provider.rs @@ -348,7 +348,7 @@ async fn dispatch_batch( handlers: &HashMap, transport: &mut dyn AdapterTransport, ) { - use saikuro_core::value::Value as CoreValue; + use saikuro_event::Value as CoreValue; let id = envelope.id; let items = match envelope.batch_items { diff --git a/Build/adapters/rust/src/value.rs b/Build/adapters/rust/src/value.rs index 5e4d5886..89319725 100644 --- a/Build/adapters/rust/src/value.rs +++ b/Build/adapters/rust/src/value.rs @@ -2,7 +2,7 @@ //! //! The Saikuro wire format uses MessagePack. Internally this adapter works //! with `serde_json::Value` for ergonomic Rust use, converting to/from -//! the `saikuro_core::value::Value` at the transport boundary. +//! the `saikuro_event::Value` at the transport boundary. /// The value type used throughout the Saikuro Rust adapter. /// @@ -11,8 +11,8 @@ /// and gives you `.as_i64()`, `.as_str()`, `json!()`, etc. for free. pub type Value = serde_json::Value; -/// Convert a `saikuro_core::value::Value` into a [`Value`] (JSON). -pub fn core_to_json(v: saikuro_core::value::Value) -> Value { +/// Convert a `saikuro_event::Value` into a [`Value`] (JSON). +pub fn core_to_json(v: saikuro_event::Value) -> Value { match serde_json::to_value(&v) { Ok(j) => j, Err(e) => { @@ -22,13 +22,13 @@ pub fn core_to_json(v: saikuro_core::value::Value) -> Value { } } -/// Convert a [`Value`] (JSON) into `saikuro_core::value::Value`. -pub fn json_to_core(v: Value) -> saikuro_core::value::Value { +/// Convert a [`Value`] (JSON) into `saikuro_event::Value`. +pub fn json_to_core(v: Value) -> saikuro_event::Value { match serde_json::from_value(v) { Ok(c) => c, Err(e) => { tracing::warn!(error = %e, "json_to_core deserialization failed"); - saikuro_core::value::Value::Null + saikuro_event::Value::Null } } } diff --git a/Build/adapters/rust/tests/integration.rs b/Build/adapters/rust/tests/integration.rs index 3b08584f..e907d0af 100644 --- a/Build/adapters/rust/tests/integration.rs +++ b/Build/adapters/rust/tests/integration.rs @@ -314,7 +314,7 @@ fn resource_roundtrip_with_simulated_runtime() { assert_eq!(env.target, "files.open"); let response = - ResponseEnvelope::ok(env.id, saikuro_core::value::Value::String("ok".into())); + ResponseEnvelope::ok(env.id, saikuro_event::Value::String("ok".into())); runtime_side .send(bytes::Bytes::from( response.to_msgpack().expect("encode response"), @@ -353,7 +353,7 @@ fn stream_roundtrip_with_simulated_runtime() { assert_eq!(env.target, "events.watch"); let item1 = - ResponseEnvelope::stream_item(env.id, 0, saikuro_core::value::Value::Int(1)); + ResponseEnvelope::stream_item(env.id, 0, saikuro_event::Value::Int(1)); runtime_side .send(bytes::Bytes::from( item1.to_msgpack().expect("encode item1"), @@ -362,7 +362,7 @@ fn stream_roundtrip_with_simulated_runtime() { .expect("send item1"); let item2 = - ResponseEnvelope::stream_item(env.id, 1, saikuro_core::value::Value::Int(2)); + ResponseEnvelope::stream_item(env.id, 1, saikuro_event::Value::Int(2)); runtime_side .send(bytes::Bytes::from( item2.to_msgpack().expect("encode item2"), @@ -428,7 +428,7 @@ fn channel_send_receive_and_close_with_simulated_runtime() { let outbound = ResponseEnvelope::stream_item( open_env.id, 0, - saikuro_core::value::Value::String("pong".into()), + saikuro_event::Value::String("pong".into()), ); runtime_side .send(bytes::Bytes::from( @@ -555,7 +555,7 @@ fn client_acknowledges_announce_on_connect() { invocation_type: InvocationType::Announce, id: announce_id, target: "$announce".into(), - args: vec![saikuro_core::value::Value::Null], + args: vec![saikuro_event::Value::Null], meta: Default::default(), capability: None, batch_items: None, @@ -591,8 +591,8 @@ fn envelope_roundtrip_msgpack_preserves_fields() { let original = Envelope::call( "math.add", vec![ - saikuro_core::value::Value::Int(1), - saikuro_core::value::Value::Int(2), + saikuro_event::Value::Int(1), + saikuro_event::Value::Int(2), ], ) .expect("entropy available"); diff --git a/Build/crates/saikuro-core/codec/msgpack.rs b/Build/crates/saikuro-core/codec/msgpack.rs index fe3d15b2..2ef9a943 100644 --- a/Build/crates/saikuro-core/codec/msgpack.rs +++ b/Build/crates/saikuro-core/codec/msgpack.rs @@ -1,10 +1,10 @@ use alloc::vec::Vec; -use core::convert::Infallible; use messagepack_serde::{ - messagepack_core::{encode::int::EncodeMinimizeInt, io::IoWrite, io::RError, Encode}, + messagepack_core::{encode::int::EncodeMinimizeInt, io::IoWrite, Encode}, ser::NumEncoder, }; use serde::{Deserialize, Serialize}; +use saikuro_event::{DecodeError, EncodeError}; /// Encodes numbers exactly like rmp-serde struct RmpCompatible; @@ -95,12 +95,6 @@ impl NumEncoder for RmpCompatible { } } -/// Encoding error produced by [`to_vec`]. -pub type EncodeError = messagepack_serde::ser::Error; - -/// Decoding error produced by [`from_slice`]. -pub type DecodeError = messagepack_serde::de::Error; - /// Serialize a value to MessagePack bytes. pub fn to_vec(value: &T) -> Result, EncodeError> { messagepack_serde::ser::to_vec_with_config(value, RmpCompatible) diff --git a/Build/crates/saikuro-core/error/mod.rs b/Build/crates/saikuro-core/error/mod.rs deleted file mode 100644 index 16460402..00000000 --- a/Build/crates/saikuro-core/error/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod error; -pub mod io; - -pub use error::*; -pub use io::*; diff --git a/Build/crates/saikuro-core/lib.rs b/Build/crates/saikuro-core/lib.rs index c1726788..2756cacd 100644 --- a/Build/crates/saikuro-core/lib.rs +++ b/Build/crates/saikuro-core/lib.rs @@ -3,9 +3,6 @@ #[macro_use] extern crate alloc; -mod error; -pub use error::*; - mod protocol; pub use protocol::*; diff --git a/Build/crates/saikuro-core/protocol/envelope.rs b/Build/crates/saikuro-core/protocol/envelope.rs index 66e5381d..75206c9a 100644 --- a/Build/crates/saikuro-core/protocol/envelope.rs +++ b/Build/crates/saikuro-core/protocol/envelope.rs @@ -4,9 +4,8 @@ use serde::{ Deserialize, Serialize, }; -use crate::{ - capability::CapabilityToken, invocation::InvocationId, value::Value, PROTOCOL_VERSION, -}; +use crate::{capability::CapabilityToken, invocation::InvocationId, PROTOCOL_VERSION}; +use saikuro_event::Value; /// Maximum number of key/value metadata entries an [`Envelope`] can carry. pub const ENVELOPE_META_CAPACITY: usize = 16; @@ -121,12 +120,12 @@ macro_rules! impl_msgpack { ($ty:ty) => { impl $ty { /// Serialise this envelope to MessagePack bytes. - pub fn to_msgpack(&self) -> Result, crate::msgpack::EncodeError> { + pub fn to_msgpack(&self) -> Result, saikuro_event::EncodeError> { crate::msgpack::to_vec(self) } /// Deserialise from MessagePack bytes. - pub fn from_msgpack(bytes: &[u8]) -> Result { + pub fn from_msgpack(bytes: &[u8]) -> Result { crate::msgpack::from_slice(bytes) } } @@ -141,7 +140,7 @@ impl Envelope { pub fn call( target: impl Into, args: Vec, - ) -> Result { + ) -> Result { Ok(Self { version: PROTOCOL_VERSION, invocation_type: InvocationType::Call, @@ -160,7 +159,7 @@ impl Envelope { pub fn cast( target: impl Into, args: Vec, - ) -> Result { + ) -> Result { let mut envelope = Self::call(target, args)?; envelope.invocation_type = InvocationType::Cast; Ok(envelope) @@ -170,7 +169,7 @@ impl Envelope { pub fn stream_open( target: impl Into, args: Vec, - ) -> Result { + ) -> Result { let mut envelope = Self::call(target, args)?; envelope.invocation_type = InvocationType::Stream; Ok(envelope) @@ -180,14 +179,14 @@ impl Envelope { pub fn channel_open( target: impl Into, args: Vec, - ) -> Result { + ) -> Result { let mut envelope = Self::call(target, args)?; envelope.invocation_type = InvocationType::Channel; Ok(envelope) } /// Construct a schema-announcement envelope. - pub fn announce(schema_value: Value) -> Result { + pub fn announce(schema_value: Value) -> Result { let mut envelope = Self::call("$saikuro.announce", vec![schema_value])?; envelope.invocation_type = InvocationType::Announce; Ok(envelope) @@ -197,7 +196,7 @@ impl Envelope { pub fn resource( target: impl Into, args: Vec, - ) -> Result { + ) -> Result { let mut envelope = Self::call(target, args)?; envelope.invocation_type = InvocationType::Resource; Ok(envelope) @@ -239,7 +238,7 @@ pub struct ResponseEnvelope { /// Error detail present when `ok` is `false`. #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, + pub error: Option, /// For streaming responses: the sequence number of this item. #[serde(skip_serializing_if = "Option::is_none")] @@ -276,7 +275,7 @@ impl ResponseEnvelope { } /// Construct an error response. - pub fn err(id: InvocationId, detail: crate::error::ErrorDetail) -> Self { + pub fn err(id: InvocationId, detail: saikuro_event::ErrorDetail) -> Self { Self { id, ok: false, diff --git a/Build/crates/saikuro-core/protocol/invocation.rs b/Build/crates/saikuro-core/protocol/invocation.rs index b52dc223..538a7d46 100644 --- a/Build/crates/saikuro-core/protocol/invocation.rs +++ b/Build/crates/saikuro-core/protocol/invocation.rs @@ -70,7 +70,7 @@ impl InvocationId { /// /// Returns an error when the configured entropy backend is unavailable. #[inline] - pub fn new() -> Result { + pub fn new() -> Result { saikuro_random::uuid_v4().map(Self) } diff --git a/Build/crates/saikuro-core/protocol/schema.rs b/Build/crates/saikuro-core/protocol/schema.rs index 33ea5f5d..2609a25f 100644 --- a/Build/crates/saikuro-core/protocol/schema.rs +++ b/Build/crates/saikuro-core/protocol/schema.rs @@ -2,6 +2,7 @@ use alloc::{boxed::Box, string::String, vec::Vec}; use serde::{Deserialize, Serialize}; use crate::capability::CapabilityToken; +use saikuro_event::Value; /// The protocol version this schema was compiled against. pub const SCHEMA_VERSION: u32 = 1; @@ -130,12 +131,11 @@ impl TypeDescriptor { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum Visibility { - /// Callable by any peer that has the required capabilities. - #[default] + /// Callable by any namespace, including external callers. Public, - /// Callable only by peers in the same cluster/process group. + /// Callable only by functions within the same root schema. Internal, - /// Not exposed at all; exists only for documentation purposes. + /// Callable only by code compiled into the same binary. Private, } @@ -151,7 +151,7 @@ pub struct ArgumentDescriptor { pub optional: bool, /// Default value used when the argument is omitted. #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, + pub default: Option, /// Human-readable documentation. #[serde(skip_serializing_if = "Option::is_none")] pub doc: Option, diff --git a/Build/crates/saikuro-core/value/mod.rs b/Build/crates/saikuro-core/value/mod.rs index ccf969fd..2ac20a78 100644 --- a/Build/crates/saikuro-core/value/mod.rs +++ b/Build/crates/saikuro-core/value/mod.rs @@ -1,7 +1,5 @@ -pub mod value; pub mod capability; pub mod resource; -pub use value::*; pub use capability::*; pub use resource::*; diff --git a/Build/crates/saikuro-core/value/resource.rs b/Build/crates/saikuro-core/value/resource.rs index 63e462a3..206cca1b 100644 --- a/Build/crates/saikuro-core/value/resource.rs +++ b/Build/crates/saikuro-core/value/resource.rs @@ -2,7 +2,7 @@ use alloc::{borrow::ToOwned, boxed::Box, string::String}; use core::fmt; use serde::{Deserialize, Serialize}; -use crate::value::{Value, ValueMap}; +use saikuro_event::{Value, ValueMap}; // ResourceHandle /// An opaque, serialisable reference to large or external data. diff --git a/Build/crates/saikuro-log/Cargo.toml b/Build/crates/saikuro-event/Cargo.toml similarity index 64% rename from Build/crates/saikuro-log/Cargo.toml rename to Build/crates/saikuro-event/Cargo.toml index 5a8084d9..604478ee 100644 --- a/Build/crates/saikuro-log/Cargo.toml +++ b/Build/crates/saikuro-event/Cargo.toml @@ -1,24 +1,20 @@ [package] -name = "saikuro-log" -description = "Logging types and sinks for Saikuro" +name = "saikuro-event" +description = "Unified error taxonomy, structured logging, and value types for Saikuro" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -keywords = ["ipc", "cross-language", "saikuro", "logging", "log"] - -[lib] -path = "lib.rs" +keywords = ["ipc", "cross-language", "saikuro", "error", "logging"] [features] default = ["std", "native", "stderr", "null", "filter"] std = [] -native = ["std", "saikuro-core/native"] -no_std = ["saikuro-core/no_std"] -wasm = ["saikuro-core/wasm"] -embedded = ["dep:embedded-io-async", "dep:spin", "saikuro-core/embedded"] - +native = ["std"] +no_std = [] +wasm = [] +embedded = ["dep:embedded-io-async", "dep:spin"] stderr = ["native"] tracing = ["native", "dep:tracing"] console = ["wasm", "dep:web-sys"] @@ -27,12 +23,18 @@ collector = ["dep:spin"] null = [] filter = [] +[lib] +path = "lib.rs" + [dependencies] -saikuro-core = { workspace = true, default-features = false } serde = { workspace = true } serde_json = { workspace = true, default-features = false, features = ["alloc"] } +serde_bytes = { workspace = true } heapless = { workspace = true } +thiserror = { workspace = true, default-features = false } strum = { workspace = true } +messagepack-serde = { workspace = true } +getrandom = { workspace = true, optional = true } tracing = { workspace = true, optional = true } web-sys = { workspace = true, optional = true, features = ["console"] } embedded-io-async = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-event/core_events/codec.rs b/Build/crates/saikuro-event/core_events/codec.rs new file mode 100644 index 00000000..3a941bd5 --- /dev/null +++ b/Build/crates/saikuro-event/core_events/codec.rs @@ -0,0 +1,5 @@ +/// Encoding error produced by the MessagePack serializer. +pub type EncodeError = messagepack_serde::ser::Error; + +/// Decoding error produced by the MessagePack deserializer. +pub type DecodeError = messagepack_serde::de::Error; diff --git a/Build/crates/saikuro-core/error/error.rs b/Build/crates/saikuro-event/core_events/event.rs similarity index 55% rename from Build/crates/saikuro-core/error/error.rs rename to Build/crates/saikuro-event/core_events/event.rs index 21d3e570..1d59ba10 100644 --- a/Build/crates/saikuro-core/error/error.rs +++ b/Build/crates/saikuro-event/core_events/event.rs @@ -6,11 +6,13 @@ use thiserror::Error; use crate::io::{ IoError, IoErrorKind }; use crate::value::Value; -/// Maximum number of structured context entries an [`ErrorDetail`] can carry. -pub const ERROR_DETAIL_CAPACITY: usize = 16; +/// Maximum number of structured context entries an [`ErrorDetail`] or +/// [`LogRecord`] can carry. +pub const CONTEXT_CAPACITY: usize = 16; -/// Fixed-capacity map of structured context entries on [`ErrorDetail`]. -pub type DetailMap = heapless::FnvIndexMap; +/// Fixed-capacity map of structured context entries on [`ErrorDetail`] and +/// [`LogRecord`]. +pub type ContextMap = heapless::FnvIndexMap; /// All error codes transmitted on the wire. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -66,6 +68,50 @@ pub enum ErrorCode { /// Out-of-order sequence number detected on an ordered stream. OutOfOrder, + // Storage errors + /// A key does not exist in the namespace. + KeyNotFound, + /// The key already exists and the operation required it to be absent. + KeyAlreadyExists, + /// The namespace already exists and the operation required it to be absent. + NamespaceAlreadyExists, + /// The requested storage backend is not available on this target. + BackendNotAvailable, + /// The storage backend does not implement the requested operation. + OperationNotSupported, + /// The operation would exceed a configured storage or rate quota. + QuotaExceeded, + /// A value could not be serialized. + Serialization, + /// A value could not be deserialized. + Deserialization, + + // Additional transport errors + /// The transport connection was refused by the remote endpoint. + ConnectionRefused, + /// A send over the transport failed. + SendFailed, + /// A receive over the transport failed. + ReceiveFailed, + /// The byte stream could not be framed into a message. + FramingError, + /// The transport is not supported on this target. + TransportNotSupported, + + // Additional routing errors + /// A routing target was malformed (expected `namespace.function`). + MalformedTarget, + /// An entropy or DRBG operation failed. + Entropy, + /// The named stream does not exist. + StreamNotFound, + /// The named channel does not exist. + ChannelNotFound, + /// A send to a stream or channel failed. + SendError, + /// A batch item failed to dispatch. + BatchItemFailed, + // Catch-all /// An error category not covered by the above codes. Internal, @@ -88,8 +134,8 @@ pub struct ErrorDetail { pub message: String, /// Optional structured context (stack traces, field paths, …). - #[serde(default, skip_serializing_if = "DetailMap::is_empty")] - pub details: DetailMap, + #[serde(default, skip_serializing_if = "ContextMap::is_empty")] + pub details: ContextMap, } impl ErrorDetail { @@ -98,14 +144,14 @@ impl ErrorDetail { Self { code, message: message.into(), - details: DetailMap::new(), + details: ContextMap::new(), } } - /// Add a detail entry and return `self` for chaining. - /// Fails with [`SaikuroError::CapacityExceeded`] if the detail bag is - /// already at [`ERROR_DETAIL_CAPACITY`] entries. - pub fn with_detail( + /// Add a context entry and return `self` for chaining. + /// Fails with [`SaikuroError::CapacityExceeded`] if the context bag is + /// already at [`CONTEXT_CAPACITY`] entries. + pub fn with_context( mut self, key: impl Into, value: impl Into @@ -148,6 +194,34 @@ pub enum SaikuroError { #[error("malformed envelope: {0}")] MalformedEnvelope(String), + #[error("schema is frozen; updates are rejected: {0}")] FrozenSchema(String), + + #[error("schema capacity exceeded")] + SchemaCapacity, + + #[error("batch envelope missing batch_items")] + MissingBatch, + + #[error("batch envelope has no items")] + EmptyBatch, + + #[error("visibility '{visibility}' denied for {target}")] VisibilityDenied { + target: String, + visibility: String, + }, + + #[error("argument count mismatch: expected {expected}, got {received}")] ArgumentArity { + expected: usize, + received: usize, + }, + + #[error("argument '{name}' (#{position}) expected {expected}, got {received}")] ArgumentType { + name: String, + position: usize, + expected: String, + received: String, + }, + // Routing #[error("no provider registered for namespace: {0}")] NoProvider(String), @@ -197,10 +271,56 @@ pub enum SaikuroError { received: u64, }, + // Storage + #[error("key not found: {0}")] KeyNotFound(String), + + #[error("key already exists: {0}")] KeyAlreadyExists(String), + + #[error("namespace already exists: {0}")] NamespaceAlreadyExists(String), + + #[error("storage backend not available: {0}")] BackendNotAvailable(String), + + #[error("operation not supported by backend: {0}")] OperationNotSupported(String), + + #[error("quota exceeded: {0}")] QuotaExceeded(String), + + #[error("serialization error: {0}")] Serialization(String), + + #[error("deserialization error: {0}")] Deserialization(String), + + // Additional transport + #[error("connection refused: {0}")] ConnectionRefused(String), + + #[error("transport send failed: {0}")] SendFailed(String), + + #[error("transport receive failed: {0}")] ReceiveFailed(String), + + #[error("framing error: {0}")] FramingError(String), + + #[error("transport not supported on this platform")] + TransportNotSupported, + + // Additional routing + #[error("malformed target '{0}': must be 'namespace.function'")] MalformedTarget(String), + + #[error("stream not found: {0}")] StreamNotFound(String), + + #[error("channel not found: {0}")] ChannelNotFound(String), + + #[error("send error: {0}")] SendError(String), + + #[error("batch item {index} failed: {reason}")] BatchItemFailed { + index: usize, + reason: String, + }, + + // Entropy + #[error("entropy error: {0}")] Entropy(String), + // Serialisation - #[error("msgpack encode error: {0}")] MsgpackEncode(#[from] crate::msgpack::EncodeError), + #[error("msgpack encode error: {0}")] MsgpackEncode(#[from] crate::codec::EncodeError), - #[error("msgpack decode error: {0}")] MsgpackDecode(#[from] crate::msgpack::DecodeError), + #[error("msgpack decode error: {0}")] MsgpackDecode(#[from] crate::codec::DecodeError), // I/O #[error("I/O error: {0}")] Io(IoError), @@ -213,9 +333,10 @@ pub enum SaikuroError { #[error("internal error: {0}")] Internal(String), } -impl From for ErrorDetail { - fn from(err: SaikuroError) -> Self { - let code = match &err { +impl SaikuroError { + /// The wire [`ErrorCode`] this error serialises as. + pub fn error_code(&self) -> ErrorCode { + match self { SaikuroError::NamespaceNotFound(_) => ErrorCode::NamespaceNotFound, SaikuroError::FunctionNotFound(_) => ErrorCode::FunctionNotFound, SaikuroError::InvalidArguments { .. } => ErrorCode::InvalidArguments, @@ -235,6 +356,25 @@ impl From for ErrorDetail { SaikuroError::StreamClosed => ErrorCode::StreamClosed, SaikuroError::ChannelClosed => ErrorCode::ChannelClosed, SaikuroError::OutOfOrder { .. } => ErrorCode::OutOfOrder, + SaikuroError::KeyNotFound(_) => ErrorCode::KeyNotFound, + SaikuroError::KeyAlreadyExists(_) => ErrorCode::KeyAlreadyExists, + SaikuroError::NamespaceAlreadyExists(_) => ErrorCode::NamespaceAlreadyExists, + SaikuroError::BackendNotAvailable(_) => ErrorCode::BackendNotAvailable, + SaikuroError::OperationNotSupported(_) => ErrorCode::OperationNotSupported, + SaikuroError::QuotaExceeded(_) => ErrorCode::QuotaExceeded, + SaikuroError::Serialization(_) => ErrorCode::Serialization, + SaikuroError::Deserialization(_) => ErrorCode::Deserialization, + SaikuroError::ConnectionRefused(_) => ErrorCode::ConnectionRefused, + SaikuroError::SendFailed(_) => ErrorCode::SendFailed, + SaikuroError::ReceiveFailed(_) => ErrorCode::ReceiveFailed, + SaikuroError::FramingError(_) => ErrorCode::FramingError, + SaikuroError::TransportNotSupported => ErrorCode::TransportNotSupported, + SaikuroError::MalformedTarget(_) => ErrorCode::MalformedTarget, + SaikuroError::StreamNotFound(_) => ErrorCode::StreamNotFound, + SaikuroError::ChannelNotFound(_) => ErrorCode::ChannelNotFound, + SaikuroError::SendError(_) => ErrorCode::SendError, + SaikuroError::BatchItemFailed { .. } => ErrorCode::BatchItemFailed, + SaikuroError::Entropy(_) => ErrorCode::Entropy, SaikuroError::MsgpackEncode(_) | SaikuroError::MsgpackDecode(_) => ErrorCode::Internal, SaikuroError::Io(e) => match e.kind { @@ -244,10 +384,21 @@ impl From for ErrorDetail { | IoErrorKind::ConnectionRefused => ErrorCode::ConnectionLost, _ => ErrorCode::Internal, } + SaikuroError::FrozenSchema(_) => ErrorCode::Internal, + SaikuroError::SchemaCapacity => ErrorCode::CapacityExceeded, + SaikuroError::MissingBatch => ErrorCode::MalformedEnvelope, + SaikuroError::EmptyBatch => ErrorCode::MalformedEnvelope, + SaikuroError::VisibilityDenied { .. } => ErrorCode::CapabilityDenied, + SaikuroError::ArgumentArity { .. } => ErrorCode::InvalidArguments, + SaikuroError::ArgumentType { .. } => ErrorCode::InvalidArguments, SaikuroError::CapacityExceeded(_) | SaikuroError::Internal(_) => ErrorCode::Internal, - }; + } + } +} - ErrorDetail::new(code, err.to_string()) +impl From for ErrorDetail { + fn from(err: SaikuroError) -> Self { + ErrorDetail::new(err.error_code(), err.to_string()) } } @@ -259,5 +410,13 @@ impl From for SaikuroError { } } +/// Convert a `getrandom` backend failure into the unified error type. +#[cfg(feature = "getrandom")] +impl From for SaikuroError { + fn from(err: getrandom::Error) -> Self { + SaikuroError::Entropy(format!("entropy backend failed: {err}")) + } +} + /// Convenience alias for `Result`. pub type Result = core::result::Result; diff --git a/Build/crates/saikuro-core/error/io.rs b/Build/crates/saikuro-event/core_events/io.rs similarity index 100% rename from Build/crates/saikuro-core/error/io.rs rename to Build/crates/saikuro-event/core_events/io.rs diff --git a/Build/crates/saikuro-event/core_events/mod.rs b/Build/crates/saikuro-event/core_events/mod.rs new file mode 100644 index 00000000..45694c6a --- /dev/null +++ b/Build/crates/saikuro-event/core_events/mod.rs @@ -0,0 +1,7 @@ +mod event; +mod io; +mod codec; + +pub use event::*; +pub use io::*; +pub use codec::*; diff --git a/Build/crates/saikuro-event/lib.rs b/Build/crates/saikuro-event/lib.rs new file mode 100644 index 00000000..81b49042 --- /dev/null +++ b/Build/crates/saikuro-event/lib.rs @@ -0,0 +1,18 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] + +//! Unified error taxonomy, structured logging, and dynamically-typed value +//! types for Saikuro. +#[cfg(not(feature = "std"))] +extern crate alloc; +#[cfg(feature = "std")] +extern crate std; + +mod value; +pub use value::*; + +mod core_events; +pub use core_events::*; + +pub mod log; +pub use log::*; diff --git a/Build/crates/saikuro-log/embedded/mod.rs b/Build/crates/saikuro-event/log/embedded/mod.rs similarity index 100% rename from Build/crates/saikuro-log/embedded/mod.rs rename to Build/crates/saikuro-event/log/embedded/mod.rs diff --git a/Build/crates/saikuro-log/embedded/serial.rs b/Build/crates/saikuro-event/log/embedded/serial.rs similarity index 100% rename from Build/crates/saikuro-log/embedded/serial.rs rename to Build/crates/saikuro-event/log/embedded/serial.rs diff --git a/Build/crates/saikuro-log/shared/level.rs b/Build/crates/saikuro-event/log/level.rs similarity index 94% rename from Build/crates/saikuro-log/shared/level.rs rename to Build/crates/saikuro-event/log/level.rs index 2d825a21..d8b3c5c7 100644 --- a/Build/crates/saikuro-log/shared/level.rs +++ b/Build/crates/saikuro-event/log/level.rs @@ -1,5 +1,3 @@ -//! Severity levels for log records. - use serde::{Deserialize, Serialize}; use strum::{Display, EnumString}; diff --git a/Build/crates/saikuro-log/lib.rs b/Build/crates/saikuro-event/log/mod.rs similarity index 72% rename from Build/crates/saikuro-log/lib.rs rename to Build/crates/saikuro-event/log/mod.rs index d7a5d671..000b6154 100644 --- a/Build/crates/saikuro-log/lib.rs +++ b/Build/crates/saikuro-event/log/mod.rs @@ -1,12 +1,15 @@ -#![cfg_attr(not(feature = "std"), no_std)] -#![warn(missing_docs)] +pub mod level; +pub mod record; +pub mod sink; -//! Logging types and sinks for Saikuro. +#[cfg(feature = "collector")] +pub mod ring; -#[cfg(not(feature = "std"))] -extern crate alloc; -#[cfg(feature = "std")] -extern crate std; +pub use level::*; +pub use record::*; +pub use sink::*; +#[cfg(feature = "collector")] +pub use ring::*; #[cfg(any( all(feature = "native", any(feature = "no_std", feature = "wasm", feature = "embedded")), @@ -22,14 +25,6 @@ compile_error!("the no_std engine cannot be combined with the std toolchain"); #[cfg(not(any(feature = "native", feature = "no_std", feature = "wasm", feature = "embedded")))] compile_error!("exactly one engine must be selected: native | no_std | wasm | embedded"); -mod shared; -pub use shared::*; - -#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] -mod base; -#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] -pub use base::*; - #[cfg(feature = "native")] mod native; #[cfg(feature = "native")] diff --git a/Build/crates/saikuro-log/native/mod.rs b/Build/crates/saikuro-event/log/native/mod.rs similarity index 67% rename from Build/crates/saikuro-log/native/mod.rs rename to Build/crates/saikuro-event/log/native/mod.rs index a527564c..f9609d65 100644 --- a/Build/crates/saikuro-log/native/mod.rs +++ b/Build/crates/saikuro-event/log/native/mod.rs @@ -1,5 +1,3 @@ -//! Host (OS) logging sinks. - pub mod stderr; #[cfg(feature = "tracing")] diff --git a/Build/crates/saikuro-log/native/stderr.rs b/Build/crates/saikuro-event/log/native/stderr.rs similarity index 100% rename from Build/crates/saikuro-log/native/stderr.rs rename to Build/crates/saikuro-event/log/native/stderr.rs diff --git a/Build/crates/saikuro-log/native/tracing.rs b/Build/crates/saikuro-event/log/native/tracing.rs similarity index 100% rename from Build/crates/saikuro-log/native/tracing.rs rename to Build/crates/saikuro-event/log/native/tracing.rs diff --git a/Build/crates/saikuro-log/shared/record.rs b/Build/crates/saikuro-event/log/record.rs similarity index 80% rename from Build/crates/saikuro-log/shared/record.rs rename to Build/crates/saikuro-event/log/record.rs index 4830d949..6be0eb9f 100644 --- a/Build/crates/saikuro-log/shared/record.rs +++ b/Build/crates/saikuro-event/log/record.rs @@ -3,21 +3,12 @@ use core::fmt; use core::str::FromStr; use serde::{Deserialize, Serialize}; -use saikuro_core::error::SaikuroError; -use saikuro_core::value::{Value, ValueMap}; - use crate::level::LogLevel; - -/// Maximum number of structured context fields a [`LogRecord`] can carry. -pub const LOG_FIELDS_CAPACITY: usize = 16; - -/// Fixed-capacity map of structured context fields on [`LogRecord`]. -pub type LogFieldMap = heapless::FnvIndexMap; +use crate::value::{Value, ValueMap}; +use crate::ContextMap; +use crate::SaikuroError; /// A structured log record forwarded from an adapter to the runtime log sink. -/// -/// The `fields` map holds any additional key/value context the emitting logger -/// attached (e.g. `err`, `id`, `duration_ms`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogRecord { /// ISO-8601 timestamp string (e.g. `"2026-01-01T00:00:00.000Z"`). @@ -33,8 +24,8 @@ pub struct LogRecord { pub msg: String, /// Additional structured context fields. - #[serde(default, skip_serializing_if = "LogFieldMap::is_empty")] - pub fields: LogFieldMap, + #[serde(default, skip_serializing_if = "ContextMap::is_empty")] + pub fields: ContextMap, } impl LogRecord { @@ -50,15 +41,15 @@ impl LogRecord { level, name: name.into(), msg: msg.into(), - fields: LogFieldMap::new(), + fields: ContextMap::new(), } } /// Add a structured field and return `self` for chaining. /// /// Fails with [`SaikuroError::CapacityExceeded`] if the record is already at - /// [`LOG_FIELDS_CAPACITY`] fields. - pub fn with_field( + /// capacity fields. + pub fn with_context( mut self, key: impl Into, value: impl Into, @@ -95,7 +86,7 @@ impl TryFrom for LogRecord { .unwrap_or(LogLevel::Info); let name = take_string(&mut map, "name").unwrap_or_default(); let msg = take_string(&mut map, "msg").unwrap_or_default(); - let mut fields = LogFieldMap::new(); + let mut fields = ContextMap::new(); for (k, v) in map.into_iter() { fields .insert(k, v) diff --git a/Build/crates/saikuro-log/shared/ring.rs b/Build/crates/saikuro-event/log/ring.rs similarity index 96% rename from Build/crates/saikuro-log/shared/ring.rs rename to Build/crates/saikuro-event/log/ring.rs index 6cfd0dbe..f94d787e 100644 --- a/Build/crates/saikuro-log/shared/ring.rs +++ b/Build/crates/saikuro-event/log/ring.rs @@ -1,5 +1,3 @@ -//! A bounded in-memory collector sink. - use alloc::vec::Vec; use spin::Mutex; diff --git a/Build/crates/saikuro-log/shared/sink.rs b/Build/crates/saikuro-event/log/sink.rs similarity index 100% rename from Build/crates/saikuro-log/shared/sink.rs rename to Build/crates/saikuro-event/log/sink.rs diff --git a/Build/crates/saikuro-log/wasm/console.rs b/Build/crates/saikuro-event/log/wasm/console.rs similarity index 100% rename from Build/crates/saikuro-log/wasm/console.rs rename to Build/crates/saikuro-event/log/wasm/console.rs diff --git a/Build/crates/saikuro-log/wasm/mod.rs b/Build/crates/saikuro-event/log/wasm/mod.rs similarity index 100% rename from Build/crates/saikuro-log/wasm/mod.rs rename to Build/crates/saikuro-event/log/wasm/mod.rs diff --git a/Build/crates/saikuro-event/value/mod.rs b/Build/crates/saikuro-event/value/mod.rs new file mode 100644 index 00000000..3fa475b2 --- /dev/null +++ b/Build/crates/saikuro-event/value/mod.rs @@ -0,0 +1,3 @@ +pub mod value; + +pub use value::{Value, ValueMap}; diff --git a/Build/crates/saikuro-core/value/value.rs b/Build/crates/saikuro-event/value/value.rs similarity index 100% rename from Build/crates/saikuro-core/value/value.rs rename to Build/crates/saikuro-event/value/value.rs diff --git a/Build/crates/saikuro-log/shared/mod.rs b/Build/crates/saikuro-log/shared/mod.rs deleted file mode 100644 index 66167517..00000000 --- a/Build/crates/saikuro-log/shared/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod level; -pub mod record; -pub mod sink; - -#[cfg(feature = "collector")] -pub mod ring; diff --git a/Build/crates/saikuro-random/Cargo.toml b/Build/crates/saikuro-random/Cargo.toml index 58110d8c..be203a97 100644 --- a/Build/crates/saikuro-random/Cargo.toml +++ b/Build/crates/saikuro-random/Cargo.toml @@ -14,9 +14,9 @@ path = "lib.rs" [features] default = ["std", "native"] std = ["rand_core/std"] -native = ["std", "getrandom", "getrandom/std"] -no_std = ["getrandom"] -wasm = ["getrandom", "getrandom/wasm_js"] +native = ["std", "getrandom", "getrandom/std", "saikuro-event/getrandom"] +no_std = ["getrandom", "saikuro-event/getrandom"] +wasm = ["getrandom", "getrandom/wasm_js", "saikuro-event/getrandom"] embedded = [] [dependencies] @@ -25,6 +25,7 @@ chacha20 = { workspace = true } portable-atomic = { workspace = true } rand_core = { workspace = true } uuid = { workspace = true } +saikuro-event = { workspace = true, default-features = false } [dev-dependencies] chacha20 = { workspace = true } diff --git a/Build/crates/saikuro-random/base/mod.rs b/Build/crates/saikuro-random/base/mod.rs index 47d7aa84..35305859 100644 --- a/Build/crates/saikuro-random/base/mod.rs +++ b/Build/crates/saikuro-random/base/mod.rs @@ -1,5 +1,7 @@ #[cfg(feature = "no_std")] -use crate::shared::{init, EntropySource, Error}; +use crate::shared::{init, EntropySource}; +#[cfg(feature = "no_std")] +use saikuro_event::SaikuroError; /// WASI entropy source, backed by `getrandom`'s built-in backend. #[cfg(feature = "no_std")] @@ -7,14 +9,14 @@ pub struct WasiEntropy; #[cfg(feature = "no_std")] impl EntropySource for WasiEntropy { - fn try_fill(&self, dest: &mut [u8]) -> Result<(), Error> { - getrandom::fill(dest).map_err(|e| Error::from(e)) + fn try_fill(&self, dest: &mut [u8]) -> Result<(), SaikuroError> { + getrandom::fill(dest).map_err(|e| SaikuroError::from(e)) } } /// Seed the process-wide DRBG from the WASI entropy source. #[cfg(feature = "no_std")] -pub fn init_default() -> Result<(), Error> { +pub fn init_default() -> Result<(), SaikuroError> { init(&WasiEntropy) } @@ -23,6 +25,6 @@ pub fn init_default() -> Result<(), Error> { /// Called automatically by [`crate::fill`] on first use. #[cfg(feature = "no_std")] #[doc(hidden)] -pub fn try_auto_seed() -> Result<(), Error> { +pub fn try_auto_seed() -> Result<(), SaikuroError> { init(&WasiEntropy) } diff --git a/Build/crates/saikuro-random/embedded/mod.rs b/Build/crates/saikuro-random/embedded/mod.rs index 6f19bbf6..a2632fff 100644 --- a/Build/crates/saikuro-random/embedded/mod.rs +++ b/Build/crates/saikuro-random/embedded/mod.rs @@ -1,10 +1,11 @@ -use crate::shared::{init, EntropySource, Error}; +use crate::shared::{init, EntropySource}; +use saikuro_event::SaikuroError; /// Seed the process-wide DRBG from an application-provided [`EntropySource`]. /// /// Call this once at startup after constructing the MCU's entropy source, e.g. /// a hardware RNG peripheral. There is no default source on `embedded`. -pub fn init_from(source: &impl EntropySource) -> Result<(), Error> { +pub fn init_from(source: &impl EntropySource) -> Result<(), SaikuroError> { init(source) } @@ -12,6 +13,6 @@ pub fn init_from(source: &impl EntropySource) -> Result<(), Error> { /// no-op that reports the DRBG as unseeded until the application calls /// [`init_from`]. #[doc(hidden)] -pub fn try_auto_seed() -> Result<(), Error> { - Err(Error::DrbgNotSeeded) +pub fn try_auto_seed() -> Result<(), SaikuroError> { + Err(SaikuroError::Entropy(format!("DRBG used before being seeded"))) } diff --git a/Build/crates/saikuro-random/native/mod.rs b/Build/crates/saikuro-random/native/mod.rs index 571bd4fe..14eda726 100644 --- a/Build/crates/saikuro-random/native/mod.rs +++ b/Build/crates/saikuro-random/native/mod.rs @@ -1,16 +1,17 @@ -use crate::shared::{init, EntropySource, Error}; +use crate::shared::{init, EntropySource}; +use saikuro_event::SaikuroError; /// OS entropy source, backed by `getrandom`/`std`. pub struct OsEntropy; impl EntropySource for OsEntropy { - fn try_fill(&self, dest: &mut [u8]) -> Result<(), Error> { - getrandom::fill(dest).map_err(|e| Error::from(e)) + fn try_fill(&self, dest: &mut [u8]) -> Result<(), SaikuroError> { + getrandom::fill(dest).map_err(|e| SaikuroError::from(e)) } } /// Seed the process-wide DRBG from the OS entropy source. -pub fn init_default() -> Result<(), Error> { +pub fn init_default() -> Result<(), SaikuroError> { init(&OsEntropy) } @@ -19,6 +20,6 @@ pub fn init_default() -> Result<(), Error> { /// Called automatically by [`crate::fill`] on first use so hosted binaries /// don't have to seed explicitly. #[doc(hidden)] -pub fn try_auto_seed() -> Result<(), Error> { +pub fn try_auto_seed() -> Result<(), SaikuroError> { init(&OsEntropy) } diff --git a/Build/crates/saikuro-random/shared/mod.rs b/Build/crates/saikuro-random/shared/mod.rs index cb1673c7..3df463b4 100644 --- a/Build/crates/saikuro-random/shared/mod.rs +++ b/Build/crates/saikuro-random/shared/mod.rs @@ -4,6 +4,7 @@ use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; use chacha20::XChaCha20; use portable_atomic::{AtomicBool, AtomicU64, Ordering}; use rand_core::{CryptoRng, RngCore, SeedableRng}; +use saikuro_event::SaikuroError; pub use uuid::Uuid; @@ -23,66 +24,25 @@ const MAX_BLOCKS: u64 = 1u64 << 32; /// Entropy source for the process-wide DRBG. pub trait EntropySource { /// Fill `dest` with fresh entropy, fully initializing every byte. - fn try_fill(&self, dest: &mut [u8]) -> Result<(), Error>; + fn try_fill(&self, dest: &mut [u8]) -> Result<(), SaikuroError>; } -/// Errors produced by the entropy facade. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Error { - /// The platform entropy backend couldn't produce bytes. - #[cfg(feature = "getrandom")] - Backend(getrandom::Error), - /// A custom (e.g. embedded hardware) entropy source failed. - Custom(&'static str), - /// The global DRBG was used before anyone seeded it. - DrbgNotSeeded, - /// The seed handed to the DRBG was too short. - InvalidSeed, - /// The DRBG keystream for the current seed ran out. - DrbgExhausted, - /// The process-wide DRBG was already initialized. - AlreadySeeded, -} - -impl core::fmt::Display for Error { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - #[cfg(feature = "getrandom")] - Error::Backend(e) => write!(f, "entropy backend failed: {e}"), - Error::Custom(s) => write!(f, "entropy source failed: {s}"), - Error::DrbgNotSeeded => write!(f, "DRBG used before being seeded"), - Error::InvalidSeed => write!(f, "DRBG seed must be at least {SEED_LEN} bytes"), - Error::DrbgExhausted => write!(f, "DRBG keystream exhausted; reseed required"), - Error::AlreadySeeded => write!(f, "DRBG has already been seeded"), - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for Error {} - -#[cfg(feature = "getrandom")] -impl From for Error { - fn from(err: getrandom::Error) -> Self { - Error::Backend(err) - } -} /// Generate keystream block `index` for the given key and nonce. fn keystream_block( key: &[u8; KEY_LEN], nonce: &[u8; NONCE_LEN], index: u64, -) -> Result<[u8; BLOCK_LEN], Error> { +) -> Result<[u8; BLOCK_LEN], SaikuroError> { let mut cipher = - XChaCha20::new_from_slices(key, nonce).map_err(|_| Error::InvalidSeed)?; + XChaCha20::new_from_slices(key, nonce).map_err(|_| SaikuroError::Entropy(format!("DRBG seed must be at least {SEED_LEN} bytes")))?; // chacha20 seeks by byte offset, not by block index. let pos = index .checked_mul(BLOCK_LEN as u64) - .ok_or(Error::DrbgExhausted)?; + .ok_or(SaikuroError::Entropy(format!("DRBG keystream exhausted")))?; cipher .try_seek(pos) - .map_err(|_| Error::DrbgExhausted)?; + .map_err(|_| SaikuroError::Entropy(format!("DRBG keystream exhausted")))?; let mut block = [0u8; BLOCK_LEN]; cipher.apply_keystream(&mut block); Ok(block) @@ -101,9 +61,9 @@ impl Drbg { /// /// The first 32 bytes are the key and the next 24 are the XChaCha20 nonce; /// anything past that is ignored. - pub fn from_seed(seed: &[u8]) -> Result { + pub fn from_seed(seed: &[u8]) -> Result { if seed.len() < SEED_LEN { - return Err(Error::InvalidSeed); + return Err(SaikuroError::Entropy(format!("DRBG seed must be at least {SEED_LEN} bytes"))); } let mut key = [0u8; KEY_LEN]; let mut nonce = [0u8; NONCE_LEN]; @@ -117,14 +77,14 @@ impl Drbg { } /// Fill `dest` with the next bytes of the keystream. - pub fn fill(&mut self, dest: &mut [u8]) -> Result<(), Error> { + pub fn fill(&mut self, dest: &mut [u8]) -> Result<(), SaikuroError> { let blocks = dest.len().div_ceil(BLOCK_LEN); let start = self.counter; let block_count = blocks as u64; let end = start .checked_add(block_count) .filter(|&end| end <= MAX_BLOCKS) - .ok_or(Error::DrbgExhausted)?; + .ok_or(SaikuroError::Entropy(format!("DRBG keystream exhausted")))?; self.counter = end; for i in 0..blocks { let block = keystream_block(&self.key, &self.nonce, start + i as u64)?; @@ -136,7 +96,7 @@ impl Drbg { } /// Fill potentially uninitialized `dest` with keystream bytes. - pub fn fill_uninit(&mut self, dest: &mut [MaybeUninit]) -> Result<(), Error> { + pub fn fill_uninit(&mut self, dest: &mut [MaybeUninit]) -> Result<(), SaikuroError> { // SAFETY: `MaybeUninit` has no validity constraints, so writing // initialized bytes through an `&mut [u8]` view is always sound. let bytes = @@ -209,16 +169,16 @@ static SEED: [AtomicU64; SEED_WORDS] = [ ]; /// Seed the process-wide DRBG from `seed`. -pub fn seed_from_slice(seed: &[u8]) -> Result<(), Error> { +pub fn seed_from_slice(seed: &[u8]) -> Result<(), SaikuroError> { if seed.len() < SEED_LEN { - return Err(Error::InvalidSeed); + return Err(SaikuroError::Entropy(format!("DRBG seed must be at least {SEED_LEN} bytes"))); } if SEEDED.load(Ordering::Acquire) || INITIALIZING .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { - return Err(Error::AlreadySeeded); + return Err(SaikuroError::Entropy(format!("DRBG has already been seeded"))); } for (i, word) in SEED.iter().enumerate() { let mut bytes = [0u8; 8]; @@ -235,7 +195,7 @@ pub fn seed_from_slice(seed: &[u8]) -> Result<(), Error> { /// /// Convenience over [`seed_from_slice`]: draw a fresh seed from `source` and /// install it. Engines expose `init_default`/`init_from` which call this. -pub fn init(source: &impl EntropySource) -> Result<(), Error> { +pub fn init(source: &impl EntropySource) -> Result<(), SaikuroError> { let mut seed = [0u8; SEED_LEN]; source.try_fill(&mut seed)?; seed_from_slice(&seed) @@ -267,13 +227,13 @@ fn read_seed() -> ([u8; KEY_LEN], [u8; NONCE_LEN]) { /// On first use, entropy-backed engines (`native`, `wasm`, `no_std`) seed the /// DRBG automatically from their platform source, so hosted binaries can call /// this without explicit setup. The `embedded` engine has no default source -/// and returns [`Error::DrbgNotSeeded`] until the application calls +/// and returns a [`SaikuroError::Entropy`] until the application calls /// [`init_from`]. -pub fn fill(dest: &mut [u8]) -> Result<(), Error> { +pub fn fill(dest: &mut [u8]) -> Result<(), SaikuroError> { if !is_seeded() { crate::try_auto_seed()?; if !is_seeded() { - return Err(Error::DrbgNotSeeded); + return Err(SaikuroError::Entropy(format!("DRBG used before being seeded"))); } } let (key, nonce) = read_seed(); @@ -290,7 +250,7 @@ pub fn fill(dest: &mut [u8]) -> Result<(), Error> { /// Fill potentially uninitialized `dest` with random bytes from the /// process-wide DRBG. -pub fn fill_uninit(dest: &mut [MaybeUninit]) -> Result<(), Error> { +pub fn fill_uninit(dest: &mut [MaybeUninit]) -> Result<(), SaikuroError> { // SAFETY: `MaybeUninit` has no validity constraints, so writing // initialized bytes through an `&mut [u8]` view is always sound. let bytes = @@ -299,21 +259,21 @@ pub fn fill_uninit(dest: &mut [MaybeUninit]) -> Result<(), Error> { } /// Draw a random `u32` from the process-wide DRBG. -pub fn u32() -> Result { +pub fn u32() -> Result { let mut bytes = [0u8; 4]; fill(&mut bytes)?; Ok(u32::from_ne_bytes(bytes)) } /// Draw a random `u64` from the process-wide DRBG. -pub fn u64() -> Result { +pub fn u64() -> Result { let mut bytes = [0u8; 8]; fill(&mut bytes)?; Ok(u64::from_ne_bytes(bytes)) } /// Generate a random RFC 4122 version 4 UUID from the process-wide DRBG. -pub fn uuid_v4() -> Result { +pub fn uuid_v4() -> Result { let mut bytes = [0u8; 16]; fill(&mut bytes)?; bytes[6] = (bytes[6] & 0x0f) | 0x40; @@ -321,13 +281,13 @@ pub fn uuid_v4() -> Result { Ok(Uuid::from_bytes(bytes)) } -fn reserve_blocks(blocks: u64) -> Result { +fn reserve_blocks(blocks: u64) -> Result { let mut current = COUNTER.load(Ordering::Relaxed); loop { let next = current .checked_add(blocks) .filter(|&next| next <= MAX_BLOCKS) - .ok_or(Error::DrbgExhausted)?; + .ok_or(SaikuroError::Entropy(format!("DRBG keystream exhausted")))?; match COUNTER.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) { Ok(_) => return Ok(current), Err(observed) => current = observed, diff --git a/Build/crates/saikuro-random/wasm/mod.rs b/Build/crates/saikuro-random/wasm/mod.rs index 209399be..9fa5392d 100644 --- a/Build/crates/saikuro-random/wasm/mod.rs +++ b/Build/crates/saikuro-random/wasm/mod.rs @@ -1,16 +1,17 @@ -use crate::shared::{init, EntropySource, Error}; +use crate::shared::{init, EntropySource}; +use saikuro_event::SaikuroError; /// Browser entropy source, backed by `getrandom`/`wasm_js`. pub struct JsEntropy; impl EntropySource for JsEntropy { - fn try_fill(&self, dest: &mut [u8]) -> Result<(), Error> { - getrandom::fill(dest).map_err(|e| Error::from(e)) + fn try_fill(&self, dest: &mut [u8]) -> Result<(), SaikuroError> { + getrandom::fill(dest).map_err(|e| SaikuroError::from(e)) } } /// Seed the process-wide DRBG from the browser entropy source. -pub fn init_default() -> Result<(), Error> { +pub fn init_default() -> Result<(), SaikuroError> { init(&JsEntropy) } @@ -18,6 +19,6 @@ pub fn init_default() -> Result<(), Error> { /// /// Called automatically by [`crate::fill`] on first use. #[doc(hidden)] -pub fn try_auto_seed() -> Result<(), Error> { +pub fn try_auto_seed() -> Result<(), SaikuroError> { init(&JsEntropy) } diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index 2203c7c7..5adb6428 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -19,36 +19,36 @@ native = [ "saikuro-core/native", "saikuro-exec/native", "saikuro-schema/native", - "saikuro-log/native", - "saikuro-log/tracing", + "saikuro-event/native", + "saikuro-event/tracing", ] no_std = [ "saikuro-core/no_std", "saikuro-exec/no_std", "saikuro-schema/no_std", - "saikuro-log/no_std", - "saikuro-log/null", + "saikuro-event/no_std", + "saikuro-event/null", ] wasm = [ "saikuro-core/wasm", "saikuro-exec/wasm", "saikuro-schema/wasm", - "saikuro-log/wasm", - "saikuro-log/console", + "saikuro-event/wasm", + "saikuro-event/console", ] embedded = [ "saikuro-core/embedded", "saikuro-exec/embedded", "saikuro-schema/embedded", - "saikuro-log/embedded", - "saikuro-log/null", + "saikuro-event/embedded", + "saikuro-event/null", ] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } saikuro-schema = { workspace = true, default-features = false } saikuro-exec = { workspace = true, default-features = false } -saikuro-log = { workspace = true, default-features = false } +saikuro-event = { workspace = true, default-features = false } async-trait = { workspace = true } thiserror = { workspace = true } diff --git a/Build/crates/saikuro-router/error/error.rs b/Build/crates/saikuro-router/error/error.rs deleted file mode 100644 index 7af1ac5f..00000000 --- a/Build/crates/saikuro-router/error/error.rs +++ /dev/null @@ -1,34 +0,0 @@ -use alloc::string::String; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum RouterError { - #[error("no provider registered for namespace '{0}'")] - NoProvider(String), - - #[error("provider '{0}' is unavailable")] - ProviderUnavailable(String), - - #[error("malformed target '{0}': must be 'namespace.function'")] - MalformedTarget(String), - - #[error("stream '{0}' not found")] - StreamNotFound(String), - - #[error("channel '{0}' not found")] - ChannelNotFound(String), - - #[error("stream already closed: '{0}'")] - StreamClosed(String), - - #[error("channel already closed: '{0}'")] - ChannelClosed(String), - - #[error("batch dispatch failed at item {index}: {reason}")] - BatchItemFailed { index: usize, reason: String }, - - #[error("send error: {0}")] - SendError(String), -} - -pub type Result = core::result::Result; diff --git a/Build/crates/saikuro-router/error/mod.rs b/Build/crates/saikuro-router/error/mod.rs deleted file mode 100644 index 7edf3bf2..00000000 --- a/Build/crates/saikuro-router/error/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod error; -pub use error::*; diff --git a/Build/crates/saikuro-router/lib.rs b/Build/crates/saikuro-router/lib.rs index 9d85c382..0538a747 100644 --- a/Build/crates/saikuro-router/lib.rs +++ b/Build/crates/saikuro-router/lib.rs @@ -3,23 +3,21 @@ extern crate alloc; -pub mod error; pub mod provider; pub mod router; pub mod stream_state; -pub use error::RouterError; pub use provider::{Provider, ProviderHandle, ProviderRegistry}; pub use router::{InvocationRouter, RouterConfig}; pub use stream_state::{ChannelState, StreamState, StreamStateStore}; // Default log sink per engine. #[cfg(feature = "native")] -pub type DefaultRouterSink = saikuro_log::TracingSink; +pub type DefaultRouterSink = saikuro_event::TracingSink; #[cfg(feature = "wasm")] -pub type DefaultRouterSink = saikuro_log::ConsoleSink; +pub type DefaultRouterSink = saikuro_event::ConsoleSink; #[cfg(any(feature = "no_std", feature = "embedded"))] -pub type DefaultRouterSink = saikuro_log::NullSink; +pub type DefaultRouterSink = saikuro_event::NullSink; // Compilation guard: exactly one engine backend must be selected. #[cfg(not(any( diff --git a/Build/crates/saikuro-router/provider/provider.rs b/Build/crates/saikuro-router/provider/provider.rs index f0af0bfe..ff15f79d 100644 --- a/Build/crates/saikuro-router/provider/provider.rs +++ b/Build/crates/saikuro-router/provider/provider.rs @@ -6,7 +6,7 @@ use saikuro_core::{envelope::Envelope, RegistrationToken, ResponseEnvelope}; use saikuro_exec::sync::RwLock; use saikuro_exec::{mpsc, oneshot}; -use crate::error::{Result, RouterError}; +use saikuro_event::{Result, SaikuroError}; // Pending call tracker /// A one-shot channel waiting for the response to a single Call invocation. @@ -106,7 +106,7 @@ impl Provider for ProviderHandle { response_tx, }) .await - .map_err(|_| RouterError::ProviderUnavailable(self.id.clone())) + .map_err(|_| SaikuroError::ProviderUnavailable(self.id.clone())) } fn is_alive(&self) -> bool { diff --git a/Build/crates/saikuro-router/router/router.rs b/Build/crates/saikuro-router/router/router.rs index d4f70fac..a3511c25 100644 --- a/Build/crates/saikuro-router/router/router.rs +++ b/Build/crates/saikuro-router/router/router.rs @@ -3,15 +3,13 @@ use alloc::{borrow::ToOwned, boxed::Box, format, string::ToString, sync::Arc, ve use core::time::Duration; use saikuro_core::{ envelope::{Envelope, InvocationType}, - error::{ErrorDetail, SaikuroError}, invocation::InvocationId, ResponseEnvelope, }; -use saikuro_log::{LogLevel, LogRecord, LogSink}; +use saikuro_event::{ErrorDetail, LogLevel, LogRecord, LogSink, Result, SaikuroError}; use saikuro_exec::{mpsc, oneshot, timeout, ChannelCapacity}; use crate::{ - error::{Result, RouterError}, provider::{Provider, ProviderRegistry}, stream_state::{ChannelState, DeliveryOutcome, StreamState, StreamStateStore}, DefaultRouterSink, @@ -265,7 +263,7 @@ impl InvocationRouter { } DeliveryOutcome::Closed => { self.streams.remove_channel_if(&id, &channel).await; - return error_response(id, RouterError::ChannelClosed(id.to_string()).into()); + return error_response(id, SaikuroError::ChannelClosed.into()); } DeliveryOutcome::OutOfOrder => { self.log_sink @@ -336,13 +334,13 @@ impl InvocationRouter { let response = Box::pin(self.dispatch(item)).await; // Represent each sub-response as its result value (or Null on error). results.push(if response.ok { - response.result.unwrap_or(saikuro_core::value::Value::Null) + response.result.unwrap_or(saikuro_event::Value::Null) } else { - saikuro_core::value::Value::Null + saikuro_event::Value::Null }); } - ResponseEnvelope::ok(id, saikuro_core::value::Value::Array(results)) + ResponseEnvelope::ok(id, saikuro_event::Value::Array(results)) } // Log @@ -394,12 +392,12 @@ impl InvocationRouter { let state = self .streams .get_channel(&id).await - .ok_or_else(|| RouterError::ChannelNotFound(id.to_string()))?; + .ok_or_else(|| SaikuroError::ChannelNotFound(id.to_string()))?; match state.deliver(response, inbound).await { DeliveryOutcome::Closed => { self.streams.remove_channel_if(&id, &state).await; - Err(RouterError::ChannelClosed(id.to_string())) + Err(SaikuroError::ChannelClosed) } DeliveryOutcome::OutOfOrder => { self.log_sink @@ -436,12 +434,12 @@ impl InvocationRouter { let state = self .streams .get_stream(&id).await - .ok_or_else(|| RouterError::StreamNotFound(id.to_string()))?; + .ok_or_else(|| SaikuroError::StreamNotFound(id.to_string()))?; match state.deliver(response).await { DeliveryOutcome::Closed => { self.streams.remove_stream_if(&id, &state).await; - Err(RouterError::StreamClosed(id.to_string())) + Err(SaikuroError::StreamClosed) } DeliveryOutcome::OutOfOrder => { self.log_sink @@ -465,15 +463,15 @@ impl InvocationRouter { // Helpers async fn resolve_namespace(&self, target: &str) -> Result { let ns = - namespace_of(target).ok_or_else(|| RouterError::MalformedTarget(target.to_owned()))?; + namespace_of(target).ok_or_else(|| SaikuroError::MalformedTarget(target.to_owned()))?; let handle = self .providers .get(ns).await - .ok_or_else(|| RouterError::NoProvider(ns.to_owned()))?; + .ok_or_else(|| SaikuroError::NoProvider(ns.to_owned()))?; if !handle.is_alive() { - return Err(RouterError::ProviderUnavailable(handle.id().to_owned())); + return Err(SaikuroError::ProviderUnavailable(handle.id().to_owned())); } Ok(handle) @@ -489,38 +487,18 @@ fn error_response(id: InvocationId, detail: ErrorDetail) -> ResponseEnvelope { ResponseEnvelope::err(id, detail) } -// Allow RouterError to convert into ErrorDetail -impl From for ErrorDetail { - fn from(err: RouterError) -> Self { - let code = match &err { - RouterError::NoProvider(_) => saikuro_core::error::ErrorCode::NoProvider, - RouterError::ProviderUnavailable(_) => { - saikuro_core::error::ErrorCode::ProviderUnavailable - } - RouterError::MalformedTarget(_) => saikuro_core::error::ErrorCode::MalformedEnvelope, - RouterError::StreamNotFound(_) | RouterError::ChannelNotFound(_) => { - saikuro_core::error::ErrorCode::StreamClosed - } - RouterError::StreamClosed(_) => saikuro_core::error::ErrorCode::StreamClosed, - RouterError::ChannelClosed(_) => saikuro_core::error::ErrorCode::ChannelClosed, - RouterError::BatchItemFailed { .. } => saikuro_core::error::ErrorCode::ProviderError, - RouterError::SendError(_) => saikuro_core::error::ErrorCode::ProviderUnavailable, - }; - ErrorDetail::new(code, err.to_string()) - } -} fn default_sink() -> DefaultRouterSink { #[cfg(feature = "native")] { - saikuro_log::TracingSink + saikuro_event::TracingSink } #[cfg(feature = "wasm")] { - saikuro_log::ConsoleSink + saikuro_event::ConsoleSink } #[cfg(any(feature = "no_std", feature = "embedded"))] { - saikuro_log::NullSink + saikuro_event::NullSink } } diff --git a/Build/crates/saikuro-runtime/Cargo.toml b/Build/crates/saikuro-runtime/Cargo.toml index a60a439b..a3d61e51 100644 --- a/Build/crates/saikuro-runtime/Cargo.toml +++ b/Build/crates/saikuro-runtime/Cargo.toml @@ -36,7 +36,6 @@ serde_json = { workspace = true } bytes = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } -thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } dashmap = { workspace = true } diff --git a/Build/crates/saikuro-runtime/src/connection.rs b/Build/crates/saikuro-runtime/src/connection.rs index c8887fa0..22bd5d88 100644 --- a/Build/crates/saikuro-runtime/src/connection.rs +++ b/Build/crates/saikuro-runtime/src/connection.rs @@ -42,12 +42,11 @@ use futures::future::FutureExt; use saikuro_core::{ capability::CapabilitySet, envelope::{Envelope, InvocationType}, - error::ErrorDetail, invocation::InvocationId, schema::Schema, - value::Value, RegistrationToken, ResponseEnvelope, }; +use saikuro_event::{ErrorDetail, Value}; use saikuro_exec::{mpsc, oneshot, spawn}; use saikuro_router::{ provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, @@ -266,7 +265,7 @@ where ResponseEnvelope::err( id, ErrorDetail::new( - saikuro_core::error::ErrorCode::CapabilityDenied, + saikuro_event::ErrorCode::CapabilityDenied, format!("caller lacks '{}' to invoke '{}'", missing, envelope.target), ), ), @@ -296,7 +295,7 @@ where Err(Some(Box::new(ResponseEnvelope::err( id, ErrorDetail::new( - saikuro_core::error::ErrorCode::MalformedEnvelope, + saikuro_event::ErrorCode::MalformedEnvelope, format!("msgpack decode error: {e}"), ), )))) @@ -313,7 +312,7 @@ where ) -> bool { if frame.len() > self.max_message_size { let err = ErrorDetail::new( - saikuro_core::error::ErrorCode::MessageTooLarge, + saikuro_event::ErrorCode::MessageTooLarge, format!( "frame {} bytes exceeds limit {} bytes", frame.len(), @@ -412,7 +411,7 @@ where ResponseEnvelope::err( id, ErrorDetail::new( - saikuro_core::error::ErrorCode::Internal, + saikuro_event::ErrorCode::Internal, format!("schema merge error: {e}"), ), ) @@ -424,7 +423,7 @@ where ResponseEnvelope::err( id, ErrorDetail::new( - saikuro_core::error::ErrorCode::MalformedEnvelope, + saikuro_event::ErrorCode::MalformedEnvelope, "announce envelope must carry a Schema in args[0]".to_owned(), ), ) @@ -469,7 +468,7 @@ where let _ = tx.send(ResponseEnvelope::err( item.envelope.id, ErrorDetail::new( - saikuro_core::error::ErrorCode::Internal, + saikuro_event::ErrorCode::Internal, format!("encode error: {e}"), ), )); diff --git a/Build/crates/saikuro-runtime/src/error.rs b/Build/crates/saikuro-runtime/src/error.rs deleted file mode 100644 index 5185a29a..00000000 --- a/Build/crates/saikuro-runtime/src/error.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Runtime error type. - -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum RuntimeError { - #[error("schema error: {0}")] - Schema(String), - - #[error("transport error: {0}")] - Transport(String), - - #[error("router error: {0}")] - Router(String), - - #[error("validation error: {0}")] - Validation(String), - - #[error("capability denied: {0}")] - CapabilityDenied(String), - - #[error("runtime already shut down")] - Shutdown, - - #[error("I/O error: {0}")] - Io(#[from] std::io::Error), - - #[error("serialisation error: {0}")] - Serialisation(String), - - #[error("internal error: {0}")] - Internal(String), - - #[error("entropy error: {0}")] - Entropy(#[from] saikuro_random::Error), -} - -pub type Result = std::result::Result; - -impl From for RuntimeError { - fn from(e: saikuro_schema::registry::RegistryError) -> Self { - Self::Schema(e.to_string()) - } -} - -impl From for RuntimeError { - fn from(e: saikuro_transport::error::TransportError) -> Self { - Self::Transport(e.to_string()) - } -} - -impl From for RuntimeError { - fn from(e: saikuro_router::error::RouterError) -> Self { - Self::Router(e.to_string()) - } -} diff --git a/Build/crates/saikuro-runtime/src/handle.rs b/Build/crates/saikuro-runtime/src/handle.rs index dd500e35..0695e778 100644 --- a/Build/crates/saikuro-runtime/src/handle.rs +++ b/Build/crates/saikuro-runtime/src/handle.rs @@ -27,7 +27,8 @@ use saikuro_schema::{ use saikuro_transport::traits::Transport; use tracing::{debug, info}; -use crate::{config::RuntimeConfig, connection::ConnectionHandler, error::Result}; +use crate::{config::RuntimeConfig, connection::ConnectionHandler}; +use saikuro_event::Result; /// A cheap, `Clone`-able handle to a running [`SaikuroRuntime`]. /// @@ -118,7 +119,7 @@ impl RuntimeHandle { Err(e) => { return ResponseEnvelope::err( envelope.id, - saikuro_core::error::ErrorDetail::new(e.error_code(), e.to_string()), + saikuro_event::ErrorDetail::new(e.error_code(), e.to_string()), ); } }; @@ -131,8 +132,8 @@ impl RuntimeHandle { { return ResponseEnvelope::err( envelope.id, - saikuro_core::error::ErrorDetail::new( - saikuro_core::error::ErrorCode::CapabilityDenied, + saikuro_event::ErrorDetail::new( + saikuro_event::ErrorCode::CapabilityDenied, format!("missing capability '{missing}' for '{}'", envelope.target), ), ); diff --git a/Build/crates/saikuro-runtime/src/lib.rs b/Build/crates/saikuro-runtime/src/lib.rs index 77718b62..b08bf65e 100644 --- a/Build/crates/saikuro-runtime/src/lib.rs +++ b/Build/crates/saikuro-runtime/src/lib.rs @@ -4,11 +4,9 @@ pub mod config; pub mod connection; -pub mod error; pub mod handle; pub mod runtime; pub use config::RuntimeConfig; -pub use error::RuntimeError; pub use handle::RuntimeHandle; pub use runtime::SaikuroRuntime; diff --git a/Build/crates/saikuro-schema/Cargo.toml b/Build/crates/saikuro-schema/Cargo.toml index bdaa73c7..7c46e9cc 100644 --- a/Build/crates/saikuro-schema/Cargo.toml +++ b/Build/crates/saikuro-schema/Cargo.toml @@ -14,13 +14,14 @@ path = "lib.rs" [features] default = ["std", "native"] std = [] -native = ["std", "saikuro-core/native", "saikuro-exec/native"] -no_std = ["saikuro-core/no_std", "saikuro-exec/no_std"] -wasm = ["saikuro-core/wasm", "saikuro-exec/wasm"] -embedded = ["saikuro-core/embedded", "saikuro-exec/embedded"] +native = ["std", "saikuro-core/native", "saikuro-exec/native", "saikuro-event/native"] +no_std = ["saikuro-core/no_std", "saikuro-exec/no_std", "saikuro-event/no_std"] +wasm = ["saikuro-core/wasm", "saikuro-exec/wasm", "saikuro-event/wasm"] +embedded = ["saikuro-core/embedded", "saikuro-exec/embedded", "saikuro-event/embedded"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } saikuro-exec = { path = "../saikuro-exec", default-features = false } +saikuro-event = { workspace = true, default-features = false } thiserror = { workspace = true } diff --git a/Build/crates/saikuro-schema/registry/registry.rs b/Build/crates/saikuro-schema/registry/registry.rs index 3bc63a36..645f7c9b 100644 --- a/Build/crates/saikuro-schema/registry/registry.rs +++ b/Build/crates/saikuro-schema/registry/registry.rs @@ -6,7 +6,7 @@ use saikuro_core::schema::{ use saikuro_exec::sync::RwLock; use saikuro_core::RegistrationToken; -use crate::validator::ValidationError; +use saikuro_event::SaikuroError; /// Whether the registry accepts dynamic schema updates. @@ -97,17 +97,17 @@ impl SchemaRegistry { /// Register (or replace) a namespace. /// In production mode this returns an error rather than mutating state. - pub async fn register(&self, registration: NamespaceRegistration) -> Result<(), RegistryError> { + pub async fn register(&self, registration: NamespaceRegistration) -> Result<(), SaikuroError> { let mut schemata = self.inner.write().await; if schemata.mode == RegistryMode::Production { - return Err(RegistryError::FrozenSchema(registration.namespace)); + return Err(SaikuroError::FrozenSchema(registration.namespace)); } let ns = registration.namespace.clone(); if !schemata.namespaces.contains_key(&ns) && schemata.namespaces.len() == SCHEMA_NAMESPACES_CAPACITY { - return Err(RegistryError::SchemaCapacity); + return Err(SaikuroError::SchemaCapacity); } schemata.namespaces.insert( ns, @@ -125,7 +125,7 @@ impl SchemaRegistry { &self, schema: Schema, provider_id: impl Into, - ) -> Result<(), RegistryError> { + ) -> Result<(), SaikuroError> { self.merge_schema_with_token(schema, provider_id, RegistrationToken::new()).await } @@ -135,7 +135,7 @@ impl SchemaRegistry { schema: Schema, provider_id: impl Into, registration_token: RegistrationToken, - ) -> Result<(), RegistryError> { + ) -> Result<(), SaikuroError> { let provider_id = provider_id.into(); // The whole merge happens under one write guard so a concurrent @@ -144,7 +144,7 @@ impl SchemaRegistry { if schemata.mode == RegistryMode::Production { let ns = schema.namespaces.keys().next().cloned().unwrap_or_default(); - return Err(RegistryError::FrozenSchema(ns)); + return Err(SaikuroError::FrozenSchema(ns)); } let new_namespaces = schema @@ -160,7 +160,7 @@ impl SchemaRegistry { if schemata.namespaces.len() + new_namespaces > SCHEMA_NAMESPACES_CAPACITY || schemata.types.len() + new_types > SCHEMA_TYPES_CAPACITY { - return Err(RegistryError::SchemaCapacity); + return Err(SaikuroError::SchemaCapacity); } // Merge types first (functions may reference them). @@ -196,20 +196,20 @@ impl SchemaRegistry { /// Look up the schema for a single function. /// `target` must be in `"namespace.function"` format. - pub async fn lookup_function(&self, target: &str) -> Result { + pub async fn lookup_function(&self, target: &str) -> Result { let (ns_name, fn_name) = split_target(target)?; let schemata = self.inner.read().await; let entry = schemata .namespaces .get(ns_name) - .ok_or_else(|| RegistryError::NamespaceNotFound(ns_name.to_owned()))?; + .ok_or_else(|| SaikuroError::NamespaceNotFound(ns_name.to_owned()))?; let fn_schema = entry .schema .functions .get(fn_name) - .ok_or_else(|| RegistryError::FunctionNotFound(target.to_owned()))? + .ok_or_else(|| SaikuroError::FunctionNotFound(target.to_owned()))? .clone(); Ok(FunctionRef { @@ -240,20 +240,20 @@ impl SchemaRegistry { } /// Export a snapshot of the full schema at this instant. - pub async fn snapshot(&self) -> Result { + pub async fn snapshot(&self) -> Result { let mut schema = Schema::new(); let schemata = self.inner.read().await; for (name, entry) in schemata.namespaces.iter() { schema .namespaces .insert(name.clone(), entry.schema.clone()) - .map_err(|_| RegistryError::SchemaCapacity)?; + .map_err(|_| SaikuroError::SchemaCapacity)?; } for (name, type_def) in schemata.types.iter() { schema .types .insert(name.clone(), type_def.clone()) - .map_err(|_| RegistryError::SchemaCapacity)?; + .map_err(|_| SaikuroError::SchemaCapacity)?; } Ok(schema) } @@ -284,30 +284,8 @@ pub struct FunctionRef { pub provider_id: String, } -// Registry error -#[derive(Debug, thiserror::Error)] -pub enum RegistryError { - #[error("namespace not found: {0}")] - NamespaceNotFound(String), - - #[error("function not found: {0}")] - FunctionNotFound(String), - - #[error("malformed target '{0}': must be 'namespace.function'")] - MalformedTarget(String), - - #[error("schema is frozen; cannot register namespace '{0}' in production mode")] - FrozenSchema(String), - - #[error("validation error: {0}")] - Validation(#[from] ValidationError), - - #[error("schema registry capacity exceeded")] - SchemaCapacity, -} - /// Split a `"namespace.function"` target into its two components. -fn split_target(target: &str) -> Result<(&str, &str), RegistryError> { +fn split_target(target: &str) -> Result<(&str, &str), SaikuroError> { saikuro_core::split_target(target) - .ok_or_else(|| RegistryError::MalformedTarget(target.to_owned())) + .ok_or_else(|| SaikuroError::MalformedTarget(target.to_owned())) } diff --git a/Build/crates/saikuro-schema/validator/validator.rs b/Build/crates/saikuro-schema/validator/validator.rs index 0d91c4bd..839eb0f9 100644 --- a/Build/crates/saikuro-schema/validator/validator.rs +++ b/Build/crates/saikuro-schema/validator/validator.rs @@ -5,87 +5,13 @@ use alloc::{ }; use saikuro_core::{ envelope::{Envelope, InvocationType}, - error::ErrorCode, schema::{ArgumentDescriptor, PrimitiveType, TypeDescriptor, Visibility}, - value::Value, PROTOCOL_VERSION, }; -use thiserror::Error; -use crate::registry::{FunctionRef, RegistryError, SchemaRegistry}; +use saikuro_event::{SaikuroError, Value}; -// Errors - -/// A validation failure. -#[derive(Debug, Error)] -pub enum ValidationError { - #[error("incompatible protocol version: expected {expected}, got {received}")] - IncompatibleVersion { expected: u32, received: u32 }, - - #[error("malformed envelope: {0}")] - MalformedEnvelope(String), - - #[error("schema error: {0}")] - Schema(Box), - - #[error("wrong number of arguments: expected {expected}, got {received}")] - ArgumentArity { expected: usize, received: usize }, - - #[error("argument '{name}' (position {position}): expected {expected}, got {received}")] - ArgumentType { - name: String, - position: usize, - expected: String, - received: String, - }, - - #[error("function '{target}' is {visibility:?} and cannot be called by this peer")] - VisibilityDenied { - target: String, - visibility: Visibility, - }, - - #[error("batch envelope has no items field")] - MissingBatch, - - #[error("batch envelope has an empty items list")] - EmptyBatch, - - #[error("batch item at index {index}: {source}")] - BatchItem { - index: usize, - #[source] - source: Box, - }, -} - -impl From for ValidationError { - fn from(e: RegistryError) -> Self { - ValidationError::Schema(Box::new(e)) - } -} - -impl ValidationError { - /// Map this error to the appropriate wire [`ErrorCode`]. - pub fn error_code(&self) -> ErrorCode { - match self { - Self::IncompatibleVersion { .. } => ErrorCode::IncompatibleVersion, - Self::MalformedEnvelope(_) => ErrorCode::MalformedEnvelope, - Self::Schema(e) => match e.as_ref() { - RegistryError::NamespaceNotFound(_) => ErrorCode::NamespaceNotFound, - RegistryError::FunctionNotFound(_) => ErrorCode::FunctionNotFound, - RegistryError::MalformedTarget(_) => ErrorCode::MalformedEnvelope, - RegistryError::FrozenSchema(_) => ErrorCode::Internal, - RegistryError::Validation(_) => ErrorCode::InvalidArguments, - RegistryError::SchemaCapacity => ErrorCode::Internal, - }, - Self::ArgumentArity { .. } | Self::ArgumentType { .. } => ErrorCode::InvalidArguments, - Self::VisibilityDenied { .. } => ErrorCode::CapabilityDenied, - Self::MissingBatch | Self::EmptyBatch => ErrorCode::MalformedEnvelope, - Self::BatchItem { source, .. } => source.error_code(), - } - } -} +use crate::registry::{FunctionRef, SchemaRegistry}; /// The result of a successful validation pass. #[derive(Debug)] @@ -120,10 +46,10 @@ impl InvocationValidator { } /// Validate a single envelope. - pub async fn validate(&self, envelope: &Envelope) -> Result { + pub async fn validate(&self, envelope: &Envelope) -> Result { // 1. Protocol version. if envelope.version != PROTOCOL_VERSION { - return Err(ValidationError::IncompatibleVersion { + return Err(SaikuroError::IncompatibleVersion { expected: PROTOCOL_VERSION, received: envelope.version, }); @@ -156,13 +82,13 @@ impl InvocationValidator { } // Structural checks - fn check_structural(&self, envelope: &Envelope) -> Result<(), ValidationError> { + fn check_structural(&self, envelope: &Envelope) -> Result<(), SaikuroError> { let skip_target_check = matches!( envelope.invocation_type, InvocationType::Batch | InvocationType::Log | InvocationType::Announce ); if !skip_target_check && !envelope.target.contains('.') { - return Err(ValidationError::MalformedEnvelope(format!( + return Err(SaikuroError::MalformedEnvelope(format!( "target '{}' must be in 'namespace.function' format", envelope.target ))); @@ -171,8 +97,8 @@ impl InvocationValidator { // Batch-specific: must have items, must not have a target. if envelope.invocation_type == InvocationType::Batch { match &envelope.batch_items { - None => return Err(ValidationError::MissingBatch), - Some(items) if items.is_empty() => return Err(ValidationError::EmptyBatch), + None => return Err(SaikuroError::MissingBatch), + Some(items) if items.is_empty() => return Err(SaikuroError::EmptyBatch), _ => {} } } @@ -181,7 +107,7 @@ impl InvocationValidator { } // Single-invocation validation - async fn validate_single(&self, envelope: &Envelope) -> Result { + async fn validate_single(&self, envelope: &Envelope) -> Result { // Schema lookup. let func_ref = self.registry.lookup_function(&envelope.target).await?; @@ -197,17 +123,17 @@ impl InvocationValidator { } // Batch validation - async fn validate_batch(&self, envelope: &Envelope) -> Result { + async fn validate_batch(&self, envelope: &Envelope) -> Result { let items = envelope.batch_items.as_ref().ok_or_else(|| { - ValidationError::MalformedEnvelope("batch envelope missing batch_items".into()) + SaikuroError::MalformedEnvelope("batch envelope missing batch_items".into()) })?; // Validate each item; collect the first error with its index. for (index, item) in items.iter().enumerate() { self.validate(item).await - .map_err(|source| ValidationError::BatchItem { + .map_err(|source| SaikuroError::BatchItemFailed { index, - source: Box::new(source), + reason: source.to_string(), })?; } @@ -225,17 +151,17 @@ impl InvocationValidator { &self, target: &str, visibility: &Visibility, - ) -> Result<(), ValidationError> { + ) -> Result<(), SaikuroError> { match visibility { Visibility::Public => Ok(()), Visibility::Internal if self.allow_internal => Ok(()), - Visibility::Internal => Err(ValidationError::VisibilityDenied { + Visibility::Internal => Err(SaikuroError::VisibilityDenied { target: target.to_owned(), - visibility: Visibility::Internal, + visibility: format!("{visibility:?}"), }), - Visibility::Private => Err(ValidationError::VisibilityDenied { + Visibility::Private => Err(SaikuroError::VisibilityDenied { target: target.to_owned(), - visibility: Visibility::Private, + visibility: format!("{visibility:?}"), }), } } @@ -245,7 +171,7 @@ impl InvocationValidator { target: &str, declared: &[ArgumentDescriptor], provided: &[Value], - ) -> Result<(), ValidationError> { + ) -> Result<(), SaikuroError> { // Count required args (those without defaults and not optional). let required_count = declared .iter() @@ -253,14 +179,14 @@ impl InvocationValidator { .count(); if provided.len() < required_count { - return Err(ValidationError::ArgumentArity { + return Err(SaikuroError::ArgumentArity { expected: required_count, received: provided.len(), }); } if provided.len() > declared.len() { - return Err(ValidationError::ArgumentArity { + return Err(SaikuroError::ArgumentArity { expected: declared.len(), received: provided.len(), }); @@ -290,8 +216,8 @@ impl InvocationValidator { name: &str, descriptor: &TypeDescriptor, value: &Value, - ) -> Result<(), ValidationError> { - let type_error = |expected: &str| ValidationError::ArgumentType { + ) -> Result<(), SaikuroError> { + let type_error = |expected: &str| SaikuroError::ArgumentType { name: name.to_owned(), position, expected: expected.to_owned(), @@ -339,7 +265,7 @@ impl InvocationValidator { // Stream and Channel types appear only in return-type positions; // they cannot appear in argument lists. TypeDescriptor::Stream { .. } | TypeDescriptor::Channel { .. } => { - Err(ValidationError::MalformedEnvelope( + Err(SaikuroError::MalformedEnvelope( "stream/channel types are not valid argument types".to_owned(), )) } @@ -353,7 +279,7 @@ impl InvocationValidator { name: &str, prim: &PrimitiveType, value: &Value, - ) -> Result<(), ValidationError> { + ) -> Result<(), SaikuroError> { let ok = match prim { PrimitiveType::Bool => value.as_bool().is_some(), PrimitiveType::I8 | PrimitiveType::I16 | PrimitiveType::I32 | PrimitiveType::I64 => { @@ -372,7 +298,7 @@ impl InvocationValidator { if ok { Ok(()) } else { - Err(ValidationError::ArgumentType { + Err(SaikuroError::ArgumentType { name: name.to_owned(), position, expected: prim.to_string(), diff --git a/Build/crates/saikuro-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index 06505877..627ba888 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -97,7 +97,3 @@ saikuro-exec = { workspace = true, features = ["tokio-runtime"] } tracing-subscriber = { workspace = true } futures-executor = { workspace = true } embedded-storage-async = { workspace = true } - -[[test]] -name = "flash" -required-features = ["flash-storage"] diff --git a/Build/tests/Cargo.toml b/Build/tests/Cargo.toml index c8fa9158..0f9b72fe 100644 --- a/Build/tests/Cargo.toml +++ b/Build/tests/Cargo.toml @@ -15,6 +15,7 @@ saikuro-core = { workspace = true } saikuro-schema = { workspace = true } saikuro-router = { workspace = true } saikuro-codegen = { workspace = true } +saikuro-event = { workspace = true } saikuro = { workspace = true } saikuro-exec = { workspace = true } diff --git a/Build/tests/saikuro-core/value.rs b/Build/tests/saikuro-core/value.rs index cab92bb0..b199f6f8 100644 --- a/Build/tests/saikuro-core/value.rs +++ b/Build/tests/saikuro-core/value.rs @@ -3,7 +3,7 @@ use saikuro_core::schema::{ FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, TypeMap, Visibility, }; -use saikuro_core::value::{Value, ValueMap}; +use saikuro_event::{Value, ValueMap}; /// Regression: Schema -> msgpack bytes -> Value -> msgpack bytes -> Schema must round-trip. #[test] diff --git a/Build/tests/saikuro-router/announce_dispatch.rs b/Build/tests/saikuro-router/announce_dispatch.rs index 24cb1445..f47c4792 100644 --- a/Build/tests/saikuro-router/announce_dispatch.rs +++ b/Build/tests/saikuro-router/announce_dispatch.rs @@ -160,7 +160,7 @@ fn announce_in_production_mode_returns_error() { // The registry rejects with FrozenSchema; the handler maps that to Internal. assert_eq!( err.code, - saikuro_core::error::ErrorCode::Internal, + saikuro_event::ErrorCode::Internal, "expected Internal error code for frozen registry, got {:?}", err.code ); @@ -200,7 +200,7 @@ fn announce_with_invalid_schema_returns_error() { let err = resp.error.expect("error detail"); assert_eq!( err.code, - saikuro_core::error::ErrorCode::MalformedEnvelope, + saikuro_event::ErrorCode::MalformedEnvelope, "expected MalformedEnvelope for bad args[0], got {:?}", err.code ); @@ -233,7 +233,7 @@ fn announce_with_no_args_returns_error() { let err = resp.error.expect("error detail"); assert_eq!( err.code, - saikuro_core::error::ErrorCode::MalformedEnvelope, + saikuro_event::ErrorCode::MalformedEnvelope, "expected MalformedEnvelope for empty args, got {:?}", err.code ); diff --git a/Build/tests/saikuro-router/resource_dispatch.rs b/Build/tests/saikuro-router/resource_dispatch.rs index c5a26173..f0d694b5 100644 --- a/Build/tests/saikuro-router/resource_dispatch.rs +++ b/Build/tests/saikuro-router/resource_dispatch.rs @@ -338,7 +338,7 @@ fn resource_handle_from_value_rejects_non_map() { /// `ResourceHandle::from_value` returns `None` for a map that has no `id` field. #[test] fn resource_handle_from_value_rejects_missing_id() { - use saikuro_core::value::ValueMap; + use saikuro_event::ValueMap; let mut map = ValueMap::new(); map.insert("size".to_owned(), Value::Int(100)).ok(); let v = Value::Map(Box::new(map)); diff --git a/Build/tests/saikuro-router/sandbox_dispatch.rs b/Build/tests/saikuro-router/sandbox_dispatch.rs index 72c9e9a8..57672576 100644 --- a/Build/tests/saikuro-router/sandbox_dispatch.rs +++ b/Build/tests/saikuro-router/sandbox_dispatch.rs @@ -364,7 +364,7 @@ fn sandbox_handler_denies_internal_function_invocation() { let err = resp.error.expect("error detail must be present"); assert_eq!( err.code, - saikuro_core::error::ErrorCode::CapabilityDenied, + saikuro_event::ErrorCode::CapabilityDenied, "expected CapabilityDenied, got {:?}", err.code ); diff --git a/Build/tests/saikuro-schema/registry.rs b/Build/tests/saikuro-schema/registry.rs index 9b662c5e..799c32dc 100644 --- a/Build/tests/saikuro-schema/registry.rs +++ b/Build/tests/saikuro-schema/registry.rs @@ -1,6 +1,7 @@ use saikuro_core::schema::{PrimitiveType, Schema, TypeDefinition, TypeDescriptor}; use saikuro_core::RegistrationToken; -use saikuro_schema::registry::{RegistryError, SchemaRegistry}; +use saikuro_event::SaikuroError; +use saikuro_schema::registry::SchemaRegistry; #[test] fn frozen_registry_rejects_type_only_merge() { @@ -18,7 +19,7 @@ fn frozen_registry_rejects_type_only_merge() { assert!(matches!( registry.merge_schema(update, "provider"), - Err(RegistryError::FrozenSchema(_)) + Err(SaikuroError::FrozenSchema(_)) )); assert!(registry.snapshot().expect("snapshot").types.is_empty()); } diff --git a/Build/tests/saikuro-schema/schema_validation.rs b/Build/tests/saikuro-schema/schema_validation.rs index 20c3c95a..c9b8f16f 100644 --- a/Build/tests/saikuro-schema/schema_validation.rs +++ b/Build/tests/saikuro-schema/schema_validation.rs @@ -2,16 +2,15 @@ use saikuro_core::{ envelope::{Envelope, InvocationType}, - error::ErrorCode, schema::{ ArgumentDescriptor, FunctionMap, FunctionSchema, NamespaceSchema, PrimitiveType, TypeDescriptor, Visibility, }, - value::Value, }; +use saikuro_event::{ErrorCode, SaikuroError, Value}; use saikuro_schema::{ registry::{NamespaceRegistration, SchemaRegistry}, - validator::{InvocationValidator, ValidationError}, + validator::InvocationValidator, }; // Helpers @@ -136,7 +135,7 @@ fn wrong_arity_fails_validation() { // too few args let env_few = Envelope::call("math.add", vec![Value::Int(1)]).expect("entropy available"); let err = validator.validate(&env_few).unwrap_err(); - assert!(matches!(err, ValidationError::ArgumentArity { .. })); + assert!(matches!(err, SaikuroError::ArgumentArity { .. })); assert_eq!(err.error_code(), ErrorCode::InvalidArguments); // too many args @@ -146,7 +145,7 @@ fn wrong_arity_fails_validation() { ) .expect("entropy available"); let err = validator.validate(&env_many).unwrap_err(); - assert!(matches!(err, ValidationError::ArgumentArity { .. })); + assert!(matches!(err, SaikuroError::ArgumentArity { .. })); } #[test] @@ -162,7 +161,7 @@ fn wrong_type_fails_validation() { .expect("entropy available"); let err = validator.validate(&env).unwrap_err(); assert!( - matches!(err, ValidationError::ArgumentType { .. }), + matches!(err, SaikuroError::ArgumentType { .. }), "expected ArgumentType, got {err:?}" ); assert_eq!(err.error_code(), ErrorCode::InvalidArguments); @@ -176,7 +175,7 @@ fn internal_visibility_denied_for_external_callers() { let env = Envelope::call("math.internal_op", vec![]).expect("entropy available"); let err = validator.validate(&env).unwrap_err(); assert!( - matches!(err, ValidationError::VisibilityDenied { .. }), + matches!(err, SaikuroError::VisibilityDenied { .. }), "expected VisibilityDenied, got {err:?}" ); assert_eq!(err.error_code(), ErrorCode::CapabilityDenied); @@ -190,7 +189,7 @@ fn private_function_denied_for_external_callers() { let env = Envelope::call("math.secret", vec![]).expect("entropy available"); let err = validator.validate(&env).unwrap_err(); assert!( - matches!(err, ValidationError::VisibilityDenied { .. }), + matches!(err, SaikuroError::VisibilityDenied { .. }), "expected VisibilityDenied for private fn, got {err:?}" ); } @@ -207,7 +206,7 @@ fn batch_with_no_items_fails() { let err = validator.validate(&env).unwrap_err(); assert!( - matches!(err, ValidationError::MissingBatch), + matches!(err, SaikuroError::MissingBatch), "expected MissingBatch, got {err:?}" ); assert_eq!(err.error_code(), ErrorCode::MalformedEnvelope); @@ -224,7 +223,7 @@ fn batch_with_empty_items_fails() { env.batch_items = Some(vec![]); let err = validator.validate(&env).unwrap_err(); - assert!(matches!(err, ValidationError::EmptyBatch)); + assert!(matches!(err, SaikuroError::EmptyBatch)); } #[test] @@ -235,7 +234,7 @@ fn malformed_target_without_dot_fails() { let env = Envelope::call("nofunctionpart", vec![]).expect("entropy available"); let err = validator.validate(&env).unwrap_err(); assert!( - matches!(err, ValidationError::MalformedEnvelope(_)), + matches!(err, SaikuroError::MalformedEnvelope(_)), "expected MalformedEnvelope, got {err:?}" ); } diff --git a/Build/tests/saikuro-schema/validator.rs b/Build/tests/saikuro-schema/validator.rs index b149b1db..0e3844bb 100644 --- a/Build/tests/saikuro-schema/validator.rs +++ b/Build/tests/saikuro-schema/validator.rs @@ -1,6 +1,7 @@ use saikuro_core::envelope::{Envelope, InvocationType}; +use saikuro_event::SaikuroError; use saikuro_schema::registry::SchemaRegistry; -use saikuro_schema::validator::{InvocationValidator, ValidationError}; +use saikuro_schema::validator::InvocationValidator; #[test] fn batch_with_empty_items_returns_empty_batch_error() { @@ -13,5 +14,5 @@ fn batch_with_empty_items_returns_empty_batch_error() { batch.batch_items = Some(vec![]); let result = validator.validate(&batch); - assert!(matches!(result, Err(ValidationError::EmptyBatch))); + assert!(matches!(result, Err(SaikuroError::EmptyBatch))); } From 50f72f6283fac5e333aa66a8943547abd025495f Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Sat, 15 Aug 2026 01:04:03 -0600 Subject: [PATCH 32/43] saikuro-storage --- Build/Cargo.toml | 5 +- Build/adapters/rust/Cargo.toml | 12 +- Build/adapters/rust/src/error.rs | 6 - Build/adapters/rust/src/lib.rs | 5 +- Build/adapters/rust/src/storage.rs | 343 ++++--- Build/adapters/rust/tests/integration.rs | 1 + .../crates/saikuro-event/core_events/event.rs | 52 + Build/crates/saikuro-storage/Cargo.toml | 84 +- .../{src => common}/inmemory.rs | 31 +- Build/crates/saikuro-storage/common/mod.rs | 5 + .../saikuro-storage/common/sqlite/actor.rs | 149 +++ .../saikuro-storage/common/sqlite/direct.rs | 60 ++ .../saikuro-storage/common/sqlite/mod.rs | 206 ++++ .../crates/saikuro-storage/embedded/flash.rs | 465 +++++++++ Build/crates/saikuro-storage/embedded/mod.rs | 2 + Build/crates/saikuro-storage/lib.rs | 227 +++++ .../saikuro-storage/{src => native}/fs.rs | 40 +- Build/crates/saikuro-storage/native/mod.rs | 5 + .../saikuro-storage/{src => native}/sled.rs | 51 +- .../saikuro-storage/{src => shared}/config.rs | 18 +- Build/crates/saikuro-storage/shared/mod.rs | 3 + .../saikuro-storage/shared/traits/backend.rs | 26 + .../saikuro-storage/shared/traits/ext.rs | 64 ++ .../saikuro-storage/shared/traits/file.rs | 32 + .../saikuro-storage/shared/traits/kv.rs | 40 + .../saikuro-storage/shared/traits/mod.rs | 10 + .../saikuro-storage/{src => shared}/util.rs | 8 - Build/crates/saikuro-storage/src/error.rs | 109 --- Build/crates/saikuro-storage/src/flash.rs | 717 -------------- Build/crates/saikuro-storage/src/lib.rs | 237 ----- .../saikuro-storage/src/local_storage.rs | 8 - .../saikuro-storage/src/session_storage.rs | 8 - Build/crates/saikuro-storage/src/sqlite.rs | 258 ----- Build/crates/saikuro-storage/src/traits.rs | 219 ----- Build/crates/saikuro-storage/wasi/mod.rs | 10 + Build/crates/saikuro-storage/wasi/preview1.rs | 437 +++++++++ Build/crates/saikuro-storage/wasi/preview2.rs | 298 ++++++ .../wasi/wit/deps/wasi-keyvalue.wit | 24 + .../crates/saikuro-storage/wasi/wit/world.wit | 5 + .../{src => wasm}/fs_access.rs | 80 +- .../{src => wasm}/indexeddb.rs | 56 +- .../saikuro-storage/wasm/local_storage.rs | 8 + Build/crates/saikuro-storage/wasm/mod.rs | 17 + .../saikuro-storage/{src => wasm}/opfs.rs | 66 +- .../saikuro-storage/wasm/session_storage.rs | 8 + .../{src => wasm}/webstorage.rs | 17 +- Build/tests/saikuro-storage/flash.rs | 892 +++++++----------- 47 files changed, 2951 insertions(+), 2473 deletions(-) rename Build/crates/saikuro-storage/{src => common}/inmemory.rs (88%) create mode 100644 Build/crates/saikuro-storage/common/mod.rs create mode 100644 Build/crates/saikuro-storage/common/sqlite/actor.rs create mode 100644 Build/crates/saikuro-storage/common/sqlite/direct.rs create mode 100644 Build/crates/saikuro-storage/common/sqlite/mod.rs create mode 100644 Build/crates/saikuro-storage/embedded/flash.rs create mode 100644 Build/crates/saikuro-storage/embedded/mod.rs create mode 100644 Build/crates/saikuro-storage/lib.rs rename Build/crates/saikuro-storage/{src => native}/fs.rs (90%) create mode 100644 Build/crates/saikuro-storage/native/mod.rs rename Build/crates/saikuro-storage/{src => native}/sled.rs (81%) rename Build/crates/saikuro-storage/{src => shared}/config.rs (93%) create mode 100644 Build/crates/saikuro-storage/shared/mod.rs create mode 100644 Build/crates/saikuro-storage/shared/traits/backend.rs create mode 100644 Build/crates/saikuro-storage/shared/traits/ext.rs create mode 100644 Build/crates/saikuro-storage/shared/traits/file.rs create mode 100644 Build/crates/saikuro-storage/shared/traits/kv.rs create mode 100644 Build/crates/saikuro-storage/shared/traits/mod.rs rename Build/crates/saikuro-storage/{src => shared}/util.rs (78%) delete mode 100644 Build/crates/saikuro-storage/src/error.rs delete mode 100644 Build/crates/saikuro-storage/src/flash.rs delete mode 100644 Build/crates/saikuro-storage/src/lib.rs delete mode 100644 Build/crates/saikuro-storage/src/local_storage.rs delete mode 100644 Build/crates/saikuro-storage/src/session_storage.rs delete mode 100644 Build/crates/saikuro-storage/src/sqlite.rs delete mode 100644 Build/crates/saikuro-storage/src/traits.rs create mode 100644 Build/crates/saikuro-storage/wasi/mod.rs create mode 100644 Build/crates/saikuro-storage/wasi/preview1.rs create mode 100644 Build/crates/saikuro-storage/wasi/preview2.rs create mode 100644 Build/crates/saikuro-storage/wasi/wit/deps/wasi-keyvalue.wit create mode 100644 Build/crates/saikuro-storage/wasi/wit/world.wit rename Build/crates/saikuro-storage/{src => wasm}/fs_access.rs (86%) rename Build/crates/saikuro-storage/{src => wasm}/indexeddb.rs (85%) create mode 100644 Build/crates/saikuro-storage/wasm/local_storage.rs create mode 100644 Build/crates/saikuro-storage/wasm/mod.rs rename Build/crates/saikuro-storage/{src => wasm}/opfs.rs (88%) create mode 100644 Build/crates/saikuro-storage/wasm/session_storage.rs rename Build/crates/saikuro-storage/{src => wasm}/webstorage.rs (78%) diff --git a/Build/Cargo.toml b/Build/Cargo.toml index 873296b3..ce3bd5ff 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -72,6 +72,7 @@ embassy-time = { version = "0.3", default-features = false } embassy-futures = { version = "0.1", default-features = false } embassy-net = { version = "0.5", default-features = false } embedded-storage-async = { version = "0.4", default-features = false } +sequential-storage = { version = "8", default-features = false, features = ["alloc"] } thiserror = { version = "2", default-features = false } anyhow = "1.0" @@ -112,7 +113,9 @@ tokio-tungstenite = { version = "0.30", default-features = false } # Storage backends sled = "0.34" -rusqlite = { version = "0.40", features = ["bundled"] } +graphitesql = "0.1.6" +wit-bindgen = "0.46" +wasi = "0.14" # Futures futures-executor = "0.3" diff --git a/Build/adapters/rust/Cargo.toml b/Build/adapters/rust/Cargo.toml index 515f88f2..610dc2bd 100644 --- a/Build/adapters/rust/Cargo.toml +++ b/Build/adapters/rust/Cargo.toml @@ -15,18 +15,18 @@ name = "saikuro-rust-schema" path = "src/cli/saikuro_rust_schema.rs" [features] -default = ["tcp", "unix", "ws", "storage", "saikuro-exec/tokio-runtime"] +default = ["tcp", "unix", "ws", "storage", "saikuro-exec/native"] tcp = ["saikuro-transport/native-transport"] unix = ["saikuro-transport/native-transport"] ws = ["saikuro-transport/native-ws"] wasm = ["saikuro-transport/wasm-runtime", "saikuro-transport/ws-transport", "saikuro-random/wasm"] # Storage backends: platform-agnostic factory in storage module -storage = ["saikuro-storage/native-storage"] -storage-fs = ["saikuro-storage/fs-storage"] -storage-sled = ["saikuro-storage/sled-storage"] -storage-sqlite = ["saikuro-storage/sqlite-storage"] -wasm-storage = ["saikuro-storage/wasm-storage"] +storage = ["saikuro-storage/native"] +storage-fs = ["saikuro-storage/fs"] +storage-sled = ["saikuro-storage/sled"] +storage-sqlite = ["saikuro-storage/sqlite"] +wasm-storage = ["saikuro-storage/wasm"] [dependencies] saikuro-core = { path = "../../crates/saikuro-core", default-features = false } diff --git a/Build/adapters/rust/src/error.rs b/Build/adapters/rust/src/error.rs index ab8d37f3..b3d388e6 100644 --- a/Build/adapters/rust/src/error.rs +++ b/Build/adapters/rust/src/error.rs @@ -72,9 +72,3 @@ impl From for Error { Self::Transport(e.to_string()) } } - -impl From for Error { - fn from(e: saikuro_storage::StorageError) -> Self { - Self::Storage(e.to_string()) - } -} diff --git a/Build/adapters/rust/src/lib.rs b/Build/adapters/rust/src/lib.rs index e2eb7114..f2535db9 100644 --- a/Build/adapters/rust/src/lib.rs +++ b/Build/adapters/rust/src/lib.rs @@ -24,4 +24,7 @@ pub use transport::InMemoryTransport; pub use value::Value; #[cfg(all(not(target_arch = "wasm32"), feature = "storage"))] -pub use storage::{create_storage, create_transient_storage}; +pub use saikuro_storage::traits::{FileBackend, KeyValueBackend, KeyValueBackendExt, StorageBackend}; + +#[cfg(all(not(target_arch = "wasm32"), feature = "storage"))] +pub use storage::{create_storage, create_transient_storage, Storage}; diff --git a/Build/adapters/rust/src/storage.rs b/Build/adapters/rust/src/storage.rs index 605e7084..eddc8237 100644 --- a/Build/adapters/rust/src/storage.rs +++ b/Build/adapters/rust/src/storage.rs @@ -1,108 +1,237 @@ -use saikuro_storage::traits::StorageBackend; -use saikuro_storage::{BackendKind, PersistenceMode, StorageConfig}; +use bytes::Bytes; +use saikuro_storage::traits::{FileBackend, KeyValueBackend, Result as KvResult, StorageBackend}; +use saikuro_storage::{BackendKind, InMemoryStorage, PersistenceMode, StorageConfig}; -use crate::error::Error; -use crate::error::Result; +#[cfg(feature = "storage-fs")] +use saikuro_storage::FilesystemStorage; +#[cfg(feature = "storage-sled")] +use saikuro_storage::SledStorage; +#[cfg(feature = "storage-sqlite")] +use saikuro_storage::SqliteStorage; -/// Create a storage backend based on the given configuration. -/// -/// When [`StorageConfig::backend`] is [`BackendKind::InMemory`] (the default), -/// the platform- and persistence-aware dispatch table below is used: -/// -/// | `persistence` | native | wasm32 (with `wasm-storage`) | -/// |--------------------|---------------------------------|-------------------------------| -/// | `Transient` | `InMemoryStorage` | `InMemoryStorage` | -/// | `BestEffort` | `InMemoryStorage` | `LocalStorage` | -/// | `Durable` | error (no durable backend selected) | `IndexedDbStorage` | -/// -/// Set [`StorageConfig::backend`] to a specific [`BackendKind`] variant -/// to bypass the table and force a particular implementation. A backend -/// that is not compiled into the binary (e.g. `Filesystem` without the -/// `storage-fs` feature) returns `Err` at runtime. -pub async fn create_storage(config: &StorageConfig) -> Result> { - // Explicit backend kind overrides - match config.backend { - BackendKind::Filesystem => { - return create_filesystem(config).await; +use crate::error::{Error, Result}; + +/// A concrete storage backend chosen at runtime from [`StorageConfig`]. +pub enum Storage { + InMemory(InMemoryStorage), + #[cfg(feature = "storage-fs")] + Filesystem(FilesystemStorage), + #[cfg(feature = "storage-sled")] + Sled(SledStorage), + #[cfg(feature = "storage-sqlite")] + Sqlite(SqliteStorage), +} + +impl KeyValueBackend for Storage { + fn config(&self) -> &StorageConfig { + match self { + Storage::InMemory(b) => b.config(), + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => b.config(), + #[cfg(feature = "storage-sled")] + Storage::Sled(b) => b.config(), + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(b) => b.config(), } - BackendKind::Sled => { - return create_sled(config).await; + } + + async fn exists(&self, namespace: &str, key: &str) -> KvResult { + match self { + Storage::InMemory(b) => b.exists(namespace, key).await, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => b.exists(namespace, key).await, + #[cfg(feature = "storage-sled")] + Storage::Sled(b) => b.exists(namespace, key).await, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(b) => b.exists(namespace, key).await, + } + } + + async fn get(&self, namespace: &str, key: &str) -> KvResult> { + match self { + Storage::InMemory(b) => b.get(namespace, key).await, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => b.get(namespace, key).await, + #[cfg(feature = "storage-sled")] + Storage::Sled(b) => b.get(namespace, key).await, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(b) => b.get(namespace, key).await, + } + } + + async fn put(&self, namespace: &str, key: &str, value: Bytes) -> KvResult<()> { + match self { + Storage::InMemory(b) => b.put(namespace, key, value).await, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => b.put(namespace, key, value).await, + #[cfg(feature = "storage-sled")] + Storage::Sled(b) => b.put(namespace, key, value).await, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(b) => b.put(namespace, key, value).await, } - BackendKind::Sqlite => { - return create_sqlite(config).await; + } + + async fn delete(&self, namespace: &str, key: &str) -> KvResult<()> { + match self { + Storage::InMemory(b) => b.delete(namespace, key).await, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => b.delete(namespace, key).await, + #[cfg(feature = "storage-sled")] + Storage::Sled(b) => b.delete(namespace, key).await, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(b) => b.delete(namespace, key).await, } - BackendKind::WebStorage => { - return create_web_storage(config).await; + } + + async fn list_keys(&self, namespace: &str) -> KvResult> { + match self { + Storage::InMemory(b) => b.list_keys(namespace).await, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => b.list_keys(namespace).await, + #[cfg(feature = "storage-sled")] + Storage::Sled(b) => b.list_keys(namespace).await, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(b) => b.list_keys(namespace).await, } - BackendKind::IndexedDb => { - return create_indexeddb(config).await; + } + + async fn list_namespaces(&self) -> KvResult> { + match self { + Storage::InMemory(b) => b.list_namespaces().await, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => b.list_namespaces().await, + #[cfg(feature = "storage-sled")] + Storage::Sled(b) => b.list_namespaces().await, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(b) => b.list_namespaces().await, } - BackendKind::Opfs => { - return create_opfs(config).await; + } + + async fn create_namespace(&self, namespace: &str) -> KvResult<()> { + match self { + Storage::InMemory(b) => b.create_namespace(namespace).await, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => b.create_namespace(namespace).await, + #[cfg(feature = "storage-sled")] + Storage::Sled(b) => b.create_namespace(namespace).await, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(b) => b.create_namespace(namespace).await, } - BackendKind::FsAccess => { - return create_fs_access(config).await; + } + + async fn delete_namespace(&self, namespace: &str) -> KvResult<()> { + match self { + Storage::InMemory(b) => b.delete_namespace(namespace).await, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => b.delete_namespace(namespace).await, + #[cfg(feature = "storage-sled")] + Storage::Sled(b) => b.delete_namespace(namespace).await, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(b) => b.delete_namespace(namespace).await, } - BackendKind::InMemory => { /* fall through to persistence-based dispatch */ } } - // InMemory (default): persistence-mode-based dispatch - match config.persistence { - PersistenceMode::Transient => { - let storage = saikuro_storage::InMemoryStorage::with_config(config.clone()); - Ok(Box::new(storage)) + async fn clear_namespace(&self, namespace: &str) -> KvResult<()> { + match self { + Storage::InMemory(b) => b.clear_namespace(namespace).await, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => b.clear_namespace(namespace).await, + #[cfg(feature = "storage-sled")] + Storage::Sled(b) => b.clear_namespace(namespace).await, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(b) => b.clear_namespace(namespace).await, + } + } +} + +impl StorageBackend for Storage { + fn supports_files(&self) -> bool { + match self { + Storage::InMemory(_) => false, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(_) => true, + #[cfg(feature = "storage-sled")] + Storage::Sled(_) => false, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(_) => false, + } + } + + fn as_file_backend(&self) -> Option<&dyn FileBackend> { + match self { + Storage::InMemory(_) => None, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => Some(b), + #[cfg(feature = "storage-sled")] + Storage::Sled(_) => None, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(_) => None, } + } - PersistenceMode::BestEffort => { - // wasm32 with wasm-storage -> LocalStorage (persists across page reload) - #[cfg(all(target_arch = "wasm32", feature = "wasm-storage"))] - { - let storage = saikuro_storage::LocalStorage::with_config(config.clone()); - return Ok(Box::new(storage)); - } - - // Fallback: in-memory (native, or wasm32 without wasm-storage) - #[cfg(not(all(target_arch = "wasm32", feature = "wasm-storage")))] - { - let storage = saikuro_storage::InMemoryStorage::with_config(config.clone()); - Ok(Box::new(storage)) - } + async fn flush(&self) -> KvResult<()> { + match self { + Storage::InMemory(b) => b.flush().await, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => b.flush().await, + #[cfg(feature = "storage-sled")] + Storage::Sled(b) => b.flush().await, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(b) => b.flush().await, } + } - PersistenceMode::Durable => { - // wasm32 with wasm-storage -> IndexedDB (survives page reload + clear) - #[cfg(all(target_arch = "wasm32", feature = "wasm-storage"))] - { - let storage = saikuro_storage::IndexedDbStorage::with_config(config.clone()); - return Ok(Box::new(storage)); - } - - // Not available with the default backend selection. the caller - // should set `BackendKind::Filesystem` (or `Sled`, `Sqlite`) explicitly. - #[cfg(not(all(target_arch = "wasm32", feature = "wasm-storage")))] - { - Err(Error::Storage( - "no durable storage backend selected; set `config.backend` to \ - `BackendKind::Filesystem`, `Sled`, or `Sqlite` on native, \ - or enable the 'wasm-storage' feature on wasm32" - .into(), - )) - } + async fn close(&self) -> KvResult<()> { + match self { + Storage::InMemory(b) => b.close().await, + #[cfg(feature = "storage-fs")] + Storage::Filesystem(b) => b.close().await, + #[cfg(feature = "storage-sled")] + Storage::Sled(b) => b.close().await, + #[cfg(feature = "storage-sqlite")] + Storage::Sqlite(b) => b.close().await, } } } -// Platform helper factories +/// Create a storage backend based on the given configuration. +pub async fn create_storage(config: &StorageConfig) -> Result { + match config.backend { + BackendKind::Filesystem => return create_filesystem(config).await, + BackendKind::Sled => return create_sled(config).await, + BackendKind::Sqlite => return create_sqlite(config).await, + BackendKind::WebStorage + | BackendKind::IndexedDb + | BackendKind::Opfs + | BackendKind::FsAccess => { + return Err(Error::Storage( + "browser/wasm storage backends are not available from the native factory".into(), + )); + } + BackendKind::InMemory => { /* fall through to persistence-based dispatch */ } + } + + match config.persistence { + PersistenceMode::Transient | PersistenceMode::BestEffort => Ok(Storage::InMemory( + InMemoryStorage::with_config(config.clone()), + )), + PersistenceMode::Durable => Err(Error::Storage( + "no durable storage backend selected; set `config.backend` to \ + `BackendKind::Filesystem`, `Sled`, or `Sqlite` on native" + .into(), + )), + } +} -async fn create_filesystem(_config: &StorageConfig) -> Result> { +async fn create_filesystem(_config: &StorageConfig) -> Result { #[cfg(feature = "storage-fs")] { let path = _config .storage_path .clone() .unwrap_or_else(|| std::path::PathBuf::from("./saikuro_data")); - let storage = saikuro_storage::FilesystemStorage::with_config(path, _config.clone()); - Ok(Box::new(storage)) + let storage = FilesystemStorage::with_config(path, _config.clone()); + Ok(Storage::Filesystem(storage)) } #[cfg(not(feature = "storage-fs"))] { @@ -112,15 +241,15 @@ async fn create_filesystem(_config: &StorageConfig) -> Result Result> { +async fn create_sled(_config: &StorageConfig) -> Result { #[cfg(feature = "storage-sled")] { let path = _config .storage_path .clone() .unwrap_or_else(|| std::path::PathBuf::from("./saikuro_sled")); - let storage = saikuro_storage::SledStorage::with_config(path, _config.clone())?; - Ok(Box::new(storage)) + let storage = SledStorage::with_config(path, _config.clone())?; + Ok(Storage::Sled(storage)) } #[cfg(not(feature = "storage-sled"))] { @@ -130,15 +259,15 @@ async fn create_sled(_config: &StorageConfig) -> Result> } } -async fn create_sqlite(_config: &StorageConfig) -> Result> { +async fn create_sqlite(_config: &StorageConfig) -> Result { #[cfg(feature = "storage-sqlite")] { let path = _config .storage_path .clone() - .unwrap_or_else(|| std::path::PathBuf::from("./saikuro.sqlite")); - let storage = saikuro_storage::SqliteStorage::with_config(path, _config.clone())?; - Ok(Box::new(storage)) + .unwrap_or_else(|| std::path::PathBuf::from("./saikuro_sqlite")); + let storage = SqliteStorage::with_config(path, _config.clone())?; + Ok(Storage::Sqlite(storage)) } #[cfg(not(feature = "storage-sqlite"))] { @@ -148,45 +277,7 @@ async fn create_sqlite(_config: &StorageConfig) -> Result Result> { - let storage = saikuro_storage::LocalStorage::with_config(config.clone()); - Ok(Box::new(storage)) -} - -async fn create_indexeddb(_config: &StorageConfig) -> Result> { - #[cfg(all(target_arch = "wasm32", feature = "wasm-storage"))] - { - let storage = saikuro_storage::IndexedDbStorage::with_config(_config.clone()); - return Ok(Box::new(storage)); - } - Err(Error::Storage( - "IndexedDB backend is only available on wasm32 with the 'wasm-storage' feature".into(), - )) -} - -async fn create_opfs(_config: &StorageConfig) -> Result> { - #[cfg(all(target_arch = "wasm32", feature = "wasm-storage"))] - { - let storage = saikuro_storage::OpfsStorage::with_config(_config.clone()); - return Ok(Box::new(storage)); - } - Err(Error::Storage( - "OPFS backend is only available on wasm32 with the 'wasm-storage' feature".into(), - )) -} - -async fn create_fs_access(_config: &StorageConfig) -> Result> { - #[cfg(all(target_arch = "wasm32", feature = "wasm-storage"))] - { - let storage = saikuro_storage::FsAccessStorage::pick(_config.clone()).await?; - return Ok(Box::new(storage)); - } - Err(Error::Storage( - "FS Access backend is only available on wasm32 with the 'wasm-storage' feature".into(), - )) -} - /// Create a transient (in-memory) storage backend. -pub fn create_transient_storage() -> Box { - Box::new(saikuro_storage::InMemoryStorage::new()) +pub fn create_transient_storage() -> Storage { + Storage::InMemory(InMemoryStorage::new()) } diff --git a/Build/adapters/rust/tests/integration.rs b/Build/adapters/rust/tests/integration.rs index e907d0af..4e320aa4 100644 --- a/Build/adapters/rust/tests/integration.rs +++ b/Build/adapters/rust/tests/integration.rs @@ -7,6 +7,7 @@ use saikuro_core::{ envelope::{Envelope, InvocationType}, ResponseEnvelope, }; +use saikuro_storage::KeyValueBackend; use serde_json::json; // Helpers diff --git a/Build/crates/saikuro-event/core_events/event.rs b/Build/crates/saikuro-event/core_events/event.rs index 1d59ba10..0df27255 100644 --- a/Build/crates/saikuro-event/core_events/event.rs +++ b/Build/crates/saikuro-event/core_events/event.rs @@ -396,6 +396,58 @@ impl SaikuroError { } } +impl SaikuroError { + /// Construct a [`SaikuroError::KeyNotFound`]. + pub fn key_not_found(key: impl Into) -> Self { + SaikuroError::KeyNotFound(key.into()) + } + + /// Construct a [`SaikuroError::NamespaceNotFound`]. + pub fn namespace_not_found(namespace: impl Into) -> Self { + SaikuroError::NamespaceNotFound(namespace.into()) + } + + /// Construct a [`SaikuroError::KeyAlreadyExists`]. + pub fn key_already_exists(key: impl Into) -> Self { + SaikuroError::KeyAlreadyExists(key.into()) + } + + /// Construct a [`SaikuroError::NamespaceAlreadyExists`]. + pub fn namespace_already_exists(namespace: impl Into) -> Self { + SaikuroError::NamespaceAlreadyExists(namespace.into()) + } + + /// Construct a [`SaikuroError::Serialization`]. + pub fn serialization(msg: impl Into) -> Self { + SaikuroError::Serialization(msg.into()) + } + + /// Construct a [`SaikuroError::Deserialization`]. + pub fn deserialization(msg: impl Into) -> Self { + SaikuroError::Deserialization(msg.into()) + } + + /// Construct a [`SaikuroError::Internal`]. + pub fn internal(msg: impl Into) -> Self { + SaikuroError::Internal(msg.into()) + } + + /// Construct a [`SaikuroError::OperationNotSupported`]. + pub fn not_supported(msg: impl Into) -> Self { + SaikuroError::OperationNotSupported(msg.into()) + } + + /// Construct a [`SaikuroError::BackendNotAvailable`]. + pub fn backend_not_available(msg: impl Into) -> Self { + SaikuroError::BackendNotAvailable(msg.into()) + } + + /// Construct a [`SaikuroError::QuotaExceeded`]. + pub fn quota_exceeded(msg: impl Into) -> Self { + SaikuroError::QuotaExceeded(msg.into()) + } +} + impl From for ErrorDetail { fn from(err: SaikuroError) -> Self { ErrorDetail::new(err.error_code(), err.to_string()) diff --git a/Build/crates/saikuro-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index 627ba888..9ad7423c 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -8,54 +8,86 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "storage", "key-value"] +[lib] +path = "lib.rs" + [features] -default = ["std", "native-storage"] -std = [] -native-storage = [ +default = ["std", "native", "inmemory"] +std = ["saikuro-event/std"] + +# Engine features. +native = [ "std", - "inmemory", - "local-storage", - "session-storage", + "dep:graphitesql", + "graphitesql/std", + "graphitesql/fts5", + "dep:tokio", "saikuro-core/std", - "saikuro-exec/tokio-runtime", + "saikuro-exec/native", + "saikuro-event/native", ] -inmemory = ["std", "dashmap"] -local-storage = ["inmemory"] -session-storage = ["inmemory"] -fs-storage = ["std", "dep:tokio", "saikuro-core/std", "saikuro-exec/tokio-runtime"] -sled-storage = ["std", "dep:tokio", "dep:sled", "saikuro-core/std", "saikuro-exec/tokio-runtime"] -sqlite-storage = ["std", "dep:tokio", "dep:rusqlite", "saikuro-core/std", "saikuro-exec/tokio-runtime"] -wasm-storage = [ - "std", - "inmemory", - "local-storage", - "session-storage", - "saikuro-core/std-no-os", - "saikuro-exec/wasm-runtime", +no_std = [ + "sqlite", + "saikuro-core/no_std", + "saikuro-exec/no_std", + "saikuro-event/no_std", +] +wasm = [ + "sqlite", + "graphitesql/wasm", "dep:wasm-bindgen", "dep:wasm-bindgen-futures", "dep:js-sys", "dep:web-sys", + "saikuro-core/wasm", + "saikuro-exec/wasm", + "saikuro-event/wasm", +] +embedded = [ + "sqlite", + "dep:embedded-storage-async", + "saikuro-core/embedded", + "saikuro-exec/embedded", + "saikuro-event/embedded", +] + +wasi-component = ["no_std", "dep:wasi", "dep:wit-bindgen"] +wasi-preview1 = ["no_std"] +wasi-kv = ["no_std"] + +inmemory = ["std", "dashmap"] +fs = ["native", "dep:tokio", "saikuro-core/std"] +sled = ["native", "dep:tokio", "dep:sled", "saikuro-core/std"] +sqlite = ["dep:graphitesql", "graphitesql/fts5"] +flash = [ + "embedded", + "dep:embedded-storage-async", + "dep:sequential-storage", + "saikuro-event/embedded", + "saikuro-core/embedded", + "saikuro-exec/embedded", ] -fs-access = ["wasm-storage"] -flash-storage = ["dep:embedded-storage-async"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } +saikuro-event = { path = "../saikuro-event", default-features = false } serde = { workspace = true } serde_json = { workspace = true, features = ["alloc"] } bytes = { workspace = true } -async-trait = { workspace = true } futures = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } dashmap = { workspace = true, optional = true } -tokio = { workspace = true, optional = true } +tokio = { workspace = true, optional = true, features = ["sync", "rt", "rt-multi-thread"] } sled = { workspace = true, optional = true } -rusqlite = { workspace = true, optional = true } +spin = { workspace = true } +graphitesql = { workspace = true, optional = true } +wit-bindgen = { workspace = true, optional = true } +wasi = { workspace = true, optional = true } embedded-storage-async = { workspace = true, optional = true } +sequential-storage = { workspace = true, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = { workspace = true, optional = true } @@ -93,7 +125,7 @@ web-sys = { workspace = true, optional = true, features = [ ] } [dev-dependencies] -saikuro-exec = { workspace = true, features = ["tokio-runtime"] } +saikuro-exec = { workspace = true, features = ["native"] } tracing-subscriber = { workspace = true } futures-executor = { workspace = true } embedded-storage-async = { workspace = true } diff --git a/Build/crates/saikuro-storage/src/inmemory.rs b/Build/crates/saikuro-storage/common/inmemory.rs similarity index 88% rename from Build/crates/saikuro-storage/src/inmemory.rs rename to Build/crates/saikuro-storage/common/inmemory.rs index b9443d28..4ac73a19 100644 --- a/Build/crates/saikuro-storage/src/inmemory.rs +++ b/Build/crates/saikuro-storage/common/inmemory.rs @@ -1,19 +1,12 @@ -//! In-memory storage backend using DashMap. -//! -//! This is the reference implementation and the default backend for -//! Saikuro's ephemeral storage needs. - use alloc::sync::Arc; -use async_trait::async_trait; + use bytes::Bytes; use dashmap::DashMap; use tracing::debug; -use super::{ - config::StorageConfig, - error::{Result, StorageError}, - traits::{KeyValueBackend, StorageBackend}, -}; +use crate::config::StorageConfig; +use crate::traits::{KeyValueBackend, StorageBackend}; +use saikuro_event::{Result, SaikuroError}; /// An in-memory namespace containing key-value pairs. type NamespaceStore = DashMap; @@ -37,7 +30,7 @@ impl InMemoryStorage { pub fn with_config(config: StorageConfig) -> Self { let namespaces = DashMap::new(); - if config.cleanup != super::config::CleanupPolicy::Never { + if config.cleanup != crate::config::CleanupPolicy::Never { debug!("in-memory backend does not enforce cleanup (TTL/Age/LRU); configured cleanup settings are ignored"); } debug!( @@ -77,7 +70,7 @@ impl InMemoryStorage { self.namespaces .get(&key) .map(|ns| ns.clone()) - .ok_or_else(|| StorageError::namespace_not_found(namespace)) + .ok_or_else(|| SaikuroError::namespace_not_found(namespace)) } /// Get or create a namespace (write operations). @@ -102,7 +95,7 @@ impl Default for InMemoryStorage { } } -#[async_trait] + impl KeyValueBackend for InMemoryStorage { fn config(&self) -> &StorageConfig { &self.config @@ -115,7 +108,7 @@ impl KeyValueBackend for InMemoryStorage { if self.config.auto_create_namespaces { return Ok(false); } - return Err(StorageError::namespace_not_found(namespace)); + return Err(SaikuroError::namespace_not_found(namespace)); } }; Ok(ns.contains_key(key)) @@ -128,7 +121,7 @@ impl KeyValueBackend for InMemoryStorage { if self.config.auto_create_namespaces { return Ok(None); } - return Err(StorageError::namespace_not_found(namespace)); + return Err(SaikuroError::namespace_not_found(namespace)); } }; Ok(ns.get(key).map(|v| v.clone())) @@ -156,7 +149,7 @@ impl KeyValueBackend for InMemoryStorage { if self.config.auto_create_namespaces { return Ok(vec![]); } - return Err(StorageError::namespace_not_found(namespace)); + return Err(SaikuroError::namespace_not_found(namespace)); } }; Ok(ns.iter().map(|entry| entry.key().clone()).collect()) @@ -174,7 +167,7 @@ impl KeyValueBackend for InMemoryStorage { use dashmap::mapref::entry::Entry; let key = self.apply_prefix(namespace); match self.namespaces.entry(key) { - Entry::Occupied(_) => Err(StorageError::NamespaceAlreadyExists(namespace.to_owned())), + Entry::Occupied(_) => Err(SaikuroError::NamespaceAlreadyExists(namespace.to_owned())), Entry::Vacant(e) => { e.insert(Arc::new(NamespaceStore::new())); Ok(()) @@ -198,7 +191,7 @@ impl KeyValueBackend for InMemoryStorage { } } -#[async_trait] + impl StorageBackend for InMemoryStorage { fn supports_files(&self) -> bool { false diff --git a/Build/crates/saikuro-storage/common/mod.rs b/Build/crates/saikuro-storage/common/mod.rs new file mode 100644 index 00000000..a3bfafc9 --- /dev/null +++ b/Build/crates/saikuro-storage/common/mod.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "inmemory")] +pub mod inmemory; + +#[cfg(feature = "sqlite")] +pub mod sqlite; diff --git a/Build/crates/saikuro-storage/common/sqlite/actor.rs b/Build/crates/saikuro-storage/common/sqlite/actor.rs new file mode 100644 index 00000000..5c68608a --- /dev/null +++ b/Build/crates/saikuro-storage/common/sqlite/actor.rs @@ -0,0 +1,149 @@ +use alloc::string::String; + +use std::path::PathBuf; +use std::sync::mpsc::{self, SyncSender}; +use std::thread::{self, JoinHandle}; + +use tokio::sync::oneshot; + +use graphitesql::exec::eval::Params; +use graphitesql::Connection; +use graphitesql::QueryResult; + +use crate::common::sqlite::{map_err, RawSqlite, CREATE_KV}; +use crate::shared::config::StorageConfig; +use saikuro_event::{Result, SaikuroError}; + +/// A unit of work handed to the worker thread. +enum Job { + Query { + sql: String, + params: Params, + resp: oneshot::Sender>, + }, + Batch { + sql: String, + resp: oneshot::Sender>, + }, +} + +/// Where the worker should open its database. +enum OpenTarget { + Path(PathBuf), + Memory, +} + +/// `Send + Sync` handle to the SQLite worker thread. +pub(crate) struct SqliteStorage { + config: StorageConfig, + tx: SyncSender, + #[allow(dead_code)] + worker: Option>, +} + +impl SqliteStorage { + /// Open or create a SQLite database at the given path. + pub fn new(path: impl AsRef) -> Result { + Self::with_config(path, StorageConfig::default()) + } + + /// Open or create a SQLite database with a custom configuration. + pub fn with_config(path: impl AsRef, config: StorageConfig) -> Result { + Self::spawn(OpenTarget::Path(path.as_ref().to_path_buf()), config) + } + + /// Open an in-memory SQLite database (useful for testing). + pub fn temporary() -> Result { + Self::spawn(OpenTarget::Memory, StorageConfig::default()) + } + + fn spawn(target: OpenTarget, config: StorageConfig) -> Result { + let (tx, rx) = mpsc::sync_channel::(0); + let worker = thread::Builder::new() + .name("saikuro-sqlite".into()) + .spawn(move || run_worker(target, rx)) + .map_err(|e| SaikuroError::internal(format!("spawn sqlite worker: {e}")))?; + Ok(Self { + config, + tx, + worker: Some(worker), + }) + } +} + +/// Own the connection on a dedicated thread and service requests serially. +fn run_worker(target: OpenTarget, rx: mpsc::Receiver) { + let opened = match &target { + OpenTarget::Path(p) => Connection::open(p), + OpenTarget::Memory => Connection::open_memory(), + }; + let mut conn = match opened { + Ok(c) => c, + Err(e) => { + // Surface the open failure to any queued callers, then exit. + let _ = e; + drain(rx); + return; + } + }; + if conn.execute_batch(CREATE_KV).is_err() { + drain(rx); + return; + } + loop { + match rx.recv() { + Ok(Job::Query { sql, params, resp }) => { + let r = conn.query_params(&sql, ¶ms).map_err(map_err); + let _ = resp.send(r); + } + Ok(Job::Batch { sql, resp }) => { + let r = conn.execute_batch(&sql).map_err(map_err); + let _ = resp.send(r); + } + Err(_) => break, + } + } +} + +/// Drop every pending job so callers receive a cancellation error instead of +/// hanging after the worker cannot start. +fn drain(rx: mpsc::Receiver) { + while rx.recv().is_ok() {} +} + +impl RawSqlite for SqliteStorage { + async fn query(&self, sql: &str, params: Params) -> Result { + let (tx, rx) = oneshot::channel(); + self.tx + .send(Job::Query { + sql: sql.to_owned(), + params, + resp: tx, + }) + .map_err(|_| SaikuroError::internal("sqlite worker thread is not running"))?; + rx.await + .map_err(|_| SaikuroError::internal("sqlite worker dropped the response")) + } + + async fn batch(&self, sql: &str) -> Result<()> { + let (tx, rx) = oneshot::channel(); + self.tx + .send(Job::Batch { + sql: sql.to_owned(), + resp: tx, + }) + .map_err(|_| SaikuroError::internal("sqlite worker thread is not running"))?; + rx.await + .map_err(|_| SaikuroError::internal("sqlite worker dropped the response")) + } +} + +impl SqliteStorage { + /// Compile-time guarantee that the public handle is `Send + Sync`, so it can + /// live inside the `Storage` enum alongside the other backends. + #[allow(dead_code)] + fn _assert_send_sync() { + fn is_send_sync() {} + is_send_sync::(); + } +} diff --git a/Build/crates/saikuro-storage/common/sqlite/direct.rs b/Build/crates/saikuro-storage/common/sqlite/direct.rs new file mode 100644 index 00000000..67973ec6 --- /dev/null +++ b/Build/crates/saikuro-storage/common/sqlite/direct.rs @@ -0,0 +1,60 @@ +use alloc::string::String; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use spin::Mutex; + +use graphitesql::exec::eval::Params; +use graphitesql::Connection; +use graphitesql::QueryResult; + +use crate::common::sqlite::{map_err, RawSqlite, CREATE_KV}; +use crate::shared::config::StorageConfig; +use saikuro_event::{Result, SaikuroError}; + +/// Owns the SQLite connection on the current (single) thread. +pub(crate) struct SqliteStorage { + config: StorageConfig, + conn: Arc>, +} + +impl SqliteStorage { + /// Open or create a SQLite database at the given path. + #[cfg(feature = "std")] + pub fn new(path: impl AsRef) -> Result { + Self::with_config(path, StorageConfig::default()) + } + + /// Open or create a SQLite database with a custom configuration. + #[cfg(feature = "std")] + pub fn with_config(path: impl AsRef, config: StorageConfig) -> Result { + let conn = Connection::open(path).map_err(map_err)?; + Self::from_conn(conn, config) + } + + /// Open an in-memory SQLite database (wasm / no_std / embedded / testing). + pub fn temporary() -> Result { + let conn = Connection::open_memory().map_err(map_err)?; + Self::from_conn(conn, StorageConfig::default()) + } + + fn from_conn(conn: Connection, config: StorageConfig) -> Result { + conn.execute_batch(CREATE_KV).map_err(map_err)?; + Ok(Self { + config, + conn: Arc::new(Mutex::new(conn)), + }) + } +} + +impl RawSqlite for SqliteStorage { + async fn query(&self, sql: &str, params: Params) -> Result { + let conn = self.conn.lock(); + conn.query_params(sql, ¶ms).map_err(map_err) + } + + async fn batch(&self, sql: &str) -> Result<()> { + let conn = self.conn.lock(); + conn.execute_batch(sql).map_err(map_err) + } +} diff --git a/Build/crates/saikuro-storage/common/sqlite/mod.rs b/Build/crates/saikuro-storage/common/sqlite/mod.rs new file mode 100644 index 00000000..0074183f --- /dev/null +++ b/Build/crates/saikuro-storage/common/sqlite/mod.rs @@ -0,0 +1,206 @@ +#[cfg(feature = "native")] +mod actor; +#[cfg(not(feature = "native"))] +mod direct; + +#[cfg(feature = "native")] +pub use actor::SqliteStorage; +#[cfg(not(feature = "native"))] +pub use direct::SqliteStorage; + +use alloc::string::String; +use alloc::vec::Vec; + +use bytes::Bytes; + +use graphitesql::exec::eval::Params; +use graphitesql::Value; + +use crate::shared::config::StorageConfig; +use crate::shared::traits::{KeyValueBackend, StorageBackend}; +use saikuro_event::{Result, SaikuroError}; + +/// Schema for the single key-value table shared by every engine. +pub(crate) const CREATE_KV: &str = " + CREATE TABLE IF NOT EXISTS saikuro_kv ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value BLOB NOT NULL, + PRIMARY KEY (namespace, key) + ) +"; + +/// Execution primitive shared by the actor and direct backends. +pub(crate) trait RawSqlite { + async fn query(&self, sql: &str, params: Params) -> Result; + async fn batch(&self, sql: &str) -> Result<()>; +} + +/// Map a `graphitesql` error into the crate's unified error type. +pub(crate) fn map_err(e: graphitesql::Error) -> SaikuroError { + SaikuroError::internal(format!("graphitesql error: {e:?}")) +} + +fn apply_prefix(config: &StorageConfig, namespace: &str) -> String { + match &config.namespace_prefix { + Some(prefix) => format!("{prefix}:{namespace}"), + None => namespace.to_owned(), + } +} + +fn strip_prefix(config: &StorageConfig, stored: &str) -> String { + match &config.namespace_prefix { + Some(prefix) => { + let prefixed = format!("{prefix}:"); + if stored.starts_with(&prefixed) { + stored[prefixed.len()..].to_owned() + } else { + stored.to_owned() + } + } + None => stored.to_owned(), + } +} + +fn ns_key_params(namespace: &str, key: &str) -> Params { + Params { + positional: vec![Value::Text(namespace.to_owned()), Value::Text(key.to_owned())], + named: Vec::new(), + } +} + +fn blob_of(row: &[Value]) -> Option> { + row.first().and_then(|v| match v { + Value::Blob(b) => Some(b.clone()), + _ => None, + }) +} + +impl KeyValueBackend for SqliteStorage { + fn config(&self) -> &StorageConfig { + &self.config + } + + async fn exists(&self, namespace: &str, key: &str) -> Result { + let ns = apply_prefix(self.config(), namespace); + let res = self + .query( + "SELECT 1 FROM saikuro_kv WHERE namespace = ?1 AND key = ?2", + ns_key_params(&ns, key), + ) + .await?; + Ok(!res.rows.is_empty()) + } + + async fn get(&self, namespace: &str, key: &str) -> Result> { + let ns = apply_prefix(self.config(), namespace); + let res = self + .query( + "SELECT value FROM saikuro_kv WHERE namespace = ?1 AND key = ?2", + ns_key_params(&ns, key), + ) + .await?; + Ok(res.rows.first().and_then(blob_of).map(Bytes::from)) + } + + async fn put(&self, namespace: &str, key: &str, value: Bytes) -> Result<()> { + let ns = apply_prefix(self.config(), namespace); + self.query( + "INSERT OR REPLACE INTO saikuro_kv (namespace, key, value) VALUES (?1, ?2, ?3)", + Params { + positional: vec![ + Value::Text(ns), + Value::Text(key.to_owned()), + Value::Blob(value.to_vec()), + ], + named: Vec::new(), + }, + ) + .await?; + Ok(()) + } + + async fn delete(&self, namespace: &str, key: &str) -> Result<()> { + let ns = apply_prefix(self.config(), namespace); + self.query( + "DELETE FROM saikuro_kv WHERE namespace = ?1 AND key = ?2", + ns_key_params(&ns, key), + ) + .await?; + Ok(()) + } + + async fn list_keys(&self, namespace: &str) -> Result> { + let ns = apply_prefix(self.config(), namespace); + let res = self + .query( + "SELECT key FROM saikuro_kv WHERE namespace = ?1 ORDER BY key", + Params { + positional: vec![Value::Text(ns)], + named: Vec::new(), + }, + ) + .await?; + Ok(res + .rows + .iter() + .filter_map(|row| match row.first() { + Some(Value::Text(t)) => Some(t.clone()), + _ => None, + }) + .collect()) + } + + async fn list_namespaces(&self) -> Result> { + let res = self + .query( + "SELECT DISTINCT namespace FROM saikuro_kv ORDER BY namespace", + Params { + positional: Vec::new(), + named: Vec::new(), + }, + ) + .await?; + let prefix = self.config().namespace_prefix.clone(); + Ok(res + .rows + .iter() + .filter_map(|row| match row.first() { + Some(Value::Text(t)) => Some(t.clone()), + _ => None, + }) + .filter(|n| match &prefix { + Some(p) => n.starts_with(&format!("{p}:")), + None => true, + }) + .map(|n| strip_prefix(self.config(), &n)) + .collect()) + } + + async fn create_namespace(&self, _namespace: &str) -> Result<()> { + Ok(()) + } + + async fn delete_namespace(&self, namespace: &str) -> Result<()> { + let ns = apply_prefix(self.config(), namespace); + self.query( + "DELETE FROM saikuro_kv WHERE namespace = ?1", + Params { + positional: vec![Value::Text(ns)], + named: Vec::new(), + }, + ) + .await?; + Ok(()) + } + + async fn clear_namespace(&self, namespace: &str) -> Result<()> { + self.delete_namespace(namespace).await + } +} + +impl StorageBackend for SqliteStorage { + fn supports_files(&self) -> bool { + false + } +} diff --git a/Build/crates/saikuro-storage/embedded/flash.rs b/Build/crates/saikuro-storage/embedded/flash.rs new file mode 100644 index 00000000..25346496 --- /dev/null +++ b/Build/crates/saikuro-storage/embedded/flash.rs @@ -0,0 +1,465 @@ +use alloc::collections::BTreeSet; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use bytes::Bytes; +use embedded_storage_async::nor_flash::NorFlash; +use sequential_storage::cache::Cache; +use sequential_storage::map::{MapConfig, MapStorage}; +use sequential_storage::Error as SsError; + +use crate::config::limits::MAX_NAMESPACE_LEN; +use crate::config::{FlashConfig, StorageConfig}; +use crate::traits::{KeyValueBackend, StorageBackend}; +use crate::util::{apply_prefix, strip_prefix}; +use crate::{Result, SaikuroError}; + +/// Tag byte for a namespace-existence marker key. +const TAG_NAMESPACE: u8 = 0x00; +/// Tag byte for a data record key. +const TAG_DATA: u8 = 0x01; + +/// Upper bound on a single `sequential_storage` item (key + value lengths are +/// encoded as `u16`), so an item can never exceed 64 KiB. +const MAX_ITEM_LEN: usize = 0xFFFF; + +/// Flash-backed key-value store. +pub struct FlashKvStore { + config: StorageConfig, + flash_config: FlashConfig, + inner: core::cell::RefCell, F, Cache>>>, +} + +impl FlashKvStore { + /// Construct a store over `flash` with the given geometry and size limits. + /// + /// Performs device-specific validation that `sequential_storage` cannot do + /// for itself: region alignment, at least two erase pages, region within + /// device capacity, and per-item sizes inside the 64 KiB cap. + pub fn new(flash: F, config: StorageConfig, flash_config: FlashConfig) -> Result { + let erase = F::ERASE_SIZE; + let region = flash_config.region_size(); + let capacity = flash.capacity(); + + if flash_config.base_offset as usize % erase != 0 { + return Err(SaikuroError::internal(format!( + "base_offset {} is not aligned to erase size {erase}", + flash_config.base_offset + ))); + } + if flash_config.sector_size == 0 || flash_config.sector_size % erase != 0 { + return Err(SaikuroError::internal(format!( + "sector_size {} is not a positive multiple of erase size {erase}", + flash_config.sector_size + ))); + } + if flash_config.sector_count < 2 { + return Err(SaikuroError::internal( + "flash region needs at least two sectors (one spare for compaction)", + )); + } + if region > capacity { + return Err(SaikuroError::internal(format!( + "flash region size {region} exceeds device capacity {capacity}" + ))); + } + if flash_config.max_key_len == 0 || flash_config.max_key_len > MAX_ITEM_LEN { + return Err(SaikuroError::internal(format!( + "max_key_len {} out of range (1..={MAX_ITEM_LEN})", + flash_config.max_key_len + ))); + } + if flash_config.max_value_len == 0 || flash_config.max_value_len > MAX_ITEM_LEN { + return Err(SaikuroError::internal(format!( + "max_value_len {} out of range (1..={MAX_ITEM_LEN})", + flash_config.max_value_len + ))); + } + + let start = flash_config.base_offset; + let end = start + region as u32; + let map_config = MapConfig::::try_new(start..end).map_err(|_| { + SaikuroError::internal("invalid sequential-storage region (alignment or size)") + })?; + + let inner = MapStorage::, F, Cache>>::new( + flash, + map_config, + Cache::new_uncached(), + ); + + Ok(Self { + config, + flash_config, + inner: core::cell::RefCell::new(inner), + }) + } + + /// A scratch buffer large enough for the largest permitted item, aligned to + /// the device write word. + fn scratch_buf(&self) -> Vec { + let len = (1 + + 4 + + MAX_NAMESPACE_LEN + + self.flash_config.max_key_len + + 1 + + 4 + + self.flash_config.max_value_len + + 32) + .next_multiple_of(F::WRITE_SIZE); + alloc::vec![0u8; len] + } + + /// Returns `true` if `stored_ns` has a live namespace marker. + #[allow(clippy::await_holding_refcell_ref)] + async fn namespace_exists(&self, stored_ns: &str) -> Result { + let mut inner = self.inner.borrow_mut(); + let mut buf = self.scratch_buf(); + let marker = make_marker_bytes(stored_ns); + let existing = inner + .fetch_item::>>(&mut buf, &marker) + .await + .map_err(map_err)?; + Ok(existing.is_some()) + } +} + +impl KeyValueBackend for FlashKvStore +where + F: 'static, +{ + fn config(&self) -> &StorageConfig { + &self.config + } + + #[allow(clippy::await_holding_refcell_ref)] + async fn exists(&self, namespace: &str, key: &str) -> Result { + Ok(self.get(namespace, key).await?.is_some()) + } + + #[allow(clippy::await_holding_refcell_ref)] + async fn get(&self, namespace: &str, key: &str) -> Result> { + let stored_ns = apply_prefix(&self.config, namespace); + if !self.namespace_exists(&stored_ns).await? { + if self.config.auto_create_namespaces { + return Ok(None); + } + return Err(SaikuroError::namespace_not_found(namespace.to_string())); + } + + let mut inner = self.inner.borrow_mut(); + let mut buf = self.scratch_buf(); + let k = make_key_bytes(&stored_ns, key); + let value = inner + .fetch_item::>>(&mut buf, &k) + .await + .map_err(map_err)?; + Ok(value.flatten().map(Bytes::from)) + } + + #[allow(clippy::await_holding_refcell_ref)] + async fn put(&self, namespace: &str, key: &str, value: Bytes) -> Result<()> { + let stored_ns = apply_prefix(&self.config, namespace); + if stored_ns.len() > MAX_NAMESPACE_LEN { + return Err(SaikuroError::internal(format!( + "namespace too long: {} bytes (max {MAX_NAMESPACE_LEN})", + stored_ns.len() + ))); + } + if key.len() > self.flash_config.max_key_len { + return Err(SaikuroError::internal(format!( + "key too long: {} bytes (max {})", + key.len(), + self.flash_config.max_key_len + ))); + } + if value.len() > self.flash_config.max_value_len { + return Err(SaikuroError::quota_exceeded(format!( + "value too large: {} bytes (max {})", + value.len(), + self.flash_config.max_value_len + ))); + } + + let mut inner = self.inner.borrow_mut(); + let mut buf = self.scratch_buf(); + let marker = make_marker_bytes(&stored_ns); + let existing = inner + .fetch_item::>>(&mut buf, &marker) + .await + .map_err(map_err)?; + if existing.is_none() { + if !self.config.auto_create_namespaces { + return Err(SaikuroError::namespace_not_found(namespace.to_string())); + } + inner + .store_item(&mut buf, &marker, &Some(Vec::new())) + .await + .map_err(map_err)?; + } + + let k = make_key_bytes(&stored_ns, key); + inner + .store_item(&mut buf, &k, &Some(value.to_vec())) + .await + .map_err(map_err)?; + Ok(()) + } + + #[allow(clippy::await_holding_refcell_ref)] + async fn delete(&self, namespace: &str, key: &str) -> Result<()> { + let stored_ns = apply_prefix(&self.config, namespace); + if !self.namespace_exists(&stored_ns).await? { + if self.config.auto_create_namespaces { + return Ok(()); + } + return Err(SaikuroError::namespace_not_found(namespace.to_string())); + } + + let mut inner = self.inner.borrow_mut(); + let mut buf = self.scratch_buf(); + let k = make_key_bytes(&stored_ns, key); + let value = inner + .fetch_item::>>(&mut buf, &k) + .await + .map_err(map_err)?; + if value.flatten().is_some() { + inner + .store_item(&mut buf, &k, &None::>) + .await + .map_err(map_err)?; + } + Ok(()) + } + + #[allow(clippy::await_holding_refcell_ref)] + async fn list_keys(&self, namespace: &str) -> Result> { + let stored_ns = apply_prefix(&self.config, namespace); + if !self.namespace_exists(&stored_ns).await? { + return Err(SaikuroError::namespace_not_found(namespace.to_string())); + } + + let mut inner = self.inner.borrow_mut(); + let mut buf = self.scratch_buf(); + let mut iter = inner + .fetch_all_items(&mut buf) + .await + .map_err(map_err)?; + + let mut out: Vec = Vec::new(); + while let Some((k, v)) = iter + .next::>>(&mut buf) + .await + .map_err(map_err)? + { + if let Some((TAG_DATA, ns, key)) = parse_key(&k) { + if ns == stored_ns && v.is_some() { + out.push(key.to_string()); + } + } + } + Ok(out) + } + + #[allow(clippy::await_holding_refcell_ref)] + async fn list_namespaces(&self) -> Result> { + let mut inner = self.inner.borrow_mut(); + let mut buf = self.scratch_buf(); + let mut iter = inner + .fetch_all_items(&mut buf) + .await + .map_err(map_err)?; + + let mut live: BTreeSet = BTreeSet::new(); + while let Some((k, v)) = iter + .next::>>(&mut buf) + .await + .map_err(map_err)? + { + if v.is_none() { + continue; + } + if let Some((_, ns, _)) = parse_key(&k) { + live.insert(ns.to_string()); + } + } + + Ok(live + .into_iter() + .map(|ns| strip_prefix(&self.config, &ns)) + .collect()) + } + + #[allow(clippy::await_holding_refcell_ref)] + async fn create_namespace(&self, namespace: &str) -> Result<()> { + let stored_ns = apply_prefix(&self.config, namespace); + if stored_ns.len() > MAX_NAMESPACE_LEN { + return Err(SaikuroError::internal(format!( + "namespace too long: {} bytes (max {MAX_NAMESPACE_LEN})", + stored_ns.len() + ))); + } + + let mut inner = self.inner.borrow_mut(); + let mut buf = self.scratch_buf(); + let marker = make_marker_bytes(&stored_ns); + let existing = inner + .fetch_item::>>(&mut buf, &marker) + .await + .map_err(map_err)?; + if existing.is_some() { + return Err(SaikuroError::namespace_already_exists(namespace.to_string())); + } + inner + .store_item(&mut buf, &marker, &Some(Vec::new())) + .await + .map_err(map_err)?; + Ok(()) + } + + #[allow(clippy::await_holding_refcell_ref)] + async fn delete_namespace(&self, namespace: &str) -> Result<()> { + let stored_ns = apply_prefix(&self.config, namespace); + + let mut inner = self.inner.borrow_mut(); + let mut buf = self.scratch_buf(); + + let mut to_tombstone: Vec> = Vec::new(); + { + let mut iter = inner + .fetch_all_items(&mut buf) + .await + .map_err(map_err)?; + while let Some((k, v)) = iter + .next::>>(&mut buf) + .await + .map_err(map_err)? + { + if let Some((TAG_DATA, ns, _)) = parse_key(&k) { + if ns == stored_ns && v.is_some() { + to_tombstone.push(k); + } + } + } + } + // The iterator borrows `inner`; drop it before we start writing. + // Also drop the namespace marker so the namespace no longer appears in + // `list_namespaces`. Tombstoning a missing marker is harmless. + to_tombstone.push(make_marker_bytes(&stored_ns)); + + for key in to_tombstone { + inner + .store_item(&mut buf, &key, &None::>) + .await + .map_err(map_err)?; + } + Ok(()) + } + + #[allow(clippy::await_holding_refcell_ref)] + async fn clear_namespace(&self, namespace: &str) -> Result<()> { + let stored_ns = apply_prefix(&self.config, namespace); + if !self.namespace_exists(&stored_ns).await? { + return Ok(()); + } + + let mut inner = self.inner.borrow_mut(); + let mut buf = self.scratch_buf(); + + let mut to_tombstone: Vec> = Vec::new(); + { + let mut iter = inner + .fetch_all_items(&mut buf) + .await + .map_err(map_err)?; + while let Some((k, v)) = iter + .next::>>(&mut buf) + .await + .map_err(map_err)? + { + if let Some((TAG_DATA, ns, _)) = parse_key(&k) { + if ns == stored_ns && v.is_some() { + to_tombstone.push(k); + } + } + } + } + // The iterator borrows `inner`; drop it before we start writing. + for key in to_tombstone { + inner + .store_item(&mut buf, &key, &None::>) + .await + .map_err(map_err)?; + } + Ok(()) + } +} + +impl StorageBackend for FlashKvStore +where + F: 'static, +{ + fn supports_files(&self) -> bool { + false + } +} + +/// Build a namespace marker key: `[0x00] || ns_len:u32 (LE) || ns`. +fn make_marker_bytes(stored_ns: &str) -> Vec { + let nb = stored_ns.as_bytes(); + let mut v = Vec::with_capacity(1 + 4 + nb.len()); + v.push(TAG_NAMESPACE); + v.extend_from_slice(&(nb.len() as u32).to_le_bytes()); + v.extend_from_slice(nb); + v +} + +/// Build a data record key: `[0x01] || ns_len:u32 (LE) || ns || key`. +fn make_key_bytes(stored_ns: &str, key: &str) -> Vec { + let nb = stored_ns.as_bytes(); + let kb = key.as_bytes(); + let mut v = Vec::with_capacity(1 + 4 + nb.len() + kb.len()); + v.push(TAG_DATA); + v.extend_from_slice(&(nb.len() as u32).to_le_bytes()); + v.extend_from_slice(nb); + v.extend_from_slice(kb); + v +} + +/// Parse a stored key into `(tag, namespace, key)` where `key` is empty for a +/// namespace marker. Returns `None` for malformed keys. +fn parse_key(k: &[u8]) -> Option<(u8, &str, &str)> { + if k.len() < 5 { + return None; + } + let tag = k[0]; + let ns_len = u32::from_le_bytes([k[1], k[2], k[3], k[4]]) as usize; + let rest = &k[5..]; + if rest.len() < ns_len { + return None; + } + let ns = core::str::from_utf8(&rest[..ns_len]).ok()?; + let key = core::str::from_utf8(&rest[ns_len..]).ok()?; + Some((tag, ns, key)) +} + +/// Map a `sequential_storage` error into the crate error type. +fn map_err(e: SsError) -> SaikuroError { + use SsError::*; + match e { + Storage { value } => { + SaikuroError::internal(format!("flash I/O error: {value:?}")) + } + FullStorage => SaikuroError::quota_exceeded("flash region is full"), + Corrupted { .. } => SaikuroError::internal("flash region is corrupted"), + LogicBug { .. } => SaikuroError::internal("flash storage logic bug"), + BufferTooBig => SaikuroError::internal("scratch buffer too large"), + BufferTooSmall(n) => SaikuroError::internal(format!( + "scratch buffer too small (need {n} bytes)" + )), + SerializationError(_) => SaikuroError::internal("serialization error"), + ItemTooBig => SaikuroError::internal("item exceeds the 64 KiB flash limit"), + _ => SaikuroError::internal("unknown flash storage error"), + } +} diff --git a/Build/crates/saikuro-storage/embedded/mod.rs b/Build/crates/saikuro-storage/embedded/mod.rs new file mode 100644 index 00000000..b5ed54cd --- /dev/null +++ b/Build/crates/saikuro-storage/embedded/mod.rs @@ -0,0 +1,2 @@ +#[cfg(feature = "flash")] +pub mod flash; diff --git a/Build/crates/saikuro-storage/lib.rs b/Build/crates/saikuro-storage/lib.rs new file mode 100644 index 00000000..fddc3ac0 --- /dev/null +++ b/Build/crates/saikuro-storage/lib.rs @@ -0,0 +1,227 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +#[macro_use] +extern crate alloc; + +// Exactly one storage engine may be active per build. +#[cfg(all(feature = "native", feature = "wasm"))] +compile_error!("only one storage engine may be enabled (native vs wasm)"); +#[cfg(all(feature = "native", feature = "no_std"))] +compile_error!("only one storage engine may be enabled (native vs no_std)"); +#[cfg(all(feature = "native", feature = "embedded"))] +compile_error!("only one storage engine may be enabled (native vs embedded)"); +#[cfg(all(feature = "wasm", feature = "no_std"))] +compile_error!("only one storage engine may be enabled (wasm vs no_std)"); +#[cfg(all(feature = "wasm", feature = "embedded"))] +compile_error!("only one storage engine may be enabled (wasm vs embedded)"); +#[cfg(all(feature = "no_std", feature = "embedded"))] +compile_error!("only one storage engine may be enabled (no_std vs embedded)"); + +// The `native` engine requires the standard library. +#[cfg(all(feature = "native", not(feature = "std")))] +compile_error!("the native engine requires the std toolchain"); + +#[cfg(all(feature = "no_std", feature = "std"))] +compile_error!("the no_std engine must not be combined with the std toolchain"); + +pub mod shared; +pub mod common; +#[cfg(feature = "native")] +pub mod native; +#[cfg(feature = "embedded")] +pub mod embedded; +#[cfg(feature = "wasm")] +pub mod wasm; +#[cfg(any(feature = "wasi-preview1", feature = "wasi-component"))] +pub mod wasi; + +/// Generates a web-storage-backed key-value backend. +#[macro_export] +macro_rules! impl_web_storage { + ($name:ident, $storage_fn:ident) => { + use bytes::Bytes; + use $crate::shared::traits::{KeyValueBackend, StorageBackend}; + + pub struct $name { + config: $crate::StorageConfig, + } + + impl $name { + pub fn new() -> Self { + Self { + config: $crate::StorageConfig::default(), + } + } + + pub fn with_config(config: $crate::StorageConfig) -> Self { + Self { config } + } + + fn storage(&self) -> $crate::Result { + let w = $crate::webstorage::window()?; + w.$storage_fn() + .map_err(|e| { + $crate::SaikuroError::internal(format!( + "failed to get {}: {e:?}", + stringify!($storage_fn) + )) + })? + .ok_or_else(|| { + $crate::SaikuroError::backend_not_available(stringify!($storage_fn)) + }) + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl KeyValueBackend for $name { + fn config(&self) -> &$crate::StorageConfig { + &self.config + } + + async fn exists(&self, namespace: &str, key: &str) -> $crate::Result { + let storage = self.storage()?; + let prefixed_ns = $crate::util::apply_prefix(&self.config, namespace); + let full_key = $crate::util::make_key(&prefixed_ns, key); + match $crate::webstorage::storage_get(&storage, &full_key)? { + Some(_) => Ok(true), + None => Ok(false), + } + } + + async fn get( + &self, + namespace: &str, + key: &str, + ) -> $crate::Result> { + let storage = self.storage()?; + let prefixed_ns = $crate::util::apply_prefix(&self.config, namespace); + let full_key = $crate::util::make_key(&prefixed_ns, key); + $crate::webstorage::storage_get(&storage, &full_key) + } + + async fn put( + &self, + namespace: &str, + key: &str, + value: Bytes, + ) -> $crate::Result<()> { + let storage = self.storage()?; + let prefixed_ns = $crate::util::apply_prefix(&self.config, namespace); + let full_key = $crate::util::make_key(&prefixed_ns, key); + $crate::webstorage::storage_set(&storage, &full_key, &value) + } + + async fn delete(&self, namespace: &str, key: &str) -> $crate::Result<()> { + let storage = self.storage()?; + let prefixed_ns = $crate::util::apply_prefix(&self.config, namespace); + let full_key = $crate::util::make_key(&prefixed_ns, key); + $crate::webstorage::storage_remove(&storage, &full_key); + Ok(()) + } + + async fn list_keys(&self, namespace: &str) -> $crate::Result> { + let storage = self.storage()?; + let prefixed_ns = $crate::util::apply_prefix(&self.config, namespace); + Ok($crate::webstorage::get_keys_in_namespace( + &storage, + &prefixed_ns, + )) + } + + async fn list_namespaces(&self) -> $crate::Result> { + let storage = self.storage()?; + let raw = $crate::webstorage::get_namespaces(&storage); + let result: Vec = raw + .into_iter() + .map(|ns| $crate::util::strip_prefix(&self.config, &ns)) + .collect(); + Ok(result) + } + + async fn create_namespace(&self, _namespace: &str) -> $crate::Result<()> { + Ok(()) + } + + async fn delete_namespace(&self, namespace: &str) -> $crate::Result<()> { + let storage = self.storage()?; + let prefixed_ns = $crate::util::apply_prefix(&self.config, namespace); + let prefix = $crate::util::key_prefix(&prefixed_ns); + $crate::webstorage::delete_keys_with_prefix(&storage, &prefix); + Ok(()) + } + + async fn clear_namespace(&self, namespace: &str) -> $crate::Result<()> { + self.delete_namespace(namespace).await + } + } + + impl StorageBackend for $name { + fn supports_files(&self) -> bool { + false + } + } + }; +} + +pub use shared::config::{BackendKind, CleanupPolicy, PersistenceMode, StorageConfig}; +#[cfg(feature = "flash")] +pub use shared::config::FlashConfig; + +pub use saikuro_event::{Result, SaikuroError}; + +/// Raw byte buffer used by every key-value and file backend. +pub use bytes::Bytes; + +pub use shared::traits::{ + FileBackend, KeyValueBackend, KeyValueBackendExt, StorageBackend, +}; + +pub use shared::config; +pub use shared::traits; +pub use shared::util; + +#[cfg(feature = "inmemory")] +pub use common::inmemory::InMemoryStorage; + +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +pub use wasm::indexeddb::IndexedDbStorage; + +#[cfg(feature = "wasm")] +pub use wasm::local_storage::LocalStorage; + +#[cfg(feature = "wasm")] +pub use wasm::session_storage::SessionStorage; + +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +pub use wasm::fs_access::FsAccessStorage; + +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +pub use wasm::opfs::OpfsStorage; + +// Root aliases for the wasm submodules referenced by `impl_web_storage!`. +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +pub use wasm::{fs_access, indexeddb, opfs, webstorage}; +#[cfg(feature = "wasm")] +pub use wasm::local_storage; +#[cfg(feature = "wasm")] +pub use wasm::session_storage; + +#[cfg(feature = "fs")] +pub use native::fs::FilesystemStorage; + +#[cfg(feature = "sled")] +pub use native::sled::SledStorage; + +#[cfg(feature = "sqlite")] +pub use common::sqlite::SqliteStorage; + +#[cfg(feature = "flash")] +pub use embedded::flash::FlashKvStore; + +#[cfg(any(feature = "wasi-preview1", feature = "wasi-component"))] +pub use wasi::{WasiFileStore, WasiKvStore}; diff --git a/Build/crates/saikuro-storage/src/fs.rs b/Build/crates/saikuro-storage/native/fs.rs similarity index 90% rename from Build/crates/saikuro-storage/src/fs.rs rename to Build/crates/saikuro-storage/native/fs.rs index 965de5ed..b2d9f3e7 100644 --- a/Build/crates/saikuro-storage/src/fs.rs +++ b/Build/crates/saikuro-storage/native/fs.rs @@ -1,15 +1,12 @@ -use async_trait::async_trait; use bytes::Bytes; use std::path::{Component, Path, PathBuf}; use tokio::task::spawn_blocking; -use super::{ - config::StorageConfig, - error::{Result, StorageError}, - traits::{FileBackend, KeyValueBackend, StorageBackend}, -}; +use crate::config::StorageConfig; +use crate::traits::{FileBackend, KeyValueBackend, StorageBackend}; +use saikuro_event::{Result, SaikuroError}; -/// Spawn blocking I/O, converting [`JoinError`] to [`StorageError`]. +/// Spawn blocking I/O, converting [`JoinError`] to [`SaikuroError`]. async fn block(f: F) -> Result where F: FnOnce() -> Result + Send + 'static, @@ -17,13 +14,10 @@ where { spawn_blocking(f) .await - .map_err(|e| StorageError::internal(format!("blocking task failed: {e}")))? + .map_err(|e| SaikuroError::internal(format!("blocking task failed: {e}")))? } /// A filesystem-backed storage backend for native targets. -/// -/// Stores key-value data under `{base_dir}/kv/namespaces/{ns}/{key}` and -/// file data under `{base_dir}/files/{path}`. pub struct FilesystemStorage { config: StorageConfig, kv_root: PathBuf, @@ -54,17 +48,15 @@ impl FilesystemStorage { } } -// -- helpers run on the blocking pool -- - fn exists(path: &Path) -> Result { - path.try_exists().map_err(StorageError::from) + path.try_exists().map_err(SaikuroError::from) } fn read_bytes(path: &Path) -> Result> { match std::fs::read(path) { Ok(data) => Ok(Some(Bytes::from(data))), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(StorageError::from(e)), + Err(e) => Err(SaikuroError::from(e)), } } @@ -93,7 +85,7 @@ fn delete(path: &Path) -> Result<()> { match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(StorageError::from(e)), + Err(e) => Err(SaikuroError::from(e)), } } @@ -127,7 +119,7 @@ fn remove_dir(path: &Path) -> Result<()> { match std::fs::remove_dir_all(path) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(StorageError::from(e)), + Err(e) => Err(SaikuroError::from(e)), } } @@ -150,13 +142,13 @@ fn clear_dir(path: &Path) -> Result<()> { fn safe_join(root: &Path, rel: &str) -> Result { let rel = Path::new(rel); if rel.is_absolute() { - return Err(StorageError::internal(format!( + return Err(SaikuroError::internal(format!( "path must not be absolute: {rel:?}" ))); } for comp in rel.components() { if matches!(comp, Component::ParentDir) { - return Err(StorageError::internal(format!( + return Err(SaikuroError::internal(format!( "path must not contain '..': {rel:?}" ))); } @@ -178,7 +170,7 @@ fn strip_ns_prefix(prefix: &Option, name: &str) -> String { } } -#[async_trait] + impl KeyValueBackend for FilesystemStorage { fn config(&self) -> &StorageConfig { &self.config @@ -229,7 +221,7 @@ impl KeyValueBackend for FilesystemStorage { block(move || match std::fs::create_dir_all(&path) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - Err(StorageError::namespace_already_exists(&ns)) + Err(SaikuroError::namespace_already_exists(&ns)) } Err(e) => Err(e.into()), }) @@ -247,14 +239,14 @@ impl KeyValueBackend for FilesystemStorage { } } -#[async_trait] + impl FileBackend for FilesystemStorage { async fn read_file(&self, path: &str) -> Result { let full = safe_join(&self.files_root, path)?; let path_owned = path.to_owned(); block(move || read_bytes(&full)) .await? - .ok_or_else(|| StorageError::key_not_found(path_owned)) + .ok_or_else(|| SaikuroError::key_not_found(path_owned)) } async fn write_file(&self, path: &str, content: Bytes) -> Result<()> { @@ -299,7 +291,7 @@ impl FileBackend for FilesystemStorage { } } -#[async_trait] + impl StorageBackend for FilesystemStorage { fn supports_files(&self) -> bool { true diff --git a/Build/crates/saikuro-storage/native/mod.rs b/Build/crates/saikuro-storage/native/mod.rs new file mode 100644 index 00000000..5ecda520 --- /dev/null +++ b/Build/crates/saikuro-storage/native/mod.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "fs")] +pub mod fs; + +#[cfg(feature = "sled")] +pub mod sled; diff --git a/Build/crates/saikuro-storage/src/sled.rs b/Build/crates/saikuro-storage/native/sled.rs similarity index 81% rename from Build/crates/saikuro-storage/src/sled.rs rename to Build/crates/saikuro-storage/native/sled.rs index cbc65519..f55dd9a9 100644 --- a/Build/crates/saikuro-storage/src/sled.rs +++ b/Build/crates/saikuro-storage/native/sled.rs @@ -1,15 +1,13 @@ -use async_trait::async_trait; + use bytes::Bytes; use std::sync::Arc; use tokio::task::spawn_blocking; -use super::{ - config::StorageConfig, - error::{Result, StorageError}, - traits::{KeyValueBackend, StorageBackend}, -}; +use crate::config::StorageConfig; +use crate::traits::{KeyValueBackend, StorageBackend}; +use saikuro_event::{Result, SaikuroError}; -/// Spawn blocking I/O, converting [`JoinError`] to [`StorageError`]. +/// Spawn blocking I/O, converting [`JoinError`] to [`SaikuroError`]. async fn block(f: F) -> Result where F: FnOnce() -> Result + Send + 'static, @@ -17,13 +15,10 @@ where { spawn_blocking(f) .await - .map_err(|e| StorageError::internal(format!("blocking task failed: {e}")))? + .map_err(|e| SaikuroError::internal(format!("blocking task failed: {e}")))? } /// A sled-backed persistent key-value storage backend. -/// -/// Each namespace maps to a sled [`Tree`] within a single database file. -/// All I/O is dispatched to the blocking thread pool. pub struct SledStorage { config: StorageConfig, db: Arc, @@ -37,7 +32,7 @@ impl SledStorage { /// Open or create a sled database with a custom configuration. pub fn with_config(path: impl AsRef, config: StorageConfig) -> Result { - let db = sled::open(path).map_err(|e| StorageError::internal(format!("sled open: {e}")))?; + let db = sled::open(path).map_err(|e| SaikuroError::internal(format!("sled open: {e}")))?; Ok(Self { config, db: Arc::new(db), @@ -49,7 +44,7 @@ impl SledStorage { let db = sled::Config::default() .temporary(true) .open() - .map_err(|e| StorageError::internal(format!("sled temporary: {e}")))?; + .map_err(|e| SaikuroError::internal(format!("sled temporary: {e}")))?; Ok(Self { config: StorageConfig::default(), db: Arc::new(db), @@ -79,7 +74,7 @@ impl SledStorage { } } -#[async_trait] + impl KeyValueBackend for SledStorage { fn config(&self) -> &StorageConfig { &self.config @@ -92,9 +87,9 @@ impl KeyValueBackend for SledStorage { block(move || { let tree = db .open_tree(&ns) - .map_err(|e| StorageError::internal(format!("sled tree: {e}")))?; + .map_err(|e| SaikuroError::internal(format!("sled tree: {e}")))?; tree.contains_key(key.as_bytes()) - .map_err(|e| StorageError::internal(format!("sled contains_key: {e}"))) + .map_err(|e| SaikuroError::internal(format!("sled contains_key: {e}"))) }) .await } @@ -106,11 +101,11 @@ impl KeyValueBackend for SledStorage { block(move || { let tree = db .open_tree(&ns) - .map_err(|e| StorageError::internal(format!("sled tree: {e}")))?; + .map_err(|e| SaikuroError::internal(format!("sled tree: {e}")))?; match tree.get(key.as_bytes()) { Ok(Some(iv)) => Ok(Some(Bytes::from(iv.to_vec()))), Ok(None) => Ok(None), - Err(e) => Err(StorageError::internal(format!("sled get: {e}"))), + Err(e) => Err(SaikuroError::internal(format!("sled get: {e}"))), } }) .await @@ -124,9 +119,9 @@ impl KeyValueBackend for SledStorage { block(move || { let tree = db .open_tree(&ns) - .map_err(|e| StorageError::internal(format!("sled tree: {e}")))?; + .map_err(|e| SaikuroError::internal(format!("sled tree: {e}")))?; tree.insert(key.as_bytes(), val) - .map_err(|e| StorageError::internal(format!("sled insert: {e}")))?; + .map_err(|e| SaikuroError::internal(format!("sled insert: {e}")))?; Ok(()) }) .await @@ -139,9 +134,9 @@ impl KeyValueBackend for SledStorage { block(move || { let tree = db .open_tree(&ns) - .map_err(|e| StorageError::internal(format!("sled tree: {e}")))?; + .map_err(|e| SaikuroError::internal(format!("sled tree: {e}")))?; tree.remove(key.as_bytes()) - .map_err(|e| StorageError::internal(format!("sled remove: {e}")))?; + .map_err(|e| SaikuroError::internal(format!("sled remove: {e}")))?; Ok(()) }) .await @@ -153,7 +148,7 @@ impl KeyValueBackend for SledStorage { block(move || { let tree = db .open_tree(&ns) - .map_err(|e| StorageError::internal(format!("sled tree: {e}")))?; + .map_err(|e| SaikuroError::internal(format!("sled tree: {e}")))?; let keys: Vec = tree .iter() .keys() @@ -193,7 +188,7 @@ impl KeyValueBackend for SledStorage { let ns = self.apply_prefix(namespace); block(move || { db.open_tree(&ns) - .map_err(|e| StorageError::internal(format!("sled create tree: {e}")))?; + .map_err(|e| SaikuroError::internal(format!("sled create tree: {e}")))?; Ok(()) }) .await @@ -204,7 +199,7 @@ impl KeyValueBackend for SledStorage { let ns = self.apply_prefix(namespace); block(move || { db.drop_tree(&ns) - .map_err(|e| StorageError::internal(format!("sled drop tree: {e}")))?; + .map_err(|e| SaikuroError::internal(format!("sled drop tree: {e}")))?; Ok(()) }) .await @@ -216,16 +211,16 @@ impl KeyValueBackend for SledStorage { block(move || { let tree = db .open_tree(&ns) - .map_err(|e| StorageError::internal(format!("sled tree: {e}")))?; + .map_err(|e| SaikuroError::internal(format!("sled tree: {e}")))?; tree.clear() - .map_err(|e| StorageError::internal(format!("sled clear: {e}")))?; + .map_err(|e| SaikuroError::internal(format!("sled clear: {e}")))?; Ok(()) }) .await } } -#[async_trait] + impl StorageBackend for SledStorage { fn supports_files(&self) -> bool { false diff --git a/Build/crates/saikuro-storage/src/config.rs b/Build/crates/saikuro-storage/shared/config.rs similarity index 93% rename from Build/crates/saikuro-storage/src/config.rs rename to Build/crates/saikuro-storage/shared/config.rs index 8f843ffa..018fb862 100644 --- a/Build/crates/saikuro-storage/src/config.rs +++ b/Build/crates/saikuro-storage/shared/config.rs @@ -1,17 +1,7 @@ -//! Configuration for storage backends. - use alloc::string::String; use core::time::Duration; /// Selects which storage backend implementation to use at runtime. -/// -/// When [`BackendKind::InMemory`] (the default), [`StorageConfig::persistence`] -/// determines the backend via the platform-aware dispatch in -/// [`StorageBackend`](crate::traits::StorageBackend). -/// -/// Set this explicitly to bypass the automatic dispatch and force a specific -/// backend (returns an error if the backend is not available on the current -/// platform/feature set). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum BackendKind { /// In-memory DashMap backend. Works everywhere. @@ -29,7 +19,7 @@ pub enum BackendKind { Filesystem, /// Sled embedded database. Native only. Sled, - /// SQLite via `rusqlite`. Native only. + /// SQLite via `graphitesql`. Available on all engines. Sqlite, } @@ -163,7 +153,7 @@ impl StorageConfig { } /// Bounded-size limits -#[cfg(feature = "flash-storage")] +#[cfg(feature = "flash")] pub mod limits { /// Maximum length of a namespace, in bytes. Encoded as `u8` in the /// on-flash record header. @@ -187,7 +177,7 @@ pub mod limits { } /// Geometry and size limits for a flash-backed key-value store. -#[cfg(feature = "flash-storage")] +#[cfg(feature = "flash")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FlashConfig { /// Offset of the store's region inside the flash device. Must be aligned @@ -205,7 +195,7 @@ pub struct FlashConfig { pub max_value_len: usize, } -#[cfg(feature = "flash-storage")] +#[cfg(feature = "flash")] impl FlashConfig { /// A 256 KiB region using the defaults for every field. pub const DEFAULT: Self = Self { diff --git a/Build/crates/saikuro-storage/shared/mod.rs b/Build/crates/saikuro-storage/shared/mod.rs new file mode 100644 index 00000000..8586490c --- /dev/null +++ b/Build/crates/saikuro-storage/shared/mod.rs @@ -0,0 +1,3 @@ +pub mod config; +pub mod traits; +pub mod util; diff --git a/Build/crates/saikuro-storage/shared/traits/backend.rs b/Build/crates/saikuro-storage/shared/traits/backend.rs new file mode 100644 index 00000000..799b23fb --- /dev/null +++ b/Build/crates/saikuro-storage/shared/traits/backend.rs @@ -0,0 +1,26 @@ +use saikuro_event::Result; + +use super::file::FileBackend; +use super::kv::KeyValueBackend; + +/// Unified storage backend trait combining key-value and file operations. +#[allow(async_fn_in_trait)] +pub trait StorageBackend: KeyValueBackend { + /// Check if this backend supports file operations. + fn supports_files(&self) -> bool; + + /// Get the file backend, if supported. + fn as_file_backend(&self) -> Option<&dyn FileBackend> { + None + } + + /// Flush any pending writes to durable storage. + async fn flush(&self) -> Result<()> { + Ok(()) + } + + /// Close the backend and release any resources. + async fn close(&self) -> Result<()> { + Ok(()) + } +} diff --git a/Build/crates/saikuro-storage/shared/traits/ext.rs b/Build/crates/saikuro-storage/shared/traits/ext.rs new file mode 100644 index 00000000..e232ced8 --- /dev/null +++ b/Build/crates/saikuro-storage/shared/traits/ext.rs @@ -0,0 +1,64 @@ +use alloc::string::ToString; +use bytes::Bytes; +use saikuro_event::Result; +use serde::{de::DeserializeOwned, Serialize}; + +use super::kv::KeyValueBackend; + +/// Extension methods for [`KeyValueBackend`] providing JSON/MessagePack helpers. +#[allow(async_fn_in_trait)] +pub trait KeyValueBackendExt: KeyValueBackend { + /// Get a JSON-serialized value. + async fn get_json(&self, namespace: &str, key: &str) -> Result> { + match self.get(namespace, key).await? { + Some(bytes) => { + let value = serde_json::from_slice(&bytes) + .map_err(|e| saikuro_event::SaikuroError::deserialization(e.to_string()))?; + Ok(Some(value)) + } + None => Ok(None), + } + } + + /// Put a JSON-serialized value. + async fn put_json( + &self, + namespace: &str, + key: &str, + value: &T, + ) -> Result<()> { + let bytes = serde_json::to_vec(value) + .map_err(|e| saikuro_event::SaikuroError::serialization(e.to_string()))?; + self.put(namespace, key, Bytes::from(bytes)).await + } + + /// Get a MessagePack-serialized value. + async fn get_msgpack( + &self, + namespace: &str, + key: &str, + ) -> Result> { + match self.get(namespace, key).await? { + Some(bytes) => { + let value = saikuro_core::msgpack::from_slice(&bytes) + .map_err(|e| saikuro_event::SaikuroError::deserialization(e.to_string()))?; + Ok(Some(value)) + } + None => Ok(None), + } + } + + /// Put a MessagePack-serialized value. + async fn put_msgpack( + &self, + namespace: &str, + key: &str, + value: &T, + ) -> Result<()> { + let bytes = saikuro_core::msgpack::to_vec(value) + .map_err(|e| saikuro_event::SaikuroError::serialization(e.to_string()))?; + self.put(namespace, key, Bytes::from(bytes)).await + } +} + +impl KeyValueBackendExt for B {} diff --git a/Build/crates/saikuro-storage/shared/traits/file.rs b/Build/crates/saikuro-storage/shared/traits/file.rs new file mode 100644 index 00000000..a2a10943 --- /dev/null +++ b/Build/crates/saikuro-storage/shared/traits/file.rs @@ -0,0 +1,32 @@ +use alloc::string::String; +use alloc::vec::Vec; +use bytes::Bytes; +use saikuro_event::Result; + +/// A file-like storage interface for hierarchical storage. +#[allow(async_fn_in_trait)] +pub trait FileBackend: 'static { + /// Read a file's contents. + async fn read_file(&self, path: &str) -> Result; + + /// Write a file's contents, creating it if it doesn't exist. + async fn write_file(&self, path: &str, content: Bytes) -> Result<()>; + + /// Append content to an existing file. + async fn append_file(&self, path: &str, content: Bytes) -> Result<()>; + + /// Delete a file. + async fn delete_file(&self, path: &str) -> Result<()>; + + /// Check if a file exists. + async fn file_exists(&self, path: &str) -> Result; + + /// List files in a directory. + async fn list_dir(&self, path: &str) -> Result>; + + /// Create a directory. + async fn create_dir(&self, path: &str) -> Result<()>; + + /// Delete a directory and all its contents. + async fn delete_dir(&self, path: &str) -> Result<()>; +} diff --git a/Build/crates/saikuro-storage/shared/traits/kv.rs b/Build/crates/saikuro-storage/shared/traits/kv.rs new file mode 100644 index 00000000..3c953576 --- /dev/null +++ b/Build/crates/saikuro-storage/shared/traits/kv.rs @@ -0,0 +1,40 @@ +use alloc::string::String; +use alloc::vec::Vec; +use bytes::Bytes; +use saikuro_event::Result; + +use crate::config::StorageConfig; + +/// A key-value storage interface with namespace support. +#[allow(async_fn_in_trait)] +pub trait KeyValueBackend: 'static { + /// Get the configuration for this backend. + fn config(&self) -> &StorageConfig; + + /// Check if a key exists in a namespace. + async fn exists(&self, namespace: &str, key: &str) -> Result; + + /// Get raw bytes for a key. + async fn get(&self, namespace: &str, key: &str) -> Result>; + + /// Put raw bytes for a key. + async fn put(&self, namespace: &str, key: &str, value: Bytes) -> Result<()>; + + /// Delete a key. + async fn delete(&self, namespace: &str, key: &str) -> Result<()>; + + /// List all keys in a namespace. + async fn list_keys(&self, namespace: &str) -> Result>; + + /// List all namespaces. + async fn list_namespaces(&self) -> Result>; + + /// Create a namespace explicitly. + async fn create_namespace(&self, namespace: &str) -> Result<()>; + + /// Delete a namespace and all its keys. + async fn delete_namespace(&self, namespace: &str) -> Result<()>; + + /// Clear all keys in a namespace without deleting the namespace. + async fn clear_namespace(&self, namespace: &str) -> Result<()>; +} diff --git a/Build/crates/saikuro-storage/shared/traits/mod.rs b/Build/crates/saikuro-storage/shared/traits/mod.rs new file mode 100644 index 00000000..366a0b66 --- /dev/null +++ b/Build/crates/saikuro-storage/shared/traits/mod.rs @@ -0,0 +1,10 @@ +pub mod backend; +pub mod ext; +pub mod file; +pub mod kv; + +pub use backend::StorageBackend; +pub use ext::KeyValueBackendExt; +pub use file::FileBackend; +pub use kv::KeyValueBackend; +pub use saikuro_event::Result; diff --git a/Build/crates/saikuro-storage/src/util.rs b/Build/crates/saikuro-storage/shared/util.rs similarity index 78% rename from Build/crates/saikuro-storage/src/util.rs rename to Build/crates/saikuro-storage/shared/util.rs index 8597fbb0..b5508181 100644 --- a/Build/crates/saikuro-storage/src/util.rs +++ b/Build/crates/saikuro-storage/shared/util.rs @@ -1,11 +1,3 @@ -// Pure helper functions shared by webstorage, opfs, and indexeddb backends. -// These are re-exported from the wasm32-gated webstorage module so the -// impl_web_storage! macro can reach them via $crate::webstorage::*. -// -// The functions are `pub` (doc-hidden) rather than `pub(crate)` so the -// crate's integration tests can exercise them; on native they are otherwise -// only referenced from the wasm32-gated backends. - use alloc::borrow::ToOwned; use alloc::string::String; use alloc::vec::Vec; diff --git a/Build/crates/saikuro-storage/src/error.rs b/Build/crates/saikuro-storage/src/error.rs deleted file mode 100644 index 45cbb751..00000000 --- a/Build/crates/saikuro-storage/src/error.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Error types for the storage backend abstraction. - -use alloc::string::String; -use alloc::string::ToString; -use thiserror::Error; - -#[cfg(feature = "std")] -use std::io; - -pub type Result = core::result::Result; - -/// Error type for all storage backend operations. -#[derive(Error, Debug)] -pub enum StorageError { - #[error("key not found: {0}")] - KeyNotFound(String), - - #[error("namespace not found: {0}")] - NamespaceNotFound(String), - - #[error("key already exists: {0}")] - KeyAlreadyExists(String), - - #[error("namespace already exists: {0}")] - NamespaceAlreadyExists(String), - - #[cfg(feature = "std")] - #[error("io error: {0}")] - Io(#[from] io::Error), - - #[error("serialization error: {0}")] - Serialization(String), - - #[error("deserialization error: {0}")] - Deserialization(String), - - #[error("backend not available: {0}")] - BackendNotAvailable(String), - - #[error("operation not supported: {0}")] - OperationNotSupported(String), - - #[error("quota exceeded: {0}")] - QuotaExceeded(String), - - #[error("timeout: {0}")] - Timeout(String), - - #[error("internal error: {0}")] - Internal(String), -} - -impl StorageError { - pub fn key_not_found(key: impl Into) -> Self { - StorageError::KeyNotFound(key.into()) - } - - pub fn namespace_not_found(ns: impl Into) -> Self { - StorageError::NamespaceNotFound(ns.into()) - } - - pub fn serialization(msg: impl Into) -> Self { - StorageError::Serialization(msg.into()) - } - - pub fn deserialization(msg: impl Into) -> Self { - StorageError::Deserialization(msg.into()) - } - - pub fn internal(msg: impl Into) -> Self { - StorageError::Internal(msg.into()) - } - - pub fn not_supported(msg: impl Into) -> Self { - StorageError::OperationNotSupported(msg.into()) - } - - pub fn key_already_exists(key: impl Into) -> Self { - StorageError::KeyAlreadyExists(key.into()) - } - - pub fn namespace_already_exists(ns: impl Into) -> Self { - StorageError::NamespaceAlreadyExists(ns.into()) - } - - pub fn backend_not_available(msg: impl Into) -> Self { - StorageError::BackendNotAvailable(msg.into()) - } - - pub fn quota_exceeded(msg: impl Into) -> Self { - StorageError::QuotaExceeded(msg.into()) - } - - pub fn timeout(msg: impl Into) -> Self { - StorageError::Timeout(msg.into()) - } -} - -impl From for StorageError { - fn from(e: saikuro_core::msgpack::EncodeError) -> Self { - StorageError::Serialization(e.to_string()) - } -} - -impl From for StorageError { - fn from(e: saikuro_core::msgpack::DecodeError) -> Self { - StorageError::Deserialization(e.to_string()) - } -} diff --git a/Build/crates/saikuro-storage/src/flash.rs b/Build/crates/saikuro-storage/src/flash.rs deleted file mode 100644 index f190705c..00000000 --- a/Build/crates/saikuro-storage/src/flash.rs +++ /dev/null @@ -1,717 +0,0 @@ -//! Bounded key-value store over an async NOR-flash device. -//! -//! The store owns a contiguous region of [`NorFlash`] storage and lays it out -//! as a ring of `sector_count` erasable sectors. A sector starts with a u64 -//! sequence number (all-`0xFF` = empty) followed by appended records. Every -//! record carries a CRC-32 so torn writes are detected and truncated on the -//! next [`open`](FlashKvStore::open). -//! -//! Writes are log-structured: `put` and `delete` append a record to the -//! active sector; deletion appends a tombstone. When the active sector fills, -//! the store advances to the next sector in the ring. If that sector still -//! holds data, the store compacts: the live set (materialized in RAM as the -//! running index) is erased and rewritten into a fresh generation, reserving -//! one sector as the spare that guarantees the next roll has room. -//! -//! # Limits -//! -//! The Tier 2 orchestrator bounds every persisted value through -//! [`FlashConfig`]: namespaces are at most 255 bytes, keys at most -//! `max_key_len` (default 64), values at most `max_value_len` (default -//! 4096), and a record must fit inside one sector. Usable capacity is -//! `(sector_count - 1) * (sector_size - sector_header)` bytes; exceeding it -//! returns [`StorageError::QuotaExceeded`]. The running index holds the whole -//! live set in RAM, so RAM usage tracks stored bytes. -//! -//! # Reliability -//! -//! Power loss during a normal append loses at most the torn tail record -//! (CRC-checked on open). Power loss during compaction loses the store: the -//! whole region is erased before the new generation is written. A -//! crash-consistent compaction journal is future work. -//! -//! # Concurrency -//! -//! The store is single-threaded and not reentrant, like the rest of the local -//! storage tier. Flash I/O borrows the internal `RefCell` across `await`, so -//! concurrent access from multiple tasks must be serialized by the -//! application (for example behind an embassy `Mutex`); a reentrant call -//! panics on the borrow instead of corrupting the log. - -use alloc::borrow::ToOwned; -use alloc::collections::BTreeMap; -use alloc::string::String; -use alloc::vec::Vec; -use bytes::Bytes; -use core::cell::RefCell; -use embedded_storage_async::nor_flash::NorFlash; - -use super::{ - config::{limits, FlashConfig, StorageConfig}, - error::{Result, StorageError}, - traits::{LocalKeyValueBackend, LocalStorageBackend}, - util::{apply_prefix, strip_prefix}, -}; - -const RECORD_HEADER_LEN: usize = 12; -const SEQ_LEN: usize = 8; -const EMPTY_SEQ: u64 = u64::MAX; -const TYPE_PUT: u8 = 0; -const TYPE_DELETE: u8 = 1; -const TYPE_CREATE_NAMESPACE: u8 = 2; -const TYPE_DELETE_NAMESPACE: u8 = 3; - -fn align_up(n: usize, align: usize) -> usize { - n.div_ceil(align) * align -} - -fn crc32(data: &[u8]) -> u32 { - let mut crc = 0xFFFF_FFFFu32; - for &byte in data { - crc ^= byte as u32; - for _ in 0..8 { - crc = if crc & 1 != 0 { - (crc >> 1) ^ 0xEDB8_8320 - } else { - crc >> 1 - }; - } - } - !crc -} - -#[derive(Default)] -struct Namespace { - keys: BTreeMap>, -} - -#[derive(Default)] -struct Index { - namespaces: BTreeMap, -} - -#[derive(Clone, Copy)] -struct Active { - idx: usize, - seq: u64, - next_write: usize, -} - -struct Record { - typ: u8, - ns: String, - key: String, - value: Vec, -} - -struct FlashLog { - flash: F, - flash_config: FlashConfig, - index: Index, - active: Active, - opened: bool, -} - -impl FlashLog { - fn header_len(&self) -> usize { - align_up(SEQ_LEN, F::WRITE_SIZE) - } - - fn sector_start(&self, idx: usize) -> usize { - self.flash_config.base_offset as usize + idx * self.flash_config.sector_size - } - - fn sector_capacity(&self) -> usize { - self.flash_config.sector_size - self.header_len() - } - - fn encode_record(&self, typ: u8, ns: &str, key: &str, value: &[u8]) -> Result> { - if ns.len() > limits::MAX_NAMESPACE_LEN { - return Err(StorageError::internal(format!( - "namespace exceeds {} bytes: {ns}", - limits::MAX_NAMESPACE_LEN - ))); - } - if key.len() > self.flash_config.max_key_len { - return Err(StorageError::internal(format!( - "key exceeds {} bytes: {key}", - self.flash_config.max_key_len - ))); - } - if value.len() > self.flash_config.max_value_len { - return Err(StorageError::quota_exceeded(format!( - "value exceeds {} bytes", - self.flash_config.max_value_len - ))); - } - let mut header = Vec::with_capacity(RECORD_HEADER_LEN); - header.push(typ); - header.push(ns.len() as u8); - header.extend_from_slice(&(key.len() as u16).to_le_bytes()); - header.extend_from_slice(&(value.len() as u32).to_le_bytes()); - - let mut crc_buf = Vec::with_capacity(SEQ_LEN + ns.len() + key.len() + value.len()); - crc_buf.extend_from_slice(&header[..8]); - crc_buf.extend_from_slice(ns.as_bytes()); - crc_buf.extend_from_slice(key.as_bytes()); - crc_buf.extend_from_slice(value); - let crc = crc32(&crc_buf); - - let mut out = Vec::with_capacity(RECORD_HEADER_LEN + ns.len() + key.len() + value.len()); - out.extend_from_slice(&header[..]); - out.extend_from_slice(&crc.to_le_bytes()); - out.extend_from_slice(ns.as_bytes()); - out.extend_from_slice(key.as_bytes()); - out.extend_from_slice(value); - Ok(out) - } - - async fn read_exact(&mut self, offset: usize, buf: &mut [u8]) -> Result<()> { - self.flash - .read(offset as u32, buf) - .await - .map_err(|e| StorageError::internal(format!("flash read: {e:?}"))) - } - - async fn write(&mut self, offset: usize, bytes: &[u8]) -> Result<()> { - self.flash - .write(offset as u32, bytes) - .await - .map_err(|e| StorageError::internal(format!("flash write: {e:?}"))) - } - - async fn erase_sector(&mut self, idx: usize) -> Result<()> { - let base = self.sector_start(idx); - self.flash - .erase(base as u32, (base + self.flash_config.sector_size) as u32) - .await - .map_err(|e| StorageError::internal(format!("flash erase: {e:?}"))) - } - - async fn read_seq(&mut self, idx: usize) -> Result { - let mut buf = [0xFFu8; SEQ_LEN]; - self.read_exact(self.sector_start(idx), &mut buf).await?; - Ok(u64::from_le_bytes(buf)) - } - - async fn init_active(&mut self, idx: usize, seq: u64) -> Result<()> { - let mut buf = vec![0xFFu8; self.header_len()]; - buf[..SEQ_LEN].copy_from_slice(&seq.to_le_bytes()); - self.write(self.sector_start(idx), &buf).await?; - self.active = Active { - idx, - seq, - next_write: self.header_len(), - }; - Ok(()) - } - - async fn read_record( - &mut self, - sector: usize, - offset: usize, - ) -> Result> { - let sector_size = self.flash_config.sector_size; - if offset + RECORD_HEADER_LEN > sector_size { - return Ok(None); - } - let mut hdr = [0u8; RECORD_HEADER_LEN]; - self.read_exact(self.sector_start(sector) + offset, &mut hdr) - .await?; - if hdr[0] == 0xFF { - return Ok(None); - } - let ns_len = hdr[1] as usize; - let key_len = u16::from_le_bytes([hdr[2], hdr[3]]) as usize; - let value_len = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]) as usize; - let stored_crc = u32::from_le_bytes([hdr[8], hdr[9], hdr[10], hdr[11]]); - let payload_len = ns_len + key_len + value_len; - - if ns_len > limits::MAX_NAMESPACE_LEN - || key_len > self.flash_config.max_key_len - || value_len > self.flash_config.max_value_len - || offset + RECORD_HEADER_LEN + payload_len > sector_size - { - return Ok(None); - } - - let mut payload = vec![0u8; payload_len]; - self.read_exact( - self.sector_start(sector) + offset + RECORD_HEADER_LEN, - &mut payload, - ) - .await?; - let mut crc_buf = Vec::with_capacity(SEQ_LEN + payload_len); - crc_buf.extend_from_slice(&hdr[..8]); - crc_buf.extend_from_slice(&payload); - if crc32(&crc_buf) != stored_crc { - return Ok(None); - } - - let rec = Record { - typ: hdr[0], - ns: String::from_utf8_lossy(&payload[..ns_len]).into_owned(), - key: String::from_utf8_lossy(&payload[ns_len..ns_len + key_len]).into_owned(), - value: payload[ns_len + key_len..].to_vec(), - }; - let padded = align_up(RECORD_HEADER_LEN + payload_len, F::WRITE_SIZE); - Ok(Some((rec, padded))) - } - - fn apply_record(&mut self, rec: &Record, index: &mut Index) { - match rec.typ { - TYPE_PUT => { - let ns = index.namespaces.entry(rec.ns.clone()).or_default(); - ns.keys.insert(rec.key.clone(), rec.value.clone()); - } - TYPE_DELETE => { - if let Some(ns) = index.namespaces.get_mut(&rec.ns) { - ns.keys.remove(&rec.key); - } - } - TYPE_CREATE_NAMESPACE => { - index.namespaces.entry(rec.ns.clone()).or_default(); - } - TYPE_DELETE_NAMESPACE => { - index.namespaces.remove(&rec.ns); - } - _ => {} - } - } - - async fn scan_sector(&mut self, sector: usize, index: &mut Index) -> Result> { - let seq = self.read_seq(sector).await?; - if seq == EMPTY_SEQ { - return Ok(None); - } - let mut offset = self.header_len(); - while let Some((rec, padded)) = self.read_record(sector, offset).await? { - self.apply_record(&rec, index); - offset += padded; - } - Ok(Some(Active { - idx: sector, - seq, - next_write: offset, - })) - } - - async fn sector_occupied(&mut self, idx: usize) -> Result { - Ok(self.read_seq(idx).await? != EMPTY_SEQ) - } - - async fn write_record(&mut self, enc: &[u8]) -> Result<()> { - let padded = align_up(enc.len(), F::WRITE_SIZE); - let offset = self.sector_start(self.active.idx) + self.active.next_write; - let mut buf = vec![0xFFu8; padded]; - buf[..enc.len()].copy_from_slice(enc); - self.write(offset, &buf).await?; - self.active.next_write += padded; - Ok(()) - } - - /// Advance past the full active sector. Returns `true` when `pending` was - /// already written by a compaction, `false` when the caller must append it - /// into the freshly activated sector. - async fn roll(&mut self, pending: &[u8]) -> Result { - let next = (self.active.idx + 1) % self.flash_config.sector_count; - if self.sector_occupied(next).await? { - self.compact(pending).await?; - Ok(true) - } else { - self.init_active(next, self.active.seq + 1).await?; - Ok(false) - } - } - - async fn append_record(&mut self, enc: &[u8]) -> Result<()> { - let padded = align_up(enc.len(), F::WRITE_SIZE); - if padded > self.sector_capacity() { - return Err(StorageError::quota_exceeded( - "record does not fit in one sector", - )); - } - if self.active.next_write + padded > self.flash_config.sector_size && self.roll(enc).await? - { - return Ok(()); - } - self.write_record(enc).await - } - - /// Erase the region and rewrite the live index as a fresh generation. - /// - /// `pending` is the record that could not be appended to the full active - /// sector. A pending delete or namespace delete is folded into the - /// compaction (the key/namespace is dropped from the output), so a - /// shrinking operation never needs more space than the current live set. - /// Growth operations (`put`, namespace markers) append `pending` after the - /// live records and require the spare sector to survive, so they fail with - /// `QuotaExceeded` instead of wedging the store. - async fn compact(&mut self, pending: &[u8]) -> Result<()> { - let pending_type = pending[0]; - let skip_key: Option<(String, String)> = if pending_type == TYPE_DELETE { - let ns_len = pending[1] as usize; - let key_len = u16::from_le_bytes([pending[2], pending[3]]) as usize; - let ns = String::from_utf8_lossy(&pending[12..12 + ns_len]).into_owned(); - let key = - String::from_utf8_lossy(&pending[12 + ns_len..12 + ns_len + key_len]).into_owned(); - Some((ns, key)) - } else { - None - }; - let skip_ns: Option = if pending_type == TYPE_DELETE_NAMESPACE { - let ns_len = pending[1] as usize; - Some(String::from_utf8_lossy(&pending[12..12 + ns_len]).into_owned()) - } else { - None - }; - let shrinking = skip_key.is_some() || skip_ns.is_some(); - - let mut records: Vec> = Vec::new(); - for (ns, namespace) in &self.index.namespaces { - if let Some(ref skip) = skip_ns { - if ns == skip { - continue; - } - } - records.push(self.encode_record(TYPE_CREATE_NAMESPACE, ns, "", b"")?); - for (key, value) in &namespace.keys { - if let Some((ref skip_ns, ref skip_key)) = skip_key { - if ns == skip_ns && key == skip_key { - continue; - } - } - records.push(self.encode_record(TYPE_PUT, ns, key, value)?); - } - } - if !shrinking { - records.push(pending.to_vec()); - } - - let total: usize = records - .iter() - .map(|r| align_up(r.len(), F::WRITE_SIZE)) - .sum(); - let needed = total.div_ceil(self.sector_capacity()); - let spare = if shrinking { 0 } else { 1 }; - if needed + spare > self.flash_config.sector_count { - return Err(StorageError::quota_exceeded("flash region full")); - } - - for idx in 0..self.flash_config.sector_count { - self.erase_sector(idx).await?; - } - - let base_seq = self.active.seq + 1; - let mut idx = 0usize; - let mut seq = base_seq; - self.init_active(idx, seq).await?; - for record in &records { - let padded = align_up(record.len(), F::WRITE_SIZE); - if self.active.next_write + padded > self.flash_config.sector_size { - idx = (idx + 1) % self.flash_config.sector_count; - seq += 1; - self.init_active(idx, seq).await?; - } - self.write_record(record).await?; - } - Ok(()) - } - - fn ensure_opened(&self) -> Result<()> { - if self.opened { - Ok(()) - } else { - Err(StorageError::internal( - "flash store is not opened; call open() first", - )) - } - } - - async fn open(&mut self) -> Result<()> { - let mut index = Index::default(); - let mut best: Option = None; - for idx in 0..self.flash_config.sector_count { - let scanned = self.scan_sector(idx, &mut index).await?; - if let Some(active) = scanned { - match best { - None => best = Some(active), - Some(b) if active.seq > b.seq => best = Some(active), - Some(_) => {} - } - } - } - self.index = index; - self.active = match best { - Some(active) => active, - None => { - // Fresh region: write sector 0's sequence header so a later - // open recognizes the active sector. - self.init_active(0, 0).await?; - self.active - } - }; - self.opened = true; - Ok(()) - } - - async fn ensure_namespace(&mut self, stored_ns: &str) -> Result<()> { - let enc = self.encode_record(TYPE_CREATE_NAMESPACE, stored_ns, "", b"")?; - self.append_record(&enc).await?; - self.index - .namespaces - .entry(stored_ns.to_owned()) - .or_default(); - Ok(()) - } -} - -/// A bounded, durable key-value store over an async NOR-flash device. -/// -/// See the [module documentation](self) for the on-flash layout, size limits, -/// and reliability guarantees. The store owns the device and a RAM index of -/// the live set; the region is erased and rewritten by compaction, so this -/// backend never allocates beyond `max_value_len` per record plus the live -/// index. -pub struct FlashKvStore { - config: StorageConfig, - flash_config: FlashConfig, - inner: RefCell>, -} - -impl FlashKvStore { - /// Validate the store geometry against the device and construct the - /// store. Call [`open`](Self::open) before using it. - pub fn new(flash: F, config: StorageConfig, flash_config: FlashConfig) -> Result { - let geometry = FlashConfig::new( - flash_config.base_offset, - flash_config.sector_size, - flash_config.sector_count, - flash_config.max_key_len, - flash_config.max_value_len, - F::ERASE_SIZE, - ) - .map_err(|msg| StorageError::internal(format!("invalid flash config: {msg}")))?; - - let header_len = align_up(SEQ_LEN, F::WRITE_SIZE); - let record_max = align_up( - RECORD_HEADER_LEN - + limits::MAX_NAMESPACE_LEN - + geometry.max_key_len - + geometry.max_value_len, - F::WRITE_SIZE, - ); - if !(geometry.base_offset as usize).is_multiple_of(F::ERASE_SIZE) { - return Err(StorageError::internal( - "flash base offset must be aligned to the erase size", - )); - } - if !geometry.sector_size.is_multiple_of(F::WRITE_SIZE) { - return Err(StorageError::internal( - "flash sector size must be a multiple of the write size", - )); - } - if geometry.sector_size < header_len + RECORD_HEADER_LEN { - return Err(StorageError::internal( - "flash sector size too small for the record header", - )); - } - if geometry.base_offset as usize + geometry.region_size() > flash.capacity() { - return Err(StorageError::internal( - "flash region exceeds the device capacity", - )); - } - if record_max > geometry.sector_size - header_len { - return Err(StorageError::internal( - "a maximum-size record does not fit one sector", - )); - } - - Ok(Self { - config, - flash_config: geometry, - inner: RefCell::new(FlashLog { - flash, - flash_config: geometry, - index: Index::default(), - active: Active { - idx: 0, - seq: 0, - next_write: header_len, - }, - opened: false, - }), - }) - } - - /// Scan the region and rebuild the index. Safe to call again to recover - /// from a torn tail left by a power loss during a normal append. - pub async fn open(&mut self) -> Result<()> { - self.inner.get_mut().open().await - } - - /// The generic storage configuration. - pub fn config(&self) -> &StorageConfig { - &self.config - } - - /// The flash geometry and size limits. - pub fn flash_config(&self) -> FlashConfig { - self.flash_config - } - - /// Usable storage capacity in bytes: `(sector_count - 1)` sectors, the - /// last one reserved as the compaction spare. - pub fn capacity(&self) -> usize { - let header_len = align_up(SEQ_LEN, F::WRITE_SIZE); - (self.flash_config.sector_count - 1) * (self.flash_config.sector_size - header_len) - } -} - -// The borrow is held across await on purpose: the store is single-threaded -// and non-reentrant (see the module docs), and a reentrant call panics on the -// borrow instead of interleaving log writes. -#[allow(clippy::await_holding_refcell_ref)] -impl LocalKeyValueBackend for FlashKvStore { - fn config(&self) -> &StorageConfig { - &self.config - } - - async fn exists(&self, namespace: &str, key: &str) -> Result { - let inner = self.inner.borrow_mut(); - inner.ensure_opened()?; - let stored_ns = apply_prefix(&self.config, namespace); - match inner.index.namespaces.get(&stored_ns) { - Some(ns) => Ok(ns.keys.contains_key(key)), - None if self.config.auto_create_namespaces => Ok(false), - None => Err(StorageError::namespace_not_found(namespace)), - } - } - - async fn get(&self, namespace: &str, key: &str) -> Result> { - let inner = self.inner.borrow_mut(); - inner.ensure_opened()?; - let stored_ns = apply_prefix(&self.config, namespace); - match inner.index.namespaces.get(&stored_ns) { - Some(ns) => Ok(ns.keys.get(key).map(|v| Bytes::from(v.clone()))), - None if self.config.auto_create_namespaces => Ok(None), - None => Err(StorageError::namespace_not_found(namespace)), - } - } - - async fn put(&self, namespace: &str, key: &str, value: Bytes) -> Result<()> { - let mut inner = self.inner.borrow_mut(); - inner.ensure_opened()?; - let stored_ns = apply_prefix(&self.config, namespace); - if !inner.index.namespaces.contains_key(&stored_ns) { - if !self.config.auto_create_namespaces { - return Err(StorageError::namespace_not_found(namespace)); - } - inner.ensure_namespace(&stored_ns).await?; - } - let enc = inner.encode_record(TYPE_PUT, &stored_ns, key, &value)?; - inner.append_record(&enc).await?; - let ns = inner - .index - .namespaces - .get_mut(&stored_ns) - .expect("namespace ensured above"); - ns.keys.insert(key.to_owned(), value.to_vec()); - Ok(()) - } - - async fn delete(&self, namespace: &str, key: &str) -> Result<()> { - let mut inner = self.inner.borrow_mut(); - inner.ensure_opened()?; - let stored_ns = apply_prefix(&self.config, namespace); - let present = match inner.index.namespaces.get(&stored_ns) { - None => return Ok(()), - Some(ns) => ns.keys.contains_key(key), - }; - if !present { - return Ok(()); - } - let enc = inner.encode_record(TYPE_DELETE, &stored_ns, key, b"")?; - inner.append_record(&enc).await?; - let ns = inner - .index - .namespaces - .get_mut(&stored_ns) - .expect("namespace present above"); - ns.keys.remove(key); - Ok(()) - } - - async fn list_keys(&self, namespace: &str) -> Result> { - let inner = self.inner.borrow_mut(); - inner.ensure_opened()?; - let stored_ns = apply_prefix(&self.config, namespace); - match inner.index.namespaces.get(&stored_ns) { - Some(ns) => Ok(ns.keys.keys().cloned().collect()), - None if self.config.auto_create_namespaces => Ok(Vec::new()), - None => Err(StorageError::namespace_not_found(namespace)), - } - } - - async fn list_namespaces(&self) -> Result> { - let inner = self.inner.borrow_mut(); - inner.ensure_opened()?; - Ok(inner - .index - .namespaces - .keys() - .map(|ns| strip_prefix(&self.config, ns)) - .collect()) - } - - async fn create_namespace(&self, namespace: &str) -> Result<()> { - let mut inner = self.inner.borrow_mut(); - inner.ensure_opened()?; - let stored_ns = apply_prefix(&self.config, namespace); - if inner.index.namespaces.contains_key(&stored_ns) { - return Err(StorageError::namespace_already_exists(namespace)); - } - inner.ensure_namespace(&stored_ns).await?; - Ok(()) - } - - async fn delete_namespace(&self, namespace: &str) -> Result<()> { - let mut inner = self.inner.borrow_mut(); - inner.ensure_opened()?; - let stored_ns = apply_prefix(&self.config, namespace); - if !inner.index.namespaces.contains_key(&stored_ns) { - return Ok(()); - } - let enc = inner.encode_record(TYPE_DELETE_NAMESPACE, &stored_ns, "", b"")?; - inner.append_record(&enc).await?; - inner.index.namespaces.remove(&stored_ns); - Ok(()) - } - - async fn clear_namespace(&self, namespace: &str) -> Result<()> { - let mut inner = self.inner.borrow_mut(); - inner.ensure_opened()?; - let stored_ns = apply_prefix(&self.config, namespace); - let keys: Vec = match inner.index.namespaces.get(&stored_ns) { - Some(ns) => ns.keys.keys().cloned().collect(), - None => return Ok(()), - }; - for key in keys { - let enc = inner.encode_record(TYPE_DELETE, &stored_ns, &key, b"")?; - inner.append_record(&enc).await?; - let ns = inner - .index - .namespaces - .get_mut(&stored_ns) - .expect("namespace present above"); - ns.keys.remove(&key); - } - Ok(()) - } -} - -impl LocalStorageBackend for FlashKvStore { - fn supports_files(&self) -> bool { - false - } -} diff --git a/Build/crates/saikuro-storage/src/lib.rs b/Build/crates/saikuro-storage/src/lib.rs deleted file mode 100644 index a4ba9484..00000000 --- a/Build/crates/saikuro-storage/src/lib.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! Saikuro Storage Backend Abstraction -//! -//! Provides two storage tiers for key-value and file-like operations: -//! -//! - [`StorageBackend`] is the object-safe, `Send + Sync` host API used by -//! native adapters as `Box`. -//! - [`LocalStorageBackend`] and [`LocalKeyValueBackend`] use native async -//! functions for statically selected single-threaded and `no_std` backends. -//! -//! Browser storage implements the local tier because JavaScript handles and -//! their futures are thread-local. Local futures must be awaited on their -//! owning executor and must not be passed to `tokio::spawn`. -//! -//! The crate is `no_std` + `alloc` without the `std` feature: the config, -//! error, trait, and util modules compile for bare-metal MCU targets, and the -//! concrete backends (in-memory, native fs/sled/sqlite, wasm storage) require -//! `std`. - -#![cfg_attr(not(feature = "std"), no_std)] - -#[macro_use] -extern crate alloc; - -pub mod config; -pub mod error; -pub mod traits; -pub mod util; - -#[cfg(feature = "inmemory")] -pub mod inmemory; - -#[cfg(all(feature = "wasm-storage", target_arch = "wasm32"))] -pub mod fs_access; - -#[cfg(all(feature = "wasm-storage", target_arch = "wasm32"))] -pub mod indexeddb; - -#[cfg(all(feature = "wasm-storage", target_arch = "wasm32"))] -pub mod webstorage; - -#[cfg(all(feature = "wasm-storage", target_arch = "wasm32"))] -pub mod opfs; - -#[cfg(feature = "local-storage")] -pub mod local_storage; - -#[cfg(feature = "session-storage")] -pub mod session_storage; - -#[cfg(feature = "flash-storage")] -pub mod flash; - -/// Generates a web-storage-backed key-value backend. -/// -/// `$name` is the struct name (e.g., `LocalStorage`). -/// `$storage_fn` is the `Window` method to get the storage object -/// (e.g., `local_storage` or `session_storage`). -#[macro_export] -macro_rules! impl_web_storage { - ($name:ident, $storage_fn:ident) => { - use bytes::Bytes; - use $crate::traits::{LocalKeyValueBackend, LocalStorageBackend}; - - pub struct $name { - config: $crate::StorageConfig, - } - - impl $name { - pub fn new() -> Self { - Self { - config: $crate::StorageConfig::default(), - } - } - - pub fn with_config(config: $crate::StorageConfig) -> Self { - Self { config } - } - - fn storage(&self) -> $crate::error::Result { - let w = $crate::webstorage::window()?; - w.$storage_fn() - .map_err(|e| { - $crate::StorageError::internal(format!( - "failed to get {}: {e:?}", - stringify!($storage_fn) - )) - })? - .ok_or_else(|| { - $crate::StorageError::backend_not_available(stringify!($storage_fn)) - }) - } - } - - impl Default for $name { - fn default() -> Self { - Self::new() - } - } - - impl LocalKeyValueBackend for $name { - fn config(&self) -> &$crate::StorageConfig { - &self.config - } - - async fn exists(&self, namespace: &str, key: &str) -> $crate::error::Result { - let storage = self.storage()?; - let prefixed_ns = $crate::webstorage::apply_prefix(&self.config, namespace); - let full_key = $crate::webstorage::make_key(&prefixed_ns, key); - match $crate::webstorage::storage_get(&storage, &full_key)? { - Some(_) => Ok(true), - None => Ok(false), - } - } - - async fn get( - &self, - namespace: &str, - key: &str, - ) -> $crate::error::Result> { - let storage = self.storage()?; - let prefixed_ns = $crate::webstorage::apply_prefix(&self.config, namespace); - let full_key = $crate::webstorage::make_key(&prefixed_ns, key); - $crate::webstorage::storage_get(&storage, &full_key) - } - - async fn put( - &self, - namespace: &str, - key: &str, - value: Bytes, - ) -> $crate::error::Result<()> { - let storage = self.storage()?; - let prefixed_ns = $crate::webstorage::apply_prefix(&self.config, namespace); - let full_key = $crate::webstorage::make_key(&prefixed_ns, key); - $crate::webstorage::storage_set(&storage, &full_key, &value) - } - - async fn delete(&self, namespace: &str, key: &str) -> $crate::error::Result<()> { - let storage = self.storage()?; - let prefixed_ns = $crate::webstorage::apply_prefix(&self.config, namespace); - let full_key = $crate::webstorage::make_key(&prefixed_ns, key); - $crate::webstorage::storage_remove(&storage, &full_key); - Ok(()) - } - - async fn list_keys(&self, namespace: &str) -> $crate::error::Result> { - let storage = self.storage()?; - let prefixed_ns = $crate::webstorage::apply_prefix(&self.config, namespace); - Ok($crate::webstorage::get_keys_in_namespace( - &storage, - &prefixed_ns, - )) - } - - async fn list_namespaces(&self) -> $crate::error::Result> { - let storage = self.storage()?; - let raw = $crate::webstorage::get_namespaces(&storage); - let result: Vec = raw - .into_iter() - .map(|ns| $crate::webstorage::strip_prefix(&self.config, &ns)) - .collect(); - Ok(result) - } - - async fn create_namespace(&self, _namespace: &str) -> $crate::error::Result<()> { - Ok(()) - } - - async fn delete_namespace(&self, namespace: &str) -> $crate::error::Result<()> { - let storage = self.storage()?; - let prefixed_ns = $crate::webstorage::apply_prefix(&self.config, namespace); - let prefix = $crate::webstorage::key_prefix(&prefixed_ns); - $crate::webstorage::delete_keys_with_prefix(&storage, &prefix); - Ok(()) - } - - async fn clear_namespace(&self, namespace: &str) -> $crate::error::Result<()> { - self.delete_namespace(namespace).await - } - } - - impl LocalStorageBackend for $name { - fn supports_files(&self) -> bool { - false - } - } - }; -} - -pub use config::{BackendKind, CleanupPolicy, PersistenceMode, StorageConfig}; - -#[cfg(feature = "flash-storage")] -pub use config::FlashConfig; -pub use error::{Result, StorageError}; -pub use traits::{ - FileBackend, KeyValueBackend, KeyValueBackendExt, LocalFileBackend, LocalKeyValueBackend, - LocalStorageBackend, StorageBackend, -}; - -#[cfg(feature = "inmemory")] -pub use inmemory::InMemoryStorage; - -#[cfg(all(feature = "wasm-storage", target_arch = "wasm32"))] -pub use indexeddb::IndexedDbStorage; - -#[cfg(feature = "local-storage")] -pub use local_storage::LocalStorage; - -#[cfg(feature = "session-storage")] -pub use session_storage::SessionStorage; - -#[cfg(all(feature = "wasm-storage", target_arch = "wasm32"))] -pub use fs_access::FsAccessStorage; - -#[cfg(all(feature = "wasm-storage", target_arch = "wasm32"))] -pub use opfs::OpfsStorage; - -#[cfg(feature = "fs-storage")] -pub mod fs; - -#[cfg(feature = "sled-storage")] -pub mod sled; - -#[cfg(feature = "sqlite-storage")] -pub mod sqlite; - -#[cfg(feature = "fs-storage")] -pub use fs::FilesystemStorage; - -#[cfg(feature = "sled-storage")] -pub use sled::SledStorage; - -#[cfg(feature = "sqlite-storage")] -pub use sqlite::SqliteStorage; - -#[cfg(feature = "flash-storage")] -pub use flash::FlashKvStore; diff --git a/Build/crates/saikuro-storage/src/local_storage.rs b/Build/crates/saikuro-storage/src/local_storage.rs deleted file mode 100644 index a3f88f46..00000000 --- a/Build/crates/saikuro-storage/src/local_storage.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[cfg(all(target_arch = "wasm32", feature = "wasm-storage"))] -use crate::impl_web_storage; - -#[cfg(all(target_arch = "wasm32", feature = "wasm-storage"))] -impl_web_storage!(LocalStorage, local_storage); - -#[cfg(not(all(target_arch = "wasm32", feature = "wasm-storage")))] -pub use crate::InMemoryStorage as LocalStorage; diff --git a/Build/crates/saikuro-storage/src/session_storage.rs b/Build/crates/saikuro-storage/src/session_storage.rs deleted file mode 100644 index 308bcb60..00000000 --- a/Build/crates/saikuro-storage/src/session_storage.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[cfg(all(target_arch = "wasm32", feature = "wasm-storage"))] -use crate::impl_web_storage; - -#[cfg(all(target_arch = "wasm32", feature = "wasm-storage"))] -impl_web_storage!(SessionStorage, session_storage); - -#[cfg(not(all(target_arch = "wasm32", feature = "wasm-storage")))] -pub use crate::InMemoryStorage as SessionStorage; diff --git a/Build/crates/saikuro-storage/src/sqlite.rs b/Build/crates/saikuro-storage/src/sqlite.rs deleted file mode 100644 index 063dd76b..00000000 --- a/Build/crates/saikuro-storage/src/sqlite.rs +++ /dev/null @@ -1,258 +0,0 @@ -use async_trait::async_trait; -use bytes::Bytes; -use rusqlite::OptionalExtension; -use tokio::task::spawn_blocking; - -use super::{ - config::StorageConfig, - error::{Result, StorageError}, - traits::{KeyValueBackend, StorageBackend}, -}; - -/// Spawn blocking I/O, converting [`JoinError`] to [`StorageError`]. -async fn block(f: F) -> Result -where - F: FnOnce() -> Result + Send + 'static, - T: Send + 'static, -{ - spawn_blocking(f) - .await - .map_err(|e| StorageError::internal(format!("blocking task failed: {e}")))? -} - -const CREATE_KV: &str = " - CREATE TABLE IF NOT EXISTS saikuro_kv ( - namespace TEXT NOT NULL, - key TEXT NOT NULL, - value BLOB NOT NULL, - PRIMARY KEY (namespace, key) - ) -"; - -/// A SQLite-backed persistent key-value storage backend. -/// -/// Stores namespaced key-value pairs in a single table. All I/O is -/// dispatched to the blocking thread pool. -pub struct SqliteStorage { - config: StorageConfig, - conn: std::sync::Arc>, -} - -impl SqliteStorage { - /// Open or create a SQLite database at the given path. - pub fn new(path: impl AsRef) -> Result { - Self::with_config(path, StorageConfig::default()) - } - - /// Open or create a SQLite database with a custom configuration. - pub fn with_config(path: impl AsRef, config: StorageConfig) -> Result { - let conn = rusqlite::Connection::open(path) - .map_err(|e| StorageError::internal(format!("sqlite open: {e}")))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") - .map_err(|e| StorageError::internal(format!("sqlite pragma: {e}")))?; - conn.execute_batch(CREATE_KV) - .map_err(|e| StorageError::internal(format!("sqlite create table: {e}")))?; - Ok(Self { - config, - conn: std::sync::Arc::new(std::sync::Mutex::new(conn)), - }) - } - - /// Open an in-memory SQLite database (useful for testing). - pub fn temporary() -> Result { - let conn = rusqlite::Connection::open_in_memory() - .map_err(|e| StorageError::internal(format!("sqlite in-memory: {e}")))?; - conn.execute_batch(CREATE_KV) - .map_err(|e| StorageError::internal(format!("sqlite create table: {e}")))?; - Ok(Self { - config: StorageConfig::default(), - conn: std::sync::Arc::new(std::sync::Mutex::new(conn)), - }) - } - - fn apply_prefix(&self, namespace: &str) -> String { - match &self.config.namespace_prefix { - Some(prefix) => format!("{prefix}:{namespace}"), - None => namespace.to_owned(), - } - } - - #[allow(dead_code)] - fn strip_prefix(&self, stored: &str) -> String { - match &self.config.namespace_prefix { - Some(prefix) => { - let prefix_str = format!("{prefix}:"); - if stored.starts_with(&prefix_str) { - stored[prefix_str.len()..].to_owned() - } else { - stored.to_owned() - } - } - None => stored.to_owned(), - } - } -} - -#[async_trait] -impl KeyValueBackend for SqliteStorage { - fn config(&self) -> &StorageConfig { - &self.config - } - - async fn exists(&self, namespace: &str, key: &str) -> Result { - let conn = self.conn.clone(); - let ns = self.apply_prefix(namespace); - let key = key.to_owned(); - block(move || { - let conn = conn - .lock() - .map_err(|e| StorageError::internal(format!("mutex poisoned: {e}")))?; - let mut stmt = conn - .prepare_cached("SELECT 1 FROM saikuro_kv WHERE namespace = ?1 AND key = ?2") - .map_err(|e| StorageError::internal(format!("sqlite prepare: {e}")))?; - let exists = stmt - .exists(rusqlite::params![ns, key]) - .map_err(|e| StorageError::internal(format!("sqlite exists: {e}")))?; - Ok(exists) - }) - .await - } - - async fn get(&self, namespace: &str, key: &str) -> Result> { - let conn = self.conn.clone(); - let ns = self.apply_prefix(namespace); - let key = key.to_owned(); - block(move || { - let conn = conn - .lock() - .map_err(|e| StorageError::internal(format!("mutex poisoned: {e}")))?; - let mut stmt = conn - .prepare_cached("SELECT value FROM saikuro_kv WHERE namespace = ?1 AND key = ?2") - .map_err(|e| StorageError::internal(format!("sqlite prepare: {e}")))?; - let result: Option> = stmt - .query_row(rusqlite::params![ns, key], |row| row.get(0)) - .optional() - .map_err(|e| StorageError::internal(format!("sqlite query: {e}")))?; - Ok(result.map(Bytes::from)) - }) - .await - } - - async fn put(&self, namespace: &str, key: &str, value: Bytes) -> Result<()> { - let conn = self.conn.clone(); - let ns = self.apply_prefix(namespace); - let key = key.to_owned(); - let val = value.to_vec(); - block(move || { - let conn = conn - .lock() - .map_err(|e| StorageError::internal(format!("mutex poisoned: {e}")))?; - conn.execute( - "INSERT INTO saikuro_kv (namespace, key, value) VALUES (?1, ?2, ?3) - ON CONFLICT(namespace, key) DO UPDATE SET value = excluded.value", - rusqlite::params![ns, key, val], - ) - .map_err(|e| StorageError::internal(format!("sqlite insert: {e}")))?; - Ok(()) - }) - .await - } - - async fn delete(&self, namespace: &str, key: &str) -> Result<()> { - let conn = self.conn.clone(); - let ns = self.apply_prefix(namespace); - let key = key.to_owned(); - block(move || { - let conn = conn - .lock() - .map_err(|e| StorageError::internal(format!("mutex poisoned: {e}")))?; - conn.execute( - "DELETE FROM saikuro_kv WHERE namespace = ?1 AND key = ?2", - rusqlite::params![ns, key], - ) - .map_err(|e| StorageError::internal(format!("sqlite delete: {e}")))?; - Ok(()) - }) - .await - } - - async fn list_keys(&self, namespace: &str) -> Result> { - let conn = self.conn.clone(); - let ns = self.apply_prefix(namespace); - block(move || { - let conn = conn - .lock() - .map_err(|e| StorageError::internal(format!("mutex poisoned: {e}")))?; - let mut stmt = conn - .prepare_cached("SELECT key FROM saikuro_kv WHERE namespace = ?1 ORDER BY key") - .map_err(|e| StorageError::internal(format!("sqlite prepare: {e}")))?; - let keys: Vec = stmt - .query_map(rusqlite::params![ns], |row| row.get(0)) - .map_err(|e| StorageError::internal(format!("sqlite query_map: {e}")))? - .filter_map(|r| r.ok()) - .collect(); - Ok(keys) - }) - .await - } - - async fn list_namespaces(&self) -> Result> { - let conn = self.conn.clone(); - let prefix = self.config.namespace_prefix.clone(); - block(move || { - let conn = conn - .lock() - .map_err(|e| StorageError::internal(format!("mutex poisoned: {e}")))?; - let mut stmt = conn - .prepare_cached("SELECT DISTINCT namespace FROM saikuro_kv ORDER BY namespace") - .map_err(|e| StorageError::internal(format!("sqlite prepare: {e}")))?; - let names: Vec = stmt - .query_map([], |row| row.get(0)) - .map_err(|e| StorageError::internal(format!("sqlite query_map: {e}")))? - .filter_map(|r| r.ok()) - .filter(|n: &String| match &prefix { - Some(p) => n.starts_with(&format!("{p}:")), - None => true, - }) - .map(|n: String| match &prefix { - Some(p) => n[format!("{p}:").len()..].to_owned(), - None => n, - }) - .collect(); - Ok(names) - }) - .await - } - - async fn create_namespace(&self, _namespace: &str) -> Result<()> { - Ok(()) - } - - async fn delete_namespace(&self, namespace: &str) -> Result<()> { - let conn = self.conn.clone(); - let ns = self.apply_prefix(namespace); - block(move || { - let conn = conn - .lock() - .map_err(|e| StorageError::internal(format!("mutex poisoned: {e}")))?; - conn.execute( - "DELETE FROM saikuro_kv WHERE namespace = ?1", - rusqlite::params![ns], - ) - .map_err(|e| StorageError::internal(format!("sqlite delete namespace: {e}")))?; - Ok(()) - }) - .await - } - - async fn clear_namespace(&self, namespace: &str) -> Result<()> { - self.delete_namespace(namespace).await - } -} - -#[async_trait] -impl StorageBackend for SqliteStorage { - fn supports_files(&self) -> bool { - false - } -} diff --git a/Build/crates/saikuro-storage/src/traits.rs b/Build/crates/saikuro-storage/src/traits.rs deleted file mode 100644 index 9ecab25b..00000000 --- a/Build/crates/saikuro-storage/src/traits.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! Storage backend traits and utilities. - -use alloc::boxed::Box; -use alloc::string::String; -use alloc::string::ToString; -use alloc::vec::Vec; -use bytes::Bytes; -use serde::{de::DeserializeOwned, Serialize}; - -use super::{config::StorageConfig, error::Result}; - -/// A key-value storage interface with namespace support. -#[async_trait::async_trait] -pub trait KeyValueBackend: Send + Sync + 'static { - /// Get the configuration for this backend. - fn config(&self) -> &StorageConfig; - - /// Check if a key exists in a namespace. - async fn exists(&self, namespace: &str, key: &str) -> Result; - - /// Get raw bytes for a key. - async fn get(&self, namespace: &str, key: &str) -> Result>; - - /// Put raw bytes for a key. - async fn put(&self, namespace: &str, key: &str, value: Bytes) -> Result<()>; - - /// Delete a key. - async fn delete(&self, namespace: &str, key: &str) -> Result<()>; - - /// List all keys in a namespace. - async fn list_keys(&self, namespace: &str) -> Result>; - - /// List all namespaces. - async fn list_namespaces(&self) -> Result>; - - /// Create a namespace explicitly. - async fn create_namespace(&self, namespace: &str) -> Result<()>; - - /// Delete a namespace and all its keys. - async fn delete_namespace(&self, namespace: &str) -> Result<()>; - - /// Clear all keys in a namespace without deleting the namespace. - async fn clear_namespace(&self, namespace: &str) -> Result<()>; -} - -/// A file-like storage interface for hierarchical storage. -#[async_trait::async_trait] -pub trait FileBackend: Send + Sync + 'static { - /// Read a file's contents. - async fn read_file(&self, path: &str) -> Result; - - /// Write a file's contents, creating it if it doesn't exist. - async fn write_file(&self, path: &str, content: Bytes) -> Result<()>; - - /// Append content to an existing file. - async fn append_file(&self, path: &str, content: Bytes) -> Result<()>; - - /// Delete a file. - async fn delete_file(&self, path: &str) -> Result<()>; - - /// Check if a file exists. - async fn file_exists(&self, path: &str) -> Result; - - /// List files in a directory. - async fn list_dir(&self, path: &str) -> Result>; - - /// Create a directory. - async fn create_dir(&self, path: &str) -> Result<()>; - - /// Delete a directory and all its contents. - async fn delete_dir(&self, path: &str) -> Result<()>; -} - -/// Unified storage backend trait combining key-value and file operations. -#[async_trait::async_trait] -pub trait StorageBackend: KeyValueBackend { - /// Check if this backend supports file operations. - fn supports_files(&self) -> bool; - - /// Get the file backend, if supported. - fn as_file_backend(&self) -> Option<&dyn FileBackend> { - None - } - - /// Flush any pending writes to durable storage. - async fn flush(&self) -> Result<()> { - Ok(()) - } - - /// Close the backend and release any resources. - async fn close(&self) -> Result<()> { - Ok(()) - } -} - -/// Extension methods for KeyValueBackend providing JSON serialization. -#[async_trait::async_trait] -pub trait KeyValueBackendExt: KeyValueBackend { - /// Get a JSON-serialized value. - async fn get_json(&self, namespace: &str, key: &str) -> Result> { - match self.get(namespace, key).await? { - Some(bytes) => { - let value = serde_json::from_slice(&bytes) - .map_err(|e| super::error::StorageError::deserialization(e.to_string()))?; - Ok(Some(value)) - } - None => Ok(None), - } - } - - /// Put a JSON-serialized value. - async fn put_json( - &self, - namespace: &str, - key: &str, - value: &T, - ) -> Result<()> { - let bytes = serde_json::to_vec(value) - .map_err(|e| super::error::StorageError::serialization(e.to_string()))?; - self.put(namespace, key, Bytes::from(bytes)).await - } - - /// Get a MessagePack-serialized value. - async fn get_msgpack( - &self, - namespace: &str, - key: &str, - ) -> Result> { - match self.get(namespace, key).await? { - Some(bytes) => { - let value = saikuro_core::msgpack::from_slice(&bytes) - .map_err(|e| super::error::StorageError::deserialization(e.to_string()))?; - Ok(Some(value)) - } - None => Ok(None), - } - } - - /// Put a MessagePack-serialized value. - async fn put_msgpack( - &self, - namespace: &str, - key: &str, - value: &T, - ) -> Result<()> { - let bytes = saikuro_core::msgpack::to_vec(value) - .map_err(|e| super::error::StorageError::serialization(e.to_string()))?; - self.put(namespace, key, Bytes::from(bytes)).await - } -} - -impl KeyValueBackendExt for B {} - -/// A key-value backend for single-threaded runtimes. -/// -/// Unlike [`KeyValueBackend`], this trait uses native async functions and does -/// not require `Send` or `Sync`. Implementations must be used directly or -/// behind a statically selected generic type; they cannot be erased into a -/// host `Box`. -#[allow(async_fn_in_trait)] -pub trait LocalKeyValueBackend: 'static { - /// Get the configuration for this backend. - fn config(&self) -> &StorageConfig; - - /// Check if a key exists in a namespace. - async fn exists(&self, namespace: &str, key: &str) -> Result; - /// Get raw bytes for a key. - async fn get(&self, namespace: &str, key: &str) -> Result>; - /// Put raw bytes for a key. - async fn put(&self, namespace: &str, key: &str, value: Bytes) -> Result<()>; - /// Delete a key. - async fn delete(&self, namespace: &str, key: &str) -> Result<()>; - /// List all keys in a namespace. - async fn list_keys(&self, namespace: &str) -> Result>; - /// List all namespaces. - async fn list_namespaces(&self) -> Result>; - /// Create a namespace explicitly. - async fn create_namespace(&self, namespace: &str) -> Result<()>; - /// Delete a namespace and all its keys. - async fn delete_namespace(&self, namespace: &str) -> Result<()>; - /// Clear all keys in a namespace without deleting the namespace. - async fn clear_namespace(&self, namespace: &str) -> Result<()>; -} - -/// A file backend for single-threaded runtimes. -#[allow(async_fn_in_trait)] -pub trait LocalFileBackend: 'static { - /// Read a file's contents. - async fn read_file(&self, path: &str) -> Result; - /// Write a file's contents. - async fn write_file(&self, path: &str, content: Bytes) -> Result<()>; - /// Append content to an existing file. - async fn append_file(&self, path: &str, content: Bytes) -> Result<()>; - /// Delete a file. - async fn delete_file(&self, path: &str) -> Result<()>; - /// Check if a file exists. - async fn file_exists(&self, path: &str) -> Result; - /// List files in a directory. - async fn list_dir(&self, path: &str) -> Result>; - /// Create a directory. - async fn create_dir(&self, path: &str) -> Result<()>; - /// Delete a directory and its contents. - async fn delete_dir(&self, path: &str) -> Result<()>; -} - -/// A single-threaded storage backend combining key-value and file operations. -#[allow(async_fn_in_trait)] -pub trait LocalStorageBackend: LocalKeyValueBackend { - /// Check if this backend supports file operations. - fn supports_files(&self) -> bool; - /// Flush pending writes. - async fn flush(&self) -> Result<()> { - Ok(()) - } - /// Release backend resources. - async fn close(&self) -> Result<()> { - Ok(()) - } -} diff --git a/Build/crates/saikuro-storage/wasi/mod.rs b/Build/crates/saikuro-storage/wasi/mod.rs new file mode 100644 index 00000000..499a7a85 --- /dev/null +++ b/Build/crates/saikuro-storage/wasi/mod.rs @@ -0,0 +1,10 @@ +#[cfg(feature = "wasi-preview1")] +mod preview1; +#[cfg(feature = "wasi-component")] +mod preview2; + +#[cfg(feature = "wasi-preview1")] +pub use preview1::{WasiFileStore, WasiKvStore}; + +#[cfg(feature = "wasi-component")] +pub use preview2::{WasiFileStore, WasiKvStore}; diff --git a/Build/crates/saikuro-storage/wasi/preview1.rs b/Build/crates/saikuro-storage/wasi/preview1.rs new file mode 100644 index 00000000..aa5f6e1a --- /dev/null +++ b/Build/crates/saikuro-storage/wasi/preview1.rs @@ -0,0 +1,437 @@ +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; + +use bytes::Bytes; + +use crate::shared::config::StorageConfig; +use crate::shared::traits::{FileBackend, KeyValueBackend, StorageBackend}; +use saikuro_event::{Result, SaikuroError}; + +/// First preopened directory, by WASI preview1 convention. +const PREOPEN_FD: i32 = 3; + +const OFLAG_CREAT: i32 = 1 << 0; +const OFLAG_DIR: i32 = 1 << 1; +const OFLAG_TRUNC: i32 = 1 << 3; +const WHENCE_SET: i32 = 0; + +const KV_ROOT: &str = "saikuro_kv"; + +#[link(wasm_import_module = "wasi_snapshot_preview1")] +extern "C" { + fn path_open( + dirfd: i32, + dirflags: i32, + path: *const u8, + path_len: i32, + oflags: i32, + fs_rights_base: i64, + fs_rights_inheriting: i64, + fdflags: i32, + opened_fd: *mut i32, + ) -> i32; + fn fd_close(fd: i32) -> i32; + fn fd_read(fd: i32, iovs: *const Iovec, iovs_len: i32, nread: *mut i32) -> i32; + fn fd_write(fd: i32, iovs: *const Iovec, iovs_len: i32, nwritten: *mut i32) -> i32; + fn fd_seek(fd: i32, offset: i64, whence: i32, newoffset: *mut i64) -> i32; + fn path_unlink_file(dirfd: i32, path: *const u8, path_len: i32) -> i32; + fn path_create_directory(dirfd: i32, path: *const u8, path_len: i32) -> i32; + fn path_remove_directory(dirfd: i32, path: *const u8, path_len: i32) -> i32; + fn fd_readdir(fd: i32, buf: *mut u8, buf_len: usize, cookie: i64, bufused: *mut usize) -> i32; +} + +#[repr(C)] +struct Iovec { + buf: *mut u8, + buf_len: usize, +} + +fn wasi_err(code: i32) -> SaikuroError { + SaikuroError::io(format!("wasi_snapshot_preview1 error code {code}")) +} + +fn open_file(path: &str, create: bool, directory: bool) -> Result { + let bytes = path.as_bytes(); + let mut fd = 0i32; + let mut oflags = 0i32; + if create { + oflags |= OFLAG_CREAT; + } + if directory { + oflags |= OFLAG_DIR; + } else if create { + oflags |= OFLAG_TRUNC; + } + // SAFETY: `bytes` and `fd` outlive the call; the host writes `fd` on success. + let rc = unsafe { + path_open( + PREOPEN_FD, + 0, + bytes.as_ptr(), + bytes.len() as i32, + oflags, + u64::MAX, + u64::MAX, + 0, + &mut fd, + ) + }; + if rc != 0 { + return Err(wasi_err(rc)); + } + Ok(fd) +} + +fn read_all(fd: i32) -> Result> { + let mut newoff = 0i64; + // SAFETY: `newoff` outlives the call. + unsafe { + fd_seek(fd, 0, WHENCE_SET, &mut newoff); + } + let mut out = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + let iov = Iovec { + buf: buf.as_mut_ptr(), + buf_len: buf.len(), + }; + let mut nread = 0i32; + // SAFETY: `buf` and `nread` outlive the call. + let rc = unsafe { fd_read(fd, &iov as *const Iovec, 1, &mut nread) }; + if rc != 0 { + return Err(wasi_err(rc)); + } + if nread == 0 { + break; + } + out.extend_from_slice(&buf[..nread as usize]); + } + Ok(out) +} + +fn write_all(fd: i32, data: &[u8]) -> Result<()> { + let mut written = 0usize; + while written < data.len() { + let iov = Iovec { + buf: data[written..].as_ptr() as *mut u8, + buf_len: data.len() - written, + }; + let mut nwritten = 0i32; + // SAFETY: `data` outlives the call. + let rc = unsafe { fd_write(fd, &iov as *const Iovec, 1, &mut nwritten) }; + if rc != 0 { + return Err(wasi_err(rc)); + } + if nwritten == 0 { + return Err(SaikuroError::io("wasi write made no progress")); + } + written += nwritten as usize; + } + Ok(()) +} + +fn close(fd: i32) { + // SAFETY: `fd` is a valid descriptor previously returned by `path_open`. + unsafe { + fd_close(fd); + } +} + +fn read_dir_names(fd: i32) -> Result> { + let mut out = Vec::new(); + let mut cookie: i64 = 0; + let mut buf = vec![0u8; 8192]; + loop { + let mut bufused: usize = 0; + // SAFETY: `buf` and `bufused` outlive the call. + let rc = unsafe { fd_readdir(fd, buf.as_mut_ptr(), buf.len(), cookie, &mut bufused) }; + if rc != 0 { + return Err(wasi_err(rc)); + } + if bufused == 0 { + break; + } + let mut off = 0usize; + let mut last_next: u64 = 0; + while off + 24 <= bufused { + let d_next = u64::from_le_bytes(buf[off..off + 8].try_into().unwrap()); + let namlen = u32::from_le_bytes(buf[off + 16..off + 20].try_into().unwrap()) as usize; + let name_start = off + 24; + let name_end = name_start + namlen; + if name_end > bufused { + break; + } + let name = core::str::from_utf8(&buf[name_start..name_end]).unwrap_or(""); + if name != "." && name != ".." { + out.push(String::from(name)); + } + last_next = d_next; + off = (name_end + 7) & !7; + } + if off == 0 || last_next == 0 { + break; + } + cookie = last_next as i64; + if bufused < buf.len() { + break; + } + } + Ok(out) +} + +fn unlink(path: &str) -> Result<()> { + let bytes = path.as_bytes(); + // SAFETY: `bytes` outlive the call. + let rc = unsafe { path_unlink_file(PREOPEN_FD, bytes.as_ptr(), bytes.len() as i32) }; + if rc != 0 { + return Err(wasi_err(rc)); + } + Ok(()) +} + +fn mkdir(path: &str) -> Result<()> { + let bytes = path.as_bytes(); + // SAFETY: `bytes` outlive the call. + let rc = unsafe { path_create_directory(PREOPEN_FD, bytes.as_ptr(), bytes.len() as i32) }; + if rc != 0 { + return Err(wasi_err(rc)); + } + Ok(()) +} + +fn rmdir(path: &str) -> Result<()> { + let bytes = path.as_bytes(); + // SAFETY: `bytes` outlive the call. + let rc = unsafe { path_remove_directory(PREOPEN_FD, bytes.as_ptr(), bytes.len() as i32) }; + if rc != 0 { + return Err(wasi_err(rc)); + } + Ok(()) +} + +fn ns_dir(config: &StorageConfig, namespace: &str) -> String { + match &config.namespace_prefix { + Some(prefix) => format!("{KV_ROOT}/{prefix}:{namespace}"), + None => format!("{KV_ROOT}/{namespace}"), + } +} + +fn kv_path(config: &StorageConfig, namespace: &str, key: &str) -> String { + format!("{}/{}", ns_dir(config, namespace), key) +} + +/// Key-value storage backed by files under the preopened directory. +pub struct WasiKvStore { + config: StorageConfig, +} + +impl WasiKvStore { + /// Create a key-value store using the default configuration. + pub fn new() -> Self { + Self { + config: StorageConfig::default(), + } + } + + /// Create a key-value store with an explicit configuration. + pub fn with_config(config: StorageConfig) -> Self { + Self { config } + } +} + +impl Default for WasiKvStore { + fn default() -> Self { + Self::new() + } +} + +impl KeyValueBackend for WasiKvStore { + fn config(&self) -> &StorageConfig { + &self.config + } + + async fn exists(&self, namespace: &str, key: &str) -> Result { + match open_file(&kv_path(self.config(), namespace, key), false, false) { + Ok(fd) => { + close(fd); + Ok(true) + } + Err(_) => Ok(false), + } + } + + async fn get(&self, namespace: &str, key: &str) -> Result> { + let fd = match open_file(&kv_path(self.config(), namespace, key), false, false) { + Ok(fd) => fd, + Err(_) => return Ok(None), + }; + let data = read_all(fd)?; + close(fd); + Ok(Some(Bytes::from(data))) + } + + async fn put(&self, namespace: &str, key: &str, value: Bytes) -> Result<()> { + let dir = ns_dir(self.config(), namespace); + // Best-effort: ensure the namespace directory exists. + let _ = mkdir(&dir); + let fd = open_file(&kv_path(self.config(), namespace, key), true, false)?; + let res = write_all(fd, &value); + close(fd); + res + } + + async fn delete(&self, namespace: &str, key: &str) -> Result<()> { + unlink(&kv_path(self.config(), namespace, key)) + } + + async fn list_keys(&self, namespace: &str) -> Result> { + let dir = ns_dir(self.config(), namespace); + let fd = match open_file(&dir, false, true) { + Ok(fd) => fd, + Err(_) => return Ok(Vec::new()), + }; + let names = read_dir_names(fd); + close(fd); + names + } + + async fn list_namespaces(&self) -> Result> { + let fd = match open_file(KV_ROOT, false, true) { + Ok(fd) => fd, + Err(_) => return Ok(Vec::new()), + }; + let names = read_dir_names(fd); + close(fd); + let prefix = self.config().namespace_prefix.clone(); + Ok(names + .map(|names| { + names + .into_iter() + .filter(|n| match &prefix { + Some(p) => n.starts_with(&format!("{p}:")), + None => true, + }) + .map(|n| match &prefix { + Some(p) => n[(format!("{p}:").len())..].to_string(), + None => n, + }) + .collect() + }) + .unwrap_or_default()) + } + + async fn create_namespace(&self, namespace: &str) -> Result<()> { + mkdir(&ns_dir(self.config(), namespace)) + } + + async fn delete_namespace(&self, namespace: &str) -> Result<()> { + rmdir(&ns_dir(self.config(), namespace)) + } + + async fn clear_namespace(&self, namespace: &str) -> Result<()> { + self.delete_namespace(namespace).await + } +} + +impl StorageBackend for WasiKvStore { + fn supports_files(&self) -> bool { + false + } +} + +/// File storage backed by files under the preopened directory. +pub struct WasiFileStore { + config: StorageConfig, +} + +impl WasiFileStore { + /// Create a file store using the default configuration. + pub fn new() -> Self { + Self { + config: StorageConfig::default(), + } + } + + /// Create a file store with an explicit configuration. + pub fn with_config(config: StorageConfig) -> Self { + Self { config } + } +} + +impl Default for WasiFileStore { + fn default() -> Self { + Self::new() + } +} + +impl FileBackend for WasiFileStore { + async fn read_file(&self, path: &str) -> Result { + let fd = open_file(path, false, false)?; + let data = read_all(fd); + close(fd); + data.map(Bytes::from) + } + + async fn write_file(&self, path: &str, content: Bytes) -> Result<()> { + let fd = open_file(path, true, false)?; + let res = write_all(fd, &content); + close(fd); + res + } + + async fn append_file(&self, path: &str, content: Bytes) -> Result<()> { + let fd = open_file(path, true, false)?; + let existing = read_all(fd); + close(fd); + let existing = existing?; + let mut merged = existing; + merged.extend_from_slice(&content); + let fd = open_file(path, true, false)?; + let res = write_all(fd, &merged); + close(fd); + res + } + + async fn delete_file(&self, path: &str) -> Result<()> { + unlink(path) + } + + async fn file_exists(&self, path: &str) -> Result { + match open_file(path, false, false) { + Ok(fd) => { + close(fd); + Ok(true) + } + Err(_) => Ok(false), + } + } + + async fn list_dir(&self, path: &str) -> Result> { + let fd = match open_file(path, false, true) { + Ok(fd) => fd, + Err(_) => return Ok(Vec::new()), + }; + let names = read_dir_names(fd); + close(fd); + names + } + + async fn create_dir(&self, path: &str) -> Result<()> { + mkdir(path) + } + + async fn delete_dir(&self, path: &str) -> Result<()> { + rmdir(path) + } +} + +impl StorageBackend for WasiFileStore { + fn supports_files(&self) -> bool { + true + } + + fn as_file_backend(&self) -> Option<&dyn FileBackend> { + Some(self) + } +} diff --git a/Build/crates/saikuro-storage/wasi/preview2.rs b/Build/crates/saikuro-storage/wasi/preview2.rs new file mode 100644 index 00000000..0923273d --- /dev/null +++ b/Build/crates/saikuro-storage/wasi/preview2.rs @@ -0,0 +1,298 @@ +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use bytes::Bytes; + +use wasi::filesystem::{ + self, Descriptor, DescriptorFlags, DirectoryEntry, Error as FsError, OpenFlags, PathFlags, +}; +use wasi::io::streams::{InputStream, OutputStream, StreamError}; + +use crate::shared::config::StorageConfig; +use crate::shared::traits::{FileBackend, KeyValueBackend, StorageBackend}; +use saikuro_event::{Result, SaikuroError}; + +mod bindings { + wit_bindgen::generate!({ + world: "saikuro-kv", + path: "wasi/wit", + }); +} + +use bindings::wasi::keyvalue::store as kv; + +/// Bucket identifier prefix, keeping Saikuro namespaces isolated from other +/// key-value tenants on the same host. +const BUCKET_PREFIX: &str = "saikuro"; + +fn bucket_for(namespace: &str) -> Result { + let id = format!("{BUCKET_PREFIX}-{namespace}"); + kv::open(&id).map_err(map_kv_err) +} + +fn map_kv_err(e: kv::Error) -> SaikuroError { + SaikuroError::backend_unavailable(format!("wasi:keyvalue: {e:?}")) +} + +fn map_fs_err(e: FsError) -> SaikuroError { + SaikuroError::backend_unavailable(format!("wasi:filesystem: {e:?}")) +} + +fn map_stream_err(e: StreamError) -> SaikuroError { + SaikuroError::io(format!("wasi:streams: {e:?}")) +} + +/// Key-value storage backed by the host `wasi:keyvalue` store. +pub struct WasiKvStore { + config: StorageConfig, +} + +impl WasiKvStore { + /// Create a key-value store using the default configuration. + pub fn new() -> Self { + Self { + config: StorageConfig::default(), + } + } + + /// Create a key-value store with an explicit configuration. + pub fn with_config(config: StorageConfig) -> Self { + Self { config } + } + + fn ns(&self, namespace: &str) -> String { + match &self.config.namespace_prefix { + Some(prefix) => format!("{prefix}:{namespace}"), + None => namespace.to_string(), + } + } +} + +impl Default for WasiKvStore { + fn default() -> Self { + Self::new() + } +} + +impl KeyValueBackend for WasiKvStore { + fn config(&self) -> &StorageConfig { + &self.config + } + + async fn exists(&self, namespace: &str, key: &str) -> Result { + let bucket = bucket_for(&self.ns(namespace))?; + bucket.exists(key).map_err(map_kv_err) + } + + async fn get(&self, namespace: &str, key: &str) -> Result> { + let bucket = bucket_for(&self.ns(namespace))?; + match bucket.get(key).map_err(map_kv_err)? { + Some(bytes) => Ok(Some(Bytes::from(bytes))), + None => Ok(None), + } + } + + async fn put(&self, namespace: &str, key: &str, value: Bytes) -> Result<()> { + let bucket = bucket_for(&self.ns(namespace))?; + bucket.set(key, &value.to_vec()).map_err(map_kv_err) + } + + async fn delete(&self, namespace: &str, key: &str) -> Result<()> { + let bucket = bucket_for(&self.ns(namespace))?; + bucket.delete(key).map_err(map_kv_err) + } + + async fn list_keys(&self, namespace: &str) -> Result> { + let bucket = bucket_for(&self.ns(namespace))?; + let mut out = Vec::new(); + let mut cursor = None; + loop { + let resp = bucket.list_keys(cursor.clone()).map_err(map_kv_err)?; + out.extend(resp.keys); + match resp.cursor { + Some(next) => cursor = Some(next), + None => break, + } + } + Ok(out) + } + + async fn list_namespaces(&self) -> Result> { + // `wasi:keyvalue` exposes no bucket enumeration, so namespaces are not + // enumerable through this backend. Callers that need the set of + // namespaces must track them client-side. + Ok(Vec::new()) + } + + async fn create_namespace(&self, _namespace: &str) -> Result<()> { + Ok(()) + } + + async fn delete_namespace(&self, _namespace: &str) -> Result<()> { + Ok(()) + } + + async fn clear_namespace(&self, _namespace: &str) -> Result<()> { + Ok(()) + } +} + +impl StorageBackend for WasiKvStore { + fn supports_files(&self) -> bool { + false + } +} + +/// File storage backed by the host `wasi:filesystem` interface. +pub struct WasiFileStore { + config: StorageConfig, +} + +impl WasiFileStore { + /// Create a file store using the default configuration. + pub fn new() -> Self { + Self { + config: StorageConfig::default(), + } + } + + /// Create a file store with an explicit configuration. + pub fn with_config(config: StorageConfig) -> Self { + Self { config } + } + + /// The first preopened directory is the store root. + fn root(&self) -> Result { + let (descriptors, _) = filesystem::preopens().map_err(map_fs_err)?; + descriptors + .into_iter() + .next() + .ok_or_else(|| SaikuroError::backend_unavailable("wasi:filesystem has no preopened directory")) + } +} + +impl Default for WasiFileStore { + fn default() -> Self { + Self::new() + } +} + +impl FileBackend for WasiFileStore { + async fn read_file(&self, path: &str) -> Result { + let root = self.root()?; + let desc = filesystem::open_at( + &root, + PathFlags::default(), + path, + OpenFlags::empty(), + DescriptorFlags::READ, + ) + .map_err(map_fs_err)?; + let stream: InputStream = desc.read_via_stream(0).map_err(map_fs_err)?; + let mut out = Vec::new(); + loop { + match stream.read(4096).map_err(map_stream_err)? { + chunk if chunk.is_empty() => break, + chunk => out.extend_from_slice(&chunk), + } + } + Ok(Bytes::from(out)) + } + + async fn write_file(&self, path: &str, content: Bytes) -> Result<()> { + let root = self.root()?; + let desc = filesystem::open_at( + &root, + PathFlags::default(), + path, + OpenFlags::CREATE | OpenFlags::TRUNCATE, + DescriptorFlags::READ | DescriptorFlags::WRITE, + ) + .map_err(map_fs_err)?; + let stream: OutputStream = desc.write_via_stream(0).map_err(map_fs_err)?; + stream.write(&content).map_err(map_stream_err)?; + stream.flush().map_err(map_stream_err) + } + + async fn append_file(&self, path: &str, content: Bytes) -> Result<()> { + let root = self.root()?; + let desc = filesystem::open_at( + &root, + PathFlags::default(), + path, + OpenFlags::CREATE, + DescriptorFlags::READ | DescriptorFlags::WRITE, + ) + .map_err(map_fs_err)?; + let stream: OutputStream = desc.append_via_stream().map_err(map_fs_err)?; + stream.write(&content).map_err(map_stream_err)?; + stream.flush().map_err(map_stream_err) + } + + async fn delete_file(&self, path: &str) -> Result<()> { + let root = self.root()?; + root.unlink_file_at(path).map_err(map_fs_err) + } + + async fn file_exists(&self, path: &str) -> Result { + let root = self.root()?; + match filesystem::open_at( + &root, + PathFlags::default(), + path, + OpenFlags::empty(), + DescriptorFlags::READ, + ) { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } + } + + async fn list_dir(&self, path: &str) -> Result> { + let root = self.root()?; + let dir = filesystem::open_at( + &root, + PathFlags::default(), + path, + OpenFlags::DIRECTORY, + DescriptorFlags::READ, + ) + .map_err(map_fs_err)?; + let mut out = Vec::new(); + let mut cookie = 0u64; + loop { + let entries: Vec = dir.readdir(0, cookie, 4096).map_err(map_fs_err)?; + if entries.is_empty() { + break; + } + for entry in entries { + if entry.name == "." || entry.name == ".." { + continue; + } + out.push(entry.name); + } + cookie += entries.len() as u64; + } + Ok(out) + } + + async fn create_dir(&self, path: &str) -> Result<()> { + let root = self.root()?; + root.create_directory_at(path).map_err(map_fs_err) + } + + async fn delete_dir(&self, path: &str) -> Result<()> { + let root = self.root()?; + root.remove_directory_at(path).map_err(map_fs_err) + } +} + +impl StorageBackend for WasiFileStore { + fn supports_files(&self) -> bool { + true + } + + fn as_file_backend(&self) -> Option<&dyn FileBackend> { + Some(self) + } +} diff --git a/Build/crates/saikuro-storage/wasi/wit/deps/wasi-keyvalue.wit b/Build/crates/saikuro-storage/wasi/wit/deps/wasi-keyvalue.wit new file mode 100644 index 00000000..dbc7fa71 --- /dev/null +++ b/Build/crates/saikuro-storage/wasi/wit/deps/wasi-keyvalue.wit @@ -0,0 +1,24 @@ +package wasi:keyvalue@0.2.0-draft2; + +interface store { + variant error { + no-such-store, + access-denied, + other(string), + } + + record key-response { + keys: list, + cursor: option, + } + + open: func(identifier: string) -> result; + + resource bucket { + get: func(key: string) -> result>, error>; + set: func(key: string, value: list) -> result<_, error>; + delete: func(key: string) -> result<_, error>; + exists: func(key: string) -> result; + list-keys: func(cursor: option) -> result; + } +} diff --git a/Build/crates/saikuro-storage/wasi/wit/world.wit b/Build/crates/saikuro-storage/wasi/wit/world.wit new file mode 100644 index 00000000..6555ce54 --- /dev/null +++ b/Build/crates/saikuro-storage/wasi/wit/world.wit @@ -0,0 +1,5 @@ +package saikuro:bindings@0.1.0; + +world saikuro-kv { + import wasi:keyvalue/store@0.2.0-draft2; +} diff --git a/Build/crates/saikuro-storage/src/fs_access.rs b/Build/crates/saikuro-storage/wasm/fs_access.rs similarity index 86% rename from Build/crates/saikuro-storage/src/fs_access.rs rename to Build/crates/saikuro-storage/wasm/fs_access.rs index f8f2a71c..005ccdf9 100644 --- a/Build/crates/saikuro-storage/src/fs_access.rs +++ b/Build/crates/saikuro-storage/wasm/fs_access.rs @@ -12,11 +12,9 @@ use web_sys::{ FileSystemHandleKind, FileSystemRemoveOptions, }; -use super::{ - config::StorageConfig, - error::{Result, StorageError}, - traits::{LocalFileBackend, LocalKeyValueBackend, LocalStorageBackend}, -}; +use crate::config::StorageConfig; +use crate::traits::{FileBackend, KeyValueBackend, StorageBackend}; +use saikuro_event::{Result, SaikuroError}; thread_local! { static ROOT_HANDLE: RefCell> = const { RefCell::new(None) }; @@ -27,30 +25,30 @@ fn promise_await(promise: ::js_sys::Promise) -> JsFuture { } async fn pick_directory() -> Result { - let window = web_sys::window().ok_or_else(|| StorageError::internal("no window object"))?; + let window = web_sys::window().ok_or_else(|| SaikuroError::internal("no window object"))?; let promise = js_sys::Reflect::get(&window, &JsValue::from_str("showDirectoryPicker")) - .map_err(|e| StorageError::internal(format!("showDirectoryPicker not available: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("showDirectoryPicker not available: {e:?}")))?; let promise = promise .dyn_into::() .map_err(|e| { - StorageError::internal(format!("showDirectoryPicker is not a function: {e:?}")) + SaikuroError::internal(format!("showDirectoryPicker is not a function: {e:?}")) })? .call0(&window) - .map_err(|e| StorageError::internal(format!("showDirectoryPicker call failed: {e:?}")))? + .map_err(|e| SaikuroError::internal(format!("showDirectoryPicker call failed: {e:?}")))? .dyn_into::() .map_err(|e| { - StorageError::internal(format!( + SaikuroError::internal(format!( "showDirectoryPicker result is not a promise: {e:?}" )) })?; let result = promise_await(promise).await.map_err(|e| { - StorageError::internal(format!("showDirectoryPicker promise failed: {e:?}")) + SaikuroError::internal(format!("showDirectoryPicker promise failed: {e:?}")) })?; result.dyn_into::().map_err(|e| { - StorageError::internal(format!( + SaikuroError::internal(format!( "showDirectoryPicker result is not a directory handle: {e:?}" )) }) @@ -65,7 +63,7 @@ async fn get_or_create_dir( let promise = parent.get_directory_handle_with_options(name, &opts); let result = promise_await(promise) .await - .map_err(|e| StorageError::internal(format!("getOrCreateDir({name}) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("getOrCreateDir({name}) failed: {e:?}")))?; Ok(result.into()) } @@ -78,7 +76,7 @@ async fn get_or_create_file( let promise = parent.get_file_handle_with_options(name, &opts); let result = promise_await(promise) .await - .map_err(|e| StorageError::internal(format!("getOrCreateFile({name}) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("getOrCreateFile({name}) failed: {e:?}")))?; Ok(result.into()) } @@ -101,13 +99,13 @@ async fn read_file_from_handle(file: &FileSystemFileHandle) -> Result { let file_promise = file.get_file(); let file_val = promise_await(file_promise) .await - .map_err(|e| StorageError::internal(format!("getFile failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("getFile failed: {e:?}")))?; let js_file: web_sys::File = file_val.into(); let buf_promise = js_file.array_buffer(); let buf_val = promise_await(buf_promise) .await - .map_err(|e| StorageError::internal(format!("arrayBuffer failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("arrayBuffer failed: {e:?}")))?; let buf: ArrayBuffer = buf_val.into(); let uint8 = Uint8Array::new(&buf); let mut vec = vec![0u8; uint8.length() as usize]; @@ -119,19 +117,19 @@ async fn write_file_to_handle(file: &FileSystemFileHandle, data: &Bytes) -> Resu let writable_promise = file.create_writable(); let writable_val = promise_await(writable_promise) .await - .map_err(|e| StorageError::internal(format!("createWritable failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("createWritable failed: {e:?}")))?; let writable: web_sys::FileSystemWritableFileStream = writable_val.into(); let write_promise = writable .write_with_u8_array(data) - .map_err(|e| StorageError::internal(format!("write call failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("write call failed: {e:?}")))?; promise_await(write_promise) .await - .map_err(|e| StorageError::internal(format!("write failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("write failed: {e:?}")))?; promise_await(writable.close()) .await - .map_err(|e| StorageError::internal(format!("close failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("close failed: {e:?}")))?; Ok(()) } @@ -141,7 +139,7 @@ async fn append_file_to_handle(file: &FileSystemFileHandle, data: &Bytes) -> Res let file_promise = file.get_file(); let file_val = promise_await(file_promise) .await - .map_err(|e| StorageError::internal(format!("getFile(append) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("getFile(append) failed: {e:?}")))?; let js_file: web_sys::File = file_val.into(); let file_size = js_file.size() as f64; @@ -150,27 +148,27 @@ async fn append_file_to_handle(file: &FileSystemFileHandle, data: &Bytes) -> Res let writable_promise = file.create_writable_with_options(&create_opts); let writable_val = promise_await(writable_promise) .await - .map_err(|e| StorageError::internal(format!("createWritable(append) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("createWritable(append) failed: {e:?}")))?; let writable: web_sys::FileSystemWritableFileStream = writable_val.into(); // Seek to end of file let seek_promise = writable .seek_with_f64(file_size) - .map_err(|e| StorageError::internal(format!("seek(append) call failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("seek(append) call failed: {e:?}")))?; promise_await(seek_promise) .await - .map_err(|e| StorageError::internal(format!("seek(append) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("seek(append) failed: {e:?}")))?; let write_promise = writable .write_with_u8_array(data) - .map_err(|e| StorageError::internal(format!("write call(append) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("write call(append) failed: {e:?}")))?; promise_await(write_promise) .await - .map_err(|e| StorageError::internal(format!("write(append) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("write(append) failed: {e:?}")))?; promise_await(writable.close()) .await - .map_err(|e| StorageError::internal(format!("close(append) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("close(append) failed: {e:?}")))?; Ok(()) } @@ -179,7 +177,7 @@ async fn remove_entry(parent: &FileSystemDirectoryHandle, name: &str) -> Result< let promise = parent.remove_entry(name); promise_await(promise) .await - .map_err(|e| StorageError::internal(format!("removeEntry({name}) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("removeEntry({name}) failed: {e:?}")))?; Ok(()) } @@ -188,7 +186,7 @@ async fn remove_entry_recursive(parent: &FileSystemDirectoryHandle, name: &str) opts.set_recursive(true); let promise = parent.remove_entry_with_options(name, &opts); promise_await(promise).await.map_err(|e| { - StorageError::internal(format!("removeEntry({name},recursive) failed: {e:?}")) + SaikuroError::internal(format!("removeEntry({name},recursive) failed: {e:?}")) })?; Ok(()) } @@ -201,10 +199,10 @@ async fn list_entry_names( loop { let promise = iter .next() - .map_err(|e| StorageError::internal(format!("iterator next() failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("iterator next() failed: {e:?}")))?; let result = promise_await(promise) .await - .map_err(|e| StorageError::internal(format!("iterator promise failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("iterator promise failed: {e:?}")))?; let done = js_sys::Reflect::get(&result, &JsValue::from_str("done")) .ok() @@ -216,7 +214,7 @@ async fn list_entry_names( } let value = js_sys::Reflect::get(&result, &JsValue::from_str("value")) - .map_err(|_| StorageError::internal("missing value in iterator result"))?; + .map_err(|_| SaikuroError::internal("missing value in iterator result"))?; let arr = js_sys::Array::from(&value); let name = arr.get(0).as_string().unwrap_or_default(); @@ -310,7 +308,7 @@ impl FsAccessStorage { let promise = current.get_directory_handle_with_options(dir_name, &opts); let result = promise_await(promise) .await - .map_err(|e| StorageError::internal(format!("navigateToDir failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("navigateToDir failed: {e:?}")))?; current = result.into(); } else { let opts = FileSystemGetDirectoryOptions::new(); @@ -320,7 +318,7 @@ impl FsAccessStorage { current = val.into(); } Err(_) => { - return Err(StorageError::key_not_found(dirs.join("/"))); + return Err(SaikuroError::key_not_found(dirs.join("/"))); } } } @@ -329,7 +327,7 @@ impl FsAccessStorage { } } -impl LocalKeyValueBackend for FsAccessStorage { +impl KeyValueBackend for FsAccessStorage { fn config(&self) -> &StorageConfig { &self.config } @@ -411,13 +409,13 @@ impl LocalKeyValueBackend for FsAccessStorage { } } -impl LocalFileBackend for FsAccessStorage { +impl FileBackend for FsAccessStorage { async fn read_file(&self, path: &str) -> Result { let (dirs, file_name) = navigate_path(path); let parent = self.navigate_to_dir(&dirs, false).await?; let file_handle = get_file(&parent, file_name) .await? - .ok_or_else(|| StorageError::key_not_found(path))?; + .ok_or_else(|| SaikuroError::key_not_found(path))?; read_file_from_handle(&file_handle).await } @@ -470,7 +468,7 @@ impl LocalFileBackend for FsAccessStorage { async fn create_dir(&self, path: &str) -> Result<()> { let (dirs, dir_name) = navigate_path(path); if dir_name.is_empty() { - return Err(StorageError::internal("cannot create root directory")); + return Err(SaikuroError::internal("cannot create root directory")); } let parent = self.navigate_to_dir(&dirs, true).await?; let opts = FileSystemGetDirectoryOptions::new(); @@ -478,14 +476,14 @@ impl LocalFileBackend for FsAccessStorage { let promise = parent.get_directory_handle_with_options(dir_name, &opts); promise_await(promise) .await - .map_err(|e| StorageError::internal(format!("createDir({path}) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("createDir({path}) failed: {e:?}")))?; Ok(()) } async fn delete_dir(&self, path: &str) -> Result<()> { let (dirs, dir_name) = navigate_path(path); if dir_name.is_empty() { - return Err(StorageError::internal("cannot delete root directory")); + return Err(SaikuroError::internal("cannot delete root directory")); } let parent = match self.navigate_to_dir(&dirs, false).await { Ok(dir) => dir, @@ -495,7 +493,7 @@ impl LocalFileBackend for FsAccessStorage { } } -impl LocalStorageBackend for FsAccessStorage { +impl StorageBackend for FsAccessStorage { fn supports_files(&self) -> bool { true } diff --git a/Build/crates/saikuro-storage/src/indexeddb.rs b/Build/crates/saikuro-storage/wasm/indexeddb.rs similarity index 85% rename from Build/crates/saikuro-storage/src/indexeddb.rs rename to Build/crates/saikuro-storage/wasm/indexeddb.rs index 3a2506fb..1b38de0d 100644 --- a/Build/crates/saikuro-storage/src/indexeddb.rs +++ b/Build/crates/saikuro-storage/wasm/indexeddb.rs @@ -1,9 +1,3 @@ -//! IndexedDB-backed storage backend for WASM. -//! -//! Uses the browser's IndexedDB API to provide persistent key-value storage -//! that survives page reloads. Enabled automatically when the `wasm-storage` -//! feature is active on a `wasm32` target. - use bytes::Bytes; use js_sys::Uint8Array; use std::cell::RefCell; @@ -14,11 +8,9 @@ use web_sys::{ IdbVersionChangeEvent, }; -use super::{ - config::StorageConfig, - error::{Result, StorageError}, - traits::{LocalKeyValueBackend, LocalStorageBackend}, -}; +use crate::config::StorageConfig; +use crate::traits::{KeyValueBackend, StorageBackend}; +use saikuro_event::{Result, SaikuroError}; const DB_NAME: &str = "SaikuroStorage"; const STORE_NAME: &str = "kv_store"; @@ -83,15 +75,15 @@ fn bytes_to_js(val: &Bytes) -> JsValue { } async fn open_database(name: &str, version: u32) -> Result { - let window = web_sys::window().ok_or_else(|| StorageError::internal("no window object"))?; + let window = web_sys::window().ok_or_else(|| SaikuroError::internal("no window object"))?; let factory: IdbFactory = window .indexed_db() - .map_err(|_| StorageError::internal("IndexedDB API call failed"))? - .ok_or_else(|| StorageError::internal("IndexedDB not available"))?; + .map_err(|_| SaikuroError::internal("IndexedDB API call failed"))? + .ok_or_else(|| SaikuroError::internal("IndexedDB not available"))?; let open_request = factory .open_with_u32(name, version) - .map_err(|e| StorageError::internal(format!("IndexedDB open call failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("IndexedDB open call failed: {e:?}")))?; // Install onupgradeneeded handler (fires when DB is created or version // changes). The handler receives an `IdbVersionChangeEvent` whose @@ -118,7 +110,7 @@ async fn open_database(name: &str, version: u32) -> Result { let result = idb_await(open_request.unchecked_ref::()) .await - .map_err(|e| StorageError::internal(format!("IndexedDB open failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("IndexedDB open failed: {e:?}")))?; Ok(result.into()) } @@ -138,31 +130,31 @@ async fn tx( ) -> Result<(web_sys::IdbTransaction, IdbObjectStore)> { let transaction = db .transaction_with_str_and_mode(STORE_NAME, mode) - .map_err(|_| StorageError::internal("failed to create IndexedDB transaction"))?; + .map_err(|_| SaikuroError::internal("failed to create IndexedDB transaction"))?; let store = transaction .object_store(STORE_NAME) - .map_err(|_| StorageError::internal("failed to get object store"))?; + .map_err(|_| SaikuroError::internal("failed to get object store"))?; Ok((transaction, store)) } fn store_get(store: &IdbObjectStore, key: &JsValue) -> Result { let request = store .get(key) - .map_err(|_| StorageError::internal("IndexedDB get request failed"))?; + .map_err(|_| SaikuroError::internal("IndexedDB get request failed"))?; Ok(idb_await(&request)) } fn store_put(store: &IdbObjectStore, key: &JsValue, value: &JsValue) -> Result { let request = store .put_with_key(value, key) - .map_err(|_| StorageError::internal("IndexedDB put request failed"))?; + .map_err(|_| SaikuroError::internal("IndexedDB put request failed"))?; Ok(idb_await(&request)) } fn store_delete(store: &IdbObjectStore, key: &JsValue) -> Result { let request = store .delete(key) - .map_err(|_| StorageError::internal("IndexedDB delete request failed"))?; + .map_err(|_| SaikuroError::internal("IndexedDB delete request failed"))?; Ok(idb_await(&request)) } @@ -171,7 +163,7 @@ fn store_get_all_keys(store: &IdbObjectStore, query: Option<&JsValue>) -> Result Some(q) => store.get_all_keys_with_key(q), None => store.get_all_keys(), } - .map_err(|_| StorageError::internal("IndexedDB getAllKeys request failed"))?; + .map_err(|_| SaikuroError::internal("IndexedDB getAllKeys request failed"))?; Ok(idb_await(&request)) } @@ -183,7 +175,7 @@ fn prefix_range(prefix: &str) -> Result { s }; IdbKeyRange::bound(&JsValue::from(prefix), &JsValue::from(&upper)) - .map_err(|_| StorageError::internal("failed to create IDBKeyRange")) + .map_err(|_| SaikuroError::internal("failed to create IDBKeyRange")) } // IndexedDbStorage @@ -223,7 +215,7 @@ impl Default for IndexedDbStorage { } } -impl LocalKeyValueBackend for IndexedDbStorage { +impl KeyValueBackend for IndexedDbStorage { fn config(&self) -> &StorageConfig { &self.config } @@ -233,7 +225,7 @@ impl LocalKeyValueBackend for IndexedDbStorage { let (_tx, store) = tx(&db, IdbTransactionMode::Readonly).await?; match store_get(&store, &JsValue::from(make_key(namespace, key)))?.await { Ok(val) => Ok(!val.is_undefined() && !val.is_null()), - Err(e) => Err(StorageError::internal(format!( + Err(e) => Err(SaikuroError::internal(format!( "IndexedDB exists failed: {e:?}" ))), } @@ -257,7 +249,7 @@ impl LocalKeyValueBackend for IndexedDbStorage { &bytes_to_js(&value), )? .await - .map_err(|e| StorageError::internal(format!("IndexedDB put failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("IndexedDB put failed: {e:?}")))?; Ok(()) } @@ -266,7 +258,7 @@ impl LocalKeyValueBackend for IndexedDbStorage { let (_tx, store) = tx(&db, IdbTransactionMode::Readwrite).await?; store_delete(&store, &JsValue::from(make_key(namespace, key)))? .await - .map_err(|e| StorageError::internal(format!("IndexedDB delete failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("IndexedDB delete failed: {e:?}")))?; Ok(()) } @@ -277,7 +269,7 @@ impl LocalKeyValueBackend for IndexedDbStorage { let range = prefix_range(&prefix)?; let result = store_get_all_keys(&store, Some(&JsValue::from(range)))? .await - .map_err(|e| StorageError::internal(format!("IndexedDB list_keys failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("IndexedDB list_keys failed: {e:?}")))?; let prefix_len = prefix.len(); let keys: Vec = result @@ -295,7 +287,7 @@ impl LocalKeyValueBackend for IndexedDbStorage { let db = get_db().await?; let (_tx, store) = tx(&db, IdbTransactionMode::Readonly).await?; let result = store_get_all_keys(&store, None)?.await.map_err(|e| { - StorageError::internal(format!("IndexedDB list_namespaces failed: {e:?}")) + SaikuroError::internal(format!("IndexedDB list_namespaces failed: {e:?}")) })?; let mut namespaces: Vec = result @@ -321,9 +313,9 @@ impl LocalKeyValueBackend for IndexedDbStorage { let range = prefix_range(&make_key(namespace, ""))?; let request = store .delete(&JsValue::from(range)) - .map_err(|_| StorageError::internal("IndexedDB range delete failed"))?; + .map_err(|_| SaikuroError::internal("IndexedDB range delete failed"))?; idb_await(&request).await.map_err(|e| { - StorageError::internal(format!("IndexedDB delete_namespace failed: {e:?}")) + SaikuroError::internal(format!("IndexedDB delete_namespace failed: {e:?}")) })?; Ok(()) } @@ -333,7 +325,7 @@ impl LocalKeyValueBackend for IndexedDbStorage { } } -impl LocalStorageBackend for IndexedDbStorage { +impl StorageBackend for IndexedDbStorage { fn supports_files(&self) -> bool { false } diff --git a/Build/crates/saikuro-storage/wasm/local_storage.rs b/Build/crates/saikuro-storage/wasm/local_storage.rs new file mode 100644 index 00000000..9dc7343e --- /dev/null +++ b/Build/crates/saikuro-storage/wasm/local_storage.rs @@ -0,0 +1,8 @@ +#[cfg(all(target_arch = "wasm32", feature = "wasm"))] +use crate::impl_web_storage; + +#[cfg(all(target_arch = "wasm32", feature = "wasm"))] +impl_web_storage!(LocalStorage, local_storage); + +#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))] +pub use crate::InMemoryStorage as LocalStorage; diff --git a/Build/crates/saikuro-storage/wasm/mod.rs b/Build/crates/saikuro-storage/wasm/mod.rs new file mode 100644 index 00000000..4b593ea2 --- /dev/null +++ b/Build/crates/saikuro-storage/wasm/mod.rs @@ -0,0 +1,17 @@ +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +pub mod fs_access; + +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +pub mod indexeddb; + +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +pub mod webstorage; + +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +pub mod opfs; + +#[cfg(feature = "wasm")] +pub mod local_storage; + +#[cfg(feature = "wasm")] +pub mod session_storage; diff --git a/Build/crates/saikuro-storage/src/opfs.rs b/Build/crates/saikuro-storage/wasm/opfs.rs similarity index 88% rename from Build/crates/saikuro-storage/src/opfs.rs rename to Build/crates/saikuro-storage/wasm/opfs.rs index 2a75215a..7234fa84 100644 --- a/Build/crates/saikuro-storage/src/opfs.rs +++ b/Build/crates/saikuro-storage/wasm/opfs.rs @@ -8,11 +8,9 @@ use web_sys::{ FileSystemGetFileOptions, FileSystemHandle, FileSystemHandleKind, FileSystemRemoveOptions, }; -use super::{ - config::StorageConfig, - error::{Result, StorageError}, - traits::{LocalFileBackend, LocalKeyValueBackend, LocalStorageBackend}, -}; +use crate::config::StorageConfig; +use crate::traits::{FileBackend, KeyValueBackend, StorageBackend}; +use saikuro_event::{Result, SaikuroError}; const ROOT_DIR_NAME: &str = "SaikuroStorage"; @@ -25,18 +23,18 @@ thread_local! { } async fn open_root() -> Result { - let window = web_sys::window().ok_or_else(|| StorageError::internal("no window object"))?; + let window = web_sys::window().ok_or_else(|| SaikuroError::internal("no window object"))?; let storage = window.navigator().storage(); let promise = storage.get_directory(); let result = promise_await(promise) .await - .map_err(|e| StorageError::internal(format!("OPFS getDirectory failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("OPFS getDirectory failed: {e:?}")))?; let root: FileSystemDirectoryHandle = result.into(); let opts = FileSystemGetDirectoryOptions::new(); let promise = root.get_directory_handle_with_options(ROOT_DIR_NAME, &opts); let app_dir = promise_await(promise).await.map_err(|e| { - StorageError::internal(format!( + SaikuroError::internal(format!( "OPFS getDirectoryHandle({ROOT_DIR_NAME}) failed: {e:?}" )) })?; @@ -61,7 +59,7 @@ async fn ensure_dir( let promise = parent.get_directory_handle_with_options(name, &opts); let result = promise_await(promise) .await - .map_err(|e| StorageError::internal(format!("OPFS ensureDir({name}) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("OPFS ensureDir({name}) failed: {e:?}")))?; Ok(result.into()) } @@ -73,7 +71,7 @@ async fn get_or_create_file( opts.set_create(true); let promise = parent.get_file_handle_with_options(name, &opts); let result = promise_await(promise).await.map_err(|e| { - StorageError::internal(format!("OPFS getOrCreateFile({name}) failed: {e:?}")) + SaikuroError::internal(format!("OPFS getOrCreateFile({name}) failed: {e:?}")) })?; Ok(result.into()) } @@ -97,13 +95,13 @@ async fn read_file_from_handle(file: &web_sys::FileSystemFileHandle) -> Result Result< let promise = parent.remove_entry(name); promise_await(promise) .await - .map_err(|e| StorageError::internal(format!("OPFS removeEntry({name}) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("OPFS removeEntry({name}) failed: {e:?}")))?; Ok(()) } @@ -168,7 +166,7 @@ async fn remove_entry_recursive(parent: &FileSystemDirectoryHandle, name: &str) opts.set_recursive(true); let promise = parent.remove_entry_with_options(name, &opts); promise_await(promise).await.map_err(|e| { - StorageError::internal(format!("OPFS removeEntry({name},recursive) failed: {e:?}")) + SaikuroError::internal(format!("OPFS removeEntry({name},recursive) failed: {e:?}")) })?; Ok(()) } @@ -181,10 +179,10 @@ async fn list_entry_names( loop { let promise = iter .next() - .map_err(|e| StorageError::internal(format!("OPFS iterator next() failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("OPFS iterator next() failed: {e:?}")))?; let result = promise_await(promise) .await - .map_err(|e| StorageError::internal(format!("OPFS iterator promise failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("OPFS iterator promise failed: {e:?}")))?; let done = js_sys::Reflect::get(&result, &JsValue::from_str("done")) .ok() @@ -196,7 +194,7 @@ async fn list_entry_names( } let value = js_sys::Reflect::get(&result, &JsValue::from_str("value")) - .map_err(|_| StorageError::internal("missing value in iterator result"))?; + .map_err(|_| SaikuroError::internal("missing value in iterator result"))?; let arr = js_sys::Array::from(&value); let name = arr.get(0).as_string().unwrap_or_default(); @@ -284,7 +282,7 @@ impl OpfsStorage { let promise = current.get_directory_handle_with_options(dir_name, &opts); let result = promise_await(promise) .await - .map_err(|e| StorageError::internal(format!("OPFS navigate failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("OPFS navigate failed: {e:?}")))?; current = result.into(); } else { let opts = FileSystemGetDirectoryOptions::new(); @@ -294,7 +292,7 @@ impl OpfsStorage { current = val.into(); } Err(_) => { - return Err(StorageError::key_not_found(dirs.join("/"))); + return Err(SaikuroError::key_not_found(dirs.join("/"))); } } } @@ -309,7 +307,7 @@ impl Default for OpfsStorage { } } -impl LocalKeyValueBackend for OpfsStorage { +impl KeyValueBackend for OpfsStorage { fn config(&self) -> &StorageConfig { &self.config } @@ -393,13 +391,13 @@ impl LocalKeyValueBackend for OpfsStorage { } } -impl LocalFileBackend for OpfsStorage { +impl FileBackend for OpfsStorage { async fn read_file(&self, path: &str) -> Result { let (dirs, file_name) = navigate_path(path); let parent = self.navigate_to_dir(&dirs, false).await?; let file_handle = get_file(&parent, file_name) .await? - .ok_or_else(|| StorageError::key_not_found(path))?; + .ok_or_else(|| SaikuroError::key_not_found(path))?; read_file_from_handle(&file_handle).await } @@ -452,7 +450,7 @@ impl LocalFileBackend for OpfsStorage { async fn create_dir(&self, path: &str) -> Result<()> { let (dirs, dir_name) = navigate_path(path); if dir_name.is_empty() { - return Err(StorageError::internal("cannot create root directory")); + return Err(SaikuroError::internal("cannot create root directory")); } let parent = self.navigate_to_dir(&dirs, true).await?; let opts = FileSystemGetDirectoryOptions::new(); @@ -460,14 +458,14 @@ impl LocalFileBackend for OpfsStorage { let promise = parent.get_directory_handle_with_options(dir_name, &opts); promise_await(promise) .await - .map_err(|e| StorageError::internal(format!("OPFS createDir({path}) failed: {e:?}")))?; + .map_err(|e| SaikuroError::internal(format!("OPFS createDir({path}) failed: {e:?}")))?; Ok(()) } async fn delete_dir(&self, path: &str) -> Result<()> { let (dirs, dir_name) = navigate_path(path); if dir_name.is_empty() { - return Err(StorageError::internal("cannot delete root directory")); + return Err(SaikuroError::internal("cannot delete root directory")); } let parent = match self.navigate_to_dir(&dirs, false).await { Ok(dir) => dir, @@ -477,7 +475,7 @@ impl LocalFileBackend for OpfsStorage { } } -impl LocalStorageBackend for OpfsStorage { +impl StorageBackend for OpfsStorage { fn supports_files(&self) -> bool { true } diff --git a/Build/crates/saikuro-storage/wasm/session_storage.rs b/Build/crates/saikuro-storage/wasm/session_storage.rs new file mode 100644 index 00000000..7345bcf5 --- /dev/null +++ b/Build/crates/saikuro-storage/wasm/session_storage.rs @@ -0,0 +1,8 @@ +#[cfg(all(target_arch = "wasm32", feature = "wasm"))] +use crate::impl_web_storage; + +#[cfg(all(target_arch = "wasm32", feature = "wasm"))] +impl_web_storage!(SessionStorage, session_storage); + +#[cfg(not(all(target_arch = "wasm32", feature = "wasm")))] +pub use crate::InMemoryStorage as SessionStorage; diff --git a/Build/crates/saikuro-storage/src/webstorage.rs b/Build/crates/saikuro-storage/wasm/webstorage.rs similarity index 78% rename from Build/crates/saikuro-storage/src/webstorage.rs rename to Build/crates/saikuro-storage/wasm/webstorage.rs index 54483853..7cbbebd3 100644 --- a/Build/crates/saikuro-storage/src/webstorage.rs +++ b/Build/crates/saikuro-storage/wasm/webstorage.rs @@ -1,20 +1,11 @@ -// Re-export pure helpers from the unconditionally-compiled util module -// so that the impl_web_storage! macro (which uses $crate::webstorage::*) -// continues to work. -#[allow(unused_imports)] -pub(crate) use crate::util::{ - apply_prefix, decode_bytes, encode_bytes, key_prefix, make_key, strip_prefix, - NAMESPACE_SEPARATOR, -}; - use bytes::Bytes; use crate::util; -use super::error::{Result, StorageError}; +use saikuro_event::{Result, SaikuroError}; pub(crate) fn window() -> Result { - web_sys::window().ok_or_else(|| StorageError::internal("no window object")) + web_sys::window().ok_or_else(|| SaikuroError::internal("no window object")) } pub(crate) fn get_all_keys(storage: &web_sys::Storage) -> Vec { @@ -65,7 +56,7 @@ pub(crate) fn storage_get(storage: &web_sys::Storage, key: &str) -> Result Ok(Some(util::decode_bytes(&val))), Ok(None) => Ok(None), - Err(e) => Err(StorageError::internal(format!( + Err(e) => Err(SaikuroError::internal(format!( "web storage get_item failed: {e:?}" ))), } @@ -75,7 +66,7 @@ pub(crate) fn storage_set(storage: &web_sys::Storage, key: &str, value: &Bytes) let encoded = util::encode_bytes(value); storage .set_item(key, &encoded) - .map_err(|e| StorageError::internal(format!("web storage set_item failed: {e:?}"))) + .map_err(|e| SaikuroError::internal(format!("web storage set_item failed: {e:?}"))) } pub(crate) fn storage_remove(storage: &web_sys::Storage, key: &str) { diff --git a/Build/tests/saikuro-storage/flash.rs b/Build/tests/saikuro-storage/flash.rs index 09f40f86..58a5ac3a 100644 --- a/Build/tests/saikuro-storage/flash.rs +++ b/Build/tests/saikuro-storage/flash.rs @@ -1,679 +1,495 @@ -//! Integration tests for the flash-backed bounded key-value store. +//! Integration tests for the flash-backed key-value store. //! -//! The fake NOR-flash device enforces real NOR semantics: aligned reads and -//! writes, per-word write-once, erase-to-`0xFF`, and `1`-only-to-`0` bit -//! transitions. Tests share the fake behind `Rc>` so a "reboot" -//! is a fresh store opened over the same device contents. - -use std::cell::RefCell; -use std::rc::Rc; - -use bytes::Bytes; -use embedded_storage_async::nor_flash::{ - ErrorType, NorFlash, NorFlashError, NorFlashErrorKind, ReadNorFlash, -}; -use futures_executor::block_on; -use saikuro_storage::{ - FlashConfig, FlashKvStore, LocalKeyValueBackend, StorageConfig, StorageError, -}; - -const WRITE_SIZE: usize = 4; -const ERASE_SIZE: usize = 256; -const REGION_SIZE: usize = 512 * 8; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct FlashTestError { - kind: NorFlashErrorKind, -} - -impl ErrorType for FakeFlash { - type Error = FlashTestError; -} - -impl NorFlashError for FlashTestError { - fn kind(&self) -> NorFlashErrorKind { - self.kind - } -} +//! Run with: `cargo test --no-default-features --features flash -p saikuro-storage` +//! +//! (`--no-default-features` is required so only the `embedded` engine is +//! selected; the default feature set also enables `native`, which is mutually +//! exclusive with `flash`.) -impl From for FlashTestError { - fn from(kind: NorFlashErrorKind) -> Self { - Self { kind } +use futures_executor::block_on; +use saikuro_storage::{Bytes, FlashConfig, FlashKvStore, SaikuroError, StorageConfig}; + +mod mock { + use alloc::rc::Rc; + use alloc::vec::Vec; + use core::cell::RefCell; + + use embedded_storage_async::nor_flash::NorFlash; + + pub const ERASE_SIZE: usize = 256; + pub const WRITE_SIZE: usize = 4; + pub const SECTORS: usize = 8; + pub const CAPACITY: usize = ERASE_SIZE * SECTORS; + + /// A shared, in-memory mock of a `NorFlash` device. + /// + /// It models the two physical constraints of real NOR flash: + /// - a word can only be written once between erases, and + /// - a write may only clear bits (set them to 0), never set them to 1. + #[derive(Clone)] + pub struct RcFlash { + pub cells: Rc>>, + pub written: Rc>>, + pub erase_size: usize, + pub write_size: usize, + pub capacity: usize, } -} -struct FakeFlash { - data: Vec, - written: Vec, -} - -impl FakeFlash { - fn new(region: usize) -> Self { - assert_eq!(region % ERASE_SIZE, 0); - Self { - data: vec![0xFF; region], - written: vec![false; region / WRITE_SIZE], + impl RcFlash { + pub fn new() -> Self { + let mut cells = Vec::with_capacity(CAPACITY); + cells.resize(CAPACITY, 0xFF); + Self { + cells: Rc::new(RefCell::new(cells)), + written: Rc::new(RefCell::new(Vec::new())), + erase_size: ERASE_SIZE, + write_size: WRITE_SIZE, + capacity: CAPACITY, + } } - } -} -impl ReadNorFlash for FakeFlash { - const READ_SIZE: usize = 1; - - async fn read(&mut self, offset: u32, bytes: &mut [u8]) -> Result<(), Self::Error> { - let off = offset as usize; - if off + bytes.len() > self.data.len() { - return Err(NorFlashErrorKind::OutOfBounds.into()); + /// Overwrite the final erase sector with zeroes, simulating bit flips in + /// an otherwise-unused tail/spare region after a crash. + pub fn corrupt_tail(&self) { + let mut cells = self.cells.borrow_mut(); + let start = self.capacity - self.erase_size; + for b in cells.iter_mut().skip(start) { + *b = 0x00; + } } - bytes.copy_from_slice(&self.data[off..off + bytes.len()]); - Ok(()) } - fn capacity(&self) -> usize { - self.data.len() - } -} + impl NorFlash for RcFlash { + const WRITE_SIZE: usize = WRITE_SIZE; + const ERASE_SIZE: usize = ERASE_SIZE; -impl NorFlash for FakeFlash { - const WRITE_SIZE: usize = WRITE_SIZE; - const ERASE_SIZE: usize = ERASE_SIZE; - - async fn erase(&mut self, from: u32, to: u32) -> Result<(), Self::Error> { - let f = from as usize; - let t = to as usize; - if !f.is_multiple_of(ERASE_SIZE) - || !t.is_multiple_of(ERASE_SIZE) - || t <= f - || t > self.data.len() - { - return Err(NorFlashErrorKind::NotAligned.into()); - } - self.data[f..t].fill(0xFF); - for word in self - .written - .iter_mut() - .skip(f / WRITE_SIZE) - .take((t - f) / WRITE_SIZE) - { - *word = false; - } - Ok(()) - } + type Error = core::convert::Infallible; - async fn write(&mut self, offset: u32, bytes: &[u8]) -> Result<(), Self::Error> { - let off = offset as usize; - if !off.is_multiple_of(WRITE_SIZE) - || !bytes.len().is_multiple_of(WRITE_SIZE) - || off + bytes.len() > self.data.len() - { - return Err(NorFlashErrorKind::NotAligned.into()); + async fn read(&self, offset: u32, bytes: &mut [u8]) -> Result<(), Self::Error> { + let cells = self.cells.borrow(); + bytes.copy_from_slice(&cells[offset as usize..offset as usize + bytes.len()]); + Ok(()) } - for (i, &b) in bytes.iter().enumerate() { - let word = (off + i) / WRITE_SIZE; - if self.written[word] { - return Err(FlashTestError { - kind: NorFlashErrorKind::Other, - }); - } - let old = self.data[off + i]; - if old | b != old { - return Err(FlashTestError { - kind: NorFlashErrorKind::Other, - }); + + async fn write(&self, offset: u32, bytes: &[u8]) -> Result<(), Self::Error> { + assert_eq!(offset as usize % WRITE_SIZE, 0, "write must be word-aligned"); + assert_eq!(bytes.len() % WRITE_SIZE, 0, "write length must be a word multiple"); + let mut cells = self.cells.borrow_mut(); + let mut written = self.written.borrow_mut(); + for (i, &b) in bytes.iter().enumerate() { + let idx = offset as usize + i; + let old = cells[idx]; + assert!( + old | b == old, + "NOR flash cannot set bits: wrote {b:#04x} over {old:#04x}" + ); + assert!( + !written.contains(&idx), + "NOR flash cannot rewrite a word without an erase" + ); + cells[idx] = b; + written.push(idx); } + Ok(()) } - for (i, &b) in bytes.iter().enumerate() { - self.written[(off + i) / WRITE_SIZE] = true; - self.data[off + i] = b; - } - Ok(()) - } -} - -#[derive(Clone)] -struct RcFlash(Rc>); - -impl RcFlash { - fn new() -> Self { - Self(Rc::new(RefCell::new(FakeFlash::new(REGION_SIZE)))) - } -} -impl ErrorType for RcFlash { - type Error = FlashTestError; -} - -// The borrow is held across await so the fake serializes access to the shared -// device state; the tests are single-threaded, so there is no contention. -#[allow(clippy::await_holding_refcell_ref)] -impl ReadNorFlash for RcFlash { - const READ_SIZE: usize = FakeFlash::READ_SIZE; - - async fn read(&mut self, offset: u32, bytes: &mut [u8]) -> Result<(), Self::Error> { - self.0.borrow_mut().read(offset, bytes).await - } - - fn capacity(&self) -> usize { - self.0.borrow().capacity() + async fn erase(&self, from: u32, to: u32) -> Result<(), Self::Error> { + let mut cells = self.cells.borrow_mut(); + for b in cells.iter_mut().take(to as usize).skip(from as usize) { + *b = 0xFF; + } + let mut written = self.written.borrow_mut(); + written.retain(|w| *w < from as usize || *w >= to as usize); + Ok(()) + } } } -#[allow(clippy::await_holding_refcell_ref)] -impl NorFlash for RcFlash { - const WRITE_SIZE: usize = FakeFlash::WRITE_SIZE; - const ERASE_SIZE: usize = FakeFlash::ERASE_SIZE; - - async fn erase(&mut self, from: u32, to: u32) -> Result<(), Self::Error> { - self.0.borrow_mut().erase(from, to).await - } - - async fn write(&mut self, offset: u32, bytes: &[u8]) -> Result<(), Self::Error> { - self.0.borrow_mut().write(offset, bytes).await - } -} +use mock::{RcFlash, ERASE_SIZE, SECTORS}; fn flash_config() -> FlashConfig { - FlashConfig::new(0, 512, 8, 32, 128, ERASE_SIZE).expect("valid test config") -} - -fn new_store(flash: RcFlash) -> FlashKvStore { - FlashKvStore::new(flash, StorageConfig::default(), flash_config()).expect("valid store") + FlashConfig::new(0, 512, SECTORS, 32, 128, ERASE_SIZE).expect("valid flash config") } -fn expect_invalid(store: Result, StorageError>) -> StorageError { - match store { - Err(e) => e, - Ok(_) => panic!("expected store construction to fail"), +fn config(auto_create: bool) -> StorageConfig { + StorageConfig { + auto_create_namespaces: auto_create, + ..Default::default() } } -async fn open(store: &mut FlashKvStore) { - store.open().await.expect("open scans the region"); -} - -// Construction and geometry - -#[test] -fn capacity_reserves_one_spare_sector() { - let store = new_store(RcFlash::new()); - assert_eq!(store.capacity(), 7 * (512 - 8)); -} - -#[test] -fn new_rejects_sector_not_multiple_of_erase_size() { - let err = expect_invalid(FlashKvStore::new( - RcFlash::new(), - StorageConfig::default(), - FlashConfig { - base_offset: 0, - sector_size: 300, - sector_count: 8, - max_key_len: 32, - max_value_len: 128, - }, - )); - assert!(matches!(err, StorageError::Internal(_))); -} - #[test] -fn new_rejects_region_beyond_capacity() { - let err = expect_invalid(FlashKvStore::new( - RcFlash::new(), - StorageConfig::default(), - FlashConfig::new(0, 512, 9, 32, 128, ERASE_SIZE).unwrap(), - )); - assert!(matches!(err, StorageError::Internal(_))); +fn flash_config_validates_geometry() { + // base_offset not aligned to the erase size + assert!(FlashConfig::new(1, 512, SECTORS, 32, 128, ERASE_SIZE).is_err()); + // fewer than two sectors + assert!(FlashConfig::new(0, 512, 1, 32, 128, ERASE_SIZE).is_err()); + // sector size not a multiple of the erase size + assert!(FlashConfig::new(0, 511, SECTORS, 32, 128, ERASE_SIZE).is_err()); + // key length above the u16 ceiling + assert!(FlashConfig::new(0, 512, SECTORS, 70000, 128, ERASE_SIZE).is_err()); + // zero value length + assert!(FlashConfig::new(0, 512, SECTORS, 32, 0, ERASE_SIZE).is_err()); + // erase size of 1 divides 512, so this is valid + assert!(FlashConfig::new(0, 512, SECTORS, 32, 128, 1).is_ok()); } #[test] -fn new_rejects_record_larger_than_sector() { - let err = expect_invalid(FlashKvStore::new( - RcFlash::new(), - StorageConfig::default(), - FlashConfig::new(0, 512, 8, 32, 500, ERASE_SIZE).unwrap(), - )); - assert!(matches!(err, StorageError::Internal(_))); +fn store_rejects_item_over_64kib() { + // At the FlashConfig level a 70 KiB value is accepted, but sequential-storage + // cannot represent an item larger than 64 KiB, so the store must reject it. + let oversized = FlashConfig::new(0, 512, SECTORS, 32, 70000, ERASE_SIZE).unwrap(); + assert!(FlashKvStore::new(RcFlash::new(), config(true), oversized).is_err()); } #[test] -fn operations_require_open() { - let store = new_store(RcFlash::new()); - let err = block_on(store.get("ns", "k")).unwrap_err(); - assert!(matches!(err, StorageError::Internal(_))); +fn operations_do_not_require_open() { + block_on(async { + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + store + .put("ns", "a", Bytes::from_static(b"1")) + .await + .unwrap(); + assert_eq!( + store.get("ns", "a").await.unwrap(), + Some(Bytes::from_static(b"1")) + ); + }); } -// Basic key-value operations - #[test] -fn put_and_get_roundtrip() { +fn get_put_roundtrip() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - store.put("ns", "k", Bytes::from("hello")).await.unwrap(); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + store + .put("ns", "key", Bytes::from_static(b"value")) + .await + .unwrap(); assert_eq!( - store.get("ns", "k").await.unwrap(), - Some(Bytes::from("hello")) + store.get("ns", "key").await.unwrap(), + Some(Bytes::from_static(b"value")) ); + assert_eq!(store.get("ns", "missing").await.unwrap(), None); }); } #[test] -fn put_overwrites_existing() { +fn rejects_value_exceeding_max_value_len() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - store.put("ns", "k", Bytes::from("v1")).await.unwrap(); - store.put("ns", "k", Bytes::from("v2")).await.unwrap(); - assert_eq!(store.get("ns", "k").await.unwrap(), Some(Bytes::from("v2"))); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + let too_big = Bytes::from(vec![0xABu8; 129]); + let e = store.put("ns", "k", too_big).await.unwrap_err(); + assert!(matches!(e, SaikuroError::QuotaExceeded { .. })); }); } #[test] -fn get_missing_returns_none() { +fn rejects_key_exceeding_max_key_len() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - assert_eq!(store.get("ns", "missing").await.unwrap(), None); - assert!(!store.exists("ns", "missing").await.unwrap()); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + let long_key = "k".repeat(33); + assert!(store + .put("ns", &long_key, Bytes::from_static(b"v")) + .await + .is_err()); }); } #[test] -fn delete_removes_key() { +fn rejects_namespace_exceeding_255_bytes() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - store.put("ns", "k", Bytes::from("v")).await.unwrap(); - store.delete("ns", "k").await.unwrap(); - assert_eq!(store.get("ns", "k").await.unwrap(), None); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + let long_ns = "n".repeat(256); + assert!(store + .put(&long_ns, "k", Bytes::from_static(b"v")) + .await + .is_err()); + assert!(store.create_namespace(&long_ns).await.is_err()); }); } #[test] -fn delete_missing_key_does_not_error() { +fn accepts_item_at_exact_limits() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - store.delete("ns", "missing").await.unwrap(); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + let key = "k".repeat(32); + let value = Bytes::from(vec![0xCDu8; 128]); + store.put("ns", &key, value.clone()).await.unwrap(); + assert_eq!(store.get("ns", &key).await.unwrap(), Some(value)); }); } #[test] -fn list_keys_and_namespaces() { +fn auto_create_creates_namespace_implicitly() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - store.put("ns1", "a", Bytes::from("1")).await.unwrap(); - store.put("ns1", "b", Bytes::from("2")).await.unwrap(); - store.put("ns2", "k", Bytes::from("3")).await.unwrap(); - - let mut keys = store.list_keys("ns1").await.unwrap(); - keys.sort(); - assert_eq!(keys, vec!["a", "b"]); - assert_eq!(store.list_keys("ns2").await.unwrap(), vec!["k"]); - - let mut nss = store.list_namespaces().await.unwrap(); - nss.sort(); - assert_eq!(nss, vec!["ns1", "ns2"]); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + store + .put("auto", "k", Bytes::from_static(b"v")) + .await + .unwrap(); + assert!(store.exists("auto", "k").await.unwrap()); + let namespaces = store.list_namespaces().await.unwrap(); + assert!(namespaces.contains(&"auto".to_string())); }); } -// Namespace lifecycle - #[test] -fn namespace_marker_survives_key_deletion() { +fn auto_create_disabled_returns_not_found() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - store.create_namespace("empty").await.unwrap(); - store.put("empty", "k", Bytes::from("v")).await.unwrap(); - store.delete("empty", "k").await.unwrap(); - assert_eq!(store.list_namespaces().await.unwrap(), vec!["empty"]); + let store = FlashKvStore::new(RcFlash::new(), config(false), flash_config()); + let v = Bytes::from_static(b"v"); + let e = store.put("absent", "k", v.clone()).await.unwrap_err(); + assert!(matches!(e, SaikuroError::NamespaceNotFound(_))); + let e = store.get("absent", "k").await.unwrap_err(); + assert!(matches!(e, SaikuroError::NamespaceNotFound(_))); }); } #[test] -fn create_existing_namespace_errors() { +fn prefix_isolation() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - store.create_namespace("ns").await.unwrap(); - let err = store.create_namespace("ns").await.unwrap_err(); - assert!(matches!(err, StorageError::NamespaceAlreadyExists(_))); + let flash = RcFlash::new(); + let prod = FlashKvStore::new( + flash.clone(), + StorageConfig { + namespace_prefix: Some("prod".to_string()), + auto_create_namespaces: true, + ..Default::default() + }, + flash_config(), + ); + let v = Bytes::from_static(b"v"); + prod.put("ns", "k", v.clone()).await.unwrap(); + + let dev = FlashKvStore::new( + flash.clone(), + StorageConfig { + namespace_prefix: Some("dev".to_string()), + auto_create_namespaces: true, + ..Default::default() + }, + flash_config(), + ); + assert_eq!(dev.get("ns", "k").await.unwrap(), None); + assert_eq!(prod.get("ns", "k").await.unwrap(), Some(v)); }); } #[test] -fn delete_namespace_removes_keys_and_marker() { +fn namespaces_are_independent() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - store.put("ns", "k", Bytes::from("v")).await.unwrap(); - store.delete_namespace("ns").await.unwrap(); - assert_eq!(store.get("ns", "k").await.unwrap(), None); - assert!(store.list_namespaces().await.unwrap().is_empty()); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + let va = Bytes::from_static(b"a"); + let vb = Bytes::from_static(b"b"); + store.put("a", "k", va.clone()).await.unwrap(); + store.put("b", "k", vb.clone()).await.unwrap(); + assert_eq!(store.get("a", "k").await.unwrap(), Some(va)); + assert_eq!(store.get("b", "k").await.unwrap(), Some(vb)); }); } #[test] -fn clear_namespace_keeps_namespace() { +fn durability_survives_reboot() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - store.put("ns", "k", Bytes::from("v")).await.unwrap(); - store.clear_namespace("ns").await.unwrap(); - assert_eq!(store.get("ns", "k").await.unwrap(), None); - assert_eq!(store.list_namespaces().await.unwrap(), vec!["ns"]); - store.put("ns", "k2", Bytes::from("v")).await.unwrap(); - assert!(store.exists("ns", "k2").await.unwrap()); + let flash = RcFlash::new(); + { + let store = FlashKvStore::new(flash.clone(), config(true), flash_config()); + store.create_namespace("user").await.unwrap(); + store + .put("user", "name", Bytes::from_static(b"neo")) + .await + .unwrap(); + store + .put("user", "role", Bytes::from_static(b"admin")) + .await + .unwrap(); + } + // New instance mounted over the same flash: committed data survives. + let store = FlashKvStore::new(flash.clone(), config(true), flash_config()); + assert!(store.exists("user", "name").await.unwrap()); + assert_eq!( + store.get("user", "name").await.unwrap(), + Some(Bytes::from_static(b"neo")) + ); + assert_eq!( + store.get("user", "role").await.unwrap(), + Some(Bytes::from_static(b"admin")) + ); }); } -// Size limits (Tier 2 orchestration bounds) - #[test] -fn rejects_key_over_limit() { +fn mount_tolerates_tail_corruption() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - let long_key = "x".repeat(33); - let err = store - .put("ns", &long_key, Bytes::from("v")) + let flash = RcFlash::new(); + let store = FlashKvStore::new(flash.clone(), config(true), flash_config()); + store + .put("ns", "a", Bytes::from_static(b"1")) .await - .unwrap_err(); - assert!(matches!(err, StorageError::Internal(_))); + .unwrap(); + drop(store); + // Simulate bit flips in an otherwise-unused tail region after a crash. + flash.corrupt_tail(); + let store = FlashKvStore::new(flash, config(true), flash_config()); + assert_eq!( + store.get("ns", "a").await.unwrap(), + Some(Bytes::from_static(b"1")) + ); }); } #[test] -fn rejects_value_over_limit() { +fn compaction_rolls_over_without_data_loss() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - let big = Bytes::from(vec![0u8; 129]); - let err = store.put("ns", "k", big).await.unwrap_err(); - assert!(matches!(err, StorageError::QuotaExceeded(_))); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + let mut written = Vec::new(); + for i in 0..200u32 { + let key = format!("k{i}"); + let value = Bytes::from(vec![(i & 0xFF) as u8; 10]); + match store.put("ns", &key, value.clone()).await { + Ok(()) => written.push((key, value)), + Err(e) if matches!(e, SaikuroError::QuotaExceeded { .. }) => break, + Err(e) => panic!("unexpected error: {e:?}"), + } + } + assert!( + written.len() >= 20, + "expected rollover to absorb at least 20 items, got {}", + written.len() + ); + for (key, value) in &written { + assert_eq!(store.get("ns", key).await.unwrap(), Some(value.clone())); + } }); } #[test] -fn rejects_namespace_over_255_bytes() { +fn quota_exceeded_when_region_full_then_recoverable() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - let long_ns = "n".repeat(256); - let err = store - .put(&long_ns, "k", Bytes::from("v")) - .await - .unwrap_err(); - assert!(matches!(err, StorageError::Internal(_))); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + let value = Bytes::from(vec![0xABu8; 29]); + let mut count = 0; + loop { + let key = format!("k{count}"); + match store.put("ns", &key, value.clone()).await { + Ok(()) => count += 1, + Err(e) if matches!(e, SaikuroError::QuotaExceeded { .. }) => break, + Err(e) => panic!("unexpected error: {e:?}"), + } + assert!(count < 1000, "never hit the quota"); + } + assert!(count >= 5, "expected a handful of items before full, got {count}"); + // Freeing space makes the region writable again. + for i in 0..count / 2 { + store.delete("ns", &format!("k{i}")).await.unwrap(); + } + store.put("ns", "extra", value.clone()).await.unwrap(); + assert_eq!(store.get("ns", "extra").await.unwrap(), Some(value)); }); } -// auto_create_namespaces = false - #[test] -fn put_errors_on_missing_namespace_without_auto_create() { +fn delete_is_idempotent() { block_on(async { - let mut store = FlashKvStore::new( - RcFlash::new(), - StorageConfig { - auto_create_namespaces: false, - ..Default::default() - }, - flash_config(), - ) - .unwrap(); - open(&mut store).await; - let err = store - .put("manual", "k", Bytes::from("v")) - .await - .unwrap_err(); - assert!(matches!(err, StorageError::NamespaceNotFound(_))); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + let v = Bytes::from_static(b"v"); + store.put("ns", "k", v).await.unwrap(); + store.delete("ns", "k").await.unwrap(); + store.delete("ns", "k").await.unwrap(); + assert_eq!(store.get("ns", "k").await.unwrap(), None); }); } -// Namespace prefix isolation - #[test] -fn namespace_prefix_isolates_storage() { +fn list_keys_returns_only_live() { block_on(async { - let mut a = FlashKvStore::new( - RcFlash::new(), - StorageConfig::default().with_prefix("tenant_a"), - flash_config(), - ) - .unwrap(); - let mut b = FlashKvStore::new( - RcFlash::new(), - StorageConfig::default().with_prefix("tenant_b"), - flash_config(), - ) - .unwrap(); - open(&mut a).await; - open(&mut b).await; - - a.put("ns", "k", Bytes::from("from_a")).await.unwrap(); - b.put("ns", "k", Bytes::from("from_b")).await.unwrap(); - - assert_eq!(a.get("ns", "k").await.unwrap(), Some(Bytes::from("from_a"))); - assert_eq!(b.get("ns", "k").await.unwrap(), Some(Bytes::from("from_b"))); - assert_eq!(a.list_namespaces().await.unwrap(), vec!["ns"]); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + let v = Bytes::from_static(b"v"); + store.put("ns", "a", v.clone()).await.unwrap(); + store.put("ns", "b", v.clone()).await.unwrap(); + store.delete("ns", "a").await.unwrap(); + let mut keys = store.list_keys("ns").await.unwrap(); + keys.sort(); + assert_eq!(keys, vec!["b".to_string()]); }); } -// Durability and rollover - #[test] -fn data_survives_reboot() { +fn list_namespaces_includes_all() { block_on(async { - let flash = RcFlash::new(); - { - let mut store = new_store(flash.clone()); - open(&mut store).await; - store - .put("ns", "k", Bytes::from("persisted")) - .await - .unwrap(); - } - { - let mut store = new_store(flash.clone()); - open(&mut store).await; - assert_eq!( - store.get("ns", "k").await.unwrap(), - Some(Bytes::from("persisted")) - ); - } + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + store.create_namespace("a").await.unwrap(); + store.create_namespace("b").await.unwrap(); + let mut ns = store.list_namespaces().await.unwrap(); + ns.sort(); + assert_eq!(ns, vec!["a".to_string(), "b".to_string()]); }); } #[test] -fn rolls_across_sectors_and_reads_back() { +fn namespace_marker_records_existence() { block_on(async { - let flash = RcFlash::new(); - { - let mut store = new_store(flash.clone()); - open(&mut store).await; - for i in 0..40 { - store - .put("ns", &format!("k{i}"), Bytes::from(vec![i as u8; 10])) - .await - .unwrap(); - } - } - { - let mut store = new_store(flash.clone()); - open(&mut store).await; - for i in 0..40 { - assert_eq!( - store.get("ns", &format!("k{i}")).await.unwrap(), - Some(Bytes::from(vec![i as u8; 10])), - "key k{i} after reboot" - ); - } - } + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + store.create_namespace("ns").await.unwrap(); + let mut ns = store.list_namespaces().await.unwrap(); + ns.sort(); + assert_eq!(ns, vec!["ns".to_string()]); + let e = store.create_namespace("ns").await.unwrap_err(); + assert!(matches!(e, SaikuroError::NamespaceAlreadyExists(_))); }); } #[test] -fn compaction_reclaims_space_under_overwrite() { +fn tombstone_distinguishes_present_from_absent() { block_on(async { - let flash = RcFlash::new(); - let mut store = new_store(flash.clone()); - open(&mut store).await; - for i in 0..8 { - store - .put("ns", &format!("k{i}"), Bytes::from(vec![i as u8; 100])) - .await - .unwrap(); - } - for round in 0..60 { - store - .put("ns", "k0", Bytes::from(vec![round as u8; 100])) - .await - .unwrap(); - } - for i in 1..8 { - assert_eq!( - store.get("ns", &format!("k{i}")).await.unwrap(), - Some(Bytes::from(vec![i as u8; 100])), - "key k{i} after compaction" - ); - } - assert_eq!( - store.get("ns", "k0").await.unwrap(), - Some(Bytes::from(vec![59u8; 100])) - ); - - let mut reopened = new_store(flash.clone()); - open(&mut reopened).await; - for i in 1..8 { - assert_eq!( - reopened.get("ns", &format!("k{i}")).await.unwrap(), - Some(Bytes::from(vec![i as u8; 100])) - ); - } + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + let v = Bytes::from_static(b"v"); + store.put("ns", "k", v).await.unwrap(); + assert!(store.exists("ns", "k").await.unwrap()); + store.delete("ns", "k").await.unwrap(); + assert!(!store.exists("ns", "k").await.unwrap()); + assert_eq!(store.get("ns", "k").await.unwrap(), None); + assert!(!store.exists("ns", "never").await.unwrap()); + assert_eq!(store.get("ns", "never").await.unwrap(), None); }); } #[test] -fn compaction_preserves_tombstones_and_markers() { +fn clear_namespace_keeps_namespace() { block_on(async { - let flash = RcFlash::new(); - let mut store = new_store(flash.clone()); - open(&mut store).await; - store - .put("ns", "dead", Bytes::from(vec![1u8; 100])) - .await - .unwrap(); - store - .put("ns", "live", Bytes::from(vec![2u8; 100])) - .await - .unwrap(); - store.delete("ns", "dead").await.unwrap(); - for _ in 0..60 { - store - .put("ns", "churn", Bytes::from(vec![3u8; 100])) - .await - .unwrap(); - } - assert_eq!(store.get("ns", "dead").await.unwrap(), None); - assert_eq!( - store.get("ns", "live").await.unwrap(), - Some(Bytes::from(vec![2u8; 100])) - ); - - let mut reopened = new_store(flash.clone()); - open(&mut reopened).await; - assert_eq!(reopened.get("ns", "dead").await.unwrap(), None); - assert_eq!( - reopened.get("ns", "live").await.unwrap(), - Some(Bytes::from(vec![2u8; 100])) - ); - let mut nss = reopened.list_namespaces().await.unwrap(); - nss.sort(); - assert_eq!(nss, vec!["ns"]); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + let v = Bytes::from_static(b"v"); + store.create_namespace("ns").await.unwrap(); + store.put("ns", "a", v).await.unwrap(); + store.clear_namespace("ns").await.unwrap(); + assert_eq!(store.get("ns", "a").await.unwrap(), None); + assert!(!store.exists("ns", "a").await.unwrap()); + let ns = store.list_namespaces().await.unwrap(); + assert!(ns.contains(&"ns".to_string())); }); } -// Quota - #[test] -fn quota_exceeded_when_region_full_then_recoverable() { +fn delete_namespace_removes_all() { block_on(async { - let mut store = new_store(RcFlash::new()); - open(&mut store).await; - - let mut err = None; - for i in 0..64 { - if let Err(e) = store - .put("ns", &format!("k{i}"), Bytes::from(vec![i as u8; 100])) - .await - { - err = Some((i, e)); - break; - } - } - let (full_at, err) = err.expect("region must fill before 64 distinct keys"); - assert!(matches!(err, StorageError::QuotaExceeded(_))); - assert!( - full_at >= 29, - "region should hold ~30 keys, filled at {full_at}" - ); - - for i in 0..full_at { - assert_eq!( - store.get("ns", &format!("k{i}")).await.unwrap(), - Some(Bytes::from(vec![i as u8; 100])), - "key k{i} after quota error" - ); - } - - for i in 0..4 { - store.delete("ns", &format!("k{i}")).await.unwrap(); - } - store - .put("ns", &format!("k{full_at}"), Bytes::from(vec![9u8; 100])) - .await - .expect("deleting keys must free compaction space"); - assert_eq!( - store.get("ns", &format!("k{full_at}")).await.unwrap(), - Some(Bytes::from(vec![9u8; 100])) - ); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + let v = Bytes::from_static(b"v"); + store.create_namespace("ns").await.unwrap(); + store.put("ns", "a", v.clone()).await.unwrap(); + store.put("ns", "b", v).await.unwrap(); + store.delete_namespace("ns").await.unwrap(); + // The namespace is gone entirely. + let e = store.get("ns", "a").await.unwrap_err(); + assert!(matches!(e, SaikuroError::NamespaceNotFound(_))); + let ns = store.list_namespaces().await.unwrap(); + assert!(!ns.contains(&"ns".to_string())); }); } -// Torn-write recovery - #[test] -fn open_truncates_torn_tail_record() { +fn deleting_unknown_namespace_is_ok() { block_on(async { - let flash = RcFlash::new(); - { - let mut store = new_store(flash.clone()); - open(&mut store).await; - store - .put("ns", "a", Bytes::from(vec![1u8; 10])) - .await - .unwrap(); - store - .put("ns", "b", Bytes::from(vec![2u8; 10])) - .await - .unwrap(); - } - // Sector 0: 8-byte seq header, namespace marker at [8..24), "a" at - // [24..52), "b" at [52..80). Corrupt "b"'s ns_len byte so its header - // is invalid. - { - let mut fake = flash.0.borrow_mut(); - fake.data[52 + 1] = 0xFF; - } - let mut reopened = new_store(flash.clone()); - open(&mut reopened).await; - assert_eq!( - reopened.get("ns", "a").await.unwrap(), - Some(Bytes::from(vec![1u8; 10])) - ); - assert_eq!(reopened.get("ns", "b").await.unwrap(), None); + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + store.delete_namespace("ghost").await.unwrap(); }); } From 647af77638e4c469e2d7dd2bc3bd0b13760f12ec Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Sat, 15 Aug 2026 13:44:01 -0600 Subject: [PATCH 33/43] saikuro-transport --- Build/Cargo.toml | 1 + Build/crates/saikuro-net/lib.rs | 19 +- Build/crates/saikuro-net/wasm/mod.rs | 5 - Build/crates/saikuro-transport/Cargo.toml | 61 ++- .../io_transport.rs} | 80 +--- .../saikuro-transport/src/embedded/mod.rs | 5 + .../saikuro-transport/src/embedded/tcp.rs | 193 ++++++++ Build/crates/saikuro-transport/src/framing.rs | 317 ------------- Build/crates/saikuro-transport/src/lib.rs | 194 ++++---- .../saikuro-transport/src/native/framed.rs | 181 ++++++++ .../saikuro-transport/src/native/mod.rs | 11 + .../saikuro-transport/src/{ => native}/tcp.rs | 24 +- .../src/{ => native}/unix.rs | 18 +- .../saikuro-transport/src/native/websocket.rs | 176 ++++++++ .../src/{ => shared}/error.rs | 11 - .../saikuro-transport/src/shared/framed.rs | 36 ++ .../saikuro-transport/src/shared/framing.rs | 101 +++++ .../saikuro-transport/src/shared/host.rs | 158 +++++++ .../src/{ => shared}/memory.rs | 8 +- .../saikuro-transport/src/shared/mod.rs | 7 + .../src/{ => shared}/selector.rs | 17 - .../src/{ => shared}/traits.rs | 60 ++- .../crates/saikuro-transport/src/wasi/host.rs | 94 ++++ .../crates/saikuro-transport/src/wasi/mod.rs | 9 + .../saikuro-transport/src/wasi/preview1.rs | 199 ++++++++ .../saikuro-transport/src/wasi/preview2.rs | 115 +++++ .../crates/saikuro-transport/src/wasi/tcp.rs | 193 ++++++++ .../src/wasm/host_browser.rs | 251 ++++++++++ .../crates/saikuro-transport/src/wasm/mod.rs | 5 + .../saikuro-transport/src/wasm/websocket.rs | 220 +++++++++ .../crates/saikuro-transport/src/wasm_host.rs | 385 ---------------- .../crates/saikuro-transport/src/websocket.rs | 427 ------------------ 32 files changed, 2167 insertions(+), 1414 deletions(-) delete mode 100644 Build/crates/saikuro-net/wasm/mod.rs rename Build/crates/saikuro-transport/src/{embedded_io.rs => embedded/io_transport.rs} (56%) create mode 100644 Build/crates/saikuro-transport/src/embedded/mod.rs create mode 100644 Build/crates/saikuro-transport/src/embedded/tcp.rs delete mode 100644 Build/crates/saikuro-transport/src/framing.rs create mode 100644 Build/crates/saikuro-transport/src/native/framed.rs create mode 100644 Build/crates/saikuro-transport/src/native/mod.rs rename Build/crates/saikuro-transport/src/{ => native}/tcp.rs (85%) rename Build/crates/saikuro-transport/src/{ => native}/unix.rs (89%) create mode 100644 Build/crates/saikuro-transport/src/native/websocket.rs rename Build/crates/saikuro-transport/src/{ => shared}/error.rs (67%) create mode 100644 Build/crates/saikuro-transport/src/shared/framed.rs create mode 100644 Build/crates/saikuro-transport/src/shared/framing.rs create mode 100644 Build/crates/saikuro-transport/src/shared/host.rs rename Build/crates/saikuro-transport/src/{ => shared}/memory.rs (91%) create mode 100644 Build/crates/saikuro-transport/src/shared/mod.rs rename Build/crates/saikuro-transport/src/{ => shared}/selector.rs (81%) rename Build/crates/saikuro-transport/src/{ => shared}/traits.rs (59%) create mode 100644 Build/crates/saikuro-transport/src/wasi/host.rs create mode 100644 Build/crates/saikuro-transport/src/wasi/mod.rs create mode 100644 Build/crates/saikuro-transport/src/wasi/preview1.rs create mode 100644 Build/crates/saikuro-transport/src/wasi/preview2.rs create mode 100644 Build/crates/saikuro-transport/src/wasi/tcp.rs create mode 100644 Build/crates/saikuro-transport/src/wasm/host_browser.rs create mode 100644 Build/crates/saikuro-transport/src/wasm/mod.rs create mode 100644 Build/crates/saikuro-transport/src/wasm/websocket.rs delete mode 100644 Build/crates/saikuro-transport/src/wasm_host.rs delete mode 100644 Build/crates/saikuro-transport/src/websocket.rs diff --git a/Build/Cargo.toml b/Build/Cargo.toml index ce3bd5ff..4c4a4b70 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -116,6 +116,7 @@ sled = "0.34" graphitesql = "0.1.6" wit-bindgen = "0.46" wasi = "0.14" +wasip1 = "0.14" # Futures futures-executor = "0.3" diff --git a/Build/crates/saikuro-net/lib.rs b/Build/crates/saikuro-net/lib.rs index d3c9495d..4d4e1855 100644 --- a/Build/crates/saikuro-net/lib.rs +++ b/Build/crates/saikuro-net/lib.rs @@ -3,7 +3,7 @@ //! Networking and IO facade for Saikuro. -// Exactly one engine must be selected +// Exactly one engine must be selected. #[cfg(any( all(feature = "native", any(feature = "no_std", feature = "wasm", feature = "embedded")), all(feature = "no_std", any(feature = "native", feature = "wasm", feature = "embedded")), @@ -18,25 +18,12 @@ compile_error!("exactly one engine must be enabled: native | no_std | wasm | emb #[cfg(all(feature = "std", feature = "no_std"))] compile_error!("the no_std engine cannot be combined with the std toolchain"); -mod shared; -pub use shared::*; - -#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] -mod base; -#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] -pub use base::*; - #[cfg(feature = "native")] mod native; #[cfg(feature = "native")] -pub use native::*; - -#[cfg(feature = "wasm")] -mod wasm; -#[cfg(feature = "wasm")] -pub use wasm::*; +pub use native::{net, io}; #[cfg(feature = "embedded")] mod embedded; #[cfg(feature = "embedded")] -pub use embedded::*; +pub use embedded::{net, io}; diff --git a/Build/crates/saikuro-net/wasm/mod.rs b/Build/crates/saikuro-net/wasm/mod.rs deleted file mode 100644 index ad19d953..00000000 --- a/Build/crates/saikuro-net/wasm/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Browser (`wasm32-unknown-unknown`) engine. -//! -//! Networking on the browser has no TCP/UDP socket API exposed to Rust, so -//! `net`/`io` are not provided here. A WASI (preview1 or preview2 component) -//! build should select the `no_std` engine instead. diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index 83463f28..9550f223 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "saikuro-transport" -description = "Transport layer for Saikuro: in-memory, Unix socket, TCP, and WebSocket backends" +description = "Transport layer for Saikuro: in-memory, Unix socket, TCP, WASI, and WebSocket backends" version.workspace = true edition.workspace = true authors.workspace = true @@ -9,38 +9,53 @@ repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "transport", "async"] [features] -default = ["std", "native-transport"] +default = ["std", "native", "tcp", "ws"] std = [] -embassy = ["saikuro-exec/embassy-runtime", "saikuro-core/embedded"] -embedded-io = ["dep:embedded-io-async"] -native-transport = ["std", "saikuro-exec/tokio-runtime", "saikuro-core/std"] -ws-transport = [] -wasm-runtime = [ +native = [ "std", - "saikuro-core/std-no-os", - "saikuro-exec/wasm-runtime", - "saikuro-random/wasm", + "saikuro-core/native", + "saikuro-net/native", + "saikuro-exec/native", + "dep:tokio-tungstenite", ] -native-ws = ["ws-transport", "std", "saikuro-exec/tokio-runtime", "saikuro-core/std", "tokio-tungstenite"] +no_std = ["saikuro-core/no_std", "saikuro-net/no_std", "saikuro-exec/no_std"] +wasm = ["saikuro-core/wasm", "saikuro-net/wasm", "saikuro-exec/wasm"] +embedded = [ + "saikuro-core/embedded", + "saikuro-net/embedded", + "saikuro-exec/embedded", + "dep:embedded-io-async", + "dep:embassy-sync", +] + +tcp = [] +unix = [] +ws = [] +wasm-host = ["wasm"] +wasi-tcp = ["no_std", "saikuro-exec/no_std", "dep:embedded-io-async"] +wasi-host = ["no_std", "saikuro-exec/no_std", "wasi-tcp"] +wasi-preview2 = ["dep:wasi"] +wasi-preview1 = [] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } - -serde = { workspace = true } -bytes = { workspace = true } +saikuro-net = { path = "../saikuro-net", default-features = false } +saikuro-exec = { path = "../saikuro-exec", default-features = false } +saikuro-random = { path = "../saikuro-random", default-features = false } +serde = { workspace = true } +bytes = { workspace = true, default-features = false, features = ["alloc"] } async-trait = { workspace = true } -futures = { workspace = true } +futures = { workspace = true, default-features = false, features = ["async-await", "alloc"] } pin-project-lite = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true } -saikuro-exec = { workspace = true, default-features = false } -saikuro-random = { workspace = true, default-features = false } +thiserror = { workspace = true } +tracing = { workspace = true, default-features = false, features = ["log", "attributes"] } embedded-io-async = { workspace = true, optional = true } +embassy-sync = { workspace = true, optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio-tungstenite = { workspace = true, optional = true } -[target.'cfg(target_arch = "wasm32")'.dependencies] +[target.'cfg(all(target_arch = "wasm32", feature = "wasm"))'.dependencies] send_wrapper = { workspace = true } wasm-bindgen = { workspace = true } js-sys = { workspace = true } @@ -56,6 +71,10 @@ web-sys = { workspace = true, features = [ "BinaryType", ] } +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasi = { workspace = true, optional = true } +wasip1 = { workspace = true, optional = true } + [dev-dependencies] tracing-subscriber = { workspace = true } -futures = { workspace = true, features = ["executor"] } +futures = { workspace = true, default-features = false, features = ["async-await", "alloc", "executor"] } diff --git a/Build/crates/saikuro-transport/src/embedded_io.rs b/Build/crates/saikuro-transport/src/embedded/io_transport.rs similarity index 56% rename from Build/crates/saikuro-transport/src/embedded_io.rs rename to Build/crates/saikuro-transport/src/embedded/io_transport.rs index 58fe9112..5d8dfaef 100644 --- a/Build/crates/saikuro-transport/src/embedded_io.rs +++ b/Build/crates/saikuro-transport/src/embedded/io_transport.rs @@ -1,26 +1,10 @@ use alloc::string::ToString; use bytes::{Bytes, BytesMut}; -use core::future::Future; -use embedded_io_async::{Error, Read, Write}; +use embedded_io_async::{Read, Write}; -use crate::error::{Result, TransportError}; - -const HEADER_LEN: usize = 4; - -/// A local, statically-dispatched sender for a transport. -pub trait LocalTransportSender { - /// Send one length-prefixed binary frame and flush it to the writer. - fn send(&mut self, frame: Bytes) -> impl Future> + '_; - - /// Flush and close the writer if its implementation supports shutdown. - fn close(&mut self) -> impl Future> + '_; -} - -/// A local, statically-dispatched receiver for a transport. -pub trait LocalTransportReceiver { - /// Receive the next frame. `Ok(None)` is a clean EOF at a frame boundary. - fn recv(&mut self) -> impl Future>> + '_; -} +use crate::shared::framed::{read_exact, read_first_byte, write_all, HEADER_LEN}; +use crate::shared::error::{Result, TransportError}; +use crate::shared::traits::{LocalTransportReceiver, LocalTransportSender}; /// A framed transport composed from independently owned reader and writer halves. pub struct EmbeddedIoTransport { @@ -42,10 +26,10 @@ pub struct EmbeddedIoReceiver { } impl EmbeddedIoTransport { - /// Creates a transport and rejects limits above [`crate::MAX_FRAME_SIZE`]. + /// Construct a transport from `reader` and `writer`. /// - /// No payload allocation occurs during construction or while inspecting a - /// hostile header. A zero limit is valid and permits only empty frames. + /// Errors if `max_frame_size` exceeds the crate-wide [`MAX_FRAME_SIZE`](crate::MAX_FRAME_SIZE) + /// limit. pub fn new(reader: R, writer: W, max_frame_size: usize) -> Result { if max_frame_size > crate::MAX_FRAME_SIZE { return Err(TransportError::MessageTooLarge { @@ -60,7 +44,7 @@ impl EmbeddedIoTransport { }) } - /// Splits the transport into its separately-owned local halves. + /// Split into independent sender and receiver halves. pub fn split(self) -> (EmbeddedIoSender, EmbeddedIoReceiver) { ( EmbeddedIoSender { @@ -83,8 +67,7 @@ impl LocalTransportSender for EmbeddedIoSender { limit: self.max_frame_size, }); } - - let mut header = [0; HEADER_LEN]; + let mut header = [0u8; HEADER_LEN]; header.copy_from_slice(&(frame.len() as u32).to_be_bytes()); write_all(&mut self.writer, &header).await?; write_all(&mut self.writer, &frame).await?; @@ -104,7 +87,7 @@ impl LocalTransportSender for EmbeddedIoSender { impl LocalTransportReceiver for EmbeddedIoReceiver { async fn recv(&mut self) -> Result> { - let mut header = [0; HEADER_LEN]; + let mut header = [0u8; HEADER_LEN]; if read_first_byte(&mut self.reader, &mut header[0]).await? == 0 { return Ok(None); } @@ -114,7 +97,6 @@ impl LocalTransportReceiver for EmbeddedIoReceiver { "connection closed during frame header", ) .await?; - let frame_len = u32::from_be_bytes(header) as usize; if frame_len > self.max_frame_size { return Err(TransportError::MessageTooLarge { @@ -122,7 +104,6 @@ impl LocalTransportReceiver for EmbeddedIoReceiver { limit: self.max_frame_size, }); } - let mut payload = BytesMut::zeroed(frame_len); read_exact( &mut self.reader, @@ -133,44 +114,3 @@ impl LocalTransportReceiver for EmbeddedIoReceiver { Ok(Some(payload.freeze())) } } - -async fn read_first_byte(reader: &mut R, byte: &mut u8) -> Result { - reader - .read(core::slice::from_mut(byte)) - .await - .map_err(|error| TransportError::ReceiveFailed(error.kind().to_string())) -} - -async fn read_exact( - reader: &mut R, - mut target: &mut [u8], - eof_message: &'static str, -) -> Result<()> { - while !target.is_empty() { - let count = reader - .read(target) - .await - .map_err(|error| TransportError::ReceiveFailed(error.kind().to_string()))?; - if count == 0 { - return Err(TransportError::FramingError(eof_message.into())); - } - target = &mut target[count..]; - } - Ok(()) -} - -async fn write_all(writer: &mut W, mut source: &[u8]) -> Result<()> { - while !source.is_empty() { - let count = writer - .write(source) - .await - .map_err(|error| TransportError::SendFailed(error.kind().to_string()))?; - if count == 0 { - return Err(TransportError::FramingError( - "write made no progress".into(), - )); - } - source = &source[count..]; - } - Ok(()) -} diff --git a/Build/crates/saikuro-transport/src/embedded/mod.rs b/Build/crates/saikuro-transport/src/embedded/mod.rs new file mode 100644 index 00000000..76ac8a88 --- /dev/null +++ b/Build/crates/saikuro-transport/src/embedded/mod.rs @@ -0,0 +1,5 @@ +pub mod framed; +pub mod io_transport; + +#[cfg(feature = "tcp")] +pub mod tcp; diff --git a/Build/crates/saikuro-transport/src/embedded/tcp.rs b/Build/crates/saikuro-transport/src/embedded/tcp.rs new file mode 100644 index 00000000..6fe1a09f --- /dev/null +++ b/Build/crates/saikuro-transport/src/embedded/tcp.rs @@ -0,0 +1,193 @@ +use async_trait::async_trait; +use bytes::{Bytes, BytesMut}; +use core::sync::Arc; +use embassy_sync::blocking_mutex::{CriticalSectionRawMutex, Mutex as BlockingMutex}; +use tracing::debug; + +use saikuro_net::net::{IpEndpoint, IpListenEndpoint, Stack, TcpSocket}; + +use crate::shared::framed::{read_exact, read_first_byte, write_all, HEADER_LEN}; +use crate::shared::error::{Result, TransportError}; +use crate::shared::traits::{ + Transport, TransportConnector, TransportListener, TransportReceiver, TransportSender, +}; + +type SharedSocket = Arc>>; + +/// A TCP transport connection (embedded / embassy-net). +pub struct TcpTransport { + socket: SharedSocket, + peer: IpEndpoint, +} + +impl TcpTransport { + /// Wrap an already-connected embassy-net [`TcpSocket`]. + pub fn new(socket: TcpSocket<'static>, peer: IpEndpoint) -> Self { + Self { + socket: Arc::new(BlockingMutex::new(socket)), + peer, + } + } +} + +impl Transport for TcpTransport { + type Sender = TcpSender; + type Receiver = TcpReceiver; + + fn split(self) -> (Self::Sender, Self::Receiver) { + let socket = self.socket.clone(); + ( + TcpSender { + socket: socket.clone(), + peer: self.peer, + }, + TcpReceiver { + socket, + peer: self.peer, + }, + ) + } + + fn description(&self) -> &str { + "tcp" + } +} + +/// Sending half of an embedded TCP transport. +pub struct TcpSender { + socket: SharedSocket, + peer: IpEndpoint, +} + +#[async_trait] +impl TransportSender for TcpSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + if frame.len() > crate::MAX_FRAME_SIZE { + return Err(TransportError::MessageTooLarge { + size: frame.len(), + limit: crate::MAX_FRAME_SIZE, + }); + } + let mut header = [0u8; HEADER_LEN]; + header.copy_from_slice(&(frame.len() as u32).to_be_bytes()); + let mut socket = self.socket.lock(); + write_all(&mut *socket, &header).await?; + write_all(&mut *socket, &frame).await?; + Ok(()) + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} + +/// Receiving half of an embedded TCP transport. +pub struct TcpReceiver { + socket: SharedSocket, + peer: IpEndpoint, +} + +#[async_trait] +impl TransportReceiver for TcpReceiver { + async fn recv(&mut self) -> Result> { + let mut socket = self.socket.lock(); + let mut header = [0u8; HEADER_LEN]; + if read_first_byte(&mut *socket, &mut header[0]).await? == 0 { + return Ok(None); + } + read_exact( + &mut *socket, + &mut header[1..], + "connection closed during frame header", + ) + .await?; + let frame_len = u32::from_be_bytes(header) as usize; + if frame_len > crate::MAX_FRAME_SIZE { + return Err(TransportError::MessageTooLarge { + size: frame_len, + limit: crate::MAX_FRAME_SIZE, + }); + } + let mut payload = BytesMut::zeroed(frame_len); + read_exact( + &mut *socket, + &mut payload, + "connection closed during frame payload", + ) + .await?; + Ok(Some(payload.freeze())) + } +} + +/// Establishes outgoing TCP connections over embassy-net. +pub struct TcpConnector { + stack: &'static Stack<'static>, + remote: IpEndpoint, +} + +impl TcpConnector { + /// Create a connector bound to `stack` targeting `remote`. + pub fn new(stack: &'static Stack<'static>, remote: IpEndpoint) -> Self { + Self { stack, remote } + } +} + +#[async_trait] +impl TransportConnector for TcpConnector { + type Output = TcpTransport; + + async fn connect(&self) -> Result { + debug!(remote = ?self.remote, "embedded tcp connecting"); + let socket = TcpSocket::connect(self.stack, self.remote) + .await + .map_err(|e| TransportError::ConnectionRefused(format!("tcp connect failed: {e:?}")))?; + let peer = socket.remote_endpoint().unwrap_or(self.remote); + Ok(TcpTransport::new(socket, peer)) + } +} + +/// Accepts incoming TCP connections over embassy-net. +/// +/// Each accepted connection yields a fresh [`TcpSocket`] on the shared stack. +pub struct TcpTransportListener { + stack: &'static Stack<'static>, + local: IpEndpoint, +} + +impl TcpTransportListener { + /// Create a listener bound to `local` of `stack`. + pub fn new(stack: &'static Stack<'static>, local: IpEndpoint) -> Self { + Self { stack, local } + } + + /// Return the endpoint this listener is bound to. + pub fn local_addr(&self) -> IpEndpoint { + self.local + } +} + +#[async_trait] +impl TransportListener for TcpTransportListener { + type Output = TcpTransport; + + async fn accept(&mut self) -> Result> { + // embassy-net has no TcpListener: spin up a socket, put it in + // listening mode, and await the single connection it accepts. + let mut socket = TcpSocket::new(self.stack); + socket + .accept(IpListenEndpoint { + addr: Some(self.local.addr), + port: self.local.port, + }) + .await + .map_err(|e| TransportError::ConnectionRefused(format!("tcp accept failed: {e:?}")))?; + let peer = socket.remote_endpoint().unwrap_or(self.local); + debug!(peer = ?peer, "embedded tcp accepted connection"); + Ok(Some(TcpTransport::new(socket, peer))) + } + + async fn close(&mut self) -> Result<()> { + debug!(local = ?self.local, "embedded tcp listener closing"); + Ok(()) + } +} diff --git a/Build/crates/saikuro-transport/src/framing.rs b/Build/crates/saikuro-transport/src/framing.rs deleted file mode 100644 index 7999f709..00000000 --- a/Build/crates/saikuro-transport/src/framing.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! Length-prefixed framing for byte-stream transports. -//! -//! Raw stream transports (TCP, Unix sockets) deliver an unbroken river of -//! bytes with no inherent message boundaries. We impose message framing with -//! a simple 4-byte big-endian length prefix before every frame: -//! -//! +--------+------------------------------+ -//! | u32 | payload | -//! | len | len bytes | -//! +--------+------------------------------+ -//! -//! The [`LengthPrefixedCodec`] is pure byte-slicing over `BytesMut` and -//! compiles without `std` or any tokio dependency, so the same framing logic -//! is reused verbatim by native transports and the future embedded-io -//! backend. [`FramedStream`] wraps the codec around an async byte stream and -//! is the native (`tokio`) adapter used by [`crate::tcp::TcpTransport`] and -//! [`crate::unix::UnixTransport`]. - -use bytes::{Buf, BufMut, Bytes, BytesMut}; - -use crate::error::{Result, TransportError}; - -pub use crate::MAX_FRAME_SIZE; - -/// Codec that frames a byte stream into discrete length-prefixed messages. -#[derive(Debug, Clone, Default)] -pub struct LengthPrefixedCodec { - /// Once we've read the length header we cache it here to avoid re-parsing. - pending_len: Option, - /// Payload bytes still owed for a frame whose length header exceeded - /// [`MAX_FRAME_SIZE`]. The header is consumed but the declared payload - /// must be swallowed so it is not misinterpreted as a fresh header. - discard_remaining: u64, -} - -impl LengthPrefixedCodec { - pub fn new() -> Self { - Self::default() - } - - /// Return whether decoding has consumed a header and still expects bytes. - #[cfg(feature = "native-transport")] - pub(crate) fn has_pending_frame(&self) -> bool { - self.pending_len.is_some() || self.discard_remaining != 0 - } - - /// Decode the next complete frame from `src`, returning `Ok(None)` until a - /// full frame is buffered. Consumes the header and payload from the front - /// of `src` when a frame is returned. A header over the size limit yields - /// `MessageTooLarge` and the codec then discards the declared payload on - /// subsequent calls so it resynchronizes at the next real header. - pub fn decode(&mut self, src: &mut BytesMut) -> Result> { - // Swallow any payload owed by a rejected oversized frame before - // touching normal framing state. - if self.discard_remaining > 0 { - let take = core::cmp::min(self.discard_remaining, src.len() as u64); - src.advance(take as usize); - self.discard_remaining -= take; - if self.discard_remaining > 0 { - return Ok(None); - } - } - - // Phase 1: read the 4-byte length header if we don't have it yet. - let frame_len = match self.pending_len { - Some(len) => len, - None => { - if src.len() < 4 { - // Not enough bytes yet; ask for more. - return Ok(None); - } - let len = u32::from_be_bytes([src[0], src[1], src[2], src[3]]); - src.advance(4); - self.pending_len = Some(len); - len - } - }; - - let frame_len = - usize::try_from(frame_len).map_err(|_| message_too_large(frame_len as usize))?; - - if frame_len > MAX_FRAME_SIZE { - // The declared payload will never be decoded, so count it against - // the discard budget instead of resetting and letting the next - // call misread payload bytes as a length header. - self.pending_len = None; - self.discard_remaining = frame_len as u64; - return Err(message_too_large(frame_len)); - } - - // Phase 2: wait until the full payload has arrived. - if src.len() < frame_len { - // Reserve exactly the bytes we still need to avoid churn. - src.reserve(frame_len - src.len()); - return Ok(None); - } - - // We have a complete frame. - self.pending_len = None; - let payload = src.split_to(frame_len).freeze(); - Ok(Some(payload)) - } - - /// Encode `item` as a length-prefixed frame appended to `dst`. - pub fn encode(&mut self, item: Bytes, dst: &mut BytesMut) -> Result<()> { - let len = item.len(); - if len > MAX_FRAME_SIZE { - return Err(message_too_large(len)); - } - - dst.reserve(4 + len); - dst.put_u32(u32::try_from(len).map_err(|_| message_too_large(len))?); - dst.put(item); - Ok(()) - } -} - -fn message_too_large(size: usize) -> TransportError { - TransportError::MessageTooLarge { - size, - limit: MAX_FRAME_SIZE, - } -} - -/// Async byte stream framed into discrete messages. -/// -/// Drop-in replacement for `tokio_util::codec::Framed` that stays within this -/// crate's own codec so we don't depend on tokio-util for framing. Implements -/// `Stream>` for reads and `Sink` for writes; use -/// [`FramedStream::split`] to obtain independent halves. -#[cfg(feature = "native-transport")] -pub mod framed { - use core::pin::Pin; - use core::task::{Context, Poll}; - - use bytes::{Buf, BufMut}; - use futures::{ready, Sink, Stream}; - use pin_project_lite::pin_project; - use saikuro_exec::io::{AsyncRead, AsyncWrite}; - - use super::LengthPrefixedCodec; - use crate::error::{Result, TransportError}; - - /// Minimum capacity to make available for each read when no frame is - /// pending. Large enough to amortize syscalls without over-committing - /// memory on small frames; when a frame is pending, decode reserves the - /// exact remaining frame bytes so the read spans the whole frame. - const READ_CHUNK: usize = 4096; - - pin_project! { - pub struct FramedStream { - #[pin] - inner: S, - codec: LengthPrefixedCodec, - read_buf: bytes::BytesMut, - write_buf: bytes::BytesMut, - // Set once a framing, I/O, or truncation error is surfaced so the - // stream stays terminal and later polls report the end instead of - // resuming on an unaligned byte stream. - failed: bool, - } - } - - impl FramedStream { - pub fn new(inner: S) -> Self { - Self { - inner, - codec: LengthPrefixedCodec::new(), - read_buf: bytes::BytesMut::new(), - write_buf: bytes::BytesMut::new(), - failed: false, - } - } - - /// Split into a sink (write half) and a stream (read half). - /// - /// `StreamExt::split` produces both halves from a single underlying - /// `BiLock` so they stay safely paired. - pub fn split( - self, - ) -> ( - futures::stream::SplitSink, - futures::stream::SplitStream, - ) { - futures::StreamExt::split::(self) - } - } - - impl Stream for FramedStream { - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut this = self.project(); - - if *this.failed { - return Poll::Ready(None); - } - - loop { - // decode any complete frames already buffered. - match this.codec.decode(this.read_buf) { - Ok(Some(frame)) => return Poll::Ready(Some(Ok(frame))), - Ok(None) => {} - Err(e) => { - // Corrupt or oversized frame; the byte stream is no - // longer aligned, so surface the error and terminate. - *this.failed = true; - return Poll::Ready(Some(Err(e))); - } - } - - // Read directly into the uninitialized tail of read_buf. When - // a frame is pending, decode already reserved the remaining - // frame bytes so chunk_mut spans the whole frame; otherwise - // reserve the chunk size so the read still has a writable - // target. advance_mut only appends the filled bytes, so a - // Pending read leaves no phantom bytes behind. - this.read_buf.reserve(READ_CHUNK); - let filled = { - let dst = this.read_buf.chunk_mut(); - // SAFETY: chunk_mut borrows the uninitialized tail of the - // buffer; the slice is only filled by poll_read below - // before we advance_mut by the filled length. - let dst = unsafe { dst.as_uninit_slice_mut() }; - let mut read_buf = saikuro_exec::io::ReadBuf::uninit(dst); - match ready!(this.inner.as_mut().poll_read(cx, &mut read_buf)) { - Ok(()) => read_buf.filled().len(), - Err(e) => { - *this.failed = true; - return Poll::Ready(Some(Err(TransportError::from(e)))); - } - } - }; - // SAFETY: poll_read initialized the first `filled` bytes. - unsafe { this.read_buf.advance_mut(filled) }; - - if filled == 0 { - // EOF from the peer. A clean close happens only at a - // frame boundary; leftover bytes mean a truncated frame. - if this.read_buf.is_empty() && !this.codec.has_pending_frame() { - return Poll::Ready(None); - } - *this.failed = true; - return Poll::Ready(Some(Err(TransportError::FramingError( - "connection closed mid-frame".into(), - )))); - } - - // More bytes arrived; loop back to decode them. - } - } - } - - impl Sink for FramedStream { - type Error = TransportError; - - fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - if self.as_ref().project_ref().write_buf.is_empty() { - return Poll::Ready(Ok(())); - } - self.poll_flush(cx) - } - - fn start_send(self: Pin<&mut Self>, item: bytes::Bytes) -> Result<()> { - let this = self.project(); - this.codec.encode(item, this.write_buf)?; - Ok(()) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - ready!(flush_write_buf(self.as_mut(), cx))?; - self.project() - .inner - .poll_flush(cx) - .map_err(TransportError::from) - } - - fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - ready!(flush_write_buf(self.as_mut(), cx))?; - let flushed = ready!(self.as_mut().project().inner.poll_flush(cx)); - match flushed { - Err(e) => Poll::Ready(Err(TransportError::from(e))), - Ok(()) => self - .project() - .inner - .poll_shutdown(cx) - .map_err(TransportError::from), - } - } - } - - /// Drain `write_buf` into the underlying stream until it is empty. - fn flush_write_buf( - mut stream: Pin<&mut FramedStream>, - cx: &mut Context<'_>, - ) -> Poll> { - while !stream.as_ref().project_ref().write_buf.is_empty() { - let this = stream.as_mut().project(); - let n = match ready!(this.inner.poll_write(cx, this.write_buf)) { - Ok(n) => n, - Err(e) => return Poll::Ready(Err(TransportError::from(e))), - }; - if n == 0 { - // The stream refuses to take bytes; treat as a write failure - // rather than spinning forever. - return Poll::Ready(Err(TransportError::FramingError( - "write made no progress".into(), - ))); - } - this.write_buf.advance(n); - } - Poll::Ready(Ok(())) - } -} - -#[cfg(feature = "native-transport")] -pub use framed::FramedStream; diff --git a/Build/crates/saikuro-transport/src/lib.rs b/Build/crates/saikuro-transport/src/lib.rs index 954b291b..989cdb00 100644 --- a/Build/crates/saikuro-transport/src/lib.rs +++ b/Build/crates/saikuro-transport/src/lib.rs @@ -1,127 +1,142 @@ -//! Saikuro Transport -//! -//! This crate defines the [`Transport`] trait and provides concrete -//! implementations: -//! -//! | Backend | Feature flag | Platforms | -//! |--------------------|---------------------|-------------------| -//! | [`memory`] | always on | native + wasm32 | -//! | [`unix`] | `native-transport` | Unix only | -//! | [`tcp`] | `native-transport` | native only | -//! | [`websocket`] | `native-ws`/wasm32 | native + wasm32 | -//! | [`wasm_host`] | always on (wasm32) | wasm32 only | -//! -//! The crate is `no_std` + `alloc` without the `std` feature; the in-memory -//! transport, selector, traits, and error types compile for bare-metal MCU -//! targets. Native backends (Unix/TCP/WebSocket) require `std`. +//! Pluggable, backend-agnostic transports for Saikuro. #![cfg_attr(not(feature = "std"), no_std)] #[macro_use] extern crate alloc; -pub mod error; -pub mod framing; -pub mod memory; -pub mod selector; -pub mod traits; - -#[cfg(feature = "embedded-io")] -pub mod embedded_io; - -#[cfg(all(feature = "native-transport", not(target_arch = "wasm32")))] -pub mod tcp; - -#[cfg(all( - feature = "native-transport", - not(target_arch = "wasm32"), - target_family = "unix" -))] -pub mod unix; - -#[cfg(all( - feature = "ws-transport", - any(feature = "native-ws", target_arch = "wasm32") +#[cfg(not(any( + feature = "native", + feature = "no_std", + feature = "wasm", + feature = "embedded" +)))] +compile_error!( + "saikuro-transport: enable exactly one engine feature: native, no_std, wasm, or embedded" +); + +#[cfg(any( + all(feature = "native", feature = "no_std"), + all(feature = "native", feature = "wasm"), + all(feature = "native", feature = "embedded"), + all(feature = "no_std", feature = "wasm"), + all(feature = "no_std", feature = "embedded"), + all(feature = "wasm", feature = "embedded") ))] -pub mod websocket; - -#[cfg(target_arch = "wasm32")] -pub mod wasm_host; +compile_error!( + "saikuro-transport: enable exactly one engine feature (native, no_std, wasm, embedded), not more" +); -pub use error::TransportError; -pub use memory::MemoryTransport; -pub use selector::{TransportConfig, TransportKind, TransportSelector}; -pub use traits::{Transport, TransportReceiver, TransportSender}; +#[cfg(all(feature = "no_std", feature = "std"))] +compile_error!( + "saikuro-transport: the no_std engine cannot be combined with the std toolchain feature" +); -#[cfg(feature = "embedded-io")] -pub use embedded_io::{ - EmbeddedIoReceiver, EmbeddedIoSender, EmbeddedIoTransport, LocalTransportReceiver, - LocalTransportSender, -}; +pub mod shared; -#[cfg(all(feature = "native-transport", not(target_arch = "wasm32")))] -pub use tcp::TcpTransport; +#[cfg(feature = "native")] +pub mod native; -#[cfg(all( - feature = "native-transport", - not(target_arch = "wasm32"), - target_family = "unix" -))] -pub use unix::UnixTransport; +#[cfg(feature = "embedded")] +pub mod embedded; -#[cfg(all( - feature = "ws-transport", - any(feature = "native-ws", target_arch = "wasm32") -))] -pub use websocket::WebSocketTransport; +#[cfg(feature = "wasm")] +pub mod wasm; -#[cfg(all(feature = "native-ws", not(target_arch = "wasm32")))] -pub use websocket::WsTransportListener; +#[cfg(feature = "no_std")] +pub mod wasi; -#[cfg(target_arch = "wasm32")] -pub use wasm_host::WasmHostTransport; +pub use shared::error::TransportError; +pub use shared::memory::MemoryTransport; +pub use shared::selector::{TransportConfig, TransportKind, TransportSelector}; +pub use shared::traits::{ + LocalTransport, LocalTransportConnector, LocalTransportListener, LocalTransportReceiver, + LocalTransportSender, Transport, TransportConnector, TransportListener, TransportReceiver, + TransportSender, +}; +pub use shared::host::{ + HostPipeFactory, HostPipeRecv, HostPipeSend, Role, WasmHostConnector, WasmHostListener, + WasmHostTransport, +}; -/// Maximum allowed frame size (16 MiB). Frames larger than this are rejected +#[cfg(all(feature = "native", feature = "tcp"))] +pub use native::tcp::TcpTransport; +#[cfg(all(feature = "native", feature = "unix", target_family = "unix"))] +pub use native::unix::UnixTransport; +#[cfg(all(feature = "native", feature = "ws"))] +pub use native::websocket::{WebSocketTransport, WsTransportListener}; + +#[cfg(all(feature = "embedded", feature = "tcp"))] +pub use embedded::tcp::TcpTransport; +#[cfg(feature = "embedded")] +pub use embedded::io_transport::{EmbeddedIoReceiver, EmbeddedIoSender, EmbeddedIoTransport}; + +#[cfg(all(feature = "wasm", feature = "ws"))] +pub use wasm::websocket::WebSocketTransport; +#[cfg(all(feature = "wasm", feature = "wasm-host"))] +pub use wasm::host_browser::{BroadcastChannelPipe, WasmHost}; + +#[cfg(all(feature = "no_std", feature = "wasi-tcp"))] +pub use wasi::tcp::{WasiTcpConnector, WasiTcpListener, WasiTcpTransport}; +#[cfg(all(feature = "no_std", feature = "wasi-host"))] +pub use wasi::host::{WasiHost, WasiHostConnector, WasiHostListener}; + +/// Maximum allowed frame size (16 MiB). Frames larger than this are rejected /// to prevent memory exhaustion from malformed or malicious peers. pub const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024; -/// Implements [`TransportSender`] for a sender type whose `inner` field -/// implements `Sink`. `$addr_field` is the struct field (logged with -/// `Debug` on every send/close). +/// Default capacity of internal transport channels. +pub const DEFAULT_CHANNEL_CAPACITY: saikuro_exec::ChannelCapacity = saikuro_exec::ChannelCapacity::MAX; + +/// Implements [`TransportSender`] for a native transport's sending half. +/// +/// The target struct must have an `inner` field that is an `futures` +/// `SplitSink` over `bytes::Bytes` whose `Error` is [`TransportError`]. #[macro_export] macro_rules! impl_native_sender { - ($sender:ty, $addr_field:ident, $transport:literal) => { + ($ty:ty, $addr:ident, $desc:literal) => { #[async_trait::async_trait] - impl $crate::traits::TransportSender for $sender { - async fn send(&mut self, frame: bytes::Bytes) -> $crate::error::Result<()> { - tracing::trace!($addr_field = ?self.$addr_field, bytes = frame.len(), concat!($transport, " send")); + impl $crate::shared::traits::TransportSender for $ty { + async fn send(&mut self, frame: ::bytes::Bytes) -> $crate::shared::error::Result<()> { + tracing::trace!($addr = ?self.$addr, bytes = frame.len(), concat!($desc, " send")); futures::SinkExt::send(&mut self.inner, frame).await } - async fn close(&mut self) -> $crate::error::Result<()> { - tracing::debug!($addr_field = ?self.$addr_field, concat!($transport, " sender closing")); + async fn close(&mut self) -> $crate::shared::error::Result<()> { + tracing::debug!($addr = ?self.$addr, concat!($desc, " sender closing")); futures::SinkExt::close(&mut self.inner).await } } }; } -/// Implements [`TransportReceiver`] for a receiver type whose `inner` field -/// implements `Stream>`. +/// Implements [`TransportReceiver`] for a native transport's receiving half. +/// +/// The target struct must have an `inner` field that is an `futures` +/// `SplitStream` whose `Item` is `Result`. #[macro_export] macro_rules! impl_native_receiver { - ($receiver:ty, $addr_field:ident, $transport:literal) => { + ($ty:ty, $addr:ident, $desc:literal) => { #[async_trait::async_trait] - impl $crate::traits::TransportReceiver for $receiver { - async fn recv(&mut self) -> $crate::error::Result> { + impl $crate::shared::traits::TransportReceiver for $ty { + async fn recv( + &mut self, + ) -> $crate::shared::error::Result> { match futures::StreamExt::next(&mut self.inner).await { Some(Ok(bytes)) => { - tracing::trace!($addr_field = ?self.$addr_field, bytes = bytes.len(), concat!($transport, " recv")); + tracing::trace!( + $addr = ?self.$addr, + bytes = bytes.len(), + concat!($desc, " recv") + ); Ok(Some(bytes)) } - Some(Err(e)) => Err($crate::error::TransportError::from(e)), + Some(Err(e)) => Err(e), None => { - tracing::debug!($addr_field = ?self.$addr_field, concat!($transport, " connection closed by peer")); + tracing::debug!( + $addr = ?self.$addr, + concat!($desc, " connection closed by peer") + ); Ok(None) } } @@ -129,10 +144,3 @@ macro_rules! impl_native_receiver { } }; } - -/// Default channel capacity for in-memory transports. -/// -/// This bounds memory usage and provides backpressure: if the receiver is -/// slow the sender's `send` call will yield until space frees up. -pub const DEFAULT_CHANNEL_CAPACITY: saikuro_exec::ChannelCapacity = - saikuro_exec::ChannelCapacity::MAX; diff --git a/Build/crates/saikuro-transport/src/native/framed.rs b/Build/crates/saikuro-transport/src/native/framed.rs new file mode 100644 index 00000000..0b8b2e08 --- /dev/null +++ b/Build/crates/saikuro-transport/src/native/framed.rs @@ -0,0 +1,181 @@ +use core::pin::Pin; +use core::task::{Context, Poll}; + +use bytes::{Buf, BufMut}; +use futures::{ready, Sink, Stream}; +use pin_project_lite::pin_project; +use saikuro_net::io::{AsyncRead, AsyncWrite, ReadBuf}; + +use crate::shared::error::{Result, TransportError}; +use crate::shared::framing::LengthPrefixedCodec; + +/// Minimum capacity to make available for each read when no frame is +/// pending. Large enough to amortize syscalls without over-committing +/// memory on small frames; when a frame is pending, decode reserves the +/// exact remaining frame bytes so the read spans the whole frame. +const READ_CHUNK: usize = 4096; + +pin_project! { + pub struct FramedStream { + #[pin] + inner: S, + codec: LengthPrefixedCodec, + read_buf: bytes::BytesMut, + write_buf: bytes::BytesMut, + // Set once a framing, I/O, or truncation error is surfaced so the + // stream stays terminal and later polls report the end instead of + // resuming on an unaligned byte stream. + failed: bool, + } +} + +impl FramedStream { + pub fn new(inner: S) -> Self { + Self { + inner, + codec: LengthPrefixedCodec::new(), + read_buf: bytes::BytesMut::new(), + write_buf: bytes::BytesMut::new(), + failed: false, + } + } + + /// Split into a sink (write half) and a stream (read half). + /// + /// `StreamExt::split` produces both halves from a single underlying + /// `BiLock` so they stay safely paired. + pub fn split( + self, + ) -> ( + futures::stream::SplitSink, + futures::stream::SplitStream, + ) { + futures::StreamExt::split::(self) + } +} + +impl Stream for FramedStream { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut this = self.project(); + + if *this.failed { + return Poll::Ready(None); + } + + loop { + // decode any complete frames already buffered. + match this.codec.decode(this.read_buf) { + Ok(Some(frame)) => return Poll::Ready(Some(Ok(frame))), + Ok(None) => {} + Err(e) => { + // Corrupt or oversized frame; the byte stream is no + // longer aligned, so surface the error and terminate. + *this.failed = true; + return Poll::Ready(Some(Err(e))); + } + } + + // Read directly into the uninitialized tail of read_buf. When + // a frame is pending, decode already reserved the remaining + // frame bytes so chunk_mut spans the whole frame; otherwise + // reserve the chunk size so the read still has a writable + // target. advance_mut only appends the filled bytes, so a + // Pending read leaves no phantom bytes behind. + this.read_buf.reserve(READ_CHUNK); + let filled = { + let dst = this.read_buf.chunk_mut(); + // SAFETY: chunk_mut borrows the uninitialized tail of the + // buffer; the slice is only filled by poll_read below + // before we advance_mut by the filled length. + let dst = unsafe { dst.as_uninit_slice_mut() }; + let mut read_buf = ReadBuf::uninit(dst); + match ready!(this.inner.as_mut().poll_read(cx, &mut read_buf)) { + Ok(()) => read_buf.filled().len(), + Err(e) => { + *this.failed = true; + return Poll::Ready(Some(Err(TransportError::from(e)))); + } + } + }; + // SAFETY: poll_read initialized the first `filled` bytes. + unsafe { this.read_buf.advance_mut(filled) }; + + if filled == 0 { + // EOF from the peer. A clean close happens only at a + // frame boundary; leftover bytes mean a truncated frame. + if this.read_buf.is_empty() && !this.codec.has_pending_frame() { + return Poll::Ready(None); + } + *this.failed = true; + return Poll::Ready(Some(Err(TransportError::FramingError( + "connection closed mid-frame".into(), + )))); + } + + // More bytes arrived; loop back to decode them. + } + } +} + +impl Sink for FramedStream { + type Error = TransportError; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.as_ref().project_ref().write_buf.is_empty() { + return Poll::Ready(Ok(())); + } + self.poll_flush(cx) + } + + fn start_send(self: Pin<&mut Self>, item: bytes::Bytes) -> Result<()> { + let this = self.project(); + this.codec.encode(item, this.write_buf)?; + Ok(()) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + ready!(flush_write_buf(self.as_mut(), cx))?; + self.project() + .inner + .poll_flush(cx) + .map_err(TransportError::from) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + ready!(flush_write_buf(self.as_mut(), cx))?; + let flushed = ready!(self.as_mut().project().inner.poll_flush(cx)); + match flushed { + Err(e) => Poll::Ready(Err(TransportError::from(e))), + Ok(()) => self + .project() + .inner + .poll_shutdown(cx) + .map_err(TransportError::from), + } + } +} + +/// Drain `write_buf` into the underlying stream until it is empty. +fn flush_write_buf( + mut stream: Pin<&mut FramedStream>, + cx: &mut Context<'_>, +) -> Poll> { + while !stream.as_ref().project_ref().write_buf.is_empty() { + let this = stream.as_mut().project(); + let n = match ready!(this.inner.poll_write(cx, this.write_buf)) { + Ok(n) => n, + Err(e) => return Poll::Ready(Err(TransportError::from(e))), + }; + if n == 0 { + // The stream refuses to take bytes; treat as a write failure + // rather than spinning forever. + return Poll::Ready(Err(TransportError::FramingError( + "write made no progress".into(), + ))); + } + this.write_buf.advance(n); + } + Poll::Ready(Ok(())) +} diff --git a/Build/crates/saikuro-transport/src/native/mod.rs b/Build/crates/saikuro-transport/src/native/mod.rs new file mode 100644 index 00000000..865bfd41 --- /dev/null +++ b/Build/crates/saikuro-transport/src/native/mod.rs @@ -0,0 +1,11 @@ +#[cfg(any(feature = "tcp", feature = "unix"))] +pub mod framed; + +#[cfg(feature = "tcp")] +pub mod tcp; + +#[cfg(all(feature = "unix", target_family = "unix"))] +pub mod unix; + +#[cfg(feature = "ws")] +pub mod websocket; diff --git a/Build/crates/saikuro-transport/src/tcp.rs b/Build/crates/saikuro-transport/src/native/tcp.rs similarity index 85% rename from Build/crates/saikuro-transport/src/tcp.rs rename to Build/crates/saikuro-transport/src/native/tcp.rs index 3265190d..6b95a97d 100644 --- a/Build/crates/saikuro-transport/src/tcp.rs +++ b/Build/crates/saikuro-transport/src/native/tcp.rs @@ -1,28 +1,17 @@ -//! TCP transport (native only). -//! -//! Provides a reliable, ordered, backpressure-capable byte stream over TCP -//! using the length-prefixed framing codec from [`crate::framing`]. - use crate::{impl_native_receiver, impl_native_sender}; use async_trait::async_trait; use bytes::Bytes; -use saikuro_exec::net::{TcpListener, TcpStream}; +use saikuro_net::net::{TcpListener, TcpStream}; use std::net::SocketAddr; use tracing::debug; -use crate::{ +use crate::shared::{ error::Result, framing::FramedStream, traits::{Transport, TransportConnector, TransportListener}, }; -// Transport - /// A TCP transport connection. -/// -/// Wraps a connected [`TcpStream`] with length-prefix framing. -/// Use [`TcpConnector`] to establish outgoing connections and -/// [`TcpTransportListener`] to accept incoming ones. pub struct TcpTransport { framed: FramedStream, peer_addr: SocketAddr, @@ -67,7 +56,6 @@ impl Transport for TcpTransport { } // Sender / Receiver - pub struct TcpSender { inner: futures::stream::SplitSink, Bytes>, peer_addr: SocketAddr, @@ -82,8 +70,6 @@ pub struct TcpReceiver { impl_native_receiver!(TcpReceiver, peer_addr, "tcp"); -// Connector - /// Establishes outgoing TCP connections. pub struct TcpConnector { addr: SocketAddr, @@ -106,8 +92,6 @@ impl TransportConnector for TcpConnector { } } -// Listener - /// Accepts incoming TCP connections. pub struct TcpTransportListener { inner: TcpListener, @@ -135,8 +119,8 @@ impl TransportListener for TcpTransportListener { async fn accept(&mut self) -> Result> { match self.inner.accept().await { - Ok((stream, peer)) => { - debug!(%peer, "tcp accepted connection"); + Ok((stream, _peer)) => { + debug!(peer = %_peer, "tcp accepted connection"); Ok(Some(TcpTransport::new(stream)?)) } Err(e) => Err(e.into()), diff --git a/Build/crates/saikuro-transport/src/unix.rs b/Build/crates/saikuro-transport/src/native/unix.rs similarity index 89% rename from Build/crates/saikuro-transport/src/unix.rs rename to Build/crates/saikuro-transport/src/native/unix.rs index cb5a2b5a..d79e44c8 100644 --- a/Build/crates/saikuro-transport/src/unix.rs +++ b/Build/crates/saikuro-transport/src/native/unix.rs @@ -1,25 +1,16 @@ -//! Unix domain socket transport (Unix + native only). -//! -//! On the same physical machine a Unix domain socket is faster than TCP -//! because it skips the TCP stack entirely. It uses the same -//! length-prefixed framing as the TCP transport. -//! This only works when the target OS is a Unix family OS. (yes, not you Windows >:( ) - use crate::{impl_native_receiver, impl_native_sender}; use async_trait::async_trait; use bytes::Bytes; -use saikuro_exec::net::{UnixListener, UnixStream}; +use saikuro_net::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use tracing::debug; -use crate::{ +use crate::shared::{ error::Result, framing::FramedStream, traits::{Transport, TransportConnector, TransportListener}, }; -// Transport - /// A Unix domain socket transport connection. pub struct UnixTransport { framed: FramedStream, @@ -61,7 +52,6 @@ impl Transport for UnixTransport { } // Sender / Receiver - pub struct UnixSender { inner: futures::stream::SplitSink, Bytes>, path: PathBuf, @@ -76,8 +66,6 @@ pub struct UnixReceiver { impl_native_receiver!(UnixReceiver, path, "unix"); -// Connector - /// Establishes outgoing Unix socket connections. pub struct UnixConnector { path: PathBuf, @@ -102,8 +90,6 @@ impl TransportConnector for UnixConnector { } } -// Listener - /// Accepts incoming Unix domain socket connections. pub struct UnixTransportListener { inner: UnixListener, diff --git a/Build/crates/saikuro-transport/src/native/websocket.rs b/Build/crates/saikuro-transport/src/native/websocket.rs new file mode 100644 index 00000000..d1887e4c --- /dev/null +++ b/Build/crates/saikuro-transport/src/native/websocket.rs @@ -0,0 +1,176 @@ +use async_trait::async_trait; +use bytes::Bytes; +use futures::{SinkExt, StreamExt}; +use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; +use tracing::{debug, trace}; + +use saikuro_net::net::{TcpListener, TcpStream}; + +use std::net::SocketAddr; + +use crate::shared::{ + error::{Result, TransportError}, + traits::{Transport, TransportListener, TransportReceiver, TransportSender}, +}; + +/// Wraps `tokio-tungstenite`. +pub struct WebSocketTransport { + inner: WebSocketStream>, + url: String, +} + +impl WebSocketTransport { + /// Connect to a WebSocket server at `url` (e.g. `"ws://127.0.0.1:9000"`). + pub async fn connect(url: impl Into) -> Result { + let url = url.into(); + debug!(%url, "websocket connecting"); + let (ws, _response) = connect_async(&url) + .await + .map_err(|e| TransportError::ConnectionRefused(format!("ws connect to {url} failed: {e}")))?; + Ok(Self { inner: ws, url }) + } + + /// Construct from an already-upgraded WebSocket stream (server-side accept path). + pub fn from_stream(ws: WebSocketStream>, url: String) -> Self { + Self { inner: ws, url } + } +} + +impl Transport for WebSocketTransport { + type Sender = WebSocketSender; + type Receiver = WebSocketReceiver; + + fn split(self) -> (Self::Sender, Self::Receiver) { + let url = self.url.clone(); + let (sink, stream) = self.inner.split(); + ( + WebSocketSender { + inner: sink, + url: url.clone(), + }, + WebSocketReceiver { inner: stream, url }, + ) + } + + fn description(&self) -> &str { + "websocket" + } +} + +// WebSocket transport listener (server-side accept, native only) +/// Listens for inbound TCP connections and upgrades them to WebSocket. +pub struct WsTransportListener { + inner: Option, + local_addr: SocketAddr, +} + +impl WsTransportListener { + /// Bind a TCP listener on the given address for WebSocket upgrades. + pub async fn bind(addr: SocketAddr) -> Result { + let inner = TcpListener::bind(addr).await?; + let local_addr = inner.local_addr()?; + debug!(%local_addr, "ws listener bound"); + Ok(Self { + inner: Some(inner), + local_addr, + }) + } + + /// Return the address this listener is bound to. + pub fn local_addr(&self) -> SocketAddr { + self.local_addr + } +} + +#[async_trait] +impl TransportListener for WsTransportListener { + type Output = WebSocketTransport; + + async fn accept(&mut self) -> Result> { + let inner = self + .inner + .as_ref() + .ok_or_else(|| TransportError::ConnectionRefused("listener closed".into()))?; + let (stream, peer_addr) = inner.accept().await?; + let url = format!("ws://{peer_addr}"); + let maybe_tls = MaybeTlsStream::Plain(stream); + match tokio_tungstenite::accept_async(maybe_tls).await { + Ok(ws_stream) => { + debug!(peer = %peer_addr, "ws upgrade successful"); + Ok(Some(WebSocketTransport::from_stream(ws_stream, url))) + } + Err(e) => { + tracing::warn!(peer = %peer_addr, error = %e, "ws upgrade failed"); + Err(TransportError::ConnectionRefused(format!( + "WebSocket upgrade from {peer_addr} failed: {e}" + ))) + } + } + } + + async fn close(&mut self) -> Result<()> { + debug!(local = %self.local_addr, "ws listener closing"); + drop(self.inner.take()); + Ok(()) + } +} + +/// Sending half of a native WebSocket transport. +pub struct WebSocketSender { + inner: futures::stream::SplitSink>, Message>, + url: String, +} + +#[async_trait] +impl TransportSender for WebSocketSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + trace!(url = %self.url, bytes = frame.len(), "ws send"); + self.inner + .send(Message::Binary(frame.to_vec())) + .await + .map_err(|e| TransportError::SendFailed(e.to_string())) + } + + async fn close(&mut self) -> Result<()> { + debug!(url = %self.url, "ws sender closing"); + self.inner + .send(Message::Close(None)) + .await + .map_err(|e| TransportError::SendFailed(e.to_string())) + } +} + +/// Receiving half of a native WebSocket transport. +pub struct WebSocketReceiver { + inner: futures::stream::SplitStream>>, + url: String, +} + +#[async_trait] +impl TransportReceiver for WebSocketReceiver { + async fn recv(&mut self) -> Result> { + loop { + match self.inner.next().await { + Some(Ok(Message::Binary(data))) => { + trace!(url = %self.url, bytes = data.len(), "ws recv binary"); + return Ok(Some(Bytes::from(data))); + } + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => { + continue; + } + Some(Ok(Message::Close(_))) => { + debug!(url = %self.url, "ws closed by peer"); + return Ok(None); + } + Some(Ok(other)) => { + trace!(url = %self.url, "ws ignoring non-binary frame: {:?}", other); + continue; + } + Some(Err(e)) => { + return Err(TransportError::ReceiveFailed(e.to_string())); + } + None => return Ok(None), + } + } + } +} diff --git a/Build/crates/saikuro-transport/src/error.rs b/Build/crates/saikuro-transport/src/shared/error.rs similarity index 67% rename from Build/crates/saikuro-transport/src/error.rs rename to Build/crates/saikuro-transport/src/shared/error.rs index b10bb21d..613679e7 100644 --- a/Build/crates/saikuro-transport/src/error.rs +++ b/Build/crates/saikuro-transport/src/shared/error.rs @@ -1,8 +1,3 @@ -//! Transport error type. -//! -//! The crate is `no_std` + `alloc` without the `std` feature, so the raw -//! `std::io::Error` variant is gated the same way as in saikuro-core. - use alloc::string::String; use thiserror::Error; @@ -33,12 +28,6 @@ pub enum TransportError { #[error("I/O error: {0}")] Io(#[from] std::io::Error), - #[error("msgpack encode error: {0}")] - MsgpackEncode(#[from] saikuro_core::msgpack::EncodeError), - - #[error("msgpack decode error: {0}")] - MsgpackDecode(#[from] saikuro_core::msgpack::DecodeError), - #[error("channel closed")] ChannelClosed, } diff --git a/Build/crates/saikuro-transport/src/shared/framed.rs b/Build/crates/saikuro-transport/src/shared/framed.rs new file mode 100644 index 00000000..f1f6fc5f --- /dev/null +++ b/Build/crates/saikuro-transport/src/shared/framed.rs @@ -0,0 +1,36 @@ +#![cfg(any(feature = "embedded", feature = "wasi-tcp"))] + +use alloc::string::ToString; +use embedded_io_async::{Read, Write}; + +use crate::shared::error::{Result, TransportError}; + +/// Number of big-endian length bytes that prefix every frame. +pub(crate) const HEADER_LEN: usize = 4; + +/// Read a single byte from `reader`, writing it to `first` and returning it. +pub(crate) async fn read_first_byte(reader: &mut R, first: &mut u8) -> Result { + let mut byte = [0u8; 1]; + reader + .read_exact(&mut byte) + .await + .map_err(|e| TransportError::ConnectionLost(e.to_string()))?; + *first = byte[0]; + Ok(byte[0]) +} + +/// Read exactly `buf.len()` bytes from `reader`, or fail with `msg`. +pub(crate) async fn read_exact(reader: &mut R, buf: &mut [u8], msg: &str) -> Result<()> { + reader + .read_exact(buf) + .await + .map_err(|_| TransportError::ConnectionLost(msg.into())) +} + +/// Write every byte of `buf` to `writer`. +pub(crate) async fn write_all(writer: &mut W, buf: &[u8]) -> Result<()> { + writer + .write_all(buf) + .await + .map_err(|e| TransportError::ConnectionLost(e.to_string())) +} diff --git a/Build/crates/saikuro-transport/src/shared/framing.rs b/Build/crates/saikuro-transport/src/shared/framing.rs new file mode 100644 index 00000000..239105ef --- /dev/null +++ b/Build/crates/saikuro-transport/src/shared/framing.rs @@ -0,0 +1,101 @@ +use bytes::{Buf, BufMut, Bytes, BytesMut}; + +use crate::MAX_FRAME_SIZE; +use crate::shared::error::{Result, TransportError}; + +/// Codec that frames a byte stream into discrete length-prefixed messages. +#[derive(Debug, Clone, Default)] +pub struct LengthPrefixedCodec { + /// Once we've read the length header we cache it here to avoid re-parsing. + pending_len: Option, + /// Payload bytes still owed for a frame whose length header exceeded + /// [`MAX_FRAME_SIZE`]. The header is consumed but the declared payload + /// must be swallowed so it is not misinterpreted as a fresh header. + discard_remaining: u64, +} + +impl LengthPrefixedCodec { + pub fn new() -> Self { + Self::default() + } + + /// Return whether decoding has consumed a header and still expects bytes. + #[cfg(any(feature = "tcp", feature = "unix"))] + pub(crate) fn has_pending_frame(&self) -> bool { + self.pending_len.is_some() || self.discard_remaining != 0 + } + + /// Decode the next complete frame from `src`, returning `Ok(None)` until a + /// full frame is buffered. + pub fn decode(&mut self, src: &mut BytesMut) -> Result> { + // Swallow any payload owed by a rejected oversized frame before + // touching normal framing state. + if self.discard_remaining > 0 { + let take = core::cmp::min(self.discard_remaining, src.len() as u64); + src.advance(take as usize); + self.discard_remaining -= take; + if self.discard_remaining > 0 { + return Ok(None); + } + } + + // Phase 1: read the 4-byte length header if we don't have it yet. + let frame_len = match self.pending_len { + Some(len) => len, + None => { + if src.len() < 4 { + // Not enough bytes yet; ask for more. + return Ok(None); + } + let len = u32::from_be_bytes([src[0], src[1], src[2], src[3]]); + src.advance(4); + self.pending_len = Some(len); + len + } + }; + + let frame_len = + usize::try_from(frame_len).map_err(|_| message_too_large(frame_len as usize))?; + + if frame_len > MAX_FRAME_SIZE { + // The declared payload will never be decoded, so count it against + // the discard budget instead of resetting and letting the next + // call misread payload bytes as a length header. + self.pending_len = None; + self.discard_remaining = frame_len as u64; + return Err(message_too_large(frame_len)); + } + + // Phase 2: wait until the full payload has arrived. + if src.len() < frame_len { + // Reserve exactly the bytes we still need to avoid churn. + src.reserve(frame_len - src.len()); + return Ok(None); + } + + // We have a complete frame. + self.pending_len = None; + let payload = src.split_to(frame_len).freeze(); + Ok(Some(payload)) + } + + /// Encode `item` as a length-prefixed frame appended to `dst`. + pub fn encode(&mut self, item: Bytes, dst: &mut BytesMut) -> Result<()> { + let len = item.len(); + if len > MAX_FRAME_SIZE { + return Err(message_too_large(len)); + } + + dst.reserve(4 + len); + dst.put_u32(u32::try_from(len).map_err(|_| message_too_large(len))?); + dst.put(item); + Ok(()) + } +} + +fn message_too_large(size: usize) -> TransportError { + TransportError::MessageTooLarge { + size, + limit: MAX_FRAME_SIZE, + } +} diff --git a/Build/crates/saikuro-transport/src/shared/host.rs b/Build/crates/saikuro-transport/src/shared/host.rs new file mode 100644 index 00000000..0ed4b978 --- /dev/null +++ b/Build/crates/saikuro-transport/src/shared/host.rs @@ -0,0 +1,158 @@ +use alloc::string::String; +use alloc::vec::Vec; +use bytes::Bytes; +use core::marker::PhantomData; + +use crate::shared::error::Result; +use crate::shared::traits::{ + LocalTransport, LocalTransportConnector, LocalTransportListener, LocalTransportReceiver, + LocalTransportSender, +}; + +/// Which side of a rendezvous a pipe endpoint plays. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + /// The active side: dials/reaches out and waits for an accept. + Connect, + /// The passive side: listens and replies with an accept. + Accept, +} + +/// The sending half of a host message bus, abstracted over its backend. +pub trait HostPipeSend { + /// Send a single binary frame over the bus. + async fn send(&mut self, frame: &[u8]) -> Result<()>; +} + +/// The receiving half of a host message bus, abstracted over its backend. +pub trait HostPipeRecv { + /// Receive the next binary frame, or `None` when the peer closed cleanly. + async fn recv(&mut self) -> Result>>; +} + +/// A host message bus that can be opened as a connected, framed pipe. +pub trait HostPipeFactory { + /// The sending half produced by [`open`](HostPipeFactory::open). + type Send: HostPipeSend; + /// The receiving half produced by [`open`](HostPipeFactory::open). + type Recv: HostPipeRecv; + + /// Open a connected pipe on `channel` playing `role`. + async fn open(channel: &str, role: Role) -> Result<(Self::Send, Self::Recv)>; +} + +/// A transport backed by a host message bus, generic over its pipe backend. +pub struct WasmHostTransport { + sender: S, + receiver: R, +} + +impl WasmHostTransport { + /// Wrap an already-open pipe into a transport. + pub fn new(sender: S, receiver: R) -> Self { + Self { sender, receiver } + } +} + +impl LocalTransport for WasmHostTransport { + type Sender = WasmHostSender; + type Receiver = WasmHostReceiver; + + fn split(self) -> (Self::Sender, Self::Receiver) { + ( + WasmHostSender { + pipe: self.sender, + }, + WasmHostReceiver { + pipe: self.receiver, + }, + ) + } + + fn description(&self) -> &str { + "wasm-host" + } +} + +/// Sending half of a [`WasmHostTransport`]. +pub struct WasmHostSender { + pipe: S, +} + +impl LocalTransportSender for WasmHostSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + self.pipe.send(&frame).await + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} + +/// Receiving half of a [`WasmHostTransport`]. +pub struct WasmHostReceiver { + pipe: R, +} + +impl LocalTransportReceiver for WasmHostReceiver { + async fn recv(&mut self) -> Result> { + match self.pipe.recv().await? { + Some(bytes) => Ok(Some(Bytes::from(bytes))), + None => Ok(None), + } + } +} + +/// Connects to a peer over a host message bus. +pub struct WasmHostConnector { + channel: String, + _marker: PhantomData F>, +} + +impl WasmHostConnector { + /// Create a connector that will rendezvous on `channel`. + pub fn new(channel: impl Into) -> Self { + Self { + channel: channel.into(), + _marker: PhantomData, + } + } +} + +impl LocalTransportConnector for WasmHostConnector { + type Output = WasmHostTransport; + + async fn connect(&self) -> Result { + let (sender, receiver) = F::open(&self.channel, Role::Connect).await?; + Ok(WasmHostTransport::new(sender, receiver)) + } +} + +/// Accepts inbound connections over a host message bus. +pub struct WasmHostListener { + channel: String, + _marker: PhantomData F>, +} + +impl WasmHostListener { + /// Start listening for connections on `channel`. + pub fn new(channel: impl Into) -> Self { + Self { + channel: channel.into(), + _marker: PhantomData, + } + } +} + +impl LocalTransportListener for WasmHostListener { + type Output = WasmHostTransport; + + async fn accept(&mut self) -> Result> { + let (sender, receiver) = F::open(&self.channel, Role::Accept).await?; + Ok(Some(WasmHostTransport::new(sender, receiver))) + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/Build/crates/saikuro-transport/src/memory.rs b/Build/crates/saikuro-transport/src/shared/memory.rs similarity index 91% rename from Build/crates/saikuro-transport/src/memory.rs rename to Build/crates/saikuro-transport/src/shared/memory.rs index fa3b7727..6fc67ddd 100644 --- a/Build/crates/saikuro-transport/src/memory.rs +++ b/Build/crates/saikuro-transport/src/shared/memory.rs @@ -1,9 +1,3 @@ -//! In-memory transport. -//! -//! Two tasks in the same process communicate via a pair of bounded MPSC -//! channels. There is no serialisation overhead beyond MessagePack (which -//! the runtime performs regardless of transport); frames arrive as -//! `Bytes` objects with zero copying. use alloc::boxed::Box; use alloc::string::String; use async_trait::async_trait; @@ -11,7 +5,7 @@ use bytes::Bytes; use saikuro_exec::mpsc; use tracing::trace; -use crate::{ +use crate::shared::{ error::{Result, TransportError}, traits::{Transport, TransportReceiver, TransportSender}, }; diff --git a/Build/crates/saikuro-transport/src/shared/mod.rs b/Build/crates/saikuro-transport/src/shared/mod.rs new file mode 100644 index 00000000..dbb79dfc --- /dev/null +++ b/Build/crates/saikuro-transport/src/shared/mod.rs @@ -0,0 +1,7 @@ +pub mod error; +pub mod framed; +pub mod framing; +pub mod host; +pub mod memory; +pub mod selector; +pub mod traits; diff --git a/Build/crates/saikuro-transport/src/selector.rs b/Build/crates/saikuro-transport/src/shared/selector.rs similarity index 81% rename from Build/crates/saikuro-transport/src/selector.rs rename to Build/crates/saikuro-transport/src/shared/selector.rs index 647bdaf3..63e04b98 100644 --- a/Build/crates/saikuro-transport/src/selector.rs +++ b/Build/crates/saikuro-transport/src/shared/selector.rs @@ -1,20 +1,3 @@ -//! Transport selector: automatic best-transport choice plus manual overrides. -//! -//! Rather than forcing callers to know which transport to use, the -//! [`TransportSelector`] inspects the target address and the current platform -//! and picks the most efficient backend automatically. -//! -//! | Condition | Chosen transport | -//! --------------------------------------------------------------------- -//! | Target is the same process | In-memory | -//! | Target is on the same machine (Unix) | Unix socket | -//! | Target is on the same machine (non-Unix) | TCP loopback | -//! | Target is remote, WASM context | BroadcastChannel | -//! | Target is remote, native context | TCP | -//! -//! The user can override any of these choices by supplying an explicit -//! [`TransportConfig`]. - use alloc::borrow::ToOwned; use alloc::string::String; use serde::{Deserialize, Serialize}; diff --git a/Build/crates/saikuro-transport/src/traits.rs b/Build/crates/saikuro-transport/src/shared/traits.rs similarity index 59% rename from Build/crates/saikuro-transport/src/traits.rs rename to Build/crates/saikuro-transport/src/shared/traits.rs index 75a14c7b..3d234671 100644 --- a/Build/crates/saikuro-transport/src/traits.rs +++ b/Build/crates/saikuro-transport/src/shared/traits.rs @@ -1,15 +1,8 @@ -//! Core transport traits. -//! -//! The runtime only ever talks to a [`Transport`], never to a specific -//! backend. This makes it trivial to swap backends (e.g. from Unix socket -//! to WebSocket when moving to WASM) without touching any routing or -//! schema logic. - use alloc::boxed::Box; use async_trait::async_trait; use bytes::Bytes; -use crate::error::Result; +use crate::shared::error::Result; /// A bidirectional message transport. /// @@ -19,7 +12,6 @@ use crate::error::Result; /// prefixing is handled inside the concrete implementation. /// /// ## Implementation contract -/// /// - Implementations MUST guarantee ordered delivery within a connection. /// - Implementations MUST be binary-safe (no newline stripping, etc.). /// - Implementations SHOULD apply backpressure when internal send buffers fill. @@ -90,3 +82,53 @@ pub trait TransportListener: Send + Sync + 'static { /// Stop accepting new connections. async fn close(&mut self) -> Result<()>; } + +/// Mirrors [`TransportSender`] but uses native `async fn` (return-position `impl +/// Trait`) instead of a boxed, `Send + Sync` `async_trait` future. +pub trait LocalTransportSender { + /// Send a single binary frame to the remote peer. + fn send(&mut self, frame: Bytes) -> impl core::future::Future> + '_; + /// Close the sending side gracefully. + fn close(&mut self) -> impl core::future::Future> + '_; +} + +/// A local, statically-dispatched receiving half of a transport. +pub trait LocalTransportReceiver { + /// Wait for and return the next binary frame, or `None` on clean close. + fn recv(&mut self) -> impl core::future::Future>> + '_; +} + +/// A local, statically-dispatched bidirectional transport. +pub trait LocalTransport { + /// The sender half type produced by [`split`](LocalTransport::split). + type Sender: LocalTransportSender; + /// The receiver half type produced by [`split`](LocalTransport::split). + type Receiver: LocalTransportReceiver; + + /// Split into concurrently-usable sender and receiver halves. + fn split(self) -> (Self::Sender, Self::Receiver); + + /// A human-readable description of the transport for logging. + fn description(&self) -> &str; +} + +/// A local factory that connects to a peer. +pub trait LocalTransportConnector { + /// The ready transport produced by [`connect`](LocalTransportConnector::connect). + type Output: LocalTransport; + + /// Establish a new connection. + fn connect(&self) -> impl core::future::Future> + '_; +} + +/// A local listener that accepts inbound connections. +pub trait LocalTransportListener { + /// The ready transport produced by [`accept`](LocalTransportListener::accept). + type Output: LocalTransport; + + /// Accept the next inbound connection, or `None` when shut down. + fn accept(&mut self) -> impl core::future::Future>> + '_; + + /// Stop accepting new connections. + fn close(&mut self) -> impl core::future::Future> + '_; +} diff --git a/Build/crates/saikuro-transport/src/wasi/host.rs b/Build/crates/saikuro-transport/src/wasi/host.rs new file mode 100644 index 00000000..627feb6f --- /dev/null +++ b/Build/crates/saikuro-transport/src/wasi/host.rs @@ -0,0 +1,94 @@ +use alloc::string::String; + +use bytes::Bytes; + +use crate::shared::error::{Result, TransportError}; +use crate::shared::host::{HostPipeFactory, HostPipeRecv, HostPipeSend, Role}; +use crate::wasi::tcp::{backend, WasiTcpConnector, WasiTcpListener, WasiTcpReceiver, WasiTcpSender}; + +/// Base of the deterministic loopback rendezvous port range. +const PIPE_PORT_BASE: u16 = 0xC000; +/// Number of ports in the rendezvous range (ephemeral space). +const PIPE_PORT_SPAN: u16 = 0x1000; + +/// The WASI `HostPipeFactory` backend. +pub struct WasiPipe; + +/// Sending half of a [`WasiPipe`] connection. +pub struct WasiHostSend(pub WasiTcpSender); + +/// Receiving half of a [`WasiPipe`] connection. +pub struct WasiHostRecv(pub WasiTcpReceiver); + +impl HostPipeSend for WasiHostSend { + async fn send(&mut self, frame: &[u8]) -> Result<()> { + self.0.send(Bytes::copy_from_slice(frame)).await + } +} + +impl HostPipeRecv for WasiHostRecv { + async fn recv(&mut self) -> Result>> { + match self.0.recv().await? { + Some(bytes) => Ok(Some(bytes.to_vec())), + None => Ok(None), + } + } +} + +impl HostPipeFactory for WasiPipe { + type Send = WasiHostSend; + type Recv = WasiHostRecv; + + async fn open(channel: &str, role: Role) -> Result<(Self::Send, Self::Recv)> { + let addr = loopback_addr(channel_port(channel)); + match role { + Role::Connect => { + let transport = WasiTcpConnector::new(addr).connect().await?; + let (mut tx, mut rx) = transport.split(); + tx.send(Bytes::from_static(b"connect")).await?; + match rx.recv().await? { + Some(bytes) if bytes.as_ref() == b"accept" => {} + _ => { + return Err(TransportError::ConnectionLost( + "wasi-host handshake: expected accept".into(), + )) + } + } + Ok((WasiHostSend(tx), WasiHostRecv(rx))) + } + Role::Accept => { + let mut listener = WasiTcpListener::new(addr)?; + let transport = listener + .accept() + .await? + .ok_or_else(|| TransportError::ConnectionLost("wasi-host listener closed".into()))?; + let (mut tx, mut rx) = transport.split(); + match rx.recv().await? { + Some(bytes) if bytes.as_ref() == b"connect" => {} + _ => { + return Err(TransportError::ConnectionLost( + "wasi-host handshake: expected connect".into(), + )) + } + } + tx.send(Bytes::from_static(b"accept")).await?; + Ok((WasiHostSend(tx), WasiHostRecv(rx))) + } + } + } +} + +/// Map a channel name to a deterministic loopback port. +fn channel_port(channel: &str) -> u16 { + let mut hash: u32 = 0x811c_9dc5; + for b in channel.as_bytes() { + hash ^= u32::from(*b); + hash = hash.wrapping_mul(0x0100_0193); + } + PIPE_PORT_BASE.wrapping_add((hash & (PIPE_PORT_SPAN as u32 - 1)) as u16) +} + +/// Build a `127.0.0.1:port` rendezvous address. +fn loopback_addr(port: u16) -> String { + alloc::format!("127.0.0.1:{port}") +} diff --git a/Build/crates/saikuro-transport/src/wasi/mod.rs b/Build/crates/saikuro-transport/src/wasi/mod.rs new file mode 100644 index 00000000..8a721cb3 --- /dev/null +++ b/Build/crates/saikuro-transport/src/wasi/mod.rs @@ -0,0 +1,9 @@ +pub mod framed; +#[cfg(feature = "wasi-preview2")] +pub mod preview2; +#[cfg(feature = "wasi-preview1")] +pub mod preview1; +#[cfg(feature = "wasi-tcp")] +pub mod tcp; +#[cfg(feature = "wasi-host")] +pub mod host; diff --git a/Build/crates/saikuro-transport/src/wasi/preview1.rs b/Build/crates/saikuro-transport/src/wasi/preview1.rs new file mode 100644 index 00000000..c8203afe --- /dev/null +++ b/Build/crates/saikuro-transport/src/wasi/preview1.rs @@ -0,0 +1,199 @@ +use alloc::rc::Rc; + +use embedded_io_async::{ErrorKind, Read, Write}; + +use crate::shared::error::{Result, TransportError}; +use crate::wasi::tcp::{parse_addr, parse_ipv4}; + +const AF_INET: u8 = 0; // witx address-family::inet4 +const SOCK_STREAM: u8 = 1; // witx socket-type::stream + +#[repr(C)] +struct Ciovec { + buf: *const u8, + len: usize, +} + +#[repr(C)] +struct Iovec { + buf: *const u8, + len: usize, +} + +#[repr(C)] +struct SockaddrIn { + sin_family: u8, + sin_port: u16, + sin_addr: u32, + sin_zero: [u8; 8], +} + +#[repr(C)] +struct RecvRet { + len: u32, + roflags: u16, +} + +#[link(wasm_import_module = "wasi_snapshot_preview1")] +extern "C" { + fn sock_open(family: u8, ty: u8, ret_area: *mut u32) -> u16; + fn sock_connect(fd: u32, addr: *const SockaddrIn, addr_len: u32) -> u16; + fn sock_bind(fd: u32, addr: *const SockaddrIn, addr_len: u32) -> u16; + fn sock_listen(fd: u32, backlog: u32) -> u16; + fn sock_accept(fd: u32, flags: *mut u16, ret_area: *mut u32) -> u16; + fn sock_recv(fd: u32, ri_data: *const Ciovec, ri_flags: u16, ret_area: *mut RecvRet) -> u16; + fn sock_send(fd: u32, si_data: *const Iovec, si_flags: u16, ret_area: *mut u32) -> u16; + fn fd_close(fd: u32) -> u16; +} + +/// An open preview1 socket. Owns the fd: the last `Rc` dropping closes it. +struct Connection { + fd: u32, +} + +impl Drop for Connection { + fn drop(&mut self) { + // SAFETY: fd is a valid open socket; fd_close frees it on the host. + unsafe { + let _ = fd_close(self.fd); + } + } +} + +/// A readable socket half. Shares ownership of the underlying fd. +pub struct Reader(Rc); + +/// A writable socket half. Shares ownership of the underlying fd. +pub struct Writer(Rc); + +impl Read for Reader { + async fn read(&mut self, buf: &mut [u8]) -> Result { + let iov = Ciovec { + buf: buf.as_ptr(), + len: buf.len(), + }; + let mut ret = RecvRet { len: 0, roflags: 0 }; + // SAFETY: iov aliases buf for the duration of the call and ret is written + // by the host. The fd is a valid open socket. + let rc = unsafe { sock_recv(self.0.fd, &iov, 0, &mut ret) }; + if rc != 0 { + return Err(ErrorKind::Other); + } + Ok(ret.len as usize) + } +} + +impl Write for Writer { + async fn write(&mut self, buf: &[u8]) -> Result { + let iov = Iovec { + buf: buf.as_ptr(), + len: buf.len(), + }; + let mut n = 0u32; + // SAFETY: iov aliases buf for the duration of the call and n is written + // by the host. The fd is a valid open socket. + let rc = unsafe { sock_send(self.0.fd, &iov, 0, &mut n) }; + if rc != 0 { + return Err(ErrorKind::Other); + } + Ok(n as usize) + } + + async fn flush(&mut self) -> Result<(), ErrorKind> { + Ok(()) + } +} + +/// A listening preview1 socket. +pub struct Listener { + fd: u32, +} + +impl Drop for Listener { + fn drop(&mut self) { + // SAFETY: fd is a valid open listening socket; fd_close frees it. + unsafe { + let _ = fd_close(self.fd); + } + } +} + +fn errno_ok(code: u16) -> bool { + code == 0 +} + +fn sockaddr_in(octets: [u8; 4], port: u16) -> SockaddrIn { + SockaddrIn { + sin_family: AF_INET, + sin_port: port.to_be(), + // The socket layer reads sin_addr as raw network-order bytes. A u32 + // stored little-endian has those same bytes in memory order [a,b,c,d], + // which is exactly what the host expects, so load the octets LE. + sin_addr: u32::from_le_bytes(octets), + sin_zero: [0; 8], + } +} + +/// Dial `addr` (host:port) and return the connected read/write halves. +pub fn connect(addr: &str) -> Result<(Reader, Writer)> { + let (host, port) = parse_addr(addr)?; + let octets = parse_ipv4(&host) + .ok_or_else(|| TransportError::ConnectionRefused(format!("unresolved host {host}")))?; + + let mut fd = 0u32; + // SAFETY: sock_open writes exactly one fd to ret_area on success. + let rc = unsafe { sock_open(AF_INET, SOCK_STREAM, &mut fd) }; + if !errno_ok(rc) { + return Err(TransportError::ConnectionRefused(format!("sock_open: {rc}"))); + } + let conn = Rc::new(Connection { fd }); + let sa = sockaddr_in(octets, port); + // SAFETY: sa points to a valid SockaddrIn for the duration of the call. + let rc = unsafe { sock_connect(conn.fd, &sa, core::mem::size_of::() as u32) }; + if !errno_ok(rc) { + return Err(TransportError::ConnectionRefused(format!("sock_connect: {rc}"))); + } + Ok((Reader(Rc::clone(&conn)), Writer(conn))) +} + +/// Bind and listen on `port` on all interfaces. +pub fn listen(port: u16) -> Result { + let mut fd = 0u32; + let rc = unsafe { sock_open(AF_INET, SOCK_STREAM, &mut fd) }; + if !errno_ok(rc) { + return Err(TransportError::ConnectionRefused(format!("sock_open: {rc}"))); + } + let sa = sockaddr_in([0, 0, 0, 0], port); + let rc = unsafe { sock_bind(fd, &sa, core::mem::size_of::() as u32) }; + if !errno_ok(rc) { + // SAFETY: fd is a valid open socket; free it before reporting failure. + unsafe { + let _ = fd_close(fd); + } + return Err(TransportError::ConnectionRefused(format!("sock_bind: {rc}"))); + } + let rc = unsafe { sock_listen(fd, 16) }; + if !errno_ok(rc) { + // SAFETY: fd is a valid open socket; free it before reporting failure. + unsafe { + let _ = fd_close(fd); + } + return Err(TransportError::ConnectionRefused(format!("sock_listen: {rc}"))); + } + Ok(Listener { fd }) +} + +impl Listener { + /// Accept one inbound connection and return its read/write halves. + pub fn accept(&self) -> Result<(Reader, Writer)> { + let mut flags = 0u16; + let mut fd = 0u32; + // SAFETY: host writes the accepted fd to ret_area; flags is read by host. + let rc = unsafe { sock_accept(self.fd, &mut flags, &mut fd) }; + if !errno_ok(rc) { + return Err(TransportError::ConnectionRefused(format!("sock_accept: {rc}"))); + } + let conn = Rc::new(Connection { fd }); + Ok((Reader(Rc::clone(&conn)), Writer(conn))) + } +} diff --git a/Build/crates/saikuro-transport/src/wasi/preview2.rs b/Build/crates/saikuro-transport/src/wasi/preview2.rs new file mode 100644 index 00000000..926bf737 --- /dev/null +++ b/Build/crates/saikuro-transport/src/wasi/preview2.rs @@ -0,0 +1,115 @@ +use alloc::string::String; +use alloc::vec::Vec; + +use embedded_io_async::{ErrorKind, Read, Write}; +use wasi::io::streams::{InputStream, OutputStream}; +use wasi::sockets::instance_network::instance_network; +use wasi::sockets::ip::{ + IpAddress, IpAddressFamily, IpSocketAddress, Ipv4Address, Ipv4SocketAddress, Ipv6Address, + Ipv6SocketAddress, +}; +use wasi::sockets::network::Network; +use wasi::sockets::tcp::{TcpSocket, TcpSocketType}; + +use crate::shared::error::{Result, TransportError}; +use crate::wasi::tcp::{parse_addr}; + +/// A readable socket half. +pub struct Reader(InputStream); + +/// A writable socket half. +pub struct Writer(OutputStream); + +impl Read for Reader { + async fn read(&mut self, buf: &mut [u8]) -> Result { + self.0 + .blocking_read(buf) + .map(|n| n as usize) + .map_err(|_| ErrorKind::Other) + } +} + +impl Write for Writer { + async fn write(&mut self, buf: &[u8]) -> Result { + self.0 + .blocking_write(buf) + .map(|_| buf.len()) + .map_err(|_| ErrorKind::Other) + } + + async fn flush(&mut self) -> Result<(), ErrorKind> { + self.0.blocking_flush().map_err(|_| ErrorKind::Other) + } +} + +/// A listening preview2 TCP socket. +pub struct Listener { + socket: TcpSocket, +} + +/// Map a preview2 socket error code into a transport error. +fn to_err(code: wasi::sockets::network::ErrorCode) -> TransportError { + TransportError::ConnectionRefused(format!("{code:?}")) +} + +/// Build an `IpSocketAddress` from a resolved IP and port. +fn socket_addr(ip: IpAddress, port: u16) -> IpSocketAddress { + match ip { + IpAddress::Ipv4(v4) => IpSocketAddress::Ipv4(Ipv4SocketAddress { + port, + address: v4, + }), + IpAddress::Ipv6(v6) => IpSocketAddress::Ipv6(Ipv6SocketAddress { + port, + address: v6, + flow_info: 0, + scope_id: 0, + }), + } +} + +/// Dial `addr` (host:port) and return the connected read/write halves. +pub fn connect(addr: &str) -> Result<(Reader, Writer)> { + let (host, port) = parse_addr(addr)?; + let network = instance_network(); + let addrs = network + .resolve_addresses(&host) + .map_err(to_err)?; + let ip = addrs + .into_iter() + .next() + .ok_or_else(|| TransportError::ConnectionRefused(format!("no address for {host}")))?; + let socket = TcpSocket::new(&network, IpAddressFamily::Ipv4, TcpSocketType::Stream) + .map_err(to_err)?; + socket + .start_connect(&network, socket_addr(ip, port)) + .map_err(to_err)?; + let (input, output) = socket.finish_connect().map_err(to_err)?; + Ok((Reader(input), Writer(output))) +} + +/// Bind and listen on `port` on all interfaces. +pub fn listen(port: u16) -> Result { + let network = instance_network(); + let socket = TcpSocket::new(&network, IpAddressFamily::Ipv4, TcpSocketType::Stream) + .map_err(to_err)?; + let local = IpSocketAddress::Ipv4(Ipv4SocketAddress { + port, + address: Ipv4Address { + octets: [0, 0, 0, 0], + }, + }); + socket.start_bind(&network, local).map_err(to_err)?; + socket.finish_bind().map_err(to_err)?; + socket.start_listen().map_err(to_err)?; + socket.finish_listen().map_err(to_err)?; + Ok(Listener { socket }) +} + +impl Listener { + /// Accept one inbound connection and return its read/write halves. + pub fn accept(&self) -> Result<(Reader, Writer)> { + let (_new_socket, input, output) = self.socket.accept().map_err(to_err)?; + Ok((Reader(input), Writer(output))) + } +} diff --git a/Build/crates/saikuro-transport/src/wasi/tcp.rs b/Build/crates/saikuro-transport/src/wasi/tcp.rs new file mode 100644 index 00000000..69c94530 --- /dev/null +++ b/Build/crates/saikuro-transport/src/wasi/tcp.rs @@ -0,0 +1,193 @@ +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use bytes::Bytes; +use embedded_io_async::{Read, Write}; + +use crate::shared::error::{Result, TransportError}; +use crate::shared::framed::{read_exact, read_first_byte, write_all}; +use crate::shared::traits::{ + LocalTransport, LocalTransportConnector, LocalTransportListener, LocalTransportReceiver, + LocalTransportSender, +}; + +#[cfg(all(feature = "wasi-preview2", feature = "wasi-preview1"))] +compile_error!( + "saikuro-transport: enable exactly one of wasi-preview1 / wasi-preview2 for wasi-tcp/wasi-host" +); + +#[cfg(not(any(feature = "wasi-preview2", feature = "wasi-preview1")))] +compile_error!( + "saikuro-transport: enable wasi-preview1 or wasi-preview2 to select the WASI socket backend" +); + +#[cfg(feature = "wasi-preview2")] +pub use preview2 as backend; +#[cfg(feature = "wasi-preview1")] +pub use preview1 as backend; + +/// A length-prefixed WASI TCP transport. +pub struct WasiTcpTransport { + reader: R, + writer: W, + peer: String, +} + +impl WasiTcpTransport { + /// Wrap an already-connected `(reader, writer)` pair. + pub fn new(reader: R, writer: W, peer: String) -> Self { + Self { reader, writer, peer } + } +} + +impl LocalTransport for WasiTcpTransport { + type Sender = WasiTcpSender; + type Receiver = WasiTcpReceiver; + + fn split(self) -> (Self::Sender, Self::Receiver) { + ( + WasiTcpSender { + writer: self.writer, + }, + WasiTcpReceiver { + reader: self.reader, + }, + ) + } + + fn description(&self) -> &str { + "wasi-tcp" + } +} + +/// Sending half of a [`WasiTcpTransport`]. +pub struct WasiTcpSender { + writer: W, +} + +impl LocalTransportSender for WasiTcpSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + write_a_frame(&mut self.writer, &frame).await + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} + +/// Receiving half of a [`WasiTcpTransport`]. +pub struct WasiTcpReceiver { + reader: R, +} + +impl LocalTransportReceiver for WasiTcpReceiver { + async fn recv(&mut self) -> Result> { + read_a_frame(&mut self.reader).await + } +} + +/// Write one length-prefixed frame to `writer`. +async fn write_a_frame(writer: &mut W, frame: &[u8]) -> Result<()> { + let len = frame.len(); + let header = [ + (len >> 24) as u8, + (len >> 16) as u8, + (len >> 8) as u8, + len as u8, + ]; + write_all(writer, &header).await?; + write_all(writer, frame).await?; + Ok(()) +} + +/// Read one length-prefixed frame, or `None` on a clean zero-length close. +async fn read_a_frame(reader: &mut R) -> Result> { + let mut first = 0u8; + read_first_byte(reader, &mut first).await?; + if first == 0 { + return Ok(None); + } + let mut rest = [0u8; 3]; + read_exact(reader, &mut rest, "wasi-tcp: closed during frame header").await?; + let len = ((first as usize) << 24) + | ((rest[0] as usize) << 16) + | ((rest[1] as usize) << 8) + | (rest[2] as usize); + let mut buf = Vec::with_capacity(len); + buf.resize(len, 0); + read_exact(reader, &mut buf, "wasi-tcp: closed during frame payload").await?; + Ok(Some(Bytes::from(buf))) +} + +/// Connects to a peer over WASI TCP. +pub struct WasiTcpConnector { + addr: String, +} + +impl WasiTcpConnector { + /// Create a connector that will dial `addr` (host:port). + pub fn new(addr: impl Into) -> Self { + Self { addr: addr.into() } + } +} + +impl LocalTransportConnector for WasiTcpConnector { + type Output = WasiTcpTransport; + + async fn connect(&self) -> Result { + let (reader, writer) = backend::connect(&self.addr)?; + Ok(WasiTcpTransport::new(reader, writer, self.addr.clone())) + } +} + +/// Accepts inbound WASI TCP connections on a port. +pub struct WasiTcpListener { + inner: backend::Listener, +} + +impl WasiTcpListener { + /// Start listening on `addr` (host:port); only the port is used. + pub fn new(addr: impl Into) -> Result { + let (_, port) = parse_addr(&addr.into())?; + Ok(Self { + inner: backend::listen(port)?, + }) + } +} + +impl LocalTransportListener for WasiTcpListener { + type Output = WasiTcpTransport; + + async fn accept(&mut self) -> Result> { + let (reader, writer) = self.inner.accept()?; + Ok(Some(WasiTcpTransport::new(reader, writer, String::new()))) + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} + +/// Split `host:port` into its pieces. +pub fn parse_addr(addr: &str) -> Result<(String, u16)> { + let (host, port_str) = addr + .rsplit_once(':') + .ok_or_else(|| TransportError::ConnectionRefused(format!("missing port in {addr}")))?; + let port = port_str + .parse::() + .map_err(|_| TransportError::ConnectionRefused(format!("bad port in {addr}")))?; + Ok((host.to_string(), port)) +} + +/// Parse a dotted-quad IPv4 literal. +pub fn parse_ipv4(host: &str) -> Option<[u8; 4]> { + let mut it = host.split('.'); + let a: u8 = it.next()?.parse().ok()?; + let b: u8 = it.next()?.parse().ok()?; + let c: u8 = it.next()?.parse().ok()?; + let d: u8 = it.next()?.parse().ok()?; + if it.next().is_some() { + return None; + } + Some([a, b, c, d]) +} diff --git a/Build/crates/saikuro-transport/src/wasm/host_browser.rs b/Build/crates/saikuro-transport/src/wasm/host_browser.rs new file mode 100644 index 00000000..e9fddf1a --- /dev/null +++ b/Build/crates/saikuro-transport/src/wasm/host_browser.rs @@ -0,0 +1,251 @@ +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; +use bytes::Bytes; +use core::fmt::Write; +use core::time::Duration; +use js_sys::{ArrayBuffer, Reflect, Uint8Array}; +use send_wrapper::SendWrapper; +use tracing::trace; +use wasm_bindgen::{closure::Closure, JsCast, JsValue}; +use web_sys::{BroadcastChannel, Crypto, MessageEvent}; + +use saikuro_exec::mpsc; +use saikuro_exec::oneshot; +use saikuro_exec::timeout; + +use crate::shared::error::{Result, TransportError}; +use crate::shared::host::{HostPipeFactory, HostPipeRecv, HostPipeSend, Role}; +use crate::DEFAULT_CHANNEL_CAPACITY; + +/// How long the active side waits for an accept reply before giving up. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Browser `BroadcastChannel` implementation of [`HostPipeFactory`]. +pub struct BroadcastChannelPipe; + +/// Sending half of a [`BroadcastChannelPipe`] connection. +pub struct BroadcastChannelSend { + channel: SendWrapper, +} + +/// Receiving half of a [`BroadcastChannelPipe`] connection. +pub struct BroadcastChannelRecv { + channel: SendWrapper, + rx: mpsc::Receiver, + _handler: SendWrapper>, +} + +/// The browser `wasm` engine's `WasmHostTransport` concrete type. +pub type WasmHost = crate::shared::host::WasmHostTransport; + +impl HostPipeFactory for BroadcastChannelPipe { + type Send = BroadcastChannelSend; + type Recv = BroadcastChannelRecv; + + async fn open(channel: &str, role: Role) -> Result<(Self::Send, Self::Recv)> { + match role { + Role::Connect => open_connect(channel), + Role::Accept => open_accept(channel), + } + } +} + +impl HostPipeSend for BroadcastChannelSend { + async fn send(&mut self, frame: &[u8]) -> Result<()> { + trace!(bytes = frame.len(), "wasm-host send"); + send_buffer(&self.channel, frame) + } +} + +impl HostPipeRecv for BroadcastChannelRecv { + async fn recv(&mut self) -> Result>> { + match self.rx.recv().await { + Some(bytes) => { + trace!(bytes = bytes.len(), "wasm-host recv"); + Ok(Some(bytes.to_vec())) + } + None => { + trace!("wasm-host channel closed"); + Ok(None) + } + } + } +} + +impl Drop for BroadcastChannelRecv { + fn drop(&mut self) { + self.channel.set_onmessage(None); + self.channel.close(); + } +} + +/// Active side: open a private channel, advertise connect, await accept. +async fn open_connect(channel: &str) -> Result<(BroadcastChannelSend, BroadcastChannelRecv)> { + let conn_id = short_id()?; + let private_name = format!("{}:{}", channel, conn_id); + + let private = BroadcastChannel::new(&private_name) + .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; + + let (data_tx, data_rx) = mpsc::channel::(DEFAULT_CHANNEL_CAPACITY); + let (accept_tx, accept_rx) = oneshot::channel::<()>(); + + let handler: Closure = Closure::new({ + let data_tx = data_tx; + let accept_tx = accept_tx; + let expected = conn_id.clone(); + move |event: MessageEvent| { + let data = event.data(); + // A handshake accept is a JS object, not a binary frame. + if let Some(t) = get_field(&data, "type") { + if t == "accept" && get_field(&data, "id").as_deref() == Some(expected.as_str()) { + let _ = accept_tx.try_send(()); + return; + } + } + if let Some(bytes) = extract_binary(&data) { + let _ = data_tx.try_send(Bytes::from(bytes)); + } + } + }); + private.set_onmessage(Some(handler.as_ref().unchecked_ref())); + + let base = BroadcastChannel::new(channel) + .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; + let msg = make_obj(&[("type", "connect"), ("id", &conn_id)]); + base.post_message(&msg) + .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; + drop(base); + + match timeout(CONNECT_TIMEOUT, accept_rx.recv()).await { + Ok(Some(())) => { + let send = BroadcastChannelSend { + channel: SendWrapper::new(private.clone()), + }; + let recv = BroadcastChannelRecv { + channel: SendWrapper::new(private), + rx: data_rx, + _handler: SendWrapper::new(handler), + }; + Ok((send, recv)) + } + Ok(None) => Err(TransportError::ConnectionLost("accept channel closed".into())), + Err(_) => Err(TransportError::ConnectionLost("connect timeout".into())), + } +} + +/// Passive side: listen on the base channel, answer each connect with an accept. +async fn open_accept(channel: &str) -> Result<(BroadcastChannelSend, BroadcastChannelRecv)> { + let base = BroadcastChannel::new(channel) + .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; + + let (conn_tx, conn_rx) = mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(32).expect("32 is a valid channel capacity"), + ); + let base_handler: Closure = Closure::new({ + let conn_tx = conn_tx; + move |event: MessageEvent| { + let data = event.data(); + if get_field(&data, "type").as_deref() != Some("connect") { + return; + } + if let Some(id) = get_field(&data, "id") { + let _ = conn_tx.try_send(id); + } + } + }); + base.set_onmessage(Some(base_handler.as_ref().unchecked_ref())); + + let conn_id = match conn_rx.recv().await { + Some(id) => id, + None => return Err(TransportError::ConnectionLost("base channel closed".into())), + }; + drop(base_handler); + drop(base); + + let private_name = format!("{}:{}", channel, conn_id); + let private = BroadcastChannel::new(&private_name) + .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; + + let (data_tx, data_rx) = mpsc::channel::(DEFAULT_CHANNEL_CAPACITY); + let data_handler: Closure = Closure::new({ + let data_tx = data_tx; + move |event: MessageEvent| { + if let Some(bytes) = extract_binary(&event.data()) { + let _ = data_tx.try_send(Bytes::from(bytes)); + } + } + }); + private.set_onmessage(Some(data_handler.as_ref().unchecked_ref())); + + let msg = make_obj(&[("type", "accept"), ("id", &conn_id)]); + private + .post_message(&msg) + .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; + + let send = BroadcastChannelSend { + channel: SendWrapper::new(private.clone()), + }; + let recv = BroadcastChannelRecv { + channel: SendWrapper::new(private), + rx: data_rx, + _handler: SendWrapper::new(data_handler), + }; + Ok((send, recv)) +} + +/// Generate a 128-bit random hex connection identifier via the browser CSPRNG. +fn short_id() -> Result { + let crypto: Crypto = Reflect::get(&js_sys::global(), &"crypto".into()) + .map_err(|e| TransportError::ConnectionLost(format!("crypto API not found: {e:?}")))? + .unchecked_into(); + let mut buf = [0u8; 16]; + crypto + .get_random_values_with_u8_array(&mut buf) + .map_err(|e| TransportError::ConnectionLost(format!("crypto get_random_values failed: {e:?}")))?; + Ok(buf.iter().fold(String::with_capacity(32), |mut s, b| { + let _ = write!(s, "{:02x}", b); + s + })) +} + +/// Create a JS object literal from key-value pairs. +fn make_obj(pairs: &[(&str, &str)]) -> JsValue { + let obj = js_sys::Object::new(); + for (k, v) in pairs { + let _ = js_sys::Reflect::set(&obj, &JsValue::from_str(k), &JsValue::from_str(v)); + } + JsValue::from(obj) +} + +/// Try to extract a string field from a JS object-typed `JsValue`. +fn get_field(val: &JsValue, key: &str) -> Option { + js_sys::Reflect::get(val, &JsValue::from_str(key)) + .ok() + .and_then(|v| v.as_string()) +} + +/// Pull the bytes out of a `BroadcastChannel` message payload. +fn extract_binary(data: &JsValue) -> Option> { + if let Some(buf) = data.dyn_ref::() { + Some(Uint8Array::new(buf).to_vec()) + } else if let Some(arr) = data.dyn_ref::() { + Some(arr.to_vec()) + } else { + None + } +} + +/// Post a binary frame as a freshly-allocated `ArrayBuffer`. +fn send_buffer(channel: &BroadcastChannel, frame: &[u8]) -> Result<()> { + let len = frame.len() as u32; + let buffer = ArrayBuffer::new(len); + let dst = Uint8Array::new(&buffer); + // Zero-copy view into WASM linear memory; consumed immediately in `set`. + let src = unsafe { Uint8Array::view(frame) }; + dst.set(&JsValue::from(src), 0); + channel + .post_message(&JsValue::from(buffer)) + .map_err(|e| TransportError::SendFailed(format!("{e:?}"))) +} diff --git a/Build/crates/saikuro-transport/src/wasm/mod.rs b/Build/crates/saikuro-transport/src/wasm/mod.rs new file mode 100644 index 00000000..0c896afb --- /dev/null +++ b/Build/crates/saikuro-transport/src/wasm/mod.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "ws")] +pub mod websocket; + +#[cfg(feature = "wasm-host")] +pub mod host_browser; diff --git a/Build/crates/saikuro-transport/src/wasm/websocket.rs b/Build/crates/saikuro-transport/src/wasm/websocket.rs new file mode 100644 index 00000000..3b5048c5 --- /dev/null +++ b/Build/crates/saikuro-transport/src/wasm/websocket.rs @@ -0,0 +1,220 @@ +use alloc::rc::Rc; +use alloc::string::String; +use core::cell::RefCell; + +use async_trait::async_trait; +use bytes::Bytes; +use send_wrapper::SendWrapper; +use tracing::{debug, trace}; +use wasm_bindgen::{closure::Closure, JsCast}; +use web_sys::{BinaryType, CloseEvent, ErrorEvent, Event, MessageEvent}; + +use saikuro_exec::mpsc; +use saikuro_exec::oneshot; +use saikuro_exec::timeout; + +use crate::shared::error::{Result, TransportError}; +use crate::shared::traits::{Transport, TransportReceiver, TransportSender}; +use crate::DEFAULT_CHANNEL_CAPACITY; + +/// A WebSocket transport connection (browser). +pub struct WebSocketTransport { + ws: SendWrapper, + url: String, +} + +impl WebSocketTransport { + /// Connect to a WebSocket server using the browser WebSocket API. + pub async fn connect(url: impl Into) -> Result { + let url = url.into(); + debug!(%url, "wasm websocket connecting"); + + let ws = web_sys::WebSocket::new(&url) + .map_err(|e| TransportError::ConnectionRefused(format!("{e:?}")))?; + ws.set_binary_type(BinaryType::Arraybuffer); + + let (tx, rx) = oneshot::channel::>(); + let shared: Rc>>> = Rc::new(RefCell::new(Some(tx))); + + let open_shared = Rc::clone(&shared); + let onopen = Closure::::new(move |_: Event| { + if let Some(s) = open_shared.borrow_mut().take() { + let _ = s.send(Ok(())); + } + }); + ws.set_onopen(Some(onopen.as_ref().unchecked_ref())); + + let error_shared = shared; + let onerror = Closure::::new(move |e: ErrorEvent| { + if let Some(s) = error_shared.borrow_mut().take() { + let _ = s.send(Err(TransportError::ConnectionRefused(e.message()))); + } + }); + ws.set_onerror(Some(onerror.as_ref().unchecked_ref())); + + let result = timeout(core::time::Duration::from_secs(30), async { + rx.await.unwrap_or(Err(TransportError::ConnectionRefused( + "connection cancelled".into(), + ))) + }) + .await; + + ws.set_onopen(None); + ws.set_onerror(None); + + match result { + Ok(Ok(())) => { + debug!(%url, "wasm websocket connected"); + Ok(Self { + ws: SendWrapper::new(ws), + url, + }) + } + Ok(Err(e)) => { + ws.close().ok(); + Err(e) + } + Err(_) => { + ws.close().ok(); + Err(TransportError::ConnectionRefused("connect timeout".into())) + } + } + } +} + +impl Transport for WebSocketTransport { + type Sender = WebSocketSender; + type Receiver = WebSocketReceiver; + + fn split(self) -> (Self::Sender, Self::Receiver) { + use js_sys::{ArrayBuffer, Uint8Array}; + + type WsEvent = core::result::Result, TransportError>; + + let (tx, rx) = mpsc::channel::(DEFAULT_CHANNEL_CAPACITY); + + let ws = self.ws.take(); + let ws_for_receiver = ws.clone(); + + let msg_tx = tx.clone(); + let onmsg = Closure::::new(move |event: MessageEvent| { + let data = event.data(); + let bytes = if let Some(buf) = data.dyn_ref::() { + Uint8Array::new(buf).to_vec() + } else if let Some(arr) = data.dyn_ref::() { + arr.to_vec() + } else { + return; + }; + let _ = msg_tx.try_send(Ok(Some(Bytes::from(bytes)))); + }); + let _ = ws_for_receiver.set_onmessage(Some(onmsg.as_ref().unchecked_ref())); + + let close_tx = tx.clone(); + let onclose = Closure::::new(move |_: CloseEvent| { + let _ = close_tx.try_send(Ok(None)); + }); + let _ = ws_for_receiver.set_onclose(Some(onclose.as_ref().unchecked_ref())); + + let error_tx = tx; + let onerror = Closure::::new(move |e: ErrorEvent| { + let _ = error_tx.try_send(Err(TransportError::ReceiveFailed(e.message()))); + }); + let _ = ws_for_receiver.set_onerror(Some(onerror.as_ref().unchecked_ref())); + + let url = self.url; + + ( + WebSocketSender { + ws: SendWrapper::new(ws), + url: url.clone(), + }, + WebSocketReceiver { + ws: SendWrapper::new(ws_for_receiver), + rx, + _onmsg: SendWrapper::new(onmsg), + _onclose: SendWrapper::new(onclose), + _onerror: SendWrapper::new(onerror), + url, + }, + ) + } + + fn description(&self) -> &str { + "websocket" + } +} + +/// Sending half of a WASM WebSocket transport. +/// +/// Sends binary frames via [`web_sys::WebSocket::send_with_array_buffer`]. +pub struct WebSocketSender { + ws: SendWrapper, + url: String, +} + +#[async_trait] +impl TransportSender for WebSocketSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + use js_sys::{ArrayBuffer, Uint8Array}; + use wasm_bindgen::JsValue; + + trace!(url = %self.url, bytes = frame.len(), "wasm ws send"); + let len = frame.len() as u32; + let buffer = ArrayBuffer::new(len); + let dst = Uint8Array::new(&buffer); + let src = unsafe { Uint8Array::view(frame.as_ref()) }; + dst.set(&JsValue::from(src), 0); + self.ws + .send_with_array_buffer(&buffer) + .map_err(|e| TransportError::SendFailed(format!("{e:?}"))) + } + + async fn close(&mut self) -> Result<()> { + debug!(url = %self.url, "wasm ws sender closing"); + self.ws + .close() + .map_err(|e| TransportError::SendFailed(format!("{e:?}"))) + } +} + +/// Receiving half of a WASM WebSocket transport. +/// +/// Bridges the browser's event-driven [`web_sys::WebSocket`] (`onmessage`, +/// `onclose`, `onerror`) into an async MPSC channel for the +/// [`TransportReceiver`] trait. +pub struct WebSocketReceiver { + ws: SendWrapper, + rx: mpsc::Receiver, TransportError>>, + _onmsg: SendWrapper>, + _onclose: SendWrapper>, + _onerror: SendWrapper>, + url: String, +} + +impl Drop for WebSocketReceiver { + fn drop(&mut self) { + self.ws.set_onmessage(None); + self.ws.set_onclose(None); + self.ws.set_onerror(None); + let _ = self.ws.close(); + } +} + +#[async_trait] +impl TransportReceiver for WebSocketReceiver { + async fn recv(&mut self) -> Result> { + match self.rx.recv().await { + Some(Ok(opt)) => { + if opt.is_some() { + trace!(url = %self.url, bytes = opt.as_ref().unwrap().len(), "wasm ws recv"); + } else { + debug!(url = %self.url, "wasm ws closed by peer"); + } + Ok(opt) + } + Some(Err(e)) => Err(e), + None => Ok(None), + } + } +} diff --git a/Build/crates/saikuro-transport/src/wasm_host.rs b/Build/crates/saikuro-transport/src/wasm_host.rs deleted file mode 100644 index f6b34cff..00000000 --- a/Build/crates/saikuro-transport/src/wasm_host.rs +++ /dev/null @@ -1,385 +0,0 @@ -//! WebAssembly host transport via BroadcastChannel (wasm32 only). -//! -//! Uses uniquely-named `BroadcastChannel`s (negotiated sub-channels) to -//! provide point-to-point transport between WASM contexts in the same -//! origin -//! -//! ## Connection flow -//! -//! 1. **Connector** generates a random connection ID, opens a private -//! `BroadcastChannel("{base}:{conn_id}")`, and sends a `{ type: "connect", -//! id: "{conn_id}" }` message on the well-known base channel. -//! -//! 2. **Listener** receives the connect message, opens the same private -//! channel, and sends a `{ type: "accept", id: "{conn_id}" }` reply. -//! -//! 3. Both sides wrap the private channel in a [`WasmHostTransport`] for -//! binary frame exchange. -//! -//! Because only the two peers know the private channel name, it behaves -//! like a point-to-point connection even though the underlying primitive -//! is a broadcast bus. -//! -//! ## Backpressure -//! -//! JS `onmessage` callbacks cannot suspend. If the consumer is slower than -//! the producer, frames are silently dropped at the channel boundary. The -//! protocol layer above is expected to handle retries (or senders should -//! implement their own flow control). - -use async_trait::async_trait; -use bytes::Bytes; -use js_sys::{ArrayBuffer, Reflect, Uint8Array}; -use send_wrapper::SendWrapper; -use tracing::trace; -use wasm_bindgen::{closure::Closure, JsCast, JsValue}; -use web_sys::{BroadcastChannel, Crypto, MessageEvent}; - -use saikuro_exec::mpsc; - -use crate::{ - error::{Result, TransportError}, - traits::{ - Transport, TransportConnector, TransportListener, TransportReceiver, TransportSender, - }, -}; - -use crate::DEFAULT_CHANNEL_CAPACITY; -const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -// helpers - -/// Generate a 128-bit random hex connection identifier. -fn short_id() -> Result { - let crypto: Crypto = Reflect::get(&js_sys::global(), &"crypto".into()) - .map_err(|e| TransportError::ConnectionLost(format!("crypto API not found: {e:?}")))? - .unchecked_into(); - let mut buf = [0u8; 16]; - crypto - .get_random_values_with_u8_array(&mut buf) - .map_err(|e| { - TransportError::ConnectionLost(format!("crypto get_random_values failed: {e:?}")) - })?; - Ok(buf.iter().fold(String::with_capacity(32), |mut s, b| { - use std::fmt::Write; - let _ = write!(s, "{:02x}", b); - s - })) -} - -/// Create a JS object literal from key-value pairs. -fn make_obj(pairs: &[(&str, &str)]) -> JsValue { - let obj = js_sys::Object::new(); - for (k, v) in pairs { - let _ = js_sys::Reflect::set(&obj, &JsValue::from_str(k), &JsValue::from_str(v)); - } - JsValue::from(obj) -} - -/// Try to extract a string field from a JS object-typed JsValue. -fn get_field(val: &JsValue, key: &str) -> Option { - js_sys::Reflect::get(val, &JsValue::from_str(key)) - .ok() - .and_then(|v| v.as_string()) -} - -/// Send binary data as a freshly-allocated `ArrayBuffer` on a channel. -fn send_buffer(channel: &BroadcastChannel, frame: &Bytes) -> Result<()> { - let len = frame.len() as u32; - let buffer = ArrayBuffer::new(len); - let dst = Uint8Array::new(&buffer); - // Efficient copy: create a view of our WASM-memory data, then use - // JS TypedArray.set(): one native call, no byte-by-byte overhead. - // SAFETY: Uint8Array::view creates a zero-copy view into WASM linear - // memory. It is only safe when the backing memory is not resized or - // freed while the view exists. We consume the view immediately in the - // `set` call below and never use it again. - let src = unsafe { Uint8Array::view(frame.as_ref()) }; - dst.set(&JsValue::from(src), 0); - channel - .post_message(&JsValue::from(buffer)) - .map_err(|e| TransportError::SendFailed(format!("{e:?}"))) -} - -// WasmHostTransport - -/// A transport backed by a uniquely-named `BroadcastChannel`. -/// -/// Constructed internally by [`WasmHostConnector::connect`] and -/// [`WasmHostListener::accept`]. After construction, call -/// [`Transport::split`] to obtain the sender/receiver halves. -pub struct WasmHostTransport { - sender: WasmHostSender, - receiver: WasmHostReceiver, -} - -impl WasmHostTransport { - /// Wrap a `BroadcastChannel` as a transport. - /// - /// Installs an `onmessage` handler that pushes incoming binary frames - /// into an MPSC channel for async consumption. - pub fn new(channel: BroadcastChannel, label: impl Into) -> Self { - let label = label.into(); - let (tx, rx) = mpsc::channel::(DEFAULT_CHANNEL_CAPACITY); - - let bridge_tx = tx; - let handler: Closure = Closure::new(move |event: MessageEvent| { - let data = event.data(); - let bytes = if let Some(buf) = data.dyn_ref::() { - Uint8Array::new(buf).to_vec() - } else if let Some(arr) = data.dyn_ref::() { - arr.to_vec() - } else { - return; - }; - let _ = bridge_tx.try_send(Bytes::from(bytes)); - }); - channel.set_onmessage(Some(handler.as_ref().unchecked_ref())); - - WasmHostTransport { - sender: WasmHostSender { - channel: SendWrapper::new(channel.clone()), - label: label.clone(), - }, - receiver: WasmHostReceiver { - channel: SendWrapper::new(channel), - rx, - _handler: SendWrapper::new(handler), - label, - }, - } - } -} - -impl Transport for WasmHostTransport { - type Sender = WasmHostSender; - type Receiver = WasmHostReceiver; - - fn split(self) -> (Self::Sender, Self::Receiver) { - (self.sender, self.receiver) - } - - fn description(&self) -> &str { - "wasm-host" - } -} - -// WasmHostSender - -/// Sending half of a [`WasmHostTransport`]. -pub struct WasmHostSender { - channel: SendWrapper, - label: String, -} - -#[async_trait] -impl TransportSender for WasmHostSender { - async fn send(&mut self, frame: Bytes) -> Result<()> { - trace!(label = %self.label, bytes = frame.len(), "wasm-host send"); - send_buffer(&self.channel, &frame) - } - - async fn close(&mut self) -> Result<()> { - trace!(label = %self.label, "wasm-host sender closing"); - Ok(()) - } -} - -// WasmHostReceiver - -/// Receiving half of a [`WasmHostTransport`]. -/// -/// Owns the channel lifecycle: on drop the `onmessage` handler is removed -/// and the channel is closed. -pub struct WasmHostReceiver { - channel: SendWrapper, - rx: mpsc::Receiver, - _handler: SendWrapper>, - label: String, -} - -impl Drop for WasmHostReceiver { - fn drop(&mut self) { - self.channel.set_onmessage(None); - self.channel.close(); - } -} - -#[async_trait] -impl TransportReceiver for WasmHostReceiver { - async fn recv(&mut self) -> Result> { - let result = self.rx.recv().await; - match &result { - Some(bytes) => trace!(label = %self.label, bytes = bytes.len(), "wasm-host recv"), - None => trace!(label = %self.label, "wasm-host channel closed"), - } - Ok(result) - } -} - -// WasmHostConnector - -/// Initiates an outgoing WASM host connection. -/// -/// The connector opens a private `BroadcastChannel` and sends a connect -/// request on the well-known rendezvous channel. Once the listener replies -/// on the private channel the connection is established. -pub struct WasmHostConnector { - channel_name: String, -} - -impl WasmHostConnector { - /// Create a connector that will rendezvous on `channel_name`. - pub fn new(channel_name: impl Into) -> Self { - Self { - channel_name: channel_name.into(), - } - } -} - -#[async_trait] -impl TransportConnector for WasmHostConnector { - type Output = WasmHostTransport; - - async fn connect(&self) -> Result { - let conn_id = short_id()?; - let private_name = format!("{}:{}", self.channel_name, conn_id); - - // Open private channel FIRST so we're listening before the - // connect request reaches the listener. - let private = BroadcastChannel::new(&private_name) - .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; - - // Signal channel: listener's accept reply will unblock us. - let (signal_tx, mut signal_rx) = mpsc::channel::<()>(saikuro_exec::ChannelCapacity::MIN); - - // Temporary accept handler on the private channel. - // Scoped in a block so the raw `Closure` is consumed into the - // `SendWrapper` BEFORE any await point. - let _handler_guard: SendWrapper> = { - let h: Closure = Closure::new({ - let signal = signal_tx; - let expected_id = conn_id.clone(); - move |event: MessageEvent| { - let data = event.data(); - let msg_type = get_field(&data, "type"); - let msg_id = get_field(&data, "id"); - if msg_type.as_deref() == Some("accept") - && msg_id.as_deref() == Some(&expected_id) - { - let _ = signal.try_send(()); - } - } - }); - private.set_onmessage(Some(h.as_ref().unchecked_ref())); - SendWrapper::new(h) - }; - - // Send connect request on the base channel. - let base = BroadcastChannel::new(&self.channel_name) - .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; - let msg = make_obj(&[("type", "connect"), ("id", &conn_id)]); - base.post_message(&msg) - .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; - - // Wait for accept with timeout. - let result = saikuro_exec::timeout(CONNECT_TIMEOUT, signal_rx.recv()).await; - - match result { - Ok(Some(())) => { - // Accept received. WasmHostTransport::new replaces the - // accept handler with the real data handler. - Ok(WasmHostTransport::new(private, conn_id)) - } - Ok(None) => Err(TransportError::ConnectionLost( - "accept channel closed".into(), - )), - Err(_) => Err(TransportError::ConnectionLost("connect timeout".into())), - } - } -} - -// WasmHostListener - -/// Accepts incoming WASM host connections on a well-known channel name. -/// -/// Opens a `BroadcastChannel` on the rendezvous name and installs an -/// `onmessage` handler that queues incoming connect requests. Each call -/// to [`accept`](TransportListener::accept) pops the next request, -/// opens the corresponding private channel, sends an accept reply, and -/// returns the transport. -pub struct WasmHostListener { - base_name: String, - connect_rx: mpsc::Receiver, - _base_channel: SendWrapper, - _handler: SendWrapper>, - closed: bool, -} - -impl WasmHostListener { - /// Start listening for connections on `channel_name`. - pub fn new(channel_name: impl Into) -> Result { - let base_name: String = channel_name.into(); - let base = BroadcastChannel::new(&base_name) - .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; - - let (tx, rx) = mpsc::channel::( - saikuro_exec::ChannelCapacity::try_from(32).expect("32 is a valid channel capacity"), - ); - - let handler_tx = tx; - let handler: Closure = Closure::new(move |event: MessageEvent| { - let data = event.data(); - let msg_type = get_field(&data, "type"); - if msg_type.as_deref() != Some("connect") { - return; - } - if let Some(conn_id) = get_field(&data, "id") { - let _ = handler_tx.try_send(conn_id); - } - }); - base.set_onmessage(Some(handler.as_ref().unchecked_ref())); - - Ok(Self { - base_name, - connect_rx: rx, - _base_channel: SendWrapper::new(base), - _handler: SendWrapper::new(handler), - closed: false, - }) - } -} - -#[async_trait] -impl TransportListener for WasmHostListener { - type Output = WasmHostTransport; - - async fn accept(&mut self) -> Result> { - if self.closed { - return Ok(None); - } - let conn_id = match self.connect_rx.recv().await { - Some(id) => id, - None => return Ok(None), - }; - - let private_name = format!("{}:{}", self.base_name, conn_id); - let private = BroadcastChannel::new(&private_name) - .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; - - // Send accept reply on the private channel. - let msg = make_obj(&[("type", "accept"), ("id", &conn_id)]); - private - .post_message(&msg) - .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; - - Ok(Some(WasmHostTransport::new(private, conn_id))) - } - - async fn close(&mut self) -> Result<()> { - self._base_channel.set_onmessage(None); - self._base_channel.close(); - self.closed = true; - Ok(()) - } -} diff --git a/Build/crates/saikuro-transport/src/websocket.rs b/Build/crates/saikuro-transport/src/websocket.rs deleted file mode 100644 index bcd198fc..00000000 --- a/Build/crates/saikuro-transport/src/websocket.rs +++ /dev/null @@ -1,427 +0,0 @@ -//! WebSocket transport: works on both native and wasm32. -//! -//! On native targets the implementation wraps `tokio-tungstenite` for a -//! full-duplex, TLS-capable WebSocket over TCP. -//! -//! On wasm32 targets the implementation wraps `web-sys::WebSocket` (the -//! browser's native WebSocket API) and bridges its event-driven callbacks -//! into async channels, giving the same [`Transport`]-trait interface. - -use async_trait::async_trait; -use bytes::Bytes; -use tracing::{debug, trace}; - -use crate::{ - error::{Result, TransportError}, - traits::{Transport, TransportReceiver, TransportSender}, -}; - -#[cfg(not(target_arch = "wasm32"))] -use crate::traits::TransportListener; - -#[cfg(target_arch = "wasm32")] -use crate::DEFAULT_CHANNEL_CAPACITY; - -// Native (tokio-tungstenite) implementation -#[cfg(not(target_arch = "wasm32"))] -use std::net::SocketAddr; - -#[cfg(not(target_arch = "wasm32"))] -use futures::{SinkExt, StreamExt}; - -#[cfg(not(target_arch = "wasm32"))] -use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; - -#[cfg(not(target_arch = "wasm32"))] -use saikuro_exec::net::{TcpListener, TcpStream}; - -/// A WebSocket transport connection. -/// -/// On native this wraps `tokio-tungstenite`; on wasm32 it wraps the browser's -/// `WebSocket` API. Same public API on both platforms. -pub struct WebSocketTransport { - #[cfg(not(target_arch = "wasm32"))] - inner: WebSocketStream>, - #[cfg(target_arch = "wasm32")] - ws: send_wrapper::SendWrapper, - url: String, -} - -impl WebSocketTransport { - /// Connect to a WebSocket server at `url` (e.g. `"ws://127.0.0.1:9000"`). - #[cfg(not(target_arch = "wasm32"))] - pub async fn connect(url: impl Into) -> Result { - let url = url.into(); - debug!(%url, "websocket connecting"); - let (ws, _response) = connect_async(&url).await.map_err(|e| { - TransportError::ConnectionRefused(format!("ws connect to {url} failed: {e}")) - })?; - Ok(Self { inner: ws, url }) - } - - /// Connect to a WebSocket server using the browser WebSocket API. - #[cfg(target_arch = "wasm32")] - pub async fn connect(url: impl Into) -> Result { - use send_wrapper::SendWrapper; - use std::sync::{Arc, Mutex}; - use wasm_bindgen::{closure::Closure, JsCast}; - use web_sys::{BinaryType, ErrorEvent, Event}; - - let url = url.into(); - debug!(%url, "wasm websocket connecting"); - - let ws = web_sys::WebSocket::new(&url) - .map_err(|e| TransportError::ConnectionRefused(format!("{e:?}")))?; - ws.set_binary_type(BinaryType::Arraybuffer); - - let (tx, rx) = saikuro_exec::oneshot::channel::>(); - let shared: Arc>>>> = - Arc::new(Mutex::new(Some(tx))); - - let open_shared = shared.clone(); - let onopen = Closure::::new(move |_: Event| { - if let Some(s) = open_shared.lock().unwrap_or_else(|e| e.into_inner()).take() { - let _ = s.send(Ok(())); - } - }); - ws.set_onopen(Some(onopen.as_ref().unchecked_ref())); - - let error_shared = shared; - let onerror = Closure::::new(move |e: ErrorEvent| { - if let Some(s) = error_shared - .lock() - .unwrap_or_else(|e| e.into_inner()) - .take() - { - let _ = s.send(Err(TransportError::ConnectionRefused(e.message()))); - } - }); - ws.set_onerror(Some(onerror.as_ref().unchecked_ref())); - - let result = saikuro_exec::timeout(std::time::Duration::from_secs(30), async { - rx.await.unwrap_or(Err(TransportError::ConnectionRefused( - "connection cancelled".into(), - ))) - }) - .await; - - ws.set_onopen(None); - ws.set_onerror(None); - - match result { - Ok(Ok(())) => { - debug!(%url, "wasm websocket connected"); - Ok(Self { - ws: SendWrapper::new(ws), - url, - }) - } - Ok(Err(e)) => { - ws.close().ok(); - Err(e) - } - Err(_) => { - ws.close().ok(); - Err(TransportError::ConnectionRefused("connect timeout".into())) - } - } - } - - /// Construct from an already-upgraded WebSocket stream (server-side accept path). - #[cfg(not(target_arch = "wasm32"))] - pub fn from_stream(ws: WebSocketStream>, url: String) -> Self { - Self { inner: ws, url } - } -} - -impl Transport for WebSocketTransport { - type Sender = WebSocketSender; - type Receiver = WebSocketReceiver; - - fn split(self) -> (Self::Sender, Self::Receiver) { - #[cfg(not(target_arch = "wasm32"))] - { - let url = self.url.clone(); - let (sink, stream) = self.inner.split(); - ( - WebSocketSender { - inner: sink, - url: url.clone(), - }, - WebSocketReceiver { inner: stream, url }, - ) - } - - #[cfg(target_arch = "wasm32")] - { - use js_sys::{ArrayBuffer, Uint8Array}; - use send_wrapper::SendWrapper; - use wasm_bindgen::{closure::Closure, JsCast}; - use web_sys::{CloseEvent, ErrorEvent, MessageEvent}; - - type WsEvent = std::result::Result, TransportError>; - - let (tx, rx) = saikuro_exec::mpsc::channel::(DEFAULT_CHANNEL_CAPACITY); - - let ws = self.ws.take(); - let ws_for_receiver = ws.clone(); - - let msg_tx = tx.clone(); - let onmsg = Closure::::new(move |event: MessageEvent| { - let data = event.data(); - let bytes = if let Some(buf) = data.dyn_ref::() { - Uint8Array::new(buf).to_vec() - } else if let Some(arr) = data.dyn_ref::() { - arr.to_vec() - } else { - return; - }; - let _ = msg_tx.try_send(Ok(Some(Bytes::from(bytes)))); - }); - let _ = ws_for_receiver.set_onmessage(Some(onmsg.as_ref().unchecked_ref())); - - let close_tx = tx.clone(); - let onclose = Closure::::new(move |_: CloseEvent| { - let _ = close_tx.try_send(Ok(None)); - }); - let _ = ws_for_receiver.set_onclose(Some(onclose.as_ref().unchecked_ref())); - - let error_tx = tx; - let onerror = Closure::::new(move |e: ErrorEvent| { - let _ = error_tx.try_send(Err(TransportError::ReceiveFailed(e.message()))); - }); - let _ = ws_for_receiver.set_onerror(Some(onerror.as_ref().unchecked_ref())); - - let url = self.url; - - ( - WebSocketSender { - ws: SendWrapper::new(ws), - url: url.clone(), - }, - WebSocketReceiver { - ws: SendWrapper::new(ws_for_receiver), - rx, - _onmsg: SendWrapper::new(onmsg), - _onclose: SendWrapper::new(onclose), - _onerror: SendWrapper::new(onerror), - url, - }, - ) - } - } - - fn description(&self) -> &str { - "websocket" - } -} - -// WebSocket transport listener (server-side accept, native only) -/// Listens for inbound TCP connections and upgrades them to WebSocket. -/// -/// Implements [`TransportListener`] so it can be used with the same generic -/// accept-loop as TCP and Unix listeners. Not available on wasm32 (browsers -/// cannot listen for TCP connections). -#[cfg(not(target_arch = "wasm32"))] -pub struct WsTransportListener { - inner: Option, - local_addr: SocketAddr, -} - -#[cfg(not(target_arch = "wasm32"))] -impl WsTransportListener { - /// Bind a TCP listener on the given address for WebSocket upgrades. - pub async fn bind(addr: SocketAddr) -> Result { - let inner = TcpListener::bind(addr).await?; - let local_addr = inner.local_addr()?; - debug!(%local_addr, "ws listener bound"); - Ok(Self { - inner: Some(inner), - local_addr, - }) - } - - /// Return the address this listener is bound to. - pub fn local_addr(&self) -> SocketAddr { - self.local_addr - } -} - -#[cfg(not(target_arch = "wasm32"))] -#[async_trait] -impl TransportListener for WsTransportListener { - type Output = WebSocketTransport; - - async fn accept(&mut self) -> Result> { - let inner = self - .inner - .as_ref() - .ok_or_else(|| TransportError::ConnectionRefused("listener closed".into()))?; - let (stream, peer_addr) = inner.accept().await?; - let url = format!("ws://{peer_addr}"); - let maybe_tls = MaybeTlsStream::Plain(stream); - match tokio_tungstenite::accept_async(maybe_tls).await { - Ok(ws_stream) => { - debug!(peer = %peer_addr, "ws upgrade successful"); - Ok(Some(WebSocketTransport::from_stream(ws_stream, url))) - } - Err(e) => { - tracing::warn!(peer = %peer_addr, error = %e, "ws upgrade failed"); - Err(TransportError::ConnectionRefused(format!( - "WebSocket upgrade from {peer_addr} failed: {e}" - ))) - } - } - } - - async fn close(&mut self) -> Result<()> { - debug!(local = %self.local_addr, "ws listener closing"); - drop(self.inner.take()); - Ok(()) - } -} - -// Native Sender / Receiver -#[cfg(not(target_arch = "wasm32"))] -pub struct WebSocketSender { - inner: futures::stream::SplitSink>, Message>, - url: String, -} - -#[cfg(not(target_arch = "wasm32"))] -#[async_trait] -impl TransportSender for WebSocketSender { - async fn send(&mut self, frame: Bytes) -> Result<()> { - trace!(url = %self.url, bytes = frame.len(), "ws send"); - self.inner - .send(Message::Binary(frame.to_vec())) - .await - .map_err(|e| TransportError::SendFailed(e.to_string())) - } - - async fn close(&mut self) -> Result<()> { - debug!(url = %self.url, "ws sender closing"); - self.inner - .send(Message::Close(None)) - .await - .map_err(|e| TransportError::SendFailed(e.to_string())) - } -} - -#[cfg(not(target_arch = "wasm32"))] -pub struct WebSocketReceiver { - inner: futures::stream::SplitStream>>, - url: String, -} - -#[cfg(not(target_arch = "wasm32"))] -#[async_trait] -impl TransportReceiver for WebSocketReceiver { - async fn recv(&mut self) -> Result> { - loop { - match self.inner.next().await { - Some(Ok(Message::Binary(data))) => { - trace!(url = %self.url, bytes = data.len(), "ws recv binary"); - return Ok(Some(Bytes::from(data))); - } - Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => { - continue; - } - Some(Ok(Message::Close(_))) => { - debug!(url = %self.url, "ws closed by peer"); - return Ok(None); - } - Some(Ok(other)) => { - trace!(url = %self.url, "ws ignoring non-binary frame: {:?}", other); - continue; - } - Some(Err(e)) => { - return Err(TransportError::ReceiveFailed(e.to_string())); - } - None => return Ok(None), - } - } - } -} - -// WASM Sender / Receiver (web-sys::WebSocket) -/// Sending half of a WASM WebSocket transport. -/// -/// Sends binary frames via [`web_sys::WebSocket::send_with_array_buffer`]. -#[cfg(target_arch = "wasm32")] -pub struct WebSocketSender { - ws: send_wrapper::SendWrapper, - url: String, -} - -#[cfg(target_arch = "wasm32")] -#[async_trait] -impl TransportSender for WebSocketSender { - async fn send(&mut self, frame: Bytes) -> Result<()> { - use js_sys::{ArrayBuffer, Uint8Array}; - use wasm_bindgen::JsValue; - trace!(url = %self.url, bytes = frame.len(), "wasm ws send"); - let len = frame.len() as u32; - let buffer = ArrayBuffer::new(len); - let dst = Uint8Array::new(&buffer); - let src = unsafe { Uint8Array::view(frame.as_ref()) }; - dst.set(&JsValue::from(src), 0); - self.ws - .send_with_array_buffer(&buffer) - .map_err(|e| TransportError::SendFailed(format!("{e:?}"))) - } - - async fn close(&mut self) -> Result<()> { - debug!(url = %self.url, "wasm ws sender closing"); - self.ws - .close() - .map_err(|e| TransportError::SendFailed(format!("{e:?}"))) - } -} - -/// Receiving half of a WASM WebSocket transport. -/// -/// Bridges the browser's event-driven [`web_sys::WebSocket`] (`onmessage`, -/// `onclose`, `onerror`) into an async MPSC channel for the -/// [`TransportReceiver`] trait. -#[cfg(target_arch = "wasm32")] -pub struct WebSocketReceiver { - ws: send_wrapper::SendWrapper, - rx: saikuro_exec::mpsc::Receiver, TransportError>>, - _onmsg: - send_wrapper::SendWrapper>, - _onclose: - send_wrapper::SendWrapper>, - _onerror: - send_wrapper::SendWrapper>, - url: String, -} - -#[cfg(target_arch = "wasm32")] -impl Drop for WebSocketReceiver { - fn drop(&mut self) { - self.ws.set_onmessage(None); - self.ws.set_onclose(None); - self.ws.set_onerror(None); - let _ = self.ws.close(); - } -} - -#[cfg(target_arch = "wasm32")] -#[async_trait] -impl TransportReceiver for WebSocketReceiver { - async fn recv(&mut self) -> Result> { - match self.rx.recv().await { - Some(Ok(opt)) => { - if opt.is_some() { - trace!(url = %self.url, bytes = opt.as_ref().unwrap().len(), "wasm ws recv"); - } else { - debug!(url = %self.url, "wasm ws closed by peer"); - } - Ok(opt) - } - Some(Err(e)) => Err(e), - None => Ok(None), - } - } -} From 2b95a15f982db7c8e89de8a89c9b5c65086887af Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Sat, 15 Aug 2026 13:51:29 -0600 Subject: [PATCH 34/43] oop move saikuro-transport --- Build/crates/saikuro-transport/Cargo.toml | 3 +++ .../saikuro-transport/{src => }/embedded/io_transport.rs | 0 Build/crates/saikuro-transport/{src => }/embedded/mod.rs | 0 Build/crates/saikuro-transport/{src => }/embedded/tcp.rs | 0 Build/crates/saikuro-transport/{src => }/lib.rs | 0 Build/crates/saikuro-transport/{src => }/native/framed.rs | 0 Build/crates/saikuro-transport/{src => }/native/mod.rs | 0 Build/crates/saikuro-transport/{src => }/native/tcp.rs | 0 Build/crates/saikuro-transport/{src => }/native/unix.rs | 0 Build/crates/saikuro-transport/{src => }/native/websocket.rs | 0 Build/crates/saikuro-transport/{src => }/shared/error.rs | 0 Build/crates/saikuro-transport/{src => }/shared/framed.rs | 0 Build/crates/saikuro-transport/{src => }/shared/framing.rs | 0 Build/crates/saikuro-transport/{src => }/shared/host.rs | 0 Build/crates/saikuro-transport/{src => }/shared/memory.rs | 0 Build/crates/saikuro-transport/{src => }/shared/mod.rs | 0 Build/crates/saikuro-transport/{src => }/shared/selector.rs | 0 Build/crates/saikuro-transport/{src => }/shared/traits.rs | 0 Build/crates/saikuro-transport/{src => }/wasi/host.rs | 0 Build/crates/saikuro-transport/{src => }/wasi/mod.rs | 0 Build/crates/saikuro-transport/{src => }/wasi/preview1.rs | 0 Build/crates/saikuro-transport/{src => }/wasi/preview2.rs | 0 Build/crates/saikuro-transport/{src => }/wasi/tcp.rs | 0 Build/crates/saikuro-transport/{src => }/wasm/host_browser.rs | 0 Build/crates/saikuro-transport/{src => }/wasm/mod.rs | 0 Build/crates/saikuro-transport/{src => }/wasm/websocket.rs | 0 26 files changed, 3 insertions(+) rename Build/crates/saikuro-transport/{src => }/embedded/io_transport.rs (100%) rename Build/crates/saikuro-transport/{src => }/embedded/mod.rs (100%) rename Build/crates/saikuro-transport/{src => }/embedded/tcp.rs (100%) rename Build/crates/saikuro-transport/{src => }/lib.rs (100%) rename Build/crates/saikuro-transport/{src => }/native/framed.rs (100%) rename Build/crates/saikuro-transport/{src => }/native/mod.rs (100%) rename Build/crates/saikuro-transport/{src => }/native/tcp.rs (100%) rename Build/crates/saikuro-transport/{src => }/native/unix.rs (100%) rename Build/crates/saikuro-transport/{src => }/native/websocket.rs (100%) rename Build/crates/saikuro-transport/{src => }/shared/error.rs (100%) rename Build/crates/saikuro-transport/{src => }/shared/framed.rs (100%) rename Build/crates/saikuro-transport/{src => }/shared/framing.rs (100%) rename Build/crates/saikuro-transport/{src => }/shared/host.rs (100%) rename Build/crates/saikuro-transport/{src => }/shared/memory.rs (100%) rename Build/crates/saikuro-transport/{src => }/shared/mod.rs (100%) rename Build/crates/saikuro-transport/{src => }/shared/selector.rs (100%) rename Build/crates/saikuro-transport/{src => }/shared/traits.rs (100%) rename Build/crates/saikuro-transport/{src => }/wasi/host.rs (100%) rename Build/crates/saikuro-transport/{src => }/wasi/mod.rs (100%) rename Build/crates/saikuro-transport/{src => }/wasi/preview1.rs (100%) rename Build/crates/saikuro-transport/{src => }/wasi/preview2.rs (100%) rename Build/crates/saikuro-transport/{src => }/wasi/tcp.rs (100%) rename Build/crates/saikuro-transport/{src => }/wasm/host_browser.rs (100%) rename Build/crates/saikuro-transport/{src => }/wasm/mod.rs (100%) rename Build/crates/saikuro-transport/{src => }/wasm/websocket.rs (100%) diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index 9550f223..3bdd14c0 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "transport", "async"] +[lib] +path = "lib.rs" + [features] default = ["std", "native", "tcp", "ws"] std = [] diff --git a/Build/crates/saikuro-transport/src/embedded/io_transport.rs b/Build/crates/saikuro-transport/embedded/io_transport.rs similarity index 100% rename from Build/crates/saikuro-transport/src/embedded/io_transport.rs rename to Build/crates/saikuro-transport/embedded/io_transport.rs diff --git a/Build/crates/saikuro-transport/src/embedded/mod.rs b/Build/crates/saikuro-transport/embedded/mod.rs similarity index 100% rename from Build/crates/saikuro-transport/src/embedded/mod.rs rename to Build/crates/saikuro-transport/embedded/mod.rs diff --git a/Build/crates/saikuro-transport/src/embedded/tcp.rs b/Build/crates/saikuro-transport/embedded/tcp.rs similarity index 100% rename from Build/crates/saikuro-transport/src/embedded/tcp.rs rename to Build/crates/saikuro-transport/embedded/tcp.rs diff --git a/Build/crates/saikuro-transport/src/lib.rs b/Build/crates/saikuro-transport/lib.rs similarity index 100% rename from Build/crates/saikuro-transport/src/lib.rs rename to Build/crates/saikuro-transport/lib.rs diff --git a/Build/crates/saikuro-transport/src/native/framed.rs b/Build/crates/saikuro-transport/native/framed.rs similarity index 100% rename from Build/crates/saikuro-transport/src/native/framed.rs rename to Build/crates/saikuro-transport/native/framed.rs diff --git a/Build/crates/saikuro-transport/src/native/mod.rs b/Build/crates/saikuro-transport/native/mod.rs similarity index 100% rename from Build/crates/saikuro-transport/src/native/mod.rs rename to Build/crates/saikuro-transport/native/mod.rs diff --git a/Build/crates/saikuro-transport/src/native/tcp.rs b/Build/crates/saikuro-transport/native/tcp.rs similarity index 100% rename from Build/crates/saikuro-transport/src/native/tcp.rs rename to Build/crates/saikuro-transport/native/tcp.rs diff --git a/Build/crates/saikuro-transport/src/native/unix.rs b/Build/crates/saikuro-transport/native/unix.rs similarity index 100% rename from Build/crates/saikuro-transport/src/native/unix.rs rename to Build/crates/saikuro-transport/native/unix.rs diff --git a/Build/crates/saikuro-transport/src/native/websocket.rs b/Build/crates/saikuro-transport/native/websocket.rs similarity index 100% rename from Build/crates/saikuro-transport/src/native/websocket.rs rename to Build/crates/saikuro-transport/native/websocket.rs diff --git a/Build/crates/saikuro-transport/src/shared/error.rs b/Build/crates/saikuro-transport/shared/error.rs similarity index 100% rename from Build/crates/saikuro-transport/src/shared/error.rs rename to Build/crates/saikuro-transport/shared/error.rs diff --git a/Build/crates/saikuro-transport/src/shared/framed.rs b/Build/crates/saikuro-transport/shared/framed.rs similarity index 100% rename from Build/crates/saikuro-transport/src/shared/framed.rs rename to Build/crates/saikuro-transport/shared/framed.rs diff --git a/Build/crates/saikuro-transport/src/shared/framing.rs b/Build/crates/saikuro-transport/shared/framing.rs similarity index 100% rename from Build/crates/saikuro-transport/src/shared/framing.rs rename to Build/crates/saikuro-transport/shared/framing.rs diff --git a/Build/crates/saikuro-transport/src/shared/host.rs b/Build/crates/saikuro-transport/shared/host.rs similarity index 100% rename from Build/crates/saikuro-transport/src/shared/host.rs rename to Build/crates/saikuro-transport/shared/host.rs diff --git a/Build/crates/saikuro-transport/src/shared/memory.rs b/Build/crates/saikuro-transport/shared/memory.rs similarity index 100% rename from Build/crates/saikuro-transport/src/shared/memory.rs rename to Build/crates/saikuro-transport/shared/memory.rs diff --git a/Build/crates/saikuro-transport/src/shared/mod.rs b/Build/crates/saikuro-transport/shared/mod.rs similarity index 100% rename from Build/crates/saikuro-transport/src/shared/mod.rs rename to Build/crates/saikuro-transport/shared/mod.rs diff --git a/Build/crates/saikuro-transport/src/shared/selector.rs b/Build/crates/saikuro-transport/shared/selector.rs similarity index 100% rename from Build/crates/saikuro-transport/src/shared/selector.rs rename to Build/crates/saikuro-transport/shared/selector.rs diff --git a/Build/crates/saikuro-transport/src/shared/traits.rs b/Build/crates/saikuro-transport/shared/traits.rs similarity index 100% rename from Build/crates/saikuro-transport/src/shared/traits.rs rename to Build/crates/saikuro-transport/shared/traits.rs diff --git a/Build/crates/saikuro-transport/src/wasi/host.rs b/Build/crates/saikuro-transport/wasi/host.rs similarity index 100% rename from Build/crates/saikuro-transport/src/wasi/host.rs rename to Build/crates/saikuro-transport/wasi/host.rs diff --git a/Build/crates/saikuro-transport/src/wasi/mod.rs b/Build/crates/saikuro-transport/wasi/mod.rs similarity index 100% rename from Build/crates/saikuro-transport/src/wasi/mod.rs rename to Build/crates/saikuro-transport/wasi/mod.rs diff --git a/Build/crates/saikuro-transport/src/wasi/preview1.rs b/Build/crates/saikuro-transport/wasi/preview1.rs similarity index 100% rename from Build/crates/saikuro-transport/src/wasi/preview1.rs rename to Build/crates/saikuro-transport/wasi/preview1.rs diff --git a/Build/crates/saikuro-transport/src/wasi/preview2.rs b/Build/crates/saikuro-transport/wasi/preview2.rs similarity index 100% rename from Build/crates/saikuro-transport/src/wasi/preview2.rs rename to Build/crates/saikuro-transport/wasi/preview2.rs diff --git a/Build/crates/saikuro-transport/src/wasi/tcp.rs b/Build/crates/saikuro-transport/wasi/tcp.rs similarity index 100% rename from Build/crates/saikuro-transport/src/wasi/tcp.rs rename to Build/crates/saikuro-transport/wasi/tcp.rs diff --git a/Build/crates/saikuro-transport/src/wasm/host_browser.rs b/Build/crates/saikuro-transport/wasm/host_browser.rs similarity index 100% rename from Build/crates/saikuro-transport/src/wasm/host_browser.rs rename to Build/crates/saikuro-transport/wasm/host_browser.rs diff --git a/Build/crates/saikuro-transport/src/wasm/mod.rs b/Build/crates/saikuro-transport/wasm/mod.rs similarity index 100% rename from Build/crates/saikuro-transport/src/wasm/mod.rs rename to Build/crates/saikuro-transport/wasm/mod.rs diff --git a/Build/crates/saikuro-transport/src/wasm/websocket.rs b/Build/crates/saikuro-transport/wasm/websocket.rs similarity index 100% rename from Build/crates/saikuro-transport/src/wasm/websocket.rs rename to Build/crates/saikuro-transport/wasm/websocket.rs From 7c10370506a0d4b3363094d78b9a7f9e87c55409 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Sat, 15 Aug 2026 15:00:35 -0600 Subject: [PATCH 35/43] saikuro-runtime --- .gitignore | 6 +- Build/Cargo.lock | 843 +++++++----------- Build/Cargo.toml | 2 +- Build/adapters/c/Cargo.toml | 2 +- Build/adapters/rust/Cargo.toml | 8 +- Build/crates/saikuro-event/lib.rs | 13 + Build/crates/saikuro-runtime/Cargo.toml | 114 ++- .../saikuro-runtime/src/bin/embedded.rs | 33 + Build/crates/saikuro-runtime/src/bin/wasi.rs | 44 + Build/crates/saikuro-runtime/src/bin/wasm.rs | 24 + Build/crates/saikuro-runtime/src/config.rs | 25 +- .../crates/saikuro-runtime/src/connection.rs | 99 +- Build/crates/saikuro-runtime/src/handle.rs | 31 +- Build/crates/saikuro-runtime/src/lib.rs | 11 +- Build/crates/saikuro-runtime/src/main.rs | 194 +--- Build/crates/saikuro-runtime/src/runtime.rs | 138 ++- .../saikuro-runtime/src/transport_adapter.rs | 216 +++++ Build/crates/saikuro-transport/Cargo.toml | 2 +- Build/tests/Cargo.toml | 14 +- .../saikuro-transport/transport_framing.rs | 7 - 20 files changed, 954 insertions(+), 872 deletions(-) create mode 100644 Build/crates/saikuro-runtime/src/bin/embedded.rs create mode 100644 Build/crates/saikuro-runtime/src/bin/wasi.rs create mode 100644 Build/crates/saikuro-runtime/src/bin/wasm.rs create mode 100644 Build/crates/saikuro-runtime/src/transport_adapter.rs diff --git a/.gitignore b/.gitignore index 397767db..7ff1af31 100644 --- a/.gitignore +++ b/.gitignore @@ -39,8 +39,10 @@ cache/ node_modules/ dist/ -bin/ -obj/ +Build/adapters/csharp/Saikuro/src/bin +Build/adapters/csharp/Saikuro/src/obj/ +Build/adapters/csharp/tools/extractor/bin +Build/adapters/csharp/tools/extractor/obj .pytest_cache/ __pycache__/ .ruff_cache/ diff --git a/Build/Cargo.lock b/Build/Cargo.lock index be78f8ad..3ad24d02 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -11,15 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -94,31 +85,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "base64" -version = "0.22.1" +name = "bare-metal" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3" +dependencies = [ + "rustc_version", +] [[package]] -name = "bitflags" -version = "1.3.2" +name = "bitfield" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719" [[package]] name = "bitflags" -version = "2.11.0" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "bs58" -version = "0.5.1" +name = "bitflags" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bumpalo" @@ -138,22 +129,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - -[[package]] -name = "cc" -version = "1.2.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" -dependencies = [ - "find-msvc-tools", - "shlex", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -182,18 +157,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - [[package]] name = "cipher" version = "0.4.4" @@ -251,10 +214,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "cortex-m" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f985670a83ddb4c96174f696987458ae26fe3d801faf7263b56a2b2252c2f76f" +dependencies = [ + "bare-metal", + "bitfield", + "cortex-m-macros", + "critical-section", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "volatile-register", +] + +[[package]] +name = "cortex-m-macros" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "6d1922be58519ad40368fc4ca595a2cefa51a7abf947be3b0c90586dc7dbd0e2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "cpufeatures" @@ -320,18 +303,8 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core", + "darling_macro", ] [[package]] @@ -348,37 +321,13 @@ dependencies = [ "syn", ] -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core 0.20.11", - "quote", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn", ] @@ -412,13 +361,34 @@ dependencies = [ ] [[package]] -name = "deranged" -version = "0.5.8" +name = "defmt" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ - "powerfmt", - "serde_core", + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", ] [[package]] @@ -430,21 +400,18 @@ dependencies = [ "litrs", ] -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - [[package]] name = "embassy-executor" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f64f84599b0f4296b92a4b6ac2109bc02340094bda47b9766c5f9ec6a318ebf8" dependencies = [ + "cortex-m", "critical-section", "document-features", "embassy-executor-macros", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -453,7 +420,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3577b1e9446f61381179a330fc5324b01d511624c55f25e3c66c9e3c626dbecf" dependencies = [ - "darling 0.20.11", + "darling", "proc-macro2", "quote", "syn", @@ -473,7 +440,7 @@ checksum = "49f9f2979069031c153e41075a43074c36a64492e598780b27944a605f829d23" dependencies = [ "document-features", "embassy-net-driver", - "embassy-sync 0.6.2", + "embassy-sync", "embassy-time", "embedded-io-async 0.6.1", "embedded-nal-async", @@ -488,17 +455,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524eb3c489760508f71360112bca70f6e53173e6fe48fc5f0efd0f5ab217751d" -[[package]] -name = "embassy-net-driver-channel" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7b2739fbcf6cd206ae08779c7d709087b16577d255f2ea4a45bc4bbbf305b3f" -dependencies = [ - "embassy-futures", - "embassy-net-driver", - "embassy-sync 0.7.2", -] - [[package]] name = "embassy-sync" version = "0.6.2" @@ -513,20 +469,6 @@ dependencies = [ "heapless", ] -[[package]] -name = "embassy-sync" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" -dependencies = [ - "cfg-if", - "critical-section", - "embedded-io-async 0.6.1", - "futures-core", - "futures-sink", - "heapless", -] - [[package]] name = "embassy-time" version = "0.3.2" @@ -665,24 +607,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - [[package]] name = "fluvio-wasm-timer" version = "0.2.5" @@ -706,9 +630,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.2.0" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "fs2" @@ -853,6 +777,17 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "graphitesql" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a062c2a8f9ec58e9e7a03ccf9a7698f0143bbd31be056a618b9a0bab2f2bc4fc" +dependencies = [ + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "hash32" version = "0.3.1" @@ -862,12 +797,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.14.5" @@ -879,33 +808,15 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "foldhash", ] [[package]] name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashlink" -version = "0.12.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b" -dependencies = [ - "hashbrown 0.17.1", -] +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heapless" @@ -925,34 +836,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ident_case" @@ -960,17 +847,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - [[package]] name = "indexmap" version = "2.13.0" @@ -1035,27 +911,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libm" -version = "0.2.16" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "libsqlite3-sys" -version = "0.38.1" +name = "libc" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "litrs" @@ -1119,16 +984,6 @@ dependencies = [ "serde", ] -[[package]] -name = "minicov" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" -dependencies = [ - "cc", - "walkdir", -] - [[package]] name = "mio" version = "1.2.0" @@ -1136,7 +991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys", ] @@ -1164,12 +1019,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - [[package]] name = "num-traits" version = "0.2.19" @@ -1177,7 +1026,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -1192,12 +1040,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - [[package]] name = "parking_lot" version = "0.11.2" @@ -1258,12 +1100,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - [[package]] name = "portable-atomic" version = "1.14.0" @@ -1274,10 +1110,14 @@ dependencies = [ ] [[package]] -name = "powerfmt" -version = "0.2.0" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] [[package]] name = "proc-macro2" @@ -1350,27 +1190,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "bitflags 2.13.1", ] [[package]] @@ -1422,28 +1242,12 @@ dependencies = [ ] [[package]] -name = "rsqlite-vfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" -dependencies = [ - "hashbrown 0.16.1", - "thiserror", -] - -[[package]] -name = "rusqlite" -version = "0.40.1" +name = "rustc_version" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" dependencies = [ - "bitflags 2.11.0", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", + "semver 0.9.0", ] [[package]] @@ -1515,12 +1319,29 @@ dependencies = [ "serde", "serde_bytes", "serde_json", - "spin", "strum", "thiserror", "uuid", ] +[[package]] +name = "saikuro-event" +version = "0.1.0" +dependencies = [ + "embedded-io-async 0.7.0", + "getrandom 0.3.4", + "heapless", + "messagepack-serde", + "serde", + "serde_bytes", + "serde_json", + "spin", + "strum", + "thiserror", + "tracing", + "web-sys", +] + [[package]] name = "saikuro-exec" version = "0.1.0" @@ -1528,12 +1349,10 @@ dependencies = [ "embassy-executor", "embassy-futures", "embassy-net", - "embassy-net-driver-channel", - "embassy-sync 0.6.2", + "embassy-sync", "embassy-time", "fluvio-wasm-timer", "futures", - "futures-executor", "tokio", "tokio-util", "wasm-bindgen-futures", @@ -1558,6 +1377,7 @@ dependencies = [ "getrandom 0.3.4", "portable-atomic", "rand_core 0.9.5", + "saikuro-event", "uuid", ] @@ -1567,10 +1387,10 @@ version = "0.1.0" dependencies = [ "async-trait", "saikuro-core", + "saikuro-event", "saikuro-exec", "saikuro-schema", "thiserror", - "tracing", "tracing-subscriber", ] @@ -1582,21 +1402,24 @@ dependencies = [ "async-trait", "bytes", "clap", - "dashmap 7.0.0-rc2", + "embassy-executor", "futures", - "parking_lot 0.12.5", + "portable-atomic", "saikuro-core", + "saikuro-event", "saikuro-exec", + "saikuro-net", "saikuro-random", "saikuro-router", "saikuro-schema", "saikuro-transport", "serde", "serde_json", - "serde_with", - "thiserror", + "spin", "tracing", "tracing-subscriber", + "wasi 0.14.7+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1604,61 +1427,39 @@ name = "saikuro-schema" version = "0.1.0" dependencies = [ "saikuro-core", + "saikuro-event", + "saikuro-exec", "thiserror", - "tracing", ] [[package]] name = "saikuro-storage" version = "0.1.0" dependencies = [ - "async-trait", "bytes", "dashmap 7.0.0-rc2", "embedded-storage-async", "futures", "futures-executor", + "graphitesql", "js-sys", - "rusqlite", "saikuro-core", + "saikuro-event", "saikuro-exec", + "sequential-storage", "serde", "serde_json", "sled", + "spin", "thiserror", "tokio", "tracing", "tracing-subscriber", + "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", -] - -[[package]] -name = "saikuro-tests" -version = "0.1.0" -dependencies = [ - "bytes", - "futures", - "js-sys", - "rmp-serde", - "saikuro", - "saikuro-codegen", - "saikuro-core", - "saikuro-exec", - "saikuro-random", - "saikuro-router", - "saikuro-runtime", - "saikuro-schema", - "saikuro-transport", - "serde", - "serde_json", - "tracing", - "tracing-subscriber", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test", - "web-sys", + "wit-bindgen 0.46.0", ] [[package]] @@ -1667,12 +1468,14 @@ version = "0.1.0" dependencies = [ "async-trait", "bytes", + "embassy-sync", "embedded-io-async 0.7.0", "futures", "js-sys", "pin-project-lite", "saikuro-core", "saikuro-exec", + "saikuro-net", "saikuro-random", "send_wrapper", "serde", @@ -1680,49 +1483,39 @@ dependencies = [ "tokio-tungstenite", "tracing", "tracing-subscriber", + "wasi 0.14.7+wasi-0.2.4", + "wasip1", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] [[package]] -name = "same-file" -version = "1.0.6" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "schemars" +name = "semver" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", + "semver-parser", ] [[package]] -name = "schemars" -version = "1.2.1" +name = "semver" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] -name = "scopeguard" -version = "1.2.0" +name = "semver-parser" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" [[package]] name = "send_wrapper" @@ -1730,6 +1523,16 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +[[package]] +name = "sequential-storage" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e750f14f4f6e5a81277c66311713a56106c8df0196df62a634d1fc806556832b" +dependencies = [ + "defmt", + "embedded-storage-async", +] + [[package]] name = "serde" version = "1.0.228" @@ -1783,38 +1586,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_with" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" -dependencies = [ - "base64", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.13.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -1824,12 +1595,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1900,18 +1665,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "sqlite-wasm-rs" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1985,52 +1738,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.53.0" @@ -2090,6 +1797,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2182,6 +1890,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2201,10 +1915,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] -name = "vcpkg" -version = "0.2.15" +name = "vcell" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" [[package]] name = "version_check" @@ -2219,13 +1933,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" [[package]] -name = "walkdir" -version = "2.5.0" +name = "volatile-register" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc" dependencies = [ - "same-file", - "winapi-util", + "vcell", ] [[package]] @@ -2234,13 +1947,28 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip1" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e26842486624357dbeb8f0381cf1fb42f022291fd787d4a816768fec8cc760" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -2299,43 +2027,38 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen-test" -version = "0.3.72" +name = "wasm-encoder" +version = "0.239.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fde991ccdc895cb7fbaa14b137d62af74d9011be67b71c694bfc40edd3119c" +checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" dependencies = [ - "async-trait", - "cast", - "js-sys", - "libm", - "minicov", - "nu-ansi-term", - "num-traits", - "oorandom", - "serde", - "serde_json", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test-macro", - "wasm-bindgen-test-shared", + "leb128fmt", + "wasmparser", ] [[package]] -name = "wasm-bindgen-test-macro" -version = "0.3.72" +name = "wasm-metadata" +version = "0.239.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e925354648d2a4d1bf205412e36d520a800280622eef4719678d268e5d40e978" +checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" dependencies = [ - "proc-macro2", - "quote", - "syn", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "wasm-bindgen-test-shared" -version = "0.2.122" +name = "wasmparser" +version = "0.239.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "684365b586a9a6256c1cc3544eee8680de48d6041142f581776ec7b139622ae9" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.15.5", + "indexmap", + "semver 1.0.28", +] [[package]] name = "web-sys" @@ -2363,15 +2086,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2379,78 +2093,119 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows-core" -version = "0.62.2" +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-implement", - "windows-interface", "windows-link", - "windows-result", - "windows-strings", ] [[package]] -name = "windows-implement" -version = "0.60.2" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" dependencies = [ - "proc-macro2", - "quote", - "syn", + "bitflags 2.13.1", + "futures", + "once_cell", + "wit-bindgen-rust-macro", ] [[package]] -name = "windows-interface" -version = "0.59.3" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" dependencies = [ - "proc-macro2", - "quote", - "syn", + "bitflags 2.13.1", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "wit-bindgen-core" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "cabd629f94da277abc739c71353397046401518efb2c707669f805205f0b9890" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] [[package]] -name = "windows-result" -version = "0.4.1" +name = "wit-bindgen-rust" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +checksum = "9a4232e841089fa5f3c4fc732a92e1c74e1a3958db3b12f1de5934da2027f1f4" dependencies = [ - "windows-link", + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] -name = "windows-strings" -version = "0.5.1" +name = "wit-bindgen-rust-macro" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +checksum = "1e0d4698c2913d8d9c2b220d116409c3f51a7aa8d7765151b886918367179ee9" dependencies = [ - "windows-link", + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] -name = "windows-sys" -version = "0.61.2" +name = "wit-component" +version = "0.239.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" dependencies = [ - "windows-link", + "anyhow", + "bitflags 2.13.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "wit-bindgen" -version = "0.57.1" +name = "wit-parser" +version = "0.239.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver 1.0.28", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "zmij" diff --git a/Build/Cargo.toml b/Build/Cargo.toml index 4c4a4b70..529d7947 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -116,7 +116,7 @@ sled = "0.34" graphitesql = "0.1.6" wit-bindgen = "0.46" wasi = "0.14" -wasip1 = "0.14" +wasip1 = "1" # Futures futures-executor = "0.3" diff --git a/Build/adapters/c/Cargo.toml b/Build/adapters/c/Cargo.toml index 4b8996da..44ef4076 100644 --- a/Build/adapters/c/Cargo.toml +++ b/Build/adapters/c/Cargo.toml @@ -18,7 +18,7 @@ path = "src/cli/saikuro_c_schema.rs" [features] default = ["saikuro/default"] -wasm = ["saikuro/wasm", "saikuro-exec/wasm-runtime"] +wasm = ["saikuro/wasm", "saikuro-exec/wasm"] [dependencies] saikuro = { workspace = true, default-features = false } diff --git a/Build/adapters/rust/Cargo.toml b/Build/adapters/rust/Cargo.toml index 610dc2bd..71235ce3 100644 --- a/Build/adapters/rust/Cargo.toml +++ b/Build/adapters/rust/Cargo.toml @@ -16,10 +16,10 @@ path = "src/cli/saikuro_rust_schema.rs" [features] default = ["tcp", "unix", "ws", "storage", "saikuro-exec/native"] -tcp = ["saikuro-transport/native-transport"] -unix = ["saikuro-transport/native-transport"] -ws = ["saikuro-transport/native-ws"] -wasm = ["saikuro-transport/wasm-runtime", "saikuro-transport/ws-transport", "saikuro-random/wasm"] +tcp = ["saikuro-transport/tcp"] +unix = ["saikuro-transport/unix"] +ws = ["saikuro-transport/ws"] +wasm = ["saikuro-transport/wasm", "saikuro-transport/wasm-host", "saikuro-random/wasm"] # Storage backends: platform-agnostic factory in storage module storage = ["saikuro-storage/native"] diff --git a/Build/crates/saikuro-event/lib.rs b/Build/crates/saikuro-event/lib.rs index 81b49042..186c1660 100644 --- a/Build/crates/saikuro-event/lib.rs +++ b/Build/crates/saikuro-event/lib.rs @@ -8,6 +8,19 @@ extern crate alloc; #[cfg(feature = "std")] extern crate std; +#[cfg(any( + all(feature = "native", feature = "no_std"), + all(feature = "native", feature = "wasm"), + all(feature = "native", feature = "embedded"), + all(feature = "no_std", feature = "wasm"), + all(feature = "no_std", feature = "embedded"), + all(feature = "wasm", feature = "embedded"), +))] +compile_error!("saikuro-event: enable exactly one engine (native / no_std / wasm / embedded)"); + +#[cfg(all(feature = "no_std", feature = "std"))] +compile_error!("saikuro-event: the no_std engine cannot be combined with the std toolchain"); + mod value; pub use value::*; diff --git a/Build/crates/saikuro-runtime/Cargo.toml b/Build/crates/saikuro-runtime/Cargo.toml index a3d61e51..4dff8cec 100644 --- a/Build/crates/saikuro-runtime/Cargo.toml +++ b/Build/crates/saikuro-runtime/Cargo.toml @@ -11,38 +11,110 @@ keywords = ["ipc", "cross-language", "saikuro", "runtime", "async"] [[bin]] name = "saikuro-runtime" path = "src/main.rs" -required-features = ["native-transport"] +required-features = ["native"] + +[[bin]] +name = "saikuro-runtime-embedded" +path = "src/bin/embedded.rs" +required-features = ["embedded", "tcp"] + +[[bin]] +name = "saikuro-runtime-wasm" +path = "src/bin/wasm.rs" +required-features = ["wasm"] + +[[bin]] +name = "saikuro-runtime-wasi" +path = "src/bin/wasi.rs" +required-features = ["no_std"] [features] -default = ["native-transport"] -native-transport = ["saikuro-transport/native-transport", "saikuro-exec/tokio-runtime"] -ws-transport = ["saikuro-transport/native-ws"] -wasm-runtime = [ - "saikuro-transport/wasm-runtime", - "saikuro-exec/wasm-runtime", +default = ["std", "native", "tcp", "unix"] + +# Engine axes (exactly one of native / no_std / wasm / embedded; std is orthogonal). +std = [] +native = [ + "std", + "saikuro-core/native", + "saikuro-schema/native", + "saikuro-transport/native", + "saikuro-router/native", + "saikuro-exec/native", + "saikuro-random/native", + "saikuro-event/native", + "saikuro-event/stderr", +] +no_std = [ + "saikuro-core/no_std", + "saikuro-schema/no_std", + "saikuro-transport/no_std", + "saikuro-router/no_std", + "saikuro-exec/no_std", + "saikuro-random/no_std", + "saikuro-event/no_std", + "saikuro-event/null", +] +wasm = [ + "saikuro-core/wasm", + "saikuro-schema/wasm", + "saikuro-transport/wasm", + "saikuro-router/wasm", + "saikuro-exec/wasm", "saikuro-random/wasm", + "saikuro-event/wasm", + "saikuro-event/console", + "wasm-host", + "dep:wasm-bindgen", ] +embedded = [ + "saikuro-core/embedded", + "saikuro-schema/embedded", + "saikuro-transport/embedded", + "saikuro-router/embedded", + "saikuro-exec/embedded", + "saikuro-random/embedded", + "saikuro-event/embedded", + "saikuro-event/null", + "dep:embassy-executor", + "dep:saikuro-net", +] + +# Transport capability features (orthogonal to engine). +tcp = ["saikuro-transport/tcp"] +unix = ["saikuro-transport/unix"] +ws = ["saikuro-transport/ws"] +wasm-host = ["saikuro-transport/wasm-host"] +wasi-tcp = ["saikuro-transport/wasi-tcp"] +wasi-host = ["saikuro-transport/wasi-host"] + +# WASI preview selection (no_std engine only). +wasi-preview1 = ["saikuro-transport/wasi-preview1"] +wasi-preview2 = ["saikuro-transport/wasi-preview2", "dep:wasi"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } -saikuro-schema = { workspace = true } -saikuro-transport = { workspace = true } -saikuro-router = { workspace = true } -saikuro-exec = { workspace = true, default-features = false } +saikuro-schema = { path = "../saikuro-schema", default-features = false } +saikuro-transport = { path = "../saikuro-transport", default-features = false } +saikuro-router = { path = "../saikuro-router", default-features = false } +saikuro-exec = { path = "../saikuro-exec", default-features = false } saikuro-random = { path = "../saikuro-random", default-features = false } +saikuro-event = { path = "../saikuro-event", default-features = false } serde = { workspace = true } -serde_json = { workspace = true } -bytes = { workspace = true } +serde_json = { workspace = true, features = ["alloc"] } +bytes = { workspace = true, default-features = false } async-trait = { workspace = true } futures = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -dashmap = { workspace = true } -parking_lot = { workspace = true } -serde_with = { workspace = true } +tracing = { workspace = true, default-features = false, features = ["log", "attributes"] } +spin = { workspace = true } +portable-atomic = { workspace = true } -anyhow = { workspace = true } -clap = { workspace = true, features = ["env"] } +wasi = { workspace = true, optional = true } +embassy-executor = { workspace = true, optional = true } +saikuro-net = { path = "../saikuro-net", default-features = false, optional = true } +wasm-bindgen = { workspace = true, optional = true } -[dev-dependencies] +[target.'cfg(feature = "native")'.dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["env"] } +tracing-subscriber = { workspace = true } diff --git a/Build/crates/saikuro-runtime/src/bin/embedded.rs b/Build/crates/saikuro-runtime/src/bin/embedded.rs new file mode 100644 index 00000000..54bcc1d4 --- /dev/null +++ b/Build/crates/saikuro-runtime/src/bin/embedded.rs @@ -0,0 +1,33 @@ +#![cfg(feature = "embedded")] + +extern crate alloc; + +use saikuro_exec::watch; +use saikuro_net::net::Stack; +use saikuro_runtime::SaikuroRuntime; +use saikuro_transport::embedded::tcp::TcpTransportListener; + +/// Host-provided board support. +mod board { + use saikuro_net::net::Stack; + + pub fn stack() -> &'static Stack<'static> { + compile_error!("provide `crate::board::stack() -> &'static Stack<'static>` in the firmware"); + } + + pub fn endpoint() -> saikuro_net::net::IpEndpoint { + compile_error!("provide `crate::board::endpoint() -> IpEndpoint` in the firmware"); + } +} + +#[embassy_executor::main] +async fn main() { + let stack = board::stack(); + let runtime = SaikuroRuntime::builder().build(); + + let (_shutdown_tx, shutdown_rx) = watch::channel(false); + + runtime + .serve(vec![TcpTransportListener::new(stack, board::endpoint())], shutdown_rx) + .await; +} diff --git a/Build/crates/saikuro-runtime/src/bin/wasi.rs b/Build/crates/saikuro-runtime/src/bin/wasi.rs new file mode 100644 index 00000000..c3618dca --- /dev/null +++ b/Build/crates/saikuro-runtime/src/bin/wasi.rs @@ -0,0 +1,44 @@ +#![cfg(feature = "no_std")] +#![no_std] + +extern crate alloc; + +use alloc::sync::Arc; + +use saikuro_exec::watch; +use saikuro_runtime::transport_adapter::{HostPipeListener, LocalRuntimeListener}; +use saikuro_runtime::SaikuroRuntime; +use saikuro_transport::wasi::host::WasiPipe; +use saikuro_transport::wasi::tcp::WasiTcpListener; + +/// WASI command entry point. Returns a process exit code. +#[no_mangle] +pub extern "C" fn _start() -> i32 { + let runtime = Arc::new(SaikuroRuntime::builder().build()); + let (_shutdown_tx, shutdown_rx) = watch::channel(false); + + let tcp = match WasiTcpListener::new("0.0.0.0:7700") { + Ok(listener) => LocalRuntimeListener::new(listener), + Err(_) => return 1, + }; + let pipe = HostPipeListener::::new("saikuro"); + + saikuro_exec::block_on(async move { + let mut rx1 = shutdown_rx.clone(); + let mut rx2 = shutdown_rx.clone(); + + let tcp_task = { + let rt = runtime.clone(); + saikuro_exec::spawn(async move { rt.serve(vec![tcp], rx1).await; }) + }; + let pipe_task = { + let rt = runtime.clone(); + saikuro_exec::spawn(async move { rt.serve(vec![pipe], rx2).await; }) + }; + + let _ = tcp_task.await; + let _ = pipe_task.await; + }); + + 0 +} diff --git a/Build/crates/saikuro-runtime/src/bin/wasm.rs b/Build/crates/saikuro-runtime/src/bin/wasm.rs new file mode 100644 index 00000000..90226bda --- /dev/null +++ b/Build/crates/saikuro-runtime/src/bin/wasm.rs @@ -0,0 +1,24 @@ +#![cfg(feature = "wasm")] + +use saikuro_exec::watch; +use saikuro_runtime::transport_adapter::HostPipeListener; +use saikuro_runtime::SaikuroRuntime; +use saikuro_transport::wasm::host_browser::BroadcastChannelPipe; + +/// Start the runtime, listening for adapters that rendezvous on `channel`. +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn start(channel: String) { + let runtime = SaikuroRuntime::builder().build(); + let (_shutdown_tx, shutdown_rx) = watch::channel(false); + saikuro_exec::spawn(async move { + runtime + .serve(vec![HostPipeListener::::new(channel)], shutdown_rx) + .await; + }); +} + +/// Pump the executor once. Call from the browser event loop. +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn pump() { + saikuro_exec::pump(); +} diff --git a/Build/crates/saikuro-runtime/src/config.rs b/Build/crates/saikuro-runtime/src/config.rs index 8a8315d5..19563866 100644 --- a/Build/crates/saikuro-runtime/src/config.rs +++ b/Build/crates/saikuro-runtime/src/config.rs @@ -1,8 +1,5 @@ -//! Runtime configuration. - use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; -use serde_with::serde_as; -use std::time::Duration; +use core::time::Duration; use saikuro_exec::ChannelCapacity; use saikuro_router::router::RouterConfig; @@ -10,7 +7,6 @@ use saikuro_schema::registry::RegistryMode; use saikuro_transport::selector::TransportConfig; /// Top-level runtime configuration. -#[serde_as] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeConfig { /// Whether the runtime starts in development or production mode. @@ -22,7 +18,7 @@ pub struct RuntimeConfig { pub max_in_flight_calls: usize, /// Default timeout for `Call` invocations. - #[serde_as(as = "serde_with::DurationMilliSeconds")] + #[serde(with = "duration_ms")] #[serde(default = "default_call_timeout")] pub call_timeout: Duration, @@ -121,3 +117,20 @@ where { serializer.serialize_u64(capacity.get() as u64) } + +/// (De)serialize a [`Duration`] as integer milliseconds, engine-agnostic +/// (works under `std` and `core`). +mod duration_ms { + use core::time::Duration; + + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(duration: &Duration, serializer: S) -> Result { + serializer.serialize_u64(duration.as_millis() as u64) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let millis = u64::deserialize(deserializer)?; + Ok(Duration::from_millis(millis)) + } +} diff --git a/Build/crates/saikuro-runtime/src/connection.rs b/Build/crates/saikuro-runtime/src/connection.rs index 22bd5d88..dd7d6c9f 100644 --- a/Build/crates/saikuro-runtime/src/connection.rs +++ b/Build/crates/saikuro-runtime/src/connection.rs @@ -1,43 +1,9 @@ -//! Connection handler: one instance per connected adapter peer. -//! -//! Each time an adapter connects over any transport backend a -//! [`ConnectionHandler`] is spawned. It owns the transport halves and -//! drives the read loop: receive a frame -> decode envelope -> validate -> -//! capability-check -> route -> encode response -> send back. -//! -//! ## Dual-role connections -//! -//! A single transport connection can act in **two roles simultaneously**: -//! -//! - **Client role**: the peer sends `Envelope` frames (requests to the -//! runtime). The runtime validates, capability-checks, routes, and replies -//! with a `ResponseEnvelope`. -//! -//! - **Provider role**: after sending an `Announce` envelope, the peer becomes -//! a provider for its declared namespaces. When the runtime needs to call -//! one of those functions, it forwards the `Envelope` to the peer over the -//! wire and waits for a `ResponseEnvelope` reply. -//! -//! The handler distinguishes the two frame types by the presence of the `ok` -//! field: `ResponseEnvelope` always serialises an `ok` boolean; `Envelope` -//! serialises a `type` field instead. We use a peek-decode strategy to -//! classify incoming frames. -//! -//! ## System envelopes -//! -//! - `Announce`: merges the declared [`Schema`] into the live registry AND -//! registers a wire-forwarding [`ProviderHandle`] so that subsequent calls -//! from any client are forwarded to this peer. In **sandbox mode**, after -//! the ok response, a second unsolicited `Announce` frame carrying the -//! capability-filtered schema snapshot is pushed back to the peer. -//! -//! - `Log`: forwarded directly to the router's log sink. -//! -//! Connections are fully independent; a crash in one handler does not -//! affect others. +use alloc::collections::BTreeMap; +use alloc::string::String; +use alloc::sync::Arc; +use alloc::vec::Vec; use bytes::Bytes; -use dashmap::DashMap; use futures::future::FutureExt; use saikuro_core::{ capability::CapabilitySet, @@ -57,16 +23,17 @@ use saikuro_schema::{ registry::SchemaRegistry, validator::InvocationValidator, }; -use saikuro_transport::traits::{TransportReceiver, TransportSender}; use serde::Serialize; -use std::sync::Arc; +use spin::Mutex; use tracing::{debug, error, info, instrument, warn}; +use crate::transport_adapter::{RuntimeReceiver, RuntimeSender}; + // Pending call map /// Tracks in-flight `Call` invocations forwarded to a wire-connected provider. /// Maps `InvocationId -> oneshot::Sender`. -type PendingCalls = Arc>>; +type PendingCalls = Arc>>>; /// Encode a serializable value as MessagePack `Bytes`. fn encode_bytes(value: &T) -> Result { @@ -81,8 +48,8 @@ fn encode_bytes(value: &T) -> Result { /// compiles cleanly on wasm32 targets. pub struct ConnectionHandler where - S: TransportSender, - R: TransportReceiver, + S: RuntimeSender + 'static, + R: RuntimeReceiver + 'static, { pub peer_id: String, /// Identity of this connection's provider registration. @@ -103,16 +70,10 @@ where impl ConnectionHandler where - S: TransportSender, - R: TransportReceiver, + S: RuntimeSender + 'static, + R: RuntimeReceiver + 'static, { /// Build a handler in **sandbox mode**. - /// - /// In sandbox mode every [`Announce`](InvocationType::Announce) processed by - /// this handler causes a capability-filtered schema snapshot to be pushed - /// back to the peer immediately after the `ok` response. This lets the peer - /// discover exactly which functions it is allowed to call without trial and - /// error. pub fn sandboxed(mut self) -> Self { self.capability_engine = CapabilityEngine::sandboxed(); self @@ -126,25 +87,18 @@ where impl ConnectionHandler where - S: TransportSender, - R: TransportReceiver, + S: RuntimeSender + 'static, + R: RuntimeReceiver + 'static, { /// Run the receive loop until the connection is closed or an unrecoverable /// error occurs. - /// - /// The loop classifies each incoming frame: - /// - If the frame decodes as a `ResponseEnvelope` (has an `ok` field) AND - /// matches a pending forwarded call, the response is delivered to the - /// caller's oneshot receiver. - /// - Otherwise the frame is treated as a new `Envelope` from the peer and - /// goes through the normal validate -> route -> reply pipeline. #[instrument(skip(self), fields(peer = %self.peer_id))] pub async fn run(mut self) { info!(peer = %self.peer_id, "connection established"); // Shared pending-call map: ForwardTask writes response_tx into this; // the recv loop reads it when a ResponseEnvelope arrives from the peer. - let pending: PendingCalls = Arc::new(DashMap::new()); + let pending: PendingCalls = Arc::new(Mutex::new(BTreeMap::new())); // Channel through which the ForwardTask sends frames TO the peer. // The recv loop serialises all outbound writes through `self.sender`. @@ -332,13 +286,8 @@ where } // Try to decode as ResponseEnvelope first. - // ResponseEnvelope has `ok`, `id`, and optionally - // `result`/`error`/`seq`/`stream_control`. - // Envelope has `type` (the discriminant) as a required field. - // We can tell them apart by attempting ResponseEnvelope decode and - // checking if the resulting `id` matches any pending call. if let Ok(resp) = saikuro_core::msgpack::from_slice::(&frame) { - if let Some((_, sender)) = pending.remove(&resp.id) { + if let Some(sender) = pending.lock().remove(&resp.id) { let _ = sender.send(resp); return true; } @@ -364,12 +313,7 @@ where true } - /// Handle a schema-announcement envelope (§6.1 development mode). - /// - /// Deserialises the [`Schema`] from `args[0]`, merges it into the live - /// schema registry, **and** registers a wire-forwarding [`ProviderHandle`] - /// for each declared namespace so that the runtime can route calls to this - /// peer. Returns `ok_empty` on success, an error response on any failure. + /// Handle a schema-announcement envelope. fn handle_announce( &self, envelope: Envelope, @@ -433,11 +377,6 @@ where /// Create and register a [`ProviderHandle`] that forwards invocations to /// the connected peer over the wire. - /// - /// Work items arrive via `work_rx`; the forwarder task encodes the - /// `Envelope` as a MessagePack frame, sends it to the peer, and records the - /// `response_tx` oneshot in `pending` so the recv loop can deliver the - /// reply when it arrives. fn register_wire_provider( &self, namespaces: Vec, @@ -481,13 +420,13 @@ where // pending entry; remove it if the send fails to preserve the // original orphan-prevention behavior. if let Some(resp_tx) = item.response_tx { - pending_clone.insert(item.envelope.id, resp_tx); + pending_clone.lock().insert(item.envelope.id, resp_tx); } // Send the frame to the peer (via the connection handler's sender). if forward_tx_clone.send(frame).await.is_err() { warn!(peer = %peer_id, "forward channel closed; provider disconnected"); - pending_clone.remove(&item.envelope.id); + pending_clone.lock().remove(&item.envelope.id); break; } } diff --git a/Build/crates/saikuro-runtime/src/handle.rs b/Build/crates/saikuro-runtime/src/handle.rs index 0695e778..e97816ec 100644 --- a/Build/crates/saikuro-runtime/src/handle.rs +++ b/Build/crates/saikuro-runtime/src/handle.rs @@ -1,33 +1,28 @@ -//! [`RuntimeHandle`]: the cheap, cloneable interface to a running Saikuro -//! runtime that async tasks and adapters interact with. -//! -//! The handle exposes the full high-level API: -//! - Schema registration / lookup -//! - Provider registration / deregistration -//! - Dispatching invocations programmatically (for in-process providers) -//! - Connecting transports and spawning connection handlers - -use std::sync::Arc; - -use parking_lot::RwLock; +use alloc::string::String; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use spin::RwLock; use saikuro_core::{ capability::CapabilitySet, envelope::Envelope, schema::Schema, RegistrationToken, ResponseEnvelope, }; use saikuro_exec::mpsc; use saikuro_router::{ - provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, + provider::{ProviderHandle, ProviderWorkItem}, router::InvocationRouter, }; use saikuro_schema::{ capability_engine::CapabilityEngine, + provider::ProviderRegistry, registry::{NamespaceRegistration, SchemaRegistry}, validator::InvocationValidator, }; -use saikuro_transport::traits::Transport; use tracing::{debug, info}; -use crate::{config::RuntimeConfig, connection::ConnectionHandler}; +use crate::config::RuntimeConfig; +use crate::connection::ConnectionHandler; +use crate::transport_adapter::RuntimeTransport; use saikuro_event::Result; /// A cheap, `Clone`-able handle to a running [`SaikuroRuntime`]. @@ -151,7 +146,7 @@ impl RuntimeHandle { /// /// `peer_caps` are the capabilities granted to this peer; they are checked /// on every invocation it sends. - pub fn accept_transport( + pub fn accept_transport( &self, transport: T, peer_id: impl Into, @@ -192,8 +187,8 @@ impl RuntimeHandle { handler: F, ) -> RegistrationToken where - F: Fn(Envelope) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, + F: Fn(Envelope) -> Fut + 'static, + Fut: core::future::Future + 'static, { let provider_id = provider_id.into(); let registration_token = RegistrationToken::new(); diff --git a/Build/crates/saikuro-runtime/src/lib.rs b/Build/crates/saikuro-runtime/src/lib.rs index b08bf65e..cdb4793e 100644 --- a/Build/crates/saikuro-runtime/src/lib.rs +++ b/Build/crates/saikuro-runtime/src/lib.rs @@ -1,11 +1,16 @@ -//! Saikuro Runtime -//! -//! This is the top-level orchestrator that wires together every component: +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(not(feature = "std"))] +extern crate alloc; + +#[macro_use] +extern crate alloc; pub mod config; pub mod connection; pub mod handle; pub mod runtime; +pub mod transport_adapter; pub use config::RuntimeConfig; pub use handle::RuntimeHandle; diff --git a/Build/crates/saikuro-runtime/src/main.rs b/Build/crates/saikuro-runtime/src/main.rs index f339604e..4cf9b482 100644 --- a/Build/crates/saikuro-runtime/src/main.rs +++ b/Build/crates/saikuro-runtime/src/main.rs @@ -1,10 +1,14 @@ -//! Saikuro Runtime Server +//! Saikuro Runtime Server (native binary) //! //! Standalone process that accepts connections from Saikuro adapters over TCP, //! WebSocket, and Unix domain sockets. It acts as the central message broker: //! adapters announce their capabilities and the runtime routes invocations //! among them. //! +//! The engine-agnostic orchestration lives in the `saikuro-runtime` library; +//! this binary only handles native concerns (CLI, `std::fs` schema loading, +//! OS signal handling) and drives [`SaikuroRuntime::serve`]. +//! //! # Usage //! //! ```text @@ -20,25 +24,16 @@ //! --json-logs Emit logs as JSON (useful for log aggregation) //! --no-tcp Disable TCP listener //! --no-ws Disable WebSocket listener -//! ``` +//! ``` -use std::{ - net::{IpAddr, SocketAddr}, - path::PathBuf, - sync::atomic::{AtomicU64, Ordering}, - sync::Arc, -}; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; use anyhow::{Context, Result}; use clap::Parser; -use saikuro_core::capability::CapabilitySet; -use saikuro_exec::{signal, sleep, spawn, timeout, watch}; - -/// Milliseconds to wait before retrying after an accept error. -const ACCEPT_BACKOFF_MS: u64 = 50; -use saikuro_runtime::{config::RuntimeMode, RuntimeConfig, SaikuroRuntime}; -use saikuro_transport::tcp::TcpTransportListener; -use saikuro_transport::traits::{Transport, TransportListener}; +use saikuro_exec::{signal, spawn, timeout, watch}; +use saikuro_runtime::config::RuntimeMode; +use saikuro_runtime::SaikuroRuntime; use tracing::{error, info, warn}; // CLI @@ -51,52 +46,31 @@ use tracing::{error, info, warn}; )] struct Args { /// Path to a schema JSON file to load at startup. - /// - /// When provided the schema is merged into the registry before any - /// adapter connects. Useful for production deployments where the schema - /// is known ahead of time. #[arg(long, value_name = "PATH")] - schema: Option, + schema: Option, /// Port to listen for raw TCP connections. - /// - /// Set to 0 to let the OS assign a port, or use --no-tcp to disable. #[arg(long, value_name = "PORT", default_value = "7700")] tcp_port: u16, /// Port to listen for WebSocket connections. - /// - /// Set to 0 to let the OS assign a port, or use --no-ws to disable. #[arg(long, value_name = "PORT", default_value = "7701")] ws_port: u16, /// Path to a Unix domain socket to create and listen on. - /// - /// Only available on Unix platforms. Ignored on Windows. #[arg(long, value_name = "PATH")] - unix: Option, + unix: Option, /// Bind address for TCP and WebSocket listeners. - /// - /// Defaults to 127.0.0.1 (loopback). Use 0.0.0.0 to listen on all - /// interfaces. #[arg(long, value_name = "ADDR", default_value = "127.0.0.1")] bind: IpAddr, /// Runtime mode. - /// - /// In production mode the schema registry is frozen: adapters cannot - /// announce new functions after startup. #[arg(long, value_name = "MODE", default_value = "development")] mode: CliMode, /// Minimum log level to emit. - #[arg( - long, - value_name = "LEVEL", - default_value = "info", - env = "SAIKURO_LOG" - )] + #[arg(long, value_name = "LEVEL", default_value = "info", env = "SAIKURO_LOG")] log_level: String, /// Emit logs as newline-delimited JSON instead of human-readable text. @@ -145,47 +119,38 @@ async fn async_main() -> Result<()> { ); // Build the runtime. - let mode: RuntimeMode = args.mode.into(); - let config = RuntimeConfig { - mode, - json_logs: args.json_logs, - ..Default::default() - }; - let runtime = Arc::new(SaikuroRuntime::builder().config(config).build()); - let handle = runtime.handle(); + let mut builder = SaikuroRuntime::builder() + .mode(args.mode.into()) + .json_logs(args.json_logs); - // Load schema from disk if requested. + // Load a baked-in schema from disk (native only). if let Some(schema_path) = &args.schema { - let raw = std::fs::read_to_string(schema_path) + let raw = std::fs::read(schema_path) .with_context(|| format!("reading schema file {}", schema_path.display()))?; - let schema: saikuro_core::schema::Schema = serde_json::from_str(&raw) - .with_context(|| format!("parsing schema file {}", schema_path.display()))?; - handle - .register_schema(schema, "static") - .context("registering static schema")?; + let bytes: &'static [u8] = Box::leak(raw.into_boxed_slice()); + builder = builder.schema_bytes(bytes); info!(path = %schema_path.display(), "loaded static schema"); } + let runtime = Arc::new(builder.build()); + // Set up graceful shutdown channel. let (shutdown_tx, shutdown_rx) = watch::channel(false); - // Spawn transport listeners. - let mut listener_tasks = Vec::new(); + // Each enabled listener type is driven by its own `serve` task. + let mut serve_tasks: Vec<_> = Vec::new(); // TCP listener. + #[cfg(feature = "tcp")] if !args.no_tcp { + use saikuro_transport::tcp::TcpTransportListener; let addr = SocketAddr::new(args.bind, args.tcp_port); match TcpTransportListener::bind(addr).await { - Ok(mut listener) => { + Ok(listener) => { info!(addr = %listener.local_addr(), "TCP listener ready"); - let h = handle.clone(); + let rt = runtime.clone(); let mut rx = shutdown_rx.clone(); - listener_tasks.push(spawn(async move { - run_listener("TCP", &mut listener, h, &mut rx, |_| { - format!("tcp-{}", uuid_short()) - }) - .await; - })); + serve_tasks.push(spawn(async move { rt.serve(vec![listener], rx).await; })); } Err(e) => { error!(addr = %addr, error = %e, "failed to bind TCP listener"); @@ -195,21 +160,16 @@ async fn async_main() -> Result<()> { } // WebSocket listener. - #[cfg(feature = "ws-transport")] + #[cfg(feature = "ws")] if !args.no_ws { use saikuro_transport::websocket::WsTransportListener; let addr = SocketAddr::new(args.bind, args.ws_port); match WsTransportListener::bind(addr).await { - Ok(mut listener) => { + Ok(listener) => { info!(addr = %listener.local_addr(), "WebSocket listener ready"); - let h = handle.clone(); + let rt = runtime.clone(); let mut rx = shutdown_rx.clone(); - listener_tasks.push(spawn(async move { - run_listener("WebSocket", &mut listener, h, &mut rx, |_| { - format!("ws-{}", uuid_short()) - }) - .await; - })); + serve_tasks.push(spawn(async move { rt.serve(vec![listener], rx).await; })); } Err(e) => { error!(addr = %addr, error = %e, "failed to bind WebSocket listener"); @@ -219,20 +179,15 @@ async fn async_main() -> Result<()> { } // Unix domain socket listener (Unix-only). - #[cfg(all(feature = "native-transport", target_family = "unix"))] + #[cfg(all(feature = "unix", target_family = "unix"))] if let Some(unix_path) = &args.unix { use saikuro_transport::unix::UnixTransportListener; match UnixTransportListener::bind(unix_path).await { - Ok(mut listener) => { + Ok(listener) => { info!(path = %unix_path.display(), "Unix socket listener ready"); - let h = handle.clone(); + let rt = runtime.clone(); let mut rx = shutdown_rx.clone(); - listener_tasks.push(spawn(async move { - run_listener("Unix", &mut listener, h, &mut rx, |_t| { - format!("unix-{}", uuid_short()) - }) - .await; - })); + serve_tasks.push(spawn(async move { rt.serve(vec![listener], rx).await; })); } Err(e) => { error!(path = %unix_path.display(), error = %e, "failed to bind Unix listener"); @@ -241,7 +196,7 @@ async fn async_main() -> Result<()> { } } - if listener_tasks.is_empty() { + if serve_tasks.is_empty() { warn!("no listeners are active; all transports were disabled"); } @@ -249,12 +204,11 @@ async fn async_main() -> Result<()> { wait_for_shutdown_signal().await; info!("shutdown signal received; stopping listeners"); - // Broadcast shutdown to all listener loops. let _ = shutdown_tx.send(true); runtime.shutdown(); - // Give listeners a moment to exit cleanly. - for task in listener_tasks { + // Allow the listener tasks to exit cleanly. + for task in serve_tasks { let _ = timeout(std::time::Duration::from_secs(5), task).await; } @@ -262,72 +216,6 @@ async fn async_main() -> Result<()> { Ok(()) } -// Transport accept loop - -/// Generic accept loop for any [`TransportListener`]. -/// -/// Accepts connections in a loop until the listener is closed or a shutdown -/// signal is received. New connections are handed to the runtime via -/// [`RuntimeHandle::accept_transport`]. -async fn run_listener( - name: &str, - listener: &mut L, - handle: saikuro_runtime::RuntimeHandle, - shutdown: &mut watch::Receiver, - peer_id: impl Fn(&L::Output) -> String, -) where - L: TransportListener, - L::Output: Transport, -{ - loop { - saikuro_exec::select! { - result = listener.accept() => { - match result { - Ok(Some(transport)) => { - let id = peer_id(&transport); - info!(peer = %id, "{name} connection accepted"); - handle.accept_transport(transport, id, CapabilitySet::default()); - } - Ok(None) => { - info!("{name} listener closed"); - break; - } - Err(e) => { - error!(error = %e, "{name} accept error"); - sleep(std::time::Duration::from_millis(ACCEPT_BACKOFF_MS)).await; - } - } - } - res = shutdown.changed() => { - if res.is_err() || *shutdown.borrow() { - info!("{name} listener shutting down"); - break; - } - } - } - } -} - -// Helpers - -/// Monotonically increasing counter for peer IDs. -static PEER_ID_COUNTER: AtomicU64 = AtomicU64::new(0); - -/// Return a short (8-char) hex string for peer IDs. -/// -/// Combines sub-second timestamp bits with a monotonic counter so IDs remain -/// unique even under high-frequency concurrent calls or a system clock -/// before Unix epoch. -fn uuid_short() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let millis = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - let count = PEER_ID_COUNTER.fetch_add(1, Ordering::Relaxed); - format!("{:012x}{:04x}", millis & 0xFFFF_FFFF_FFFF, count & 0xFFFF) -} - /// Wait for Ctrl-C (SIGINT) or SIGTERM. async fn wait_for_shutdown_signal() { let ctrl_c = async { diff --git a/Build/crates/saikuro-runtime/src/runtime.rs b/Build/crates/saikuro-runtime/src/runtime.rs index 3de986df..a1a3371c 100644 --- a/Build/crates/saikuro-runtime/src/runtime.rs +++ b/Build/crates/saikuro-runtime/src/runtime.rs @@ -1,18 +1,30 @@ -//! The main Saikuro runtime and its builder. +use alloc::sync::Arc; +use core::sync::atomic::Ordering; +use core::time::Duration; + +use portable_atomic::AtomicU64; +use saikuro_core::capability::CapabilitySet; +use saikuro_core::schema::Schema; +use saikuro_exec::{sleep, spawn, timeout, watch}; +use saikuro_router::provider::ProviderRegistry; +use saikuro_schema::{capability_engine::CapabilityEngine, registry::SchemaRegistry, validator::InvocationValidator}; +use spin::RwLock; +use tracing::{error, info}; -use std::sync::Arc; +use crate::transport_adapter::RuntimeListener; +use crate::{config::RuntimeConfig, handle::RuntimeHandle}; -use parking_lot::RwLock; -use saikuro_router::provider::ProviderRegistry; -use saikuro_schema::{ - capability_engine::CapabilityEngine, registry::SchemaRegistry, validator::InvocationValidator, -}; -use tracing::info; +/// Milliseconds to wait before retrying after an accept error. +const ACCEPT_BACKOFF_MS: u64 = 50; + +/// Monotonic counter for peer IDs (engine-agnostic, no `std::time`). +static PEER_ID_COUNTER: AtomicU64 = AtomicU64::new(0); -use crate::{ - config::{RuntimeConfig, RuntimeMode}, - handle::RuntimeHandle, -}; +/// Allocate the next unique peer identifier for an accepted connection. +fn next_peer_id() -> alloc::string::String { + let n = PEER_ID_COUNTER.fetch_add(1, Ordering::Relaxed); + alloc::format!("peer-{n:08x}") +} // Builder @@ -33,30 +45,36 @@ impl RuntimeBuilder { self } - pub fn mode(mut self, mode: RuntimeMode) -> Self { + pub fn mode(mut self, mode: crate::config::RuntimeMode) -> Self { self.config.mode = mode; self } - pub fn call_timeout(mut self, timeout: std::time::Duration) -> Self { + pub fn call_timeout(mut self, timeout: Duration) -> Self { self.config.call_timeout = timeout; self } + /// Supply the runtime schema as raw bytes (no `std::fs`). Used by the + /// non-native entries (embedded / wasm / WASI) that bake the schema in. + pub fn schema_bytes(mut self, bytes: &'static [u8]) -> Self { + self.config.schema_bytes = Some(bytes); + self + } + pub fn json_logs(mut self, enabled: bool) -> Self { self.config.json_logs = enabled; self } /// Build the runtime. This does not start any listener loops; use - /// [`RuntimeHandle`] methods to attach transports. + /// [`RuntimeHandle`] methods to attach transports, or [`SaikuroRuntime::serve`] + /// to run a set of listeners until shutdown. pub fn build(self) -> SaikuroRuntime { SaikuroRuntime::from_config(self.config) } } -// Runtime - /// The central Saikuro runtime instance. /// /// Create one with `SaikuroRuntime::builder().build()` then use the returned @@ -75,19 +93,38 @@ impl SaikuroRuntime { } fn from_config(config: RuntimeConfig) -> Self { + let schema_bytes = config.schema_bytes; let schema_registry = SchemaRegistry::new(); - if config.mode == RuntimeMode::Production { - schema_registry.freeze(); - } - - Self { + let mut runtime = Self { config, schema_registry, provider_registry: ProviderRegistry::new(), capability_engine: CapabilityEngine::new(), shutdown: Arc::new(RwLock::new(false)), + }; + + // Register a baked-in schema (embedded / wasm / WASI) or a schema the + // native entry point loaded from disk. Done before the production freeze. + if let Some(bytes) = schema_bytes { + match serde_json::from_slice::(bytes) { + Ok(schema) => { + if let Err(e) = runtime + .schema_registry + .merge_schema(schema, "static") + { + error!(error = %e, "failed to merge static schema"); + } + } + Err(e) => error!(error = %e, "failed to parse static schema"), + } } + + if runtime.config.mode == crate::config::RuntimeMode::Production { + runtime.schema_registry.freeze(); + } + + runtime } /// Return a shared reference to the schema registry. @@ -131,4 +168,61 @@ impl SaikuroRuntime { pub fn is_shutdown(&self) -> bool { *self.shutdown.read() } + + /// Run a set of listeners until the host signals shutdown via `shutdown`. + pub async fn serve( + &self, + listeners: Vec, + mut shutdown: watch::Receiver, + ) { + let mut tasks = alloc::vec::Vec::new(); + for mut listener in listeners { + let handle = self.handle(); + let mut rx = shutdown.clone(); + tasks.push(spawn(async move { + loop { + saikuro_exec::select! { + result = listener.accept() => { + match result { + Ok(Some(transport)) => { + let id = next_peer_id(); + info!(peer = %id, "connection accepted"); + handle.accept_transport(transport, id, CapabilitySet::default()); + } + Ok(None) => { + info!("listener closed"); + break; + } + Err(e) => { + error!(error = %e, "accept error"); + sleep(Duration::from_millis(ACCEPT_BACKOFF_MS)).await; + } + } + } + changed = rx.changed() => { + if changed.is_err() || rx.borrow() { + info!("listener shutting down"); + break; + } + } + } + } + let _ = listener.close().await; + })); + } + + // Wait until the host signals shutdown. + while !shutdown.borrow() { + if shutdown.changed().await.is_err() { + break; + } + } + + // Allow in-flight listener tasks to observe the shutdown flag. + for task in tasks { + let _ = timeout(Duration::from_secs(5), task).await; + } + + info!("saikuro runtime listener set stopped"); + } } diff --git a/Build/crates/saikuro-runtime/src/transport_adapter.rs b/Build/crates/saikuro-runtime/src/transport_adapter.rs new file mode 100644 index 00000000..59bd0165 --- /dev/null +++ b/Build/crates/saikuro-runtime/src/transport_adapter.rs @@ -0,0 +1,216 @@ +use alloc::boxed::Box; +use alloc::string::String; +use async_trait::async_trait; +use bytes::Bytes; + +use saikuro_transport::shared::error::Result; +use saikuro_transport::shared::host::{HostPipeFactory, Role, WasmHostTransport}; +use saikuro_transport::shared::traits::{ + LocalTransport, LocalTransportListener, LocalTransportReceiver, LocalTransportSender, Transport, + TransportListener, TransportReceiver, TransportSender, +}; + +macro_rules! define_runtime_traits { + (SEND) => { + #[async_trait] + pub trait RuntimeSender: Send + Sync { + async fn send(&mut self, frame: Bytes) -> Result<()>; + async fn close(&mut self) -> Result<()>; + } + #[async_trait] + pub trait RuntimeReceiver: Send + Sync { + async fn recv(&mut self) -> Result>; + } + #[async_trait] + pub trait RuntimeTransport: Send + Sync { + type Sender: RuntimeSender; + type Receiver: RuntimeReceiver; + fn split(self) -> (Self::Sender, Self::Receiver); + fn description(&self) -> &str; + } + #[async_trait] + pub trait RuntimeListener: Send + Sync { + /// The concrete transport produced by a successful accept. + type Output: RuntimeTransport; + async fn accept(&mut self) -> Result>; + async fn close(&mut self) -> Result<()>; + } + }; + (NOSEND) => { + #[async_trait(?Send)] + pub trait RuntimeSender { + async fn send(&mut self, frame: Bytes) -> Result<()>; + async fn close(&mut self) -> Result<()>; + } + #[async_trait(?Send)] + pub trait RuntimeReceiver { + async fn recv(&mut self) -> Result>; + } + #[async_trait(?Send)] + pub trait RuntimeTransport { + type Sender: RuntimeSender; + type Receiver: RuntimeReceiver; + fn split(self) -> (Self::Sender, Self::Receiver); + fn description(&self) -> &str; + } + #[async_trait(?Send)] + pub trait RuntimeListener { + type Output: RuntimeTransport; + async fn accept(&mut self) -> Result>; + async fn close(&mut self) -> Result<()>; + } + }; +} + +#[cfg(feature = "native")] +define_runtime_traits!(SEND); +#[cfg(not(feature = "native"))] +define_runtime_traits!(NOSEND); + +// Blanket impls for the boxed (native) family. + +#[cfg_attr(feature = "native", async_trait)] +#[cfg_attr(not(feature = "native"), async_trait(?Send))] +impl RuntimeSender for T { + async fn send(&mut self, frame: Bytes) -> Result<()> { + T::send(self, frame).await + } + async fn close(&mut self) -> Result<()> { + T::close(self).await + } +} + +#[cfg_attr(feature = "native", async_trait)] +#[cfg_attr(not(feature = "native"), async_trait(?Send))] +impl RuntimeReceiver for T { + async fn recv(&mut self) -> Result> { + T::recv(self).await + } +} + +#[cfg_attr(feature = "native", async_trait)] +#[cfg_attr(not(feature = "native"), async_trait(?Send))] +impl RuntimeTransport for T { + type Sender = T::Sender; + type Receiver = T::Receiver; + fn split(self) -> (Self::Sender, Self::Receiver) { + (*self).split() + } + fn description(&self) -> &str { + T::description(self) + } +} + +#[cfg_attr(feature = "native", async_trait)] +#[cfg_attr(not(feature = "native"), async_trait(?Send))] +impl RuntimeListener for T { + type Output = T::Output; + async fn accept(&mut self) -> Result> { + T::accept(self).await + } + async fn close(&mut self) -> Result<()> { + T::close(self).await + } +} + + +#[cfg(not(feature = "native"))] +pub struct LocalRuntimeSender(S); + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeSender for LocalRuntimeSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + self.0.send(frame).await + } + async fn close(&mut self) -> Result<()> { + self.0.close().await + } +} + +#[cfg(not(feature = "native"))] +pub struct LocalRuntimeReceiver(R); + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeReceiver for LocalRuntimeReceiver { + async fn recv(&mut self) -> Result> { + self.0.recv().await + } +} + +#[cfg(not(feature = "native"))] +pub struct LocalRuntimeTransport(T); + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeTransport for LocalRuntimeTransport { + type Sender = LocalRuntimeSender; + type Receiver = LocalRuntimeReceiver; + fn split(self) -> (Self::Sender, Self::Receiver) { + let (sender, receiver) = (*self).0.split(); + (LocalRuntimeSender(sender), LocalRuntimeReceiver(receiver)) + } + fn description(&self) -> &str { + self.0.description() + } +} + +#[cfg(not(feature = "native"))] +pub struct LocalRuntimeListener(L); + +#[cfg(not(feature = "native"))] +impl LocalRuntimeListener { + /// Wrap a `LocalTransportListener` so it satisfies [`RuntimeListener`]. + pub fn new(listener: L) -> Self { + Self(listener) + } +} + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeListener for LocalRuntimeListener { + type Output = LocalRuntimeTransport; + async fn accept(&mut self) -> Result> { + match L::accept(&mut self.0).await? { + Some(transport) => Ok(Some(LocalRuntimeTransport(transport))), + None => Ok(None), + } + } + async fn close(&mut self) -> Result<()> { + L::close(&mut self.0).await + } +} + +/// Adapts a `HostPipeFactory` (BroadcastChannel / WASI loopback) into a +/// [`RuntimeListener`]. +#[cfg(not(feature = "native"))] +pub struct HostPipeListener { + channel: String, + _marker: core::marker::PhantomData F>, +} + +#[cfg(not(feature = "native"))] +impl HostPipeListener { + /// Start listening for a rendezvous connection on `channel`. + pub fn new(channel: impl Into) -> Self { + Self { + channel: channel.into(), + _marker: core::marker::PhantomData, + } + } +} + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeListener for HostPipeListener { + type Output = LocalRuntimeTransport>; + async fn accept(&mut self) -> Result> { + let (send, recv) = F::open(&self.channel, Role::Accept).await?; + let transport = WasmHostTransport::new(send, recv); + Ok(Some(LocalRuntimeTransport(transport))) + } + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index 3bdd14c0..92f1a51f 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -46,7 +46,7 @@ saikuro-net = { path = "../saikuro-net", default-features = false } saikuro-exec = { path = "../saikuro-exec", default-features = false } saikuro-random = { path = "../saikuro-random", default-features = false } serde = { workspace = true } -bytes = { workspace = true, default-features = false, features = ["alloc"] } +bytes = { workspace = true, default-features = false } async-trait = { workspace = true } futures = { workspace = true, default-features = false, features = ["async-await", "alloc"] } pin-project-lite = { workspace = true } diff --git a/Build/tests/Cargo.toml b/Build/tests/Cargo.toml index 0f9b72fe..2361fa09 100644 --- a/Build/tests/Cargo.toml +++ b/Build/tests/Cargo.toml @@ -7,9 +7,6 @@ authors.workspace = true license.workspace = true publish = false -[features] -wasm-runtime = ["saikuro-transport/wasm-runtime"] - [dependencies] saikuro-core = { workspace = true } saikuro-schema = { workspace = true } @@ -28,14 +25,13 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -saikuro-core = { workspace = true, features = ["std"] } -saikuro-transport = { workspace = true, features = ["native-transport"] } -saikuro-runtime = { workspace = true, features = ["native-transport"] } +saikuro-transport = { workspace = true, features = ["tcp", "unix"] } +saikuro-runtime = { workspace = true, features = ["native"] } [target.'cfg(target_arch = "wasm32")'.dependencies] -saikuro-core = { workspace = true, features = ["std-no-os"] } -saikuro-transport = { workspace = true, features = ["wasm-runtime"] } -saikuro-runtime = { workspace = true, default-features = false } +saikuro-core = { workspace = true, features = ["wasm"] } +saikuro-transport = { workspace = true, features = ["wasm", "wasm-host"] } +saikuro-runtime = { workspace = true, default-features = false, features = ["wasm"] } saikuro-random = { workspace = true, features = ["wasm"] } wasm-bindgen = { workspace = true } wasm-bindgen-test = { workspace = true } diff --git a/Build/tests/saikuro-transport/transport_framing.rs b/Build/tests/saikuro-transport/transport_framing.rs index 1da8c2cf..77426909 100644 --- a/Build/tests/saikuro-transport/transport_framing.rs +++ b/Build/tests/saikuro-transport/transport_framing.rs @@ -1,10 +1,3 @@ -//! Length-prefixed framing tests for stream transports. -//! -//! Covers the no_std [`LengthPrefixedCodec`] directly and the native -//! [`FramedStream`] adapter over an in-memory duplex stream and over real -//! TCP. The native adapter is not available on wasm32 (no -//! `native-transport` feature there), so this file is native-only. - #![cfg(not(target_arch = "wasm32"))] use bytes::{BufMut, Bytes, BytesMut}; From f4d170442f16be8af0f688f21fc1123cbff47602 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Sat, 15 Aug 2026 15:01:05 -0600 Subject: [PATCH 36/43] rustfmt --- Build/adapters/rust/src/lib.rs | 4 +- Build/adapters/rust/tests/integration.rs | 14 +- Build/crates/saikuro-codegen/lib.rs | 2 +- Build/crates/saikuro-core/codec/msgpack.rs | 2 +- Build/crates/saikuro-core/lib.rs | 17 +- .../crates/saikuro-event/core_events/codec.rs | 3 +- .../crates/saikuro-event/core_events/event.rs | 171 +++++---- Build/crates/saikuro-event/core_events/mod.rs | 4 +- Build/crates/saikuro-event/log/level.rs | 13 +- Build/crates/saikuro-event/log/mod.rs | 29 +- Build/crates/saikuro-event/log/record.rs | 6 +- Build/crates/saikuro-exec/base/exec.rs | 14 +- Build/crates/saikuro-exec/base/mpsc.rs | 349 +++++++++--------- Build/crates/saikuro-exec/base/oneshot.rs | 195 +++++----- Build/crates/saikuro-exec/base/sync.rs | 251 +++++++------ Build/crates/saikuro-exec/base/watch.rs | 217 +++++------ Build/crates/saikuro-exec/embedded/mod.rs | 2 +- Build/crates/saikuro-exec/lib.rs | 21 +- Build/crates/saikuro-exec/native/exec.rs | 5 +- Build/crates/saikuro-exec/native/mpsc.rs | 73 ++-- Build/crates/saikuro-exec/native/oneshot.rs | 41 +- Build/crates/saikuro-exec/native/sync.rs | 145 ++++---- Build/crates/saikuro-exec/native/watch.rs | 93 +++-- Build/crates/saikuro-exec/wasm/mod.rs | 2 +- Build/crates/saikuro-net/embedded/mod.rs | 2 +- Build/crates/saikuro-net/lib.rs | 19 +- Build/crates/saikuro-net/native/mod.rs | 2 +- Build/crates/saikuro-random/embedded/mod.rs | 4 +- Build/crates/saikuro-random/lib.rs | 15 +- Build/crates/saikuro-random/shared/mod.rs | 22 +- .../saikuro-router/provider/provider.rs | 3 +- Build/crates/saikuro-router/router/router.rs | 28 +- .../stream_state/stream_state.rs | 17 +- .../saikuro-runtime/src/bin/embedded.rs | 9 +- Build/crates/saikuro-runtime/src/bin/wasi.rs | 8 +- Build/crates/saikuro-runtime/src/bin/wasm.rs | 5 +- Build/crates/saikuro-runtime/src/config.rs | 2 +- Build/crates/saikuro-runtime/src/handle.rs | 4 +- Build/crates/saikuro-runtime/src/main.rs | 19 +- Build/crates/saikuro-runtime/src/runtime.rs | 9 +- .../saikuro-runtime/src/transport_adapter.rs | 5 +- .../crates/saikuro-storage/common/inmemory.rs | 2 - .../saikuro-storage/common/sqlite/mod.rs | 5 +- .../crates/saikuro-storage/embedded/flash.rs | 41 +- Build/crates/saikuro-storage/lib.rs | 33 +- Build/crates/saikuro-storage/native/fs.rs | 3 - Build/crates/saikuro-storage/native/sled.rs | 3 - Build/crates/saikuro-storage/wasi/preview2.rs | 7 +- 48 files changed, 1020 insertions(+), 920 deletions(-) diff --git a/Build/adapters/rust/src/lib.rs b/Build/adapters/rust/src/lib.rs index f2535db9..fc09df04 100644 --- a/Build/adapters/rust/src/lib.rs +++ b/Build/adapters/rust/src/lib.rs @@ -24,7 +24,9 @@ pub use transport::InMemoryTransport; pub use value::Value; #[cfg(all(not(target_arch = "wasm32"), feature = "storage"))] -pub use saikuro_storage::traits::{FileBackend, KeyValueBackend, KeyValueBackendExt, StorageBackend}; +pub use saikuro_storage::traits::{ + FileBackend, KeyValueBackend, KeyValueBackendExt, StorageBackend, +}; #[cfg(all(not(target_arch = "wasm32"), feature = "storage"))] pub use storage::{create_storage, create_transient_storage, Storage}; diff --git a/Build/adapters/rust/tests/integration.rs b/Build/adapters/rust/tests/integration.rs index 4e320aa4..584ed039 100644 --- a/Build/adapters/rust/tests/integration.rs +++ b/Build/adapters/rust/tests/integration.rs @@ -314,8 +314,7 @@ fn resource_roundtrip_with_simulated_runtime() { assert_eq!(env.invocation_type, InvocationType::Resource); assert_eq!(env.target, "files.open"); - let response = - ResponseEnvelope::ok(env.id, saikuro_event::Value::String("ok".into())); + let response = ResponseEnvelope::ok(env.id, saikuro_event::Value::String("ok".into())); runtime_side .send(bytes::Bytes::from( response.to_msgpack().expect("encode response"), @@ -353,8 +352,7 @@ fn stream_roundtrip_with_simulated_runtime() { assert_eq!(env.invocation_type, InvocationType::Stream); assert_eq!(env.target, "events.watch"); - let item1 = - ResponseEnvelope::stream_item(env.id, 0, saikuro_event::Value::Int(1)); + let item1 = ResponseEnvelope::stream_item(env.id, 0, saikuro_event::Value::Int(1)); runtime_side .send(bytes::Bytes::from( item1.to_msgpack().expect("encode item1"), @@ -362,8 +360,7 @@ fn stream_roundtrip_with_simulated_runtime() { .await .expect("send item1"); - let item2 = - ResponseEnvelope::stream_item(env.id, 1, saikuro_event::Value::Int(2)); + let item2 = ResponseEnvelope::stream_item(env.id, 1, saikuro_event::Value::Int(2)); runtime_side .send(bytes::Bytes::from( item2.to_msgpack().expect("encode item2"), @@ -591,10 +588,7 @@ fn envelope_roundtrip_msgpack_preserves_fields() { saikuro_exec::block_on(async { let original = Envelope::call( "math.add", - vec![ - saikuro_event::Value::Int(1), - saikuro_event::Value::Int(2), - ], + vec![saikuro_event::Value::Int(1), saikuro_event::Value::Int(2)], ) .expect("entropy available"); diff --git a/Build/crates/saikuro-codegen/lib.rs b/Build/crates/saikuro-codegen/lib.rs index 40b50fa0..05bef934 100644 --- a/Build/crates/saikuro-codegen/lib.rs +++ b/Build/crates/saikuro-codegen/lib.rs @@ -1,5 +1,5 @@ -pub mod shared; pub mod language; +pub mod shared; pub use shared::error::CodegenError; pub use shared::generator::{ diff --git a/Build/crates/saikuro-core/codec/msgpack.rs b/Build/crates/saikuro-core/codec/msgpack.rs index 2ef9a943..c0466530 100644 --- a/Build/crates/saikuro-core/codec/msgpack.rs +++ b/Build/crates/saikuro-core/codec/msgpack.rs @@ -3,8 +3,8 @@ use messagepack_serde::{ messagepack_core::{encode::int::EncodeMinimizeInt, io::IoWrite, Encode}, ser::NumEncoder, }; -use serde::{Deserialize, Serialize}; use saikuro_event::{DecodeError, EncodeError}; +use serde::{Deserialize, Serialize}; /// Encodes numbers exactly like rmp-serde struct RmpCompatible; diff --git a/Build/crates/saikuro-core/lib.rs b/Build/crates/saikuro-core/lib.rs index 2756cacd..217ad872 100644 --- a/Build/crates/saikuro-core/lib.rs +++ b/Build/crates/saikuro-core/lib.rs @@ -17,16 +17,19 @@ pub use codec::*; feature = "native", any(feature = "wasm", feature = "embedded", feature = "no_std") ))] -compile_error!("saikuro-core: only one engine feature (native/wasm/embedded/no_std) may be enabled"); +compile_error!( + "saikuro-core: only one engine feature (native/wasm/embedded/no_std) may be enabled" +); -#[cfg(all( - feature = "wasm", - any(feature = "embedded", feature = "no_std") -))] -compile_error!("saikuro-core: only one engine feature (native/wasm/embedded/no_std) may be enabled"); +#[cfg(all(feature = "wasm", any(feature = "embedded", feature = "no_std")))] +compile_error!( + "saikuro-core: only one engine feature (native/wasm/embedded/no_std) may be enabled" +); #[cfg(all(feature = "embedded", feature = "no_std"))] -compile_error!("saikuro-core: only one engine feature (native/wasm/embedded/no_std) may be enabled"); +compile_error!( + "saikuro-core: only one engine feature (native/wasm/embedded/no_std) may be enabled" +); #[cfg(not(any( feature = "native", diff --git a/Build/crates/saikuro-event/core_events/codec.rs b/Build/crates/saikuro-event/core_events/codec.rs index 3a941bd5..9e961921 100644 --- a/Build/crates/saikuro-event/core_events/codec.rs +++ b/Build/crates/saikuro-event/core_events/codec.rs @@ -2,4 +2,5 @@ pub type EncodeError = messagepack_serde::ser::Error; /// Decoding error produced by the MessagePack deserializer. -pub type DecodeError = messagepack_serde::de::Error; +pub type DecodeError = + messagepack_serde::de::Error; diff --git a/Build/crates/saikuro-event/core_events/event.rs b/Build/crates/saikuro-event/core_events/event.rs index 0df27255..3763ef47 100644 --- a/Build/crates/saikuro-event/core_events/event.rs +++ b/Build/crates/saikuro-event/core_events/event.rs @@ -1,9 +1,9 @@ -use alloc::string::{ String, ToString }; +use alloc::string::{String, ToString}; use core::fmt; -use serde::{ Deserialize, Serialize }; +use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::io::{ IoError, IoErrorKind }; +use crate::io::{IoError, IoErrorKind}; use crate::value::Value; /// Maximum number of structured context entries an [`ErrorDetail`] or @@ -154,7 +154,7 @@ impl ErrorDetail { pub fn with_context( mut self, key: impl Into, - value: impl Into + value: impl Into, ) -> core::result::Result { let key = key.into(); self.details @@ -176,25 +176,23 @@ impl fmt::Display for ErrorDetail { #[derive(Debug, Error)] pub enum SaikuroError { // Schema - #[error("namespace not found: {0}")] NamespaceNotFound(String), + #[error("namespace not found: {0}")] + NamespaceNotFound(String), - #[error("function not found: {0}")] FunctionNotFound(String), + #[error("function not found: {0}")] + FunctionNotFound(String), - #[error("invalid arguments for {target}: {reason}")] InvalidArguments { - target: String, - reason: String, - }, + #[error("invalid arguments for {target}: {reason}")] + InvalidArguments { target: String, reason: String }, - #[error( - "incompatible protocol version: expected {expected}, got {received}" - )] IncompatibleVersion { - expected: u32, - received: u32, - }, + #[error("incompatible protocol version: expected {expected}, got {received}")] + IncompatibleVersion { expected: u32, received: u32 }, - #[error("malformed envelope: {0}")] MalformedEnvelope(String), + #[error("malformed envelope: {0}")] + MalformedEnvelope(String), - #[error("schema is frozen; updates are rejected: {0}")] FrozenSchema(String), + #[error("schema is frozen; updates are rejected: {0}")] + FrozenSchema(String), #[error("schema capacity exceeded")] SchemaCapacity, @@ -205,17 +203,14 @@ pub enum SaikuroError { #[error("batch envelope has no items")] EmptyBatch, - #[error("visibility '{visibility}' denied for {target}")] VisibilityDenied { - target: String, - visibility: String, - }, + #[error("visibility '{visibility}' denied for {target}")] + VisibilityDenied { target: String, visibility: String }, - #[error("argument count mismatch: expected {expected}, got {received}")] ArgumentArity { - expected: usize, - received: usize, - }, + #[error("argument count mismatch: expected {expected}, got {received}")] + ArgumentArity { expected: usize, received: usize }, - #[error("argument '{name}' (#{position}) expected {expected}, got {received}")] ArgumentType { + #[error("argument '{name}' (#{position}) expected {expected}, got {received}")] + ArgumentType { name: String, position: usize, expected: String, @@ -223,38 +218,38 @@ pub enum SaikuroError { }, // Routing - #[error("no provider registered for namespace: {0}")] NoProvider(String), + #[error("no provider registered for namespace: {0}")] + NoProvider(String), - #[error("provider unavailable for namespace: {0}")] ProviderUnavailable(String), + #[error("provider unavailable for namespace: {0}")] + ProviderUnavailable(String), - #[error("batch routing conflict: {0}")] BatchRoutingConflict(String), + #[error("batch routing conflict: {0}")] + BatchRoutingConflict(String), // Capability - #[error("capability denied: caller lacks '{required}' for '{target}'")] CapabilityDenied { - target: String, - required: String, - }, + #[error("capability denied: caller lacks '{required}' for '{target}'")] + CapabilityDenied { target: String, required: String }, #[error("capability token invalid or expired")] CapabilityInvalid, // Transport - #[error("transport connection lost: {0}")] ConnectionLost(String), + #[error("transport connection lost: {0}")] + ConnectionLost(String), - #[error("message too large: {size} bytes exceeds limit {limit}")] MessageTooLarge { - size: usize, - limit: usize, - }, + #[error("message too large: {size} bytes exceeds limit {limit}")] + MessageTooLarge { size: usize, limit: usize }, - #[error("operation timed out after {millis}ms")] Timeout { - millis: u64, - }, + #[error("operation timed out after {millis}ms")] + Timeout { millis: u64 }, #[error("buffer overflow on stream/channel")] BufferOverflow, // Provider - #[error("provider returned error: {0}")] ProviderError(String), + #[error("provider returned error: {0}")] + ProviderError(String), #[error("provider panicked while handling invocation")] ProviderPanic, @@ -266,71 +261,88 @@ pub enum SaikuroError { #[error("channel closed by remote side")] ChannelClosed, - #[error("out-of-order sequence: expected {expected}, got {received}")] OutOfOrder { - expected: u64, - received: u64, - }, + #[error("out-of-order sequence: expected {expected}, got {received}")] + OutOfOrder { expected: u64, received: u64 }, // Storage - #[error("key not found: {0}")] KeyNotFound(String), + #[error("key not found: {0}")] + KeyNotFound(String), - #[error("key already exists: {0}")] KeyAlreadyExists(String), + #[error("key already exists: {0}")] + KeyAlreadyExists(String), - #[error("namespace already exists: {0}")] NamespaceAlreadyExists(String), + #[error("namespace already exists: {0}")] + NamespaceAlreadyExists(String), - #[error("storage backend not available: {0}")] BackendNotAvailable(String), + #[error("storage backend not available: {0}")] + BackendNotAvailable(String), - #[error("operation not supported by backend: {0}")] OperationNotSupported(String), + #[error("operation not supported by backend: {0}")] + OperationNotSupported(String), - #[error("quota exceeded: {0}")] QuotaExceeded(String), + #[error("quota exceeded: {0}")] + QuotaExceeded(String), - #[error("serialization error: {0}")] Serialization(String), + #[error("serialization error: {0}")] + Serialization(String), - #[error("deserialization error: {0}")] Deserialization(String), + #[error("deserialization error: {0}")] + Deserialization(String), // Additional transport - #[error("connection refused: {0}")] ConnectionRefused(String), + #[error("connection refused: {0}")] + ConnectionRefused(String), - #[error("transport send failed: {0}")] SendFailed(String), + #[error("transport send failed: {0}")] + SendFailed(String), - #[error("transport receive failed: {0}")] ReceiveFailed(String), + #[error("transport receive failed: {0}")] + ReceiveFailed(String), - #[error("framing error: {0}")] FramingError(String), + #[error("framing error: {0}")] + FramingError(String), #[error("transport not supported on this platform")] TransportNotSupported, // Additional routing - #[error("malformed target '{0}': must be 'namespace.function'")] MalformedTarget(String), + #[error("malformed target '{0}': must be 'namespace.function'")] + MalformedTarget(String), - #[error("stream not found: {0}")] StreamNotFound(String), + #[error("stream not found: {0}")] + StreamNotFound(String), - #[error("channel not found: {0}")] ChannelNotFound(String), + #[error("channel not found: {0}")] + ChannelNotFound(String), - #[error("send error: {0}")] SendError(String), + #[error("send error: {0}")] + SendError(String), - #[error("batch item {index} failed: {reason}")] BatchItemFailed { - index: usize, - reason: String, - }, + #[error("batch item {index} failed: {reason}")] + BatchItemFailed { index: usize, reason: String }, // Entropy - #[error("entropy error: {0}")] Entropy(String), + #[error("entropy error: {0}")] + Entropy(String), // Serialisation - #[error("msgpack encode error: {0}")] MsgpackEncode(#[from] crate::codec::EncodeError), + #[error("msgpack encode error: {0}")] + MsgpackEncode(#[from] crate::codec::EncodeError), - #[error("msgpack decode error: {0}")] MsgpackDecode(#[from] crate::codec::DecodeError), + #[error("msgpack decode error: {0}")] + MsgpackDecode(#[from] crate::codec::DecodeError), // I/O - #[error("I/O error: {0}")] Io(IoError), + #[error("I/O error: {0}")] + Io(IoError), /// A fixed-capacity map reached its compile-time limit. #[error("capacity exceeded: {0}")] CapacityExceeded(String), // Catch-all - #[error("internal error: {0}")] Internal(String), + #[error("internal error: {0}")] + Internal(String), } impl SaikuroError { @@ -376,14 +388,13 @@ impl SaikuroError { SaikuroError::BatchItemFailed { .. } => ErrorCode::BatchItemFailed, SaikuroError::Entropy(_) => ErrorCode::Entropy, SaikuroError::MsgpackEncode(_) | SaikuroError::MsgpackDecode(_) => ErrorCode::Internal, - SaikuroError::Io(e) => - match e.kind { - IoErrorKind::TimedOut => ErrorCode::Timeout, - | IoErrorKind::ConnectionReset - | IoErrorKind::ConnectionAborted - | IoErrorKind::ConnectionRefused => ErrorCode::ConnectionLost, - _ => ErrorCode::Internal, - } + SaikuroError::Io(e) => match e.kind { + IoErrorKind::TimedOut => ErrorCode::Timeout, + IoErrorKind::ConnectionReset + | IoErrorKind::ConnectionAborted + | IoErrorKind::ConnectionRefused => ErrorCode::ConnectionLost, + _ => ErrorCode::Internal, + }, SaikuroError::FrozenSchema(_) => ErrorCode::Internal, SaikuroError::SchemaCapacity => ErrorCode::CapacityExceeded, SaikuroError::MissingBatch => ErrorCode::MalformedEnvelope, diff --git a/Build/crates/saikuro-event/core_events/mod.rs b/Build/crates/saikuro-event/core_events/mod.rs index 45694c6a..c8e3f0f7 100644 --- a/Build/crates/saikuro-event/core_events/mod.rs +++ b/Build/crates/saikuro-event/core_events/mod.rs @@ -1,7 +1,7 @@ +mod codec; mod event; mod io; -mod codec; +pub use codec::*; pub use event::*; pub use io::*; -pub use codec::*; diff --git a/Build/crates/saikuro-event/log/level.rs b/Build/crates/saikuro-event/log/level.rs index d8b3c5c7..13cf71d3 100644 --- a/Build/crates/saikuro-event/log/level.rs +++ b/Build/crates/saikuro-event/log/level.rs @@ -3,7 +3,18 @@ use strum::{Display, EnumString}; /// Severity level of a log record, ordered from least to most severe. #[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Display, EnumString + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + Display, + EnumString, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] diff --git a/Build/crates/saikuro-event/log/mod.rs b/Build/crates/saikuro-event/log/mod.rs index 000b6154..443eb2b7 100644 --- a/Build/crates/saikuro-event/log/mod.rs +++ b/Build/crates/saikuro-event/log/mod.rs @@ -7,22 +7,39 @@ pub mod ring; pub use level::*; pub use record::*; -pub use sink::*; #[cfg(feature = "collector")] pub use ring::*; +pub use sink::*; #[cfg(any( - all(feature = "native", any(feature = "no_std", feature = "wasm", feature = "embedded")), - all(feature = "no_std", any(feature = "native", feature = "wasm", feature = "embedded")), - all(feature = "wasm", any(feature = "native", feature = "no_std", feature = "embedded")), - all(feature = "embedded", any(feature = "native", feature = "no_std", feature = "wasm")) + all( + feature = "native", + any(feature = "no_std", feature = "wasm", feature = "embedded") + ), + all( + feature = "no_std", + any(feature = "native", feature = "wasm", feature = "embedded") + ), + all( + feature = "wasm", + any(feature = "native", feature = "no_std", feature = "embedded") + ), + all( + feature = "embedded", + any(feature = "native", feature = "no_std", feature = "wasm") + ) ))] compile_error!("exactly one engine must be enabled: native | no_std | wasm | embedded"); #[cfg(all(feature = "std", feature = "no_std"))] compile_error!("the no_std engine cannot be combined with the std toolchain"); -#[cfg(not(any(feature = "native", feature = "no_std", feature = "wasm", feature = "embedded")))] +#[cfg(not(any( + feature = "native", + feature = "no_std", + feature = "wasm", + feature = "embedded" +)))] compile_error!("exactly one engine must be selected: native | no_std | wasm | embedded"); #[cfg(feature = "native")] diff --git a/Build/crates/saikuro-event/log/record.rs b/Build/crates/saikuro-event/log/record.rs index 6be0eb9f..2687e833 100644 --- a/Build/crates/saikuro-event/log/record.rs +++ b/Build/crates/saikuro-event/log/record.rs @@ -107,6 +107,10 @@ impl TryFrom for LogRecord { impl fmt::Display for LogRecord { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "[{}] {} {} : {}", self.ts, self.level, self.name, self.msg) + write!( + f, + "[{}] {} {} : {}", + self.ts, self.level, self.name, self.msg + ) } } diff --git a/Build/crates/saikuro-exec/base/exec.rs b/Build/crates/saikuro-exec/base/exec.rs index aecbbb22..053e6631 100644 --- a/Build/crates/saikuro-exec/base/exec.rs +++ b/Build/crates/saikuro-exec/base/exec.rs @@ -105,13 +105,12 @@ impl RuntimeBuilder { } pub fn block_on(fut: F) -> F::Output { - let slot: Arc>> = Arc::new(CriticalSectionMutex::new( - RefCell::new(JoinSlot { + let slot: Arc>> = + Arc::new(CriticalSectionMutex::new(RefCell::new(JoinSlot { value: None, closed: false, wakers: MultiWakerRegistration::new(), - }), - )); + }))); let task_slot = slot.clone(); let token = global_executor().spawn(async move { let result = fut.await; @@ -134,13 +133,12 @@ where F: Future + 'static, F::Output: 'static, { - let slot: Arc>> = Arc::new(CriticalSectionMutex::new( - RefCell::new(JoinSlot { + let slot: Arc>> = + Arc::new(CriticalSectionMutex::new(RefCell::new(JoinSlot { value: None, closed: false, wakers: MultiWakerRegistration::new(), - }), - )); + }))); let task_slot = slot.clone(); let token = global_executor().spawn(async move { let result = fut.await; diff --git a/Build/crates/saikuro-exec/base/mpsc.rs b/Build/crates/saikuro-exec/base/mpsc.rs index 737aa658..0cc2d126 100644 --- a/Build/crates/saikuro-exec/base/mpsc.rs +++ b/Build/crates/saikuro-exec/base/mpsc.rs @@ -1,211 +1,222 @@ // mpsc use super::*; -use crate::ChannelCapacity; pub use crate::shared::mpsc::{SendError, TrySendError}; +use crate::ChannelCapacity; - pub const CHANNEL_CAPACITY: usize = 256; - const MAX_WAITING_SENDERS: usize = 16; - - struct ChannelState { - capacity: usize, - senders: usize, - receivers: usize, - senders_waiting: MultiWakerRegistration, - receivers_waiting: MultiWakerRegistration<1>, +pub const CHANNEL_CAPACITY: usize = 256; +const MAX_WAITING_SENDERS: usize = 16; + +struct ChannelState { + capacity: usize, + senders: usize, + receivers: usize, + senders_waiting: MultiWakerRegistration, + receivers_waiting: MultiWakerRegistration<1>, +} + +impl ChannelState { + const fn new(capacity: usize) -> Self { + ChannelState { + capacity, + senders: 0, + receivers: 0, + senders_waiting: MultiWakerRegistration::new(), + receivers_waiting: MultiWakerRegistration::new(), + } } - - impl ChannelState { - const fn new(capacity: usize) -> Self { - ChannelState { - capacity, - senders: 0, - receivers: 0, - senders_waiting: MultiWakerRegistration::new(), - receivers_waiting: MultiWakerRegistration::new(), - } +} + +struct ChannelInner { + state: CriticalSectionMutex>, + channel: EmbChannel, +} + +pub struct Sender { + inner: Arc>, +} + +impl Clone for Sender { + fn clone(&self) -> Self { + self.inner.state.lock(|s| s.borrow_mut().senders += 1); + Sender { + inner: self.inner.clone(), } } +} - struct ChannelInner { - state: CriticalSectionMutex>, - channel: EmbChannel, +impl Drop for Sender { + fn drop(&mut self) { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.senders -= 1; + if state.senders == 0 { + state.receivers_waiting.wake(); + } + }); } +} - pub struct Sender { - inner: Arc>, - } +enum EnqueueOutcome { + Sent, + Full(T), + Disconnected(T), +} - impl Clone for Sender { - fn clone(&self) -> Self { - self.inner.state.lock(|s| s.borrow_mut().senders += 1); - Sender { - inner: self.inner.clone(), - } - } +impl Sender { + pub fn is_closed(&self) -> bool { + self.inner.state.lock(|s| s.borrow().receivers == 0) } - impl Drop for Sender { - fn drop(&mut self) { - self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - state.senders -= 1; - if state.senders == 0 { - state.receivers_waiting.wake(); - } - }); - } + fn enqueue(&self, value: T) -> EnqueueOutcome { + self.inner.state.lock(|s| { + let state = s.borrow_mut(); + if state.receivers == 0 { + return EnqueueOutcome::Disconnected(value); + } + if self.inner.channel.len() >= state.capacity { + return EnqueueOutcome::Full(value); + } + match self.inner.channel.try_send(value) { + Ok(()) => EnqueueOutcome::Sent, + Err(EmbTrySendError::Full(value)) => EnqueueOutcome::Full(value), + } + }) } - enum EnqueueOutcome { - Sent, - Full(T), - Disconnected(T), + fn has_capacity(&self) -> bool { + self.inner.state.lock(|s| { + let state = s.borrow(); + self.inner.channel.len() < state.capacity + }) } - impl Sender { - pub fn is_closed(&self) -> bool { - self.inner.state.lock(|s| s.borrow().receivers == 0) - } - - fn enqueue(&self, value: T) -> EnqueueOutcome { - self.inner.state.lock(|s| { - let state = s.borrow_mut(); - if state.receivers == 0 { - return EnqueueOutcome::Disconnected(value); - } - if self.inner.channel.len() >= state.capacity { - return EnqueueOutcome::Full(value); - } - match self.inner.channel.try_send(value) { - Ok(()) => EnqueueOutcome::Sent, - Err(EmbTrySendError::Full(value)) => EnqueueOutcome::Full(value), - } - }) - } - - fn has_capacity(&self) -> bool { - self.inner.state.lock(|s| { - let state = s.borrow(); - self.inner.channel.len() < state.capacity - }) + pub fn try_send(&self, value: T) -> Result<(), TrySendError> { + match self.enqueue(value) { + EnqueueOutcome::Sent => Ok(()), + EnqueueOutcome::Full(value) => Err(TrySendError::Full(value)), + EnqueueOutcome::Disconnected(value) => Err(TrySendError::Disconnected(value)), } + } - pub fn try_send(&self, value: T) -> Result<(), TrySendError> { - match self.enqueue(value) { - EnqueueOutcome::Sent => Ok(()), - EnqueueOutcome::Full(value) => Err(TrySendError::Full(value)), - EnqueueOutcome::Disconnected(value) => Err(TrySendError::Disconnected(value)), + pub async fn send(&self, value: T) -> Result<(), SendError> { + let mut pending = Some(value); + poll_fn(move |cx| loop { + if self.is_closed() { + let message = pending + .take() + .expect("mpsc send message restored on Full path"); + return Poll::Ready(Err(SendError(message))); } - } - - pub async fn send(&self, value: T) -> Result<(), SendError> { - let mut pending = Some(value); - poll_fn(move |cx| { - loop { + let message = pending + .take() + .expect("mpsc send message restored on Full path"); + match self.enqueue(message) { + EnqueueOutcome::Sent => return Poll::Ready(Ok(())), + EnqueueOutcome::Disconnected(message) => { + return Poll::Ready(Err(SendError(message))) + } + EnqueueOutcome::Full(message) => { + pending = Some(message); + self.inner + .state + .lock(|s| s.borrow_mut().senders_waiting.register(cx.waker())); if self.is_closed() { - let message = pending.take().expect("mpsc send message restored on Full path"); + let message = pending + .take() + .expect("mpsc send message restored on Full path"); return Poll::Ready(Err(SendError(message))); } - let message = pending.take().expect("mpsc send message restored on Full path"); - match self.enqueue(message) { - EnqueueOutcome::Sent => return Poll::Ready(Ok(())), - EnqueueOutcome::Disconnected(message) => { - return Poll::Ready(Err(SendError(message))) - } - EnqueueOutcome::Full(message) => { - pending = Some(message); - self.inner - .state - .lock(|s| s.borrow_mut().senders_waiting.register(cx.waker())); - if self.is_closed() { - let message = pending.take().expect("mpsc send message restored on Full path"); - return Poll::Ready(Err(SendError(message))); - } - if self.has_capacity() { - continue; - } - return Poll::Pending; - } + if self.has_capacity() { + continue; } + return Poll::Pending; } - }) - .await - } + } + }) + .await } +} + +pub struct Receiver { + inner: Arc>, +} - pub struct Receiver { - inner: Arc>, +impl Drop for Receiver { + fn drop(&mut self) { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.receivers -= 1; + if state.receivers == 0 { + state.senders_waiting.wake(); + } + }); } +} - impl Drop for Receiver { - fn drop(&mut self) { - self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - state.receivers -= 1; - if state.receivers == 0 { - state.senders_waiting.wake(); - } - }); - } +impl Receiver { + pub async fn recv(&mut self) -> Option { + poll_fn(|cx| self.poll_recv(cx)).await } - impl Receiver { - pub async fn recv(&mut self) -> Option { - poll_fn(|cx| self.poll_recv(cx)).await - } + fn poll_recv(&self, cx: &mut Context<'_>) -> Poll> { + let all_senders_gone = self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.receivers_waiting.register(cx.waker()); + state.senders == 0 + }); - fn poll_recv(&self, cx: &mut Context<'_>) -> Poll> { - let all_senders_gone = self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - state.receivers_waiting.register(cx.waker()); - state.senders == 0 - }); + if let Ok(value) = self.inner.channel.try_receive() { + self.inner + .state + .lock(|s| s.borrow_mut().senders_waiting.wake()); + return Poll::Ready(Some(value)); + } - if let Ok(value) = self.inner.channel.try_receive() { - self.inner.state.lock(|s| s.borrow_mut().senders_waiting.wake()); - return Poll::Ready(Some(value)); - } + if all_senders_gone { + return Poll::Ready(None); + } - if all_senders_gone { - return Poll::Ready(None); + match self.inner.channel.poll_receive(cx) { + Poll::Ready(value) => { + self.inner + .state + .lock(|s| s.borrow_mut().senders_waiting.wake()); + Poll::Ready(Some(value)) } - - match self.inner.channel.poll_receive(cx) { - Poll::Ready(value) => { - self.inner.state.lock(|s| s.borrow_mut().senders_waiting.wake()); - Poll::Ready(Some(value)) - } - Poll::Pending => { - if self.inner.state.lock(|s| s.borrow().senders) == 0 { - match self.inner.channel.try_receive() { - Ok(value) => { - self.inner.state.lock(|s| s.borrow_mut().senders_waiting.wake()); - Poll::Ready(Some(value)) - } - Err(_) => Poll::Ready(None), + Poll::Pending => { + if self.inner.state.lock(|s| s.borrow().senders) == 0 { + match self.inner.channel.try_receive() { + Ok(value) => { + self.inner + .state + .lock(|s| s.borrow_mut().senders_waiting.wake()); + Poll::Ready(Some(value)) } - } else { - Poll::Pending + Err(_) => Poll::Ready(None), } + } else { + Poll::Pending } } } } - - pub fn channel(capacity: ChannelCapacity) -> (Sender, Receiver) { - let inner = Arc::new(ChannelInner { - state: CriticalSectionMutex::new(RefCell::new(ChannelState::new(capacity.get()))), - channel: EmbChannel::new(), - }); - inner.state.lock(|s| { - let mut state = s.borrow_mut(); - state.senders = 1; - state.receivers = 1; - }); - ( - Sender { inner: inner.clone() }, - Receiver { inner }, - ) - } - +} + +pub fn channel(capacity: ChannelCapacity) -> (Sender, Receiver) { + let inner = Arc::new(ChannelInner { + state: CriticalSectionMutex::new(RefCell::new(ChannelState::new(capacity.get()))), + channel: EmbChannel::new(), + }); + inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.senders = 1; + state.receivers = 1; + }); + ( + Sender { + inner: inner.clone(), + }, + Receiver { inner }, + ) +} diff --git a/Build/crates/saikuro-exec/base/oneshot.rs b/Build/crates/saikuro-exec/base/oneshot.rs index 74cd8890..b1b01b80 100644 --- a/Build/crates/saikuro-exec/base/oneshot.rs +++ b/Build/crates/saikuro-exec/base/oneshot.rs @@ -3,118 +3,121 @@ use super::*; pub use crate::shared::oneshot::RecvError; - enum State { - Empty, - Waiting(Waker), - Ready(T), - Closed, - } +enum State { + Empty, + Waiting(Waker), + Ready(T), + Closed, +} - struct InnerData { - channel: State, - receiver_alive: bool, - } +struct InnerData { + channel: State, + receiver_alive: bool, +} - struct Inner { - state: CriticalSectionMutex>>, - } +struct Inner { + state: CriticalSectionMutex>>, +} - pub struct Sender { - inner: Arc>, - } +pub struct Sender { + inner: Arc>, +} - impl Sender { - pub fn send(self, value: T) -> Result<(), T> { - self.inner.state.lock(|s| { - let mut data = s.borrow_mut(); - if !data.receiver_alive { - return Err(value); +impl Sender { + pub fn send(self, value: T) -> Result<(), T> { + self.inner.state.lock(|s| { + let mut data = s.borrow_mut(); + if !data.receiver_alive { + return Err(value); + } + match core::mem::replace(&mut data.channel, State::Empty) { + State::Empty => data.channel = State::Ready(value), + State::Waiting(waker) => { + data.channel = State::Ready(value); + waker.wake(); } - match core::mem::replace(&mut data.channel, State::Empty) { - State::Empty => data.channel = State::Ready(value), - State::Waiting(waker) => { - data.channel = State::Ready(value); - waker.wake(); - } - State::Ready(v) => { - data.channel = State::Ready(v); - core::unreachable!("oneshot sender cannot send twice"); - } - State::Closed => { - data.channel = State::Closed; - core::unreachable!("oneshot sender cannot send on a closed channel"); - } + State::Ready(v) => { + data.channel = State::Ready(v); + core::unreachable!("oneshot sender cannot send twice"); } - Ok(()) - }) - } - } - - impl Drop for Sender { - fn drop(&mut self) { - self.inner.state.lock(|s| { - let mut data = s.borrow_mut(); - if matches!(data.channel, State::Ready(_)) { - return; + State::Closed => { + data.channel = State::Closed; + core::unreachable!("oneshot sender cannot send on a closed channel"); } - let old = core::mem::replace(&mut data.channel, State::Closed); - if let State::Waiting(waker) = old { - waker.wake(); - } - }); - } + } + Ok(()) + }) } +} - pub struct Receiver { - inner: Arc>, +impl Drop for Sender { + fn drop(&mut self) { + self.inner.state.lock(|s| { + let mut data = s.borrow_mut(); + if matches!(data.channel, State::Ready(_)) { + return; + } + let old = core::mem::replace(&mut data.channel, State::Closed); + if let State::Waiting(waker) = old { + waker.wake(); + } + }); } +} + +pub struct Receiver { + inner: Arc>, +} - impl Drop for Receiver { - fn drop(&mut self) { - self.inner.state.lock(|s| s.borrow_mut().receiver_alive = false); - } +impl Drop for Receiver { + fn drop(&mut self) { + self.inner + .state + .lock(|s| s.borrow_mut().receiver_alive = false); } +} - impl Future for Receiver { - type Output = Result; +impl Future for Receiver { + type Output = Result; - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - self.get_mut().inner.state.lock(|s| { - let mut data = s.borrow_mut(); - match core::mem::replace(&mut data.channel, State::Empty) { - State::Ready(value) => { - data.channel = State::Closed; - Poll::Ready(Ok(value)) - } - State::Closed => Poll::Ready(Err(RecvError)), - State::Empty => { + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.get_mut().inner.state.lock(|s| { + let mut data = s.borrow_mut(); + match core::mem::replace(&mut data.channel, State::Empty) { + State::Ready(value) => { + data.channel = State::Closed; + Poll::Ready(Ok(value)) + } + State::Closed => Poll::Ready(Err(RecvError)), + State::Empty => { + data.channel = State::Waiting(cx.waker().clone()); + Poll::Pending + } + State::Waiting(w) => { + if w.will_wake(cx.waker()) { + data.channel = State::Waiting(w); + } else { data.channel = State::Waiting(cx.waker().clone()); - Poll::Pending - } - State::Waiting(w) => { - if w.will_wake(cx.waker()) { - data.channel = State::Waiting(w); - } else { - data.channel = State::Waiting(cx.waker().clone()); - w.wake(); - } - Poll::Pending + w.wake(); } + Poll::Pending } - }) - } - } - - pub fn channel() -> (Sender, Receiver) { - let inner = Arc::new(Inner { - state: CriticalSectionMutex::new(RefCell::new(InnerData { - channel: State::Empty, - receiver_alive: true, - })), - }); - ( - Sender { inner: inner.clone() }, - Receiver { inner }, - ) + } + }) } +} +pub fn channel() -> (Sender, Receiver) { + let inner = Arc::new(Inner { + state: CriticalSectionMutex::new(RefCell::new(InnerData { + channel: State::Empty, + receiver_alive: true, + })), + }); + ( + Sender { + inner: inner.clone(), + }, + Receiver { inner }, + ) +} diff --git a/Build/crates/saikuro-exec/base/sync.rs b/Build/crates/saikuro-exec/base/sync.rs index f6f7481f..b6e79109 100644 --- a/Build/crates/saikuro-exec/base/sync.rs +++ b/Build/crates/saikuro-exec/base/sync.rs @@ -2,158 +2,157 @@ use super::*; - pub struct Mutex { - inner: embassy_sync::mutex::Mutex, - } - - impl Mutex { - pub const fn new(value: T) -> Self { - Mutex { - inner: embassy_sync::mutex::Mutex::new(value), - } +pub struct Mutex { + inner: embassy_sync::mutex::Mutex, +} + +impl Mutex { + pub const fn new(value: T) -> Self { + Mutex { + inner: embassy_sync::mutex::Mutex::new(value), } - - pub async fn lock(&self) -> MutexGuard<'_, T> { - MutexGuard { - inner: self.inner.lock().await, - } - } - } - - pub struct MutexGuard<'a, T> { - inner: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, } - impl core::ops::Deref for MutexGuard<'_, T> { - type Target = T; - fn deref(&self) -> &T { - &self.inner + pub async fn lock(&self) -> MutexGuard<'_, T> { + MutexGuard { + inner: self.inner.lock().await, } } +} - impl core::ops::DerefMut for MutexGuard<'_, T> { - fn deref_mut(&mut self) -> &mut T { - &mut self.inner - } - } +pub struct MutexGuard<'a, T> { + inner: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, +} - pub struct RwLock { - inner: embassy_sync::mutex::Mutex, +impl core::ops::Deref for MutexGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.inner } +} - impl RwLock { - pub const fn new(value: T) -> Self { - RwLock { - inner: embassy_sync::mutex::Mutex::new(value), - } - } - - pub async fn read(&self) -> RwLockReadGuard<'_, T> { - RwLockReadGuard { - guard: self.inner.lock().await, - } - } - - pub async fn write(&self) -> RwLockWriteGuard<'_, T> { - RwLockWriteGuard { - guard: self.inner.lock().await, - } - } +impl core::ops::DerefMut for MutexGuard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.inner } +} - pub struct RwLockReadGuard<'a, T> { - guard: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, - } +pub struct RwLock { + inner: embassy_sync::mutex::Mutex, +} - impl core::ops::Deref for RwLockReadGuard<'_, T> { - type Target = T; - fn deref(&self) -> &T { - &self.guard +impl RwLock { + pub const fn new(value: T) -> Self { + RwLock { + inner: embassy_sync::mutex::Mutex::new(value), } } - pub struct RwLockWriteGuard<'a, T> { - guard: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, - } - - impl core::ops::Deref for RwLockWriteGuard<'_, T> { - type Target = T; - fn deref(&self) -> &T { - &self.guard + pub async fn read(&self) -> RwLockReadGuard<'_, T> { + RwLockReadGuard { + guard: self.inner.lock().await, } } - impl core::ops::DerefMut for RwLockWriteGuard<'_, T> { - fn deref_mut(&mut self) -> &mut T { - &mut self.guard + pub async fn write(&self) -> RwLockWriteGuard<'_, T> { + RwLockWriteGuard { + guard: self.inner.lock().await, } } - - const MAX_BARRIER_WAITERS: usize = 16; - - pub struct Barrier { - inner: Arc, - } - - struct BarrierInner { - state: CriticalSectionMutex>, - } - - struct BarrierState { - count: usize, - arrived: usize, - generation: u64, - waiting: MultiWakerRegistration, - } - - impl Barrier { - pub fn new(n: usize) -> Self { - assert!(n > 0, "saikuro-exec: Barrier::new requires n > 0"); - let inner = Arc::new(BarrierInner { - state: CriticalSectionMutex::new(RefCell::new(BarrierState { - count: n, - arrived: 0, - generation: 0, - waiting: MultiWakerRegistration::new(), - })), - }); - Barrier { inner } - } - - pub async fn wait(&self) { - let pre_release_generation = self.inner.state.lock(|s| { +} + +pub struct RwLockReadGuard<'a, T> { + guard: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, +} + +impl core::ops::Deref for RwLockReadGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.guard + } +} + +pub struct RwLockWriteGuard<'a, T> { + guard: embassy_sync::mutex::MutexGuard<'a, CriticalSectionRawMutex, T>, +} + +impl core::ops::Deref for RwLockWriteGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.guard + } +} + +impl core::ops::DerefMut for RwLockWriteGuard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.guard + } +} + +const MAX_BARRIER_WAITERS: usize = 16; + +pub struct Barrier { + inner: Arc, +} + +struct BarrierInner { + state: CriticalSectionMutex>, +} + +struct BarrierState { + count: usize, + arrived: usize, + generation: u64, + waiting: MultiWakerRegistration, +} + +impl Barrier { + pub fn new(n: usize) -> Self { + assert!(n > 0, "saikuro-exec: Barrier::new requires n > 0"); + let inner = Arc::new(BarrierInner { + state: CriticalSectionMutex::new(RefCell::new(BarrierState { + count: n, + arrived: 0, + generation: 0, + waiting: MultiWakerRegistration::new(), + })), + }); + Barrier { inner } + } + + pub async fn wait(&self) { + let pre_release_generation = self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.arrived += 1; + if state.arrived == state.count { + state.arrived = 0; + state.generation += 1; + state.waiting.wake(); + None + } else { + Some(state.generation) + } + }); + let Some(mut gen) = pre_release_generation else { + return; + }; + poll_fn(move |cx| { + self.inner.state.lock(|s| { let mut state = s.borrow_mut(); - state.arrived += 1; - if state.arrived == state.count { - state.arrived = 0; - state.generation += 1; - state.waiting.wake(); - None + if state.generation != gen { + gen = state.generation; + Poll::Ready(()) } else { - Some(state.generation) - } - }); - let Some(mut gen) = pre_release_generation else { - return; - }; - poll_fn(move |cx| { - self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); + state.waiting.register(cx.waker()); if state.generation != gen { gen = state.generation; Poll::Ready(()) } else { - state.waiting.register(cx.waker()); - if state.generation != gen { - gen = state.generation; - Poll::Ready(()) - } else { - Poll::Pending - } + Poll::Pending } - }) + } }) - .await - } + }) + .await } - +} diff --git a/Build/crates/saikuro-exec/base/watch.rs b/Build/crates/saikuro-exec/base/watch.rs index 2a237a01..f0d1089f 100644 --- a/Build/crates/saikuro-exec/base/watch.rs +++ b/Build/crates/saikuro-exec/base/watch.rs @@ -3,128 +3,129 @@ use super::*; pub use crate::shared::watch::{RecvError, SendError}; - const MAX_WAITING_RECEIVERS: usize = 16; - - struct WatchState { - value: T, - version: u64, - senders: usize, - receivers: usize, - waiting: MultiWakerRegistration, - } - - struct WatchInner { - state: CriticalSectionMutex>>, - } - - pub struct Sender { - inner: Arc>, - } - - impl Sender { - pub fn send(&self, value: T) -> Result<(), SendError> { - self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - if state.receivers == 0 { - return Err(SendError(value)); - } - state.value = value; - state.version += 1; - state.waiting.wake(); - Ok(()) - }) - } - } - - impl Clone for Sender { - fn clone(&self) -> Self { - self.inner.state.lock(|s| s.borrow_mut().senders += 1); - Sender { inner: self.inner.clone() } - } +const MAX_WAITING_RECEIVERS: usize = 16; + +struct WatchState { + value: T, + version: u64, + senders: usize, + receivers: usize, + waiting: MultiWakerRegistration, +} + +struct WatchInner { + state: CriticalSectionMutex>>, +} + +pub struct Sender { + inner: Arc>, +} + +impl Sender { + pub fn send(&self, value: T) -> Result<(), SendError> { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + if state.receivers == 0 { + return Err(SendError(value)); + } + state.value = value; + state.version += 1; + state.waiting.wake(); + Ok(()) + }) } +} - impl Drop for Sender { - fn drop(&mut self) { - self.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - state.senders -= 1; - if state.senders == 0 { - state.waiting.wake(); - } - }); +impl Clone for Sender { + fn clone(&self) -> Self { + self.inner.state.lock(|s| s.borrow_mut().senders += 1); + Sender { + inner: self.inner.clone(), } } - - pub struct Receiver { - inner: Arc>, - version: u64, +} + +impl Drop for Sender { + fn drop(&mut self) { + self.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.senders -= 1; + if state.senders == 0 { + state.waiting.wake(); + } + }); } +} - impl Receiver { - pub fn borrow(&self) -> T { - self.inner.state.lock(|s| s.borrow().value.clone()) - } +pub struct Receiver { + inner: Arc>, + version: u64, +} - pub fn changed(&mut self) -> ChangedFuture<'_, T> { - ChangedFuture { receiver: self } - } +impl Receiver { + pub fn borrow(&self) -> T { + self.inner.state.lock(|s| s.borrow().value.clone()) } - impl Clone for Receiver { - fn clone(&self) -> Self { - self.inner.state.lock(|s| s.borrow_mut().receivers += 1); - Receiver { - inner: self.inner.clone(), - version: self.version, - } - } + pub fn changed(&mut self) -> ChangedFuture<'_, T> { + ChangedFuture { receiver: self } } - - impl Drop for Receiver { - fn drop(&mut self) { - self.inner.state.lock(|s| s.borrow_mut().receivers -= 1); +} + +impl Clone for Receiver { + fn clone(&self) -> Self { + self.inner.state.lock(|s| s.borrow_mut().receivers += 1); + Receiver { + inner: self.inner.clone(), + version: self.version, } } +} - pub struct ChangedFuture<'a, T> { - receiver: &'a mut Receiver, +impl Drop for Receiver { + fn drop(&mut self) { + self.inner.state.lock(|s| s.borrow_mut().receivers -= 1); } - - impl Future for ChangedFuture<'_, T> { - type Output = Result<(), RecvError>; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.get_mut(); - this.receiver.inner.state.lock(|s| { - let mut state = s.borrow_mut(); - state.waiting.register(cx.waker()); - let version = state.version; - if this.receiver.version != version { - this.receiver.version = version; - return Poll::Ready(Ok(())); - } - if state.senders == 0 { - return Poll::Ready(Err(RecvError)); - } - Poll::Pending - }) - } +} + +pub struct ChangedFuture<'a, T> { + receiver: &'a mut Receiver, +} + +impl Future for ChangedFuture<'_, T> { + type Output = Result<(), RecvError>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + this.receiver.inner.state.lock(|s| { + let mut state = s.borrow_mut(); + state.waiting.register(cx.waker()); + let version = state.version; + if this.receiver.version != version { + this.receiver.version = version; + return Poll::Ready(Ok(())); + } + if state.senders == 0 { + return Poll::Ready(Err(RecvError)); + } + Poll::Pending + }) } +} - pub fn channel(initial: T) -> (Sender, Receiver) { - let inner = Arc::new(WatchInner { - state: CriticalSectionMutex::new(RefCell::new(WatchState { - value: initial, - version: 0, - senders: 1, - receivers: 1, - waiting: MultiWakerRegistration::new(), - })), - }); - let receiver = Receiver { - inner: inner.clone(), +pub fn channel(initial: T) -> (Sender, Receiver) { + let inner = Arc::new(WatchInner { + state: CriticalSectionMutex::new(RefCell::new(WatchState { + value: initial, version: 0, - }; - (Sender { inner }, receiver) - } - + senders: 1, + receivers: 1, + waiting: MultiWakerRegistration::new(), + })), + }); + let receiver = Receiver { + inner: inner.clone(), + version: 0, + }; + (Sender { inner }, receiver) +} diff --git a/Build/crates/saikuro-exec/embedded/mod.rs b/Build/crates/saikuro-exec/embedded/mod.rs index 4a3b9826..2d6a0345 100644 --- a/Build/crates/saikuro-exec/embedded/mod.rs +++ b/Build/crates/saikuro-exec/embedded/mod.rs @@ -1,7 +1,7 @@ pub mod exec; +pub use crate::base::signal; pub use crate::base::{fuse_select, sleep, timeout, yield_now}; pub use crate::base::{mpsc, oneshot, sync, watch}; -pub use crate::base::signal; pub use exec::*; diff --git a/Build/crates/saikuro-exec/lib.rs b/Build/crates/saikuro-exec/lib.rs index 18b22d0c..ca5dcf05 100644 --- a/Build/crates/saikuro-exec/lib.rs +++ b/Build/crates/saikuro-exec/lib.rs @@ -6,9 +6,18 @@ extern crate alloc; // Exactly one engine must be selected #[cfg(any( - all(feature = "native", any(feature = "no_std", feature = "wasm", feature = "embedded")), - all(feature = "no_std", any(feature = "native", feature = "wasm", feature = "embedded")), - all(feature = "wasm", any(feature = "native", feature = "no_std", feature = "embedded")), + all( + feature = "native", + any(feature = "no_std", feature = "wasm", feature = "embedded") + ), + all( + feature = "no_std", + any(feature = "native", feature = "wasm", feature = "embedded") + ), + all( + feature = "wasm", + any(feature = "native", feature = "no_std", feature = "embedded") + ), all( feature = "embedded", any(feature = "native", feature = "no_std", feature = "wasm") @@ -20,8 +29,8 @@ compile_error!("exactly one engine must be enabled: native | no_std | wasm | emb compile_error!("the no_std engine cannot be combined with the std toolchain"); mod shared; -pub use shared::{ChannelCapacity, InvalidChannelCapacity}; pub use shared::JoinError; +pub use shared::{ChannelCapacity, InvalidChannelCapacity}; #[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] mod base; @@ -43,10 +52,10 @@ mod embedded; #[cfg(feature = "embedded")] pub use embedded::*; -#[cfg(feature = "native")] -pub use tokio as _tokio; #[cfg(not(feature = "native"))] pub use futures as _futures; +#[cfg(feature = "native")] +pub use tokio as _tokio; #[macro_export] macro_rules! select { diff --git a/Build/crates/saikuro-exec/native/exec.rs b/Build/crates/saikuro-exec/native/exec.rs index 08480f23..2e03736c 100644 --- a/Build/crates/saikuro-exec/native/exec.rs +++ b/Build/crates/saikuro-exec/native/exec.rs @@ -117,7 +117,9 @@ impl Future for JoinHandle { let this = self.get_mut(); match Pin::new(&mut this.inner).poll(cx) { core::task::Poll::Ready(Ok(v)) => core::task::Poll::Ready(Ok(v)), - core::task::Poll::Ready(Err(e)) => core::task::Poll::Ready(Err(JoinError::from_tokio(e))), + core::task::Poll::Ready(Err(e)) => { + core::task::Poll::Ready(Err(JoinError::from_tokio(e))) + } core::task::Poll::Pending => core::task::Poll::Pending, } } @@ -150,4 +152,3 @@ where pub async fn yield_now() { tokio::task::yield_now().await; } - diff --git a/Build/crates/saikuro-exec/native/mpsc.rs b/Build/crates/saikuro-exec/native/mpsc.rs index b8f0a208..0d4acc63 100644 --- a/Build/crates/saikuro-exec/native/mpsc.rs +++ b/Build/crates/saikuro-exec/native/mpsc.rs @@ -1,50 +1,49 @@ -use crate::ChannelCapacity; use crate::shared::mpsc::{SendError, TrySendError}; +use crate::ChannelCapacity; - pub struct Sender { - inner: tokio::sync::mpsc::Sender, - } +pub struct Sender { + inner: tokio::sync::mpsc::Sender, +} - pub struct Receiver { - inner: tokio::sync::mpsc::Receiver, - } +pub struct Receiver { + inner: tokio::sync::mpsc::Receiver, +} - impl Clone for Sender { - fn clone(&self) -> Self { - Sender { - inner: self.inner.clone(), - } +impl Clone for Sender { + fn clone(&self) -> Self { + Sender { + inner: self.inner.clone(), } } +} + +impl Sender { + pub async fn send(&self, value: T) -> Result<(), SendError> { + self.inner + .send(value) + .await + .map_err(|e| SendError(e.into_inner())) + } - impl Sender { - pub async fn send(&self, value: T) -> Result<(), SendError> { - self.inner - .send(value) - .await - .map_err(|e| SendError(e.into_inner())) - } - - pub fn try_send(&self, value: T) -> Result<(), TrySendError> { - self.inner.try_send(value).map_err(|e| match e { - tokio::sync::mpsc::TrySendError::Full(v) => TrySendError::Full(v), - tokio::sync::mpsc::TrySendError::Closed(v) => TrySendError::Disconnected(v), - }) - } - - pub fn is_closed(&self) -> bool { - self.inner.is_closed() - } + pub fn try_send(&self, value: T) -> Result<(), TrySendError> { + self.inner.try_send(value).map_err(|e| match e { + tokio::sync::mpsc::TrySendError::Full(v) => TrySendError::Full(v), + tokio::sync::mpsc::TrySendError::Closed(v) => TrySendError::Disconnected(v), + }) } - impl Receiver { - pub async fn recv(&mut self) -> Option { - self.inner.recv().await - } + pub fn is_closed(&self) -> bool { + self.inner.is_closed() } +} - pub fn channel(capacity: ChannelCapacity) -> (Sender, Receiver) { - let (tx, rx) = tokio::sync::mpsc::channel(capacity.get()); - (Sender { inner: tx }, Receiver { inner: rx }) +impl Receiver { + pub async fn recv(&mut self) -> Option { + self.inner.recv().await } +} +pub fn channel(capacity: ChannelCapacity) -> (Sender, Receiver) { + let (tx, rx) = tokio::sync::mpsc::channel(capacity.get()); + (Sender { inner: tx }, Receiver { inner: rx }) +} diff --git a/Build/crates/saikuro-exec/native/oneshot.rs b/Build/crates/saikuro-exec/native/oneshot.rs index 2baa6fda..2b39aabe 100644 --- a/Build/crates/saikuro-exec/native/oneshot.rs +++ b/Build/crates/saikuro-exec/native/oneshot.rs @@ -4,34 +4,33 @@ use core::task::{Context, Poll}; use crate::shared::oneshot::RecvError; - pub fn channel() -> (Sender, Receiver) { - let (tx, rx) = tokio::sync::oneshot::channel(); - (Sender { inner: tx }, Receiver { inner: rx }) - } +pub fn channel() -> (Sender, Receiver) { + let (tx, rx) = tokio::sync::oneshot::channel(); + (Sender { inner: tx }, Receiver { inner: rx }) +} - pub struct Sender { - inner: tokio::sync::oneshot::Sender, - } +pub struct Sender { + inner: tokio::sync::oneshot::Sender, +} - impl Sender { - pub fn send(self, value: T) -> Result<(), T> { - self.inner.send(value) - } +impl Sender { + pub fn send(self, value: T) -> Result<(), T> { + self.inner.send(value) } +} - pub struct Receiver { - inner: tokio::sync::oneshot::Receiver, - } +pub struct Receiver { + inner: tokio::sync::oneshot::Receiver, +} - impl Future for Receiver { - type Output = Result; +impl Future for Receiver { + type Output = Result; - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match self.get_mut().inner.poll_recv(cx) { - Poll::Ready(Ok(v)) => Poll::Ready(Ok(v)), - Poll::Ready(Err(_)) => Poll::Ready(Err(RecvError)), + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match self.get_mut().inner.poll_recv(cx) { + Poll::Ready(Ok(v)) => Poll::Ready(Ok(v)), + Poll::Ready(Err(_)) => Poll::Ready(Err(RecvError)), Poll::Pending => Poll::Pending, } } - } diff --git a/Build/crates/saikuro-exec/native/sync.rs b/Build/crates/saikuro-exec/native/sync.rs index dc68be1f..13f9130e 100644 --- a/Build/crates/saikuro-exec/native/sync.rs +++ b/Build/crates/saikuro-exec/native/sync.rs @@ -1,106 +1,105 @@ use core::future::Future; use core::ops::{Deref, DerefMut}; - pub struct Mutex { - inner: tokio::sync::Mutex, - } - - impl Mutex { - pub fn new(value: T) -> Self { - Mutex { - inner: tokio::sync::Mutex::new(value), - } - } - - pub async fn lock(&self) -> MutexGuard<'_, T> { - MutexGuard { - inner: self.inner.lock().await, - } +pub struct Mutex { + inner: tokio::sync::Mutex, +} + +impl Mutex { + pub fn new(value: T) -> Self { + Mutex { + inner: tokio::sync::Mutex::new(value), } } - pub struct MutexGuard<'a, T> { - inner: tokio::sync::MutexGuard<'a, T>, - } - - impl Deref for MutexGuard<'_, T> { - type Target = T; - fn deref(&self) -> &T { - &self.inner + pub async fn lock(&self) -> MutexGuard<'_, T> { + MutexGuard { + inner: self.inner.lock().await, } } +} - impl DerefMut for MutexGuard<'_, T> { - fn deref_mut(&mut self) -> &mut T { - &mut self.inner - } - } +pub struct MutexGuard<'a, T> { + inner: tokio::sync::MutexGuard<'a, T>, +} - pub struct RwLock { - inner: tokio::sync::RwLock, +impl Deref for MutexGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.inner } +} - impl RwLock { - pub fn new(value: T) -> Self { - RwLock { - inner: tokio::sync::RwLock::new(value), - } - } +impl DerefMut for MutexGuard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.inner + } +} - pub async fn read(&self) -> RwLockReadGuard<'_, T> { - RwLockReadGuard { - guard: self.inner.read().await, - } - } +pub struct RwLock { + inner: tokio::sync::RwLock, +} - pub async fn write(&self) -> RwLockWriteGuard<'_, T> { - RwLockWriteGuard { - guard: self.inner.write().await, - } +impl RwLock { + pub fn new(value: T) -> Self { + RwLock { + inner: tokio::sync::RwLock::new(value), } } - pub struct RwLockReadGuard<'a, T> { - guard: tokio::sync::RwLockReadGuard<'a, T>, + pub async fn read(&self) -> RwLockReadGuard<'_, T> { + RwLockReadGuard { + guard: self.inner.read().await, + } } - impl Deref for RwLockReadGuard<'_, T> { - type Target = T; - fn deref(&self) -> &T { - &self.guard + pub async fn write(&self) -> RwLockWriteGuard<'_, T> { + RwLockWriteGuard { + guard: self.inner.write().await, } } +} - pub struct RwLockWriteGuard<'a, T> { - guard: tokio::sync::RwLockWriteGuard<'a, T>, - } +pub struct RwLockReadGuard<'a, T> { + guard: tokio::sync::RwLockReadGuard<'a, T>, +} - impl Deref for RwLockWriteGuard<'_, T> { - type Target = T; - fn deref(&self) -> &T { - &self.guard - } +impl Deref for RwLockReadGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.guard } +} - impl DerefMut for RwLockWriteGuard<'_, T> { - fn deref_mut(&mut self) -> &mut T { - &mut self.guard - } +pub struct RwLockWriteGuard<'a, T> { + guard: tokio::sync::RwLockWriteGuard<'a, T>, +} + +impl Deref for RwLockWriteGuard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + &self.guard } +} - pub struct Barrier { - inner: tokio::sync::Barrier, +impl DerefMut for RwLockWriteGuard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.guard } +} - impl Barrier { - pub fn new(n: usize) -> Self { - Barrier { - inner: tokio::sync::Barrier::new(n), - } - } +pub struct Barrier { + inner: tokio::sync::Barrier, +} - pub async fn wait(&self) { - self.inner.wait().await; +impl Barrier { + pub fn new(n: usize) -> Self { + Barrier { + inner: tokio::sync::Barrier::new(n), } } + pub async fn wait(&self) { + self.inner.wait().await; + } +} diff --git a/Build/crates/saikuro-exec/native/watch.rs b/Build/crates/saikuro-exec/native/watch.rs index 117cf3b4..c7df1884 100644 --- a/Build/crates/saikuro-exec/native/watch.rs +++ b/Build/crates/saikuro-exec/native/watch.rs @@ -4,68 +4,67 @@ use core::task::{Context, Poll}; use crate::shared::watch::{RecvError, SendError}; - pub fn channel(initial: T) -> (Sender, Receiver) { - let (tx, rx) = tokio::sync::watch::channel(initial); - (Sender { inner: tx }, Receiver { inner: rx }) - } +pub fn channel(initial: T) -> (Sender, Receiver) { + let (tx, rx) = tokio::sync::watch::channel(initial); + (Sender { inner: tx }, Receiver { inner: rx }) +} - pub struct Sender { - inner: tokio::sync::watch::Sender, - } +pub struct Sender { + inner: tokio::sync::watch::Sender, +} - impl Clone for Sender { - fn clone(&self) -> Self { - Sender { - inner: self.inner.clone(), - } +impl Clone for Sender { + fn clone(&self) -> Self { + Sender { + inner: self.inner.clone(), } } +} - impl Sender { - pub fn send(&self, value: T) -> Result<(), SendError> { - self.inner - .send(value) - .map_err(|e| SendError(e.into_inner())) - } +impl Sender { + pub fn send(&self, value: T) -> Result<(), SendError> { + self.inner + .send(value) + .map_err(|e| SendError(e.into_inner())) } +} - pub struct Receiver { - inner: tokio::sync::watch::Receiver, - } +pub struct Receiver { + inner: tokio::sync::watch::Receiver, +} - impl Clone for Receiver { - fn clone(&self) -> Self { - Receiver { - inner: self.inner.clone(), - } +impl Clone for Receiver { + fn clone(&self) -> Self { + Receiver { + inner: self.inner.clone(), } } +} - impl Receiver { - pub fn borrow(&self) -> T { - self.inner.borrow() - } - - pub fn changed(&mut self) -> ChangedFuture<'_, T> { - ChangedFuture { receiver: self } - } +impl Receiver { + pub fn borrow(&self) -> T { + self.inner.borrow() } - pub struct ChangedFuture<'a, T> { - receiver: &'a mut Receiver, + pub fn changed(&mut self) -> ChangedFuture<'_, T> { + ChangedFuture { receiver: self } } +} - impl Future for ChangedFuture<'_, T> { - type Output = Result<(), RecvError>; +pub struct ChangedFuture<'a, T> { + receiver: &'a mut Receiver, +} - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.get_mut(); - let mut fut = this.receiver.inner.changed(); - match Pin::new(&mut fut).poll(cx) { - Poll::Ready(Ok(())) => Poll::Ready(Ok(())), - Poll::Ready(Err(_)) => Poll::Ready(Err(RecvError)), - Poll::Pending => Poll::Pending, - } +impl Future for ChangedFuture<'_, T> { + type Output = Result<(), RecvError>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + let mut fut = this.receiver.inner.changed(); + match Pin::new(&mut fut).poll(cx) { + Poll::Ready(Ok(())) => Poll::Ready(Ok(())), + Poll::Ready(Err(_)) => Poll::Ready(Err(RecvError)), + Poll::Pending => Poll::Pending, } } - +} diff --git a/Build/crates/saikuro-exec/wasm/mod.rs b/Build/crates/saikuro-exec/wasm/mod.rs index 4a3b9826..2d6a0345 100644 --- a/Build/crates/saikuro-exec/wasm/mod.rs +++ b/Build/crates/saikuro-exec/wasm/mod.rs @@ -1,7 +1,7 @@ pub mod exec; +pub use crate::base::signal; pub use crate::base::{fuse_select, sleep, timeout, yield_now}; pub use crate::base::{mpsc, oneshot, sync, watch}; -pub use crate::base::signal; pub use exec::*; diff --git a/Build/crates/saikuro-net/embedded/mod.rs b/Build/crates/saikuro-net/embedded/mod.rs index fe89eccc..b7845abe 100644 --- a/Build/crates/saikuro-net/embedded/mod.rs +++ b/Build/crates/saikuro-net/embedded/mod.rs @@ -1,2 +1,2 @@ -pub mod net; pub mod io; +pub mod net; diff --git a/Build/crates/saikuro-net/lib.rs b/Build/crates/saikuro-net/lib.rs index 4d4e1855..a8d65905 100644 --- a/Build/crates/saikuro-net/lib.rs +++ b/Build/crates/saikuro-net/lib.rs @@ -5,9 +5,18 @@ // Exactly one engine must be selected. #[cfg(any( - all(feature = "native", any(feature = "no_std", feature = "wasm", feature = "embedded")), - all(feature = "no_std", any(feature = "native", feature = "wasm", feature = "embedded")), - all(feature = "wasm", any(feature = "native", feature = "no_std", feature = "embedded")), + all( + feature = "native", + any(feature = "no_std", feature = "wasm", feature = "embedded") + ), + all( + feature = "no_std", + any(feature = "native", feature = "wasm", feature = "embedded") + ), + all( + feature = "wasm", + any(feature = "native", feature = "no_std", feature = "embedded") + ), all( feature = "embedded", any(feature = "native", feature = "no_std", feature = "wasm") @@ -21,9 +30,9 @@ compile_error!("the no_std engine cannot be combined with the std toolchain"); #[cfg(feature = "native")] mod native; #[cfg(feature = "native")] -pub use native::{net, io}; +pub use native::{io, net}; #[cfg(feature = "embedded")] mod embedded; #[cfg(feature = "embedded")] -pub use embedded::{net, io}; +pub use embedded::{io, net}; diff --git a/Build/crates/saikuro-net/native/mod.rs b/Build/crates/saikuro-net/native/mod.rs index fe89eccc..b7845abe 100644 --- a/Build/crates/saikuro-net/native/mod.rs +++ b/Build/crates/saikuro-net/native/mod.rs @@ -1,2 +1,2 @@ -pub mod net; pub mod io; +pub mod net; diff --git a/Build/crates/saikuro-random/embedded/mod.rs b/Build/crates/saikuro-random/embedded/mod.rs index a2632fff..ca540f60 100644 --- a/Build/crates/saikuro-random/embedded/mod.rs +++ b/Build/crates/saikuro-random/embedded/mod.rs @@ -14,5 +14,7 @@ pub fn init_from(source: &impl EntropySource) -> Result<(), SaikuroError> { /// [`init_from`]. #[doc(hidden)] pub fn try_auto_seed() -> Result<(), SaikuroError> { - Err(SaikuroError::Entropy(format!("DRBG used before being seeded"))) + Err(SaikuroError::Entropy(format!( + "DRBG used before being seeded" + ))) } diff --git a/Build/crates/saikuro-random/lib.rs b/Build/crates/saikuro-random/lib.rs index beef1e1a..73d9aae2 100644 --- a/Build/crates/saikuro-random/lib.rs +++ b/Build/crates/saikuro-random/lib.rs @@ -4,9 +4,18 @@ //! Randomness and entropy facade for Saikuro. #[cfg(any( - all(feature = "native", any(feature = "no_std", feature = "wasm", feature = "embedded")), - all(feature = "no_std", any(feature = "native", feature = "wasm", feature = "embedded")), - all(feature = "wasm", any(feature = "native", feature = "no_std", feature = "embedded")), + all( + feature = "native", + any(feature = "no_std", feature = "wasm", feature = "embedded") + ), + all( + feature = "no_std", + any(feature = "native", feature = "wasm", feature = "embedded") + ), + all( + feature = "wasm", + any(feature = "native", feature = "no_std", feature = "embedded") + ), all( feature = "embedded", any(feature = "native", feature = "no_std", feature = "wasm") diff --git a/Build/crates/saikuro-random/shared/mod.rs b/Build/crates/saikuro-random/shared/mod.rs index 3df463b4..7c240933 100644 --- a/Build/crates/saikuro-random/shared/mod.rs +++ b/Build/crates/saikuro-random/shared/mod.rs @@ -27,15 +27,15 @@ pub trait EntropySource { fn try_fill(&self, dest: &mut [u8]) -> Result<(), SaikuroError>; } - /// Generate keystream block `index` for the given key and nonce. fn keystream_block( key: &[u8; KEY_LEN], nonce: &[u8; NONCE_LEN], index: u64, ) -> Result<[u8; BLOCK_LEN], SaikuroError> { - let mut cipher = - XChaCha20::new_from_slices(key, nonce).map_err(|_| SaikuroError::Entropy(format!("DRBG seed must be at least {SEED_LEN} bytes")))?; + let mut cipher = XChaCha20::new_from_slices(key, nonce).map_err(|_| { + SaikuroError::Entropy(format!("DRBG seed must be at least {SEED_LEN} bytes")) + })?; // chacha20 seeks by byte offset, not by block index. let pos = index .checked_mul(BLOCK_LEN as u64) @@ -63,7 +63,9 @@ impl Drbg { /// anything past that is ignored. pub fn from_seed(seed: &[u8]) -> Result { if seed.len() < SEED_LEN { - return Err(SaikuroError::Entropy(format!("DRBG seed must be at least {SEED_LEN} bytes"))); + return Err(SaikuroError::Entropy(format!( + "DRBG seed must be at least {SEED_LEN} bytes" + ))); } let mut key = [0u8; KEY_LEN]; let mut nonce = [0u8; NONCE_LEN]; @@ -171,14 +173,18 @@ static SEED: [AtomicU64; SEED_WORDS] = [ /// Seed the process-wide DRBG from `seed`. pub fn seed_from_slice(seed: &[u8]) -> Result<(), SaikuroError> { if seed.len() < SEED_LEN { - return Err(SaikuroError::Entropy(format!("DRBG seed must be at least {SEED_LEN} bytes"))); + return Err(SaikuroError::Entropy(format!( + "DRBG seed must be at least {SEED_LEN} bytes" + ))); } if SEEDED.load(Ordering::Acquire) || INITIALIZING .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { - return Err(SaikuroError::Entropy(format!("DRBG has already been seeded"))); + return Err(SaikuroError::Entropy(format!( + "DRBG has already been seeded" + ))); } for (i, word) in SEED.iter().enumerate() { let mut bytes = [0u8; 8]; @@ -233,7 +239,9 @@ pub fn fill(dest: &mut [u8]) -> Result<(), SaikuroError> { if !is_seeded() { crate::try_auto_seed()?; if !is_seeded() { - return Err(SaikuroError::Entropy(format!("DRBG used before being seeded"))); + return Err(SaikuroError::Entropy(format!( + "DRBG used before being seeded" + ))); } } let (key, nonce) = read_seed(); diff --git a/Build/crates/saikuro-router/provider/provider.rs b/Build/crates/saikuro-router/provider/provider.rs index ff15f79d..5571f68d 100644 --- a/Build/crates/saikuro-router/provider/provider.rs +++ b/Build/crates/saikuro-router/provider/provider.rs @@ -176,8 +176,7 @@ impl ProviderRegistry { } } } - None => { - } + None => {} } } state.by_provider.insert(provider_key, namespaces); diff --git a/Build/crates/saikuro-router/router/router.rs b/Build/crates/saikuro-router/router/router.rs index a3511c25..b8d5dfdd 100644 --- a/Build/crates/saikuro-router/router/router.rs +++ b/Build/crates/saikuro-router/router/router.rs @@ -154,7 +154,10 @@ impl InvocationRouter { "", LogLevel::Warn, "saikuro.router", - format!("provider dropped response sender without replying (id={})", id), + format!( + "provider dropped response sender without replying (id={})", + id + ), )) .await; error_response( @@ -298,7 +301,8 @@ impl InvocationRouter { let (outbound_tx, outbound_rx) = mpsc::channel(self.config.channel_capacity); let state = ChannelState::new(inbound_tx, outbound_tx); self.streams - .insert_channel(id, state, inbound_rx, outbound_rx).await; + .insert_channel(id, state, inbound_rx, outbound_rx) + .await; if let Err(e) = provider.send_invocation(envelope, None).await { self.streams.remove_channel(&id).await; @@ -360,7 +364,10 @@ impl InvocationRouter { "", LogLevel::Warn, "saikuro.router", - format!("failed to parse LogRecord from log envelope (id={}): {e}", id), + format!( + "failed to parse LogRecord from log envelope (id={}): {e}", + id + ), )) .await; None @@ -377,7 +384,10 @@ impl InvocationRouter { "", LogLevel::Warn, "saikuro.router", - format!("log envelope has no valid LogRecord in args[0]; dropping (id={})", id), + format!( + "log envelope has no valid LogRecord in args[0]; dropping (id={})", + id + ), )) .await; } @@ -391,7 +401,8 @@ impl InvocationRouter { let id = response.id; let state = self .streams - .get_channel(&id).await + .get_channel(&id) + .await .ok_or_else(|| SaikuroError::ChannelNotFound(id.to_string()))?; match state.deliver(response, inbound).await { @@ -433,7 +444,8 @@ impl InvocationRouter { let id = response.id; let state = self .streams - .get_stream(&id).await + .get_stream(&id) + .await .ok_or_else(|| SaikuroError::StreamNotFound(id.to_string()))?; match state.deliver(response).await { @@ -467,7 +479,8 @@ impl InvocationRouter { let handle = self .providers - .get(ns).await + .get(ns) + .await .ok_or_else(|| SaikuroError::NoProvider(ns.to_owned()))?; if !handle.is_alive() { @@ -487,7 +500,6 @@ fn error_response(id: InvocationId, detail: ErrorDetail) -> ResponseEnvelope { ResponseEnvelope::err(id, detail) } - fn default_sink() -> DefaultRouterSink { #[cfg(feature = "native")] { diff --git a/Build/crates/saikuro-router/stream_state/stream_state.rs b/Build/crates/saikuro-router/stream_state/stream_state.rs index 46ffb955..b23dfbb1 100644 --- a/Build/crates/saikuro-router/stream_state/stream_state.rs +++ b/Build/crates/saikuro-router/stream_state/stream_state.rs @@ -1,7 +1,10 @@ use alloc::{collections::BTreeMap, sync::Arc}; use saikuro_core::invocation::InvocationId; use saikuro_core::ResponseEnvelope; -use saikuro_exec::{mpsc, sync::{Mutex, RwLock}}; +use saikuro_exec::{ + mpsc, + sync::{Mutex, RwLock}, +}; /// Result of attempting to deliver one frame. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -165,11 +168,19 @@ impl StreamStateStore { } pub async fn get_stream(&self, id: &InvocationId) -> Option> { - self.streams.read().await.get(id).map(|entry| entry.state.clone()) + self.streams + .read() + .await + .get(id) + .map(|entry| entry.state.clone()) } pub async fn remove_stream(&self, id: &InvocationId) -> Option> { - self.streams.write().await.remove(id).map(|entry| entry.state) + self.streams + .write() + .await + .remove(id) + .map(|entry| entry.state) } pub async fn remove_stream_if(&self, id: &InvocationId, state: &Arc) -> bool { diff --git a/Build/crates/saikuro-runtime/src/bin/embedded.rs b/Build/crates/saikuro-runtime/src/bin/embedded.rs index 54bcc1d4..baa484d3 100644 --- a/Build/crates/saikuro-runtime/src/bin/embedded.rs +++ b/Build/crates/saikuro-runtime/src/bin/embedded.rs @@ -12,7 +12,9 @@ mod board { use saikuro_net::net::Stack; pub fn stack() -> &'static Stack<'static> { - compile_error!("provide `crate::board::stack() -> &'static Stack<'static>` in the firmware"); + compile_error!( + "provide `crate::board::stack() -> &'static Stack<'static>` in the firmware" + ); } pub fn endpoint() -> saikuro_net::net::IpEndpoint { @@ -28,6 +30,9 @@ async fn main() { let (_shutdown_tx, shutdown_rx) = watch::channel(false); runtime - .serve(vec![TcpTransportListener::new(stack, board::endpoint())], shutdown_rx) + .serve( + vec![TcpTransportListener::new(stack, board::endpoint())], + shutdown_rx, + ) .await; } diff --git a/Build/crates/saikuro-runtime/src/bin/wasi.rs b/Build/crates/saikuro-runtime/src/bin/wasi.rs index c3618dca..6478d541 100644 --- a/Build/crates/saikuro-runtime/src/bin/wasi.rs +++ b/Build/crates/saikuro-runtime/src/bin/wasi.rs @@ -29,11 +29,15 @@ pub extern "C" fn _start() -> i32 { let tcp_task = { let rt = runtime.clone(); - saikuro_exec::spawn(async move { rt.serve(vec![tcp], rx1).await; }) + saikuro_exec::spawn(async move { + rt.serve(vec![tcp], rx1).await; + }) }; let pipe_task = { let rt = runtime.clone(); - saikuro_exec::spawn(async move { rt.serve(vec![pipe], rx2).await; }) + saikuro_exec::spawn(async move { + rt.serve(vec![pipe], rx2).await; + }) }; let _ = tcp_task.await; diff --git a/Build/crates/saikuro-runtime/src/bin/wasm.rs b/Build/crates/saikuro-runtime/src/bin/wasm.rs index 90226bda..672fd9af 100644 --- a/Build/crates/saikuro-runtime/src/bin/wasm.rs +++ b/Build/crates/saikuro-runtime/src/bin/wasm.rs @@ -12,7 +12,10 @@ pub fn start(channel: String) { let (_shutdown_tx, shutdown_rx) = watch::channel(false); saikuro_exec::spawn(async move { runtime - .serve(vec![HostPipeListener::::new(channel)], shutdown_rx) + .serve( + vec![HostPipeListener::::new(channel)], + shutdown_rx, + ) .await; }); } diff --git a/Build/crates/saikuro-runtime/src/config.rs b/Build/crates/saikuro-runtime/src/config.rs index 19563866..25139c4f 100644 --- a/Build/crates/saikuro-runtime/src/config.rs +++ b/Build/crates/saikuro-runtime/src/config.rs @@ -1,5 +1,5 @@ -use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; use core::time::Duration; +use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; use saikuro_exec::ChannelCapacity; use saikuro_router::router::RouterConfig; diff --git a/Build/crates/saikuro-runtime/src/handle.rs b/Build/crates/saikuro-runtime/src/handle.rs index e97816ec..4a95616c 100644 --- a/Build/crates/saikuro-runtime/src/handle.rs +++ b/Build/crates/saikuro-runtime/src/handle.rs @@ -2,7 +2,6 @@ use alloc::string::String; use alloc::sync::Arc; use alloc::vec::Vec; -use spin::RwLock; use saikuro_core::{ capability::CapabilitySet, envelope::Envelope, schema::Schema, RegistrationToken, ResponseEnvelope, @@ -18,6 +17,7 @@ use saikuro_schema::{ registry::{NamespaceRegistration, SchemaRegistry}, validator::InvocationValidator, }; +use spin::RwLock; use tracing::{debug, info}; use crate::config::RuntimeConfig; @@ -127,7 +127,7 @@ impl RuntimeHandle { { return ResponseEnvelope::err( envelope.id, - saikuro_event::ErrorDetail::new( + saikuro_event::ErrorDetail::new( saikuro_event::ErrorCode::CapabilityDenied, format!("missing capability '{missing}' for '{}'", envelope.target), ), diff --git a/Build/crates/saikuro-runtime/src/main.rs b/Build/crates/saikuro-runtime/src/main.rs index 4cf9b482..8ac0ec8e 100644 --- a/Build/crates/saikuro-runtime/src/main.rs +++ b/Build/crates/saikuro-runtime/src/main.rs @@ -70,7 +70,12 @@ struct Args { mode: CliMode, /// Minimum log level to emit. - #[arg(long, value_name = "LEVEL", default_value = "info", env = "SAIKURO_LOG")] + #[arg( + long, + value_name = "LEVEL", + default_value = "info", + env = "SAIKURO_LOG" + )] log_level: String, /// Emit logs as newline-delimited JSON instead of human-readable text. @@ -150,7 +155,9 @@ async fn async_main() -> Result<()> { info!(addr = %listener.local_addr(), "TCP listener ready"); let rt = runtime.clone(); let mut rx = shutdown_rx.clone(); - serve_tasks.push(spawn(async move { rt.serve(vec![listener], rx).await; })); + serve_tasks.push(spawn(async move { + rt.serve(vec![listener], rx).await; + })); } Err(e) => { error!(addr = %addr, error = %e, "failed to bind TCP listener"); @@ -169,7 +176,9 @@ async fn async_main() -> Result<()> { info!(addr = %listener.local_addr(), "WebSocket listener ready"); let rt = runtime.clone(); let mut rx = shutdown_rx.clone(); - serve_tasks.push(spawn(async move { rt.serve(vec![listener], rx).await; })); + serve_tasks.push(spawn(async move { + rt.serve(vec![listener], rx).await; + })); } Err(e) => { error!(addr = %addr, error = %e, "failed to bind WebSocket listener"); @@ -187,7 +196,9 @@ async fn async_main() -> Result<()> { info!(path = %unix_path.display(), "Unix socket listener ready"); let rt = runtime.clone(); let mut rx = shutdown_rx.clone(); - serve_tasks.push(spawn(async move { rt.serve(vec![listener], rx).await; })); + serve_tasks.push(spawn(async move { + rt.serve(vec![listener], rx).await; + })); } Err(e) => { error!(path = %unix_path.display(), error = %e, "failed to bind Unix listener"); diff --git a/Build/crates/saikuro-runtime/src/runtime.rs b/Build/crates/saikuro-runtime/src/runtime.rs index a1a3371c..428e0261 100644 --- a/Build/crates/saikuro-runtime/src/runtime.rs +++ b/Build/crates/saikuro-runtime/src/runtime.rs @@ -7,7 +7,9 @@ use saikuro_core::capability::CapabilitySet; use saikuro_core::schema::Schema; use saikuro_exec::{sleep, spawn, timeout, watch}; use saikuro_router::provider::ProviderRegistry; -use saikuro_schema::{capability_engine::CapabilityEngine, registry::SchemaRegistry, validator::InvocationValidator}; +use saikuro_schema::{ + capability_engine::CapabilityEngine, registry::SchemaRegistry, validator::InvocationValidator, +}; use spin::RwLock; use tracing::{error, info}; @@ -109,10 +111,7 @@ impl SaikuroRuntime { if let Some(bytes) = schema_bytes { match serde_json::from_slice::(bytes) { Ok(schema) => { - if let Err(e) = runtime - .schema_registry - .merge_schema(schema, "static") - { + if let Err(e) = runtime.schema_registry.merge_schema(schema, "static") { error!(error = %e, "failed to merge static schema"); } } diff --git a/Build/crates/saikuro-runtime/src/transport_adapter.rs b/Build/crates/saikuro-runtime/src/transport_adapter.rs index 59bd0165..ccce2b76 100644 --- a/Build/crates/saikuro-runtime/src/transport_adapter.rs +++ b/Build/crates/saikuro-runtime/src/transport_adapter.rs @@ -6,8 +6,8 @@ use bytes::Bytes; use saikuro_transport::shared::error::Result; use saikuro_transport::shared::host::{HostPipeFactory, Role, WasmHostTransport}; use saikuro_transport::shared::traits::{ - LocalTransport, LocalTransportListener, LocalTransportReceiver, LocalTransportSender, Transport, - TransportListener, TransportReceiver, TransportSender, + LocalTransport, LocalTransportListener, LocalTransportReceiver, LocalTransportSender, + Transport, TransportListener, TransportReceiver, TransportSender, }; macro_rules! define_runtime_traits { @@ -113,7 +113,6 @@ impl RuntimeListener for T { } } - #[cfg(not(feature = "native"))] pub struct LocalRuntimeSender(S); diff --git a/Build/crates/saikuro-storage/common/inmemory.rs b/Build/crates/saikuro-storage/common/inmemory.rs index 4ac73a19..a1587ce6 100644 --- a/Build/crates/saikuro-storage/common/inmemory.rs +++ b/Build/crates/saikuro-storage/common/inmemory.rs @@ -95,7 +95,6 @@ impl Default for InMemoryStorage { } } - impl KeyValueBackend for InMemoryStorage { fn config(&self) -> &StorageConfig { &self.config @@ -191,7 +190,6 @@ impl KeyValueBackend for InMemoryStorage { } } - impl StorageBackend for InMemoryStorage { fn supports_files(&self) -> bool { false diff --git a/Build/crates/saikuro-storage/common/sqlite/mod.rs b/Build/crates/saikuro-storage/common/sqlite/mod.rs index 0074183f..87297e8d 100644 --- a/Build/crates/saikuro-storage/common/sqlite/mod.rs +++ b/Build/crates/saikuro-storage/common/sqlite/mod.rs @@ -64,7 +64,10 @@ fn strip_prefix(config: &StorageConfig, stored: &str) -> String { fn ns_key_params(namespace: &str, key: &str) -> Params { Params { - positional: vec![Value::Text(namespace.to_owned()), Value::Text(key.to_owned())], + positional: vec![ + Value::Text(namespace.to_owned()), + Value::Text(key.to_owned()), + ], named: Vec::new(), } } diff --git a/Build/crates/saikuro-storage/embedded/flash.rs b/Build/crates/saikuro-storage/embedded/flash.rs index 25346496..2b130c1a 100644 --- a/Build/crates/saikuro-storage/embedded/flash.rs +++ b/Build/crates/saikuro-storage/embedded/flash.rs @@ -83,11 +83,8 @@ impl FlashKvStore { SaikuroError::internal("invalid sequential-storage region (alignment or size)") })?; - let inner = MapStorage::, F, Cache>>::new( - flash, - map_config, - Cache::new_uncached(), - ); + let inner = + MapStorage::, F, Cache>>::new(flash, map_config, Cache::new_uncached()); Ok(Self { config, @@ -242,10 +239,7 @@ where let mut inner = self.inner.borrow_mut(); let mut buf = self.scratch_buf(); - let mut iter = inner - .fetch_all_items(&mut buf) - .await - .map_err(map_err)?; + let mut iter = inner.fetch_all_items(&mut buf).await.map_err(map_err)?; let mut out: Vec = Vec::new(); while let Some((k, v)) = iter @@ -266,10 +260,7 @@ where async fn list_namespaces(&self) -> Result> { let mut inner = self.inner.borrow_mut(); let mut buf = self.scratch_buf(); - let mut iter = inner - .fetch_all_items(&mut buf) - .await - .map_err(map_err)?; + let mut iter = inner.fetch_all_items(&mut buf).await.map_err(map_err)?; let mut live: BTreeSet = BTreeSet::new(); while let Some((k, v)) = iter @@ -309,7 +300,9 @@ where .await .map_err(map_err)?; if existing.is_some() { - return Err(SaikuroError::namespace_already_exists(namespace.to_string())); + return Err(SaikuroError::namespace_already_exists( + namespace.to_string(), + )); } inner .store_item(&mut buf, &marker, &Some(Vec::new())) @@ -327,10 +320,7 @@ where let mut to_tombstone: Vec> = Vec::new(); { - let mut iter = inner - .fetch_all_items(&mut buf) - .await - .map_err(map_err)?; + let mut iter = inner.fetch_all_items(&mut buf).await.map_err(map_err)?; while let Some((k, v)) = iter .next::>>(&mut buf) .await @@ -369,10 +359,7 @@ where let mut to_tombstone: Vec> = Vec::new(); { - let mut iter = inner - .fetch_all_items(&mut buf) - .await - .map_err(map_err)?; + let mut iter = inner.fetch_all_items(&mut buf).await.map_err(map_err)?; while let Some((k, v)) = iter .next::>>(&mut buf) .await @@ -448,16 +435,14 @@ fn parse_key(k: &[u8]) -> Option<(u8, &str, &str)> { fn map_err(e: SsError) -> SaikuroError { use SsError::*; match e { - Storage { value } => { - SaikuroError::internal(format!("flash I/O error: {value:?}")) - } + Storage { value } => SaikuroError::internal(format!("flash I/O error: {value:?}")), FullStorage => SaikuroError::quota_exceeded("flash region is full"), Corrupted { .. } => SaikuroError::internal("flash region is corrupted"), LogicBug { .. } => SaikuroError::internal("flash storage logic bug"), BufferTooBig => SaikuroError::internal("scratch buffer too large"), - BufferTooSmall(n) => SaikuroError::internal(format!( - "scratch buffer too small (need {n} bytes)" - )), + BufferTooSmall(n) => { + SaikuroError::internal(format!("scratch buffer too small (need {n} bytes)")) + } SerializationError(_) => SaikuroError::internal("serialization error"), ItemTooBig => SaikuroError::internal("item exceeds the 64 KiB flash limit"), _ => SaikuroError::internal("unknown flash storage error"), diff --git a/Build/crates/saikuro-storage/lib.rs b/Build/crates/saikuro-storage/lib.rs index fddc3ac0..295cc1ce 100644 --- a/Build/crates/saikuro-storage/lib.rs +++ b/Build/crates/saikuro-storage/lib.rs @@ -24,16 +24,16 @@ compile_error!("the native engine requires the std toolchain"); #[cfg(all(feature = "no_std", feature = "std"))] compile_error!("the no_std engine must not be combined with the std toolchain"); -pub mod shared; pub mod common; -#[cfg(feature = "native")] -pub mod native; #[cfg(feature = "embedded")] pub mod embedded; -#[cfg(feature = "wasm")] -pub mod wasm; +#[cfg(feature = "native")] +pub mod native; +pub mod shared; #[cfg(any(feature = "wasi-preview1", feature = "wasi-component"))] pub mod wasi; +#[cfg(feature = "wasm")] +pub mod wasm; /// Generates a web-storage-backed key-value backend. #[macro_export] @@ -93,23 +93,14 @@ macro_rules! impl_web_storage { } } - async fn get( - &self, - namespace: &str, - key: &str, - ) -> $crate::Result> { + async fn get(&self, namespace: &str, key: &str) -> $crate::Result> { let storage = self.storage()?; let prefixed_ns = $crate::util::apply_prefix(&self.config, namespace); let full_key = $crate::util::make_key(&prefixed_ns, key); $crate::webstorage::storage_get(&storage, &full_key) } - async fn put( - &self, - namespace: &str, - key: &str, - value: Bytes, - ) -> $crate::Result<()> { + async fn put(&self, namespace: &str, key: &str, value: Bytes) -> $crate::Result<()> { let storage = self.storage()?; let prefixed_ns = $crate::util::apply_prefix(&self.config, namespace); let full_key = $crate::util::make_key(&prefixed_ns, key); @@ -168,18 +159,16 @@ macro_rules! impl_web_storage { }; } -pub use shared::config::{BackendKind, CleanupPolicy, PersistenceMode, StorageConfig}; #[cfg(feature = "flash")] pub use shared::config::FlashConfig; +pub use shared::config::{BackendKind, CleanupPolicy, PersistenceMode, StorageConfig}; pub use saikuro_event::{Result, SaikuroError}; /// Raw byte buffer used by every key-value and file backend. pub use bytes::Bytes; -pub use shared::traits::{ - FileBackend, KeyValueBackend, KeyValueBackendExt, StorageBackend, -}; +pub use shared::traits::{FileBackend, KeyValueBackend, KeyValueBackendExt, StorageBackend}; pub use shared::config; pub use shared::traits; @@ -204,12 +193,12 @@ pub use wasm::fs_access::FsAccessStorage; pub use wasm::opfs::OpfsStorage; // Root aliases for the wasm submodules referenced by `impl_web_storage!`. -#[cfg(all(feature = "wasm", target_arch = "wasm32"))] -pub use wasm::{fs_access, indexeddb, opfs, webstorage}; #[cfg(feature = "wasm")] pub use wasm::local_storage; #[cfg(feature = "wasm")] pub use wasm::session_storage; +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +pub use wasm::{fs_access, indexeddb, opfs, webstorage}; #[cfg(feature = "fs")] pub use native::fs::FilesystemStorage; diff --git a/Build/crates/saikuro-storage/native/fs.rs b/Build/crates/saikuro-storage/native/fs.rs index b2d9f3e7..c61a2dd6 100644 --- a/Build/crates/saikuro-storage/native/fs.rs +++ b/Build/crates/saikuro-storage/native/fs.rs @@ -170,7 +170,6 @@ fn strip_ns_prefix(prefix: &Option, name: &str) -> String { } } - impl KeyValueBackend for FilesystemStorage { fn config(&self) -> &StorageConfig { &self.config @@ -239,7 +238,6 @@ impl KeyValueBackend for FilesystemStorage { } } - impl FileBackend for FilesystemStorage { async fn read_file(&self, path: &str) -> Result { let full = safe_join(&self.files_root, path)?; @@ -291,7 +289,6 @@ impl FileBackend for FilesystemStorage { } } - impl StorageBackend for FilesystemStorage { fn supports_files(&self) -> bool { true diff --git a/Build/crates/saikuro-storage/native/sled.rs b/Build/crates/saikuro-storage/native/sled.rs index f55dd9a9..cdbd1377 100644 --- a/Build/crates/saikuro-storage/native/sled.rs +++ b/Build/crates/saikuro-storage/native/sled.rs @@ -1,4 +1,3 @@ - use bytes::Bytes; use std::sync::Arc; use tokio::task::spawn_blocking; @@ -74,7 +73,6 @@ impl SledStorage { } } - impl KeyValueBackend for SledStorage { fn config(&self) -> &StorageConfig { &self.config @@ -220,7 +218,6 @@ impl KeyValueBackend for SledStorage { } } - impl StorageBackend for SledStorage { fn supports_files(&self) -> bool { false diff --git a/Build/crates/saikuro-storage/wasi/preview2.rs b/Build/crates/saikuro-storage/wasi/preview2.rs index 0923273d..4837393a 100644 --- a/Build/crates/saikuro-storage/wasi/preview2.rs +++ b/Build/crates/saikuro-storage/wasi/preview2.rs @@ -164,10 +164,9 @@ impl WasiFileStore { /// The first preopened directory is the store root. fn root(&self) -> Result { let (descriptors, _) = filesystem::preopens().map_err(map_fs_err)?; - descriptors - .into_iter() - .next() - .ok_or_else(|| SaikuroError::backend_unavailable("wasi:filesystem has no preopened directory")) + descriptors.into_iter().next().ok_or_else(|| { + SaikuroError::backend_unavailable("wasi:filesystem has no preopened directory") + }) } } From e1285e11f2c00de0a2bc52bcc2930f36f1fee741 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Sun, 16 Aug 2026 23:10:37 -0600 Subject: [PATCH 37/43] Getting it all compiling --- Build/Cargo.lock | 368 +++++++++++++----- Build/Cargo.toml | 37 +- Build/adapters/rust/Cargo.toml | 3 +- Build/adapters/typescript/package-lock.json | 22 +- Build/crates/saikuro-codegen/shared/mod.rs | 2 + Build/crates/saikuro-core/Cargo.toml | 9 +- Build/crates/saikuro-core/protocol/schema.rs | 1 + Build/crates/saikuro-event/Cargo.toml | 3 +- .../crates/saikuro-event/core_events/event.rs | 11 +- Build/crates/saikuro-event/core_events/io.rs | 4 +- Build/crates/saikuro-event/lib.rs | 12 +- .../crates/saikuro-event/log/embedded/mod.rs | 3 + .../saikuro-event/log/embedded/serial.rs | 1 - Build/crates/saikuro-event/log/mod.rs | 3 + Build/crates/saikuro-event/log/native/mod.rs | 2 + Build/crates/saikuro-event/log/sink.rs | 1 + .../crates/saikuro-event/log/wasm/console.rs | 11 +- Build/crates/saikuro-event/log/wasm/mod.rs | 3 + Build/crates/saikuro-event/value/mod.rs | 2 + Build/crates/saikuro-exec/Cargo.toml | 23 +- Build/crates/saikuro-exec/base/exec.rs | 270 +++++++++---- Build/crates/saikuro-exec/base/mod.rs | 4 +- Build/crates/saikuro-exec/base/oneshot.rs | 16 +- Build/crates/saikuro-exec/embedded/exec.rs | 4 +- Build/crates/saikuro-exec/embedded/mod.rs | 4 - Build/crates/saikuro-exec/lib.rs | 5 + Build/crates/saikuro-exec/native/exec.rs | 14 +- Build/crates/saikuro-exec/native/mpsc.rs | 13 +- Build/crates/saikuro-exec/native/oneshot.rs | 3 +- Build/crates/saikuro-exec/native/sync.rs | 1 - Build/crates/saikuro-exec/native/watch.rs | 11 +- Build/crates/saikuro-exec/no_std/exec.rs | 1 + Build/crates/saikuro-exec/no_std/mod.rs | 3 + Build/crates/saikuro-exec/shared/mod.rs | 4 +- Build/crates/saikuro-exec/wasm/mod.rs | 4 - Build/crates/saikuro-net/Cargo.toml | 2 +- Build/crates/saikuro-net/embedded/mod.rs | 2 + Build/crates/saikuro-net/native/io.rs | 4 +- Build/crates/saikuro-net/native/mod.rs | 5 + Build/crates/saikuro-random/Cargo.toml | 8 +- Build/crates/saikuro-random/lib.rs | 3 + Build/crates/saikuro-random/native/mod.rs | 2 +- Build/crates/saikuro-random/shared/mod.rs | 25 +- Build/crates/saikuro-router/lib.rs | 9 + Build/crates/saikuro-router/provider/mod.rs | 1 + .../saikuro-router/provider/provider.rs | 31 +- Build/crates/saikuro-router/router/mod.rs | 1 + Build/crates/saikuro-router/router/router.rs | 14 +- .../crates/saikuro-router/stream_state/mod.rs | 1 + .../stream_state/stream_state.rs | 45 ++- Build/crates/saikuro-runtime/Cargo.toml | 54 +-- .../saikuro-runtime/{src => }/bin/embedded.rs | 21 +- .../saikuro-runtime/{src => }/bin/wasi.rs | 18 +- Build/crates/saikuro-runtime/embedded/mod.rs | 17 + Build/crates/saikuro-runtime/lib.rs | 87 +++++ Build/crates/saikuro-runtime/main.rs | 5 + .../{src/main.rs => native/mod.rs} | 45 +-- .../saikuro-runtime/{src => shared}/config.rs | 6 + .../{src => shared}/connection.rs | 39 +- .../saikuro-runtime/{src => shared}/handle.rs | 56 +-- .../{src/lib.rs => shared/mod.rs} | 8 - .../{src => shared}/runtime.rs | 15 +- .../shared/transport_adapter.rs | 285 ++++++++++++++ Build/crates/saikuro-runtime/src/bin/wasm.rs | 27 -- .../saikuro-runtime/src/transport_adapter.rs | 215 ---------- Build/crates/saikuro-runtime/wasm/mod.rs | 42 ++ .../saikuro-schema/capability/engine.rs | 5 +- Build/crates/saikuro-schema/capability/mod.rs | 3 +- Build/crates/saikuro-schema/lib.rs | 23 +- Build/crates/saikuro-schema/registry/mod.rs | 1 + .../saikuro-schema/registry/registry.rs | 21 +- Build/crates/saikuro-schema/validator/mod.rs | 1 + .../saikuro-schema/validator/validator.rs | 14 +- Build/crates/saikuro-storage/Cargo.toml | 1 + Build/crates/saikuro-storage/native/fs.rs | 2 + .../saikuro-storage/shared/traits/file.rs | 3 +- Build/crates/saikuro-storage/wasi/preview1.rs | 3 +- Build/crates/saikuro-storage/wasi/preview2.rs | 4 +- .../crates/saikuro-storage/wasm/fs_access.rs | 2 + Build/crates/saikuro-storage/wasm/opfs.rs | 2 + Build/crates/saikuro-transport/Cargo.toml | 38 +- .../embedded/io_transport.rs | 55 +-- .../crates/saikuro-transport/embedded/mod.rs | 1 - .../crates/saikuro-transport/embedded/tcp.rs | 119 +++--- Build/crates/saikuro-transport/lib.rs | 90 +++-- .../crates/saikuro-transport/native/framed.rs | 190 +-------- Build/crates/saikuro-transport/native/tcp.rs | 20 +- Build/crates/saikuro-transport/native/unix.rs | 23 +- .../saikuro-transport/native/websocket.rs | 10 +- .../crates/saikuro-transport/shared/framed.rs | 36 -- .../saikuro-transport/shared/framing.rs | 192 +++++---- Build/crates/saikuro-transport/shared/host.rs | 218 ++++++++--- .../crates/saikuro-transport/shared/memory.rs | 95 +++-- Build/crates/saikuro-transport/shared/mod.rs | 1 - .../crates/saikuro-transport/shared/traits.rs | 328 ++++++++++------ Build/crates/saikuro-transport/wasi/host.rs | 23 +- Build/crates/saikuro-transport/wasi/mod.rs | 11 +- .../crates/saikuro-transport/wasi/preview1.rs | 143 ++++--- .../crates/saikuro-transport/wasi/preview2.rs | 144 ++++--- Build/crates/saikuro-transport/wasi/tcp.rs | 146 +++---- .../saikuro-transport/wasi/websocket.rs | 243 ++++++++++++ .../saikuro-transport/wasm/host_browser.rs | 57 +-- Build/crates/saikuro-transport/wasm/mod.rs | 2 +- .../saikuro-transport/wasm/websocket.rs | 7 +- Build/scripts/check_matrix.py | 277 +++++++++++++ 105 files changed, 2888 insertions(+), 1637 deletions(-) create mode 100644 Build/crates/saikuro-exec/no_std/exec.rs create mode 100644 Build/crates/saikuro-exec/no_std/mod.rs rename Build/crates/saikuro-runtime/{src => }/bin/embedded.rs (51%) rename Build/crates/saikuro-runtime/{src => }/bin/wasi.rs (74%) create mode 100644 Build/crates/saikuro-runtime/embedded/mod.rs create mode 100644 Build/crates/saikuro-runtime/lib.rs create mode 100644 Build/crates/saikuro-runtime/main.rs rename Build/crates/saikuro-runtime/{src/main.rs => native/mod.rs} (82%) rename Build/crates/saikuro-runtime/{src => shared}/config.rs (94%) rename Build/crates/saikuro-runtime/{src => shared}/connection.rs (95%) rename Build/crates/saikuro-runtime/{src => shared}/handle.rs (82%) rename Build/crates/saikuro-runtime/{src/lib.rs => shared/mod.rs} (60%) rename Build/crates/saikuro-runtime/{src => shared}/runtime.rs (95%) create mode 100644 Build/crates/saikuro-runtime/shared/transport_adapter.rs delete mode 100644 Build/crates/saikuro-runtime/src/bin/wasm.rs delete mode 100644 Build/crates/saikuro-runtime/src/transport_adapter.rs create mode 100644 Build/crates/saikuro-runtime/wasm/mod.rs delete mode 100644 Build/crates/saikuro-transport/shared/framed.rs create mode 100644 Build/crates/saikuro-transport/wasi/websocket.rs create mode 100644 Build/scripts/check_matrix.py diff --git a/Build/Cargo.lock b/Build/Cargo.lock index 3ad24d02..17d1f1d2 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -79,25 +79,25 @@ dependencies = [ ] [[package]] -name = "autocfg" -version = "1.5.0" +name = "atomic-polyfill" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] [[package]] -name = "bare-metal" -version = "0.2.5" +name = "autocfg" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3" -dependencies = [ - "rustc_version", -] +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "bitfield" -version = "0.13.2" +name = "base64" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "bitflags" @@ -111,6 +111,24 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -163,7 +181,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -214,30 +232,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] -name = "cortex-m" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f985670a83ddb4c96174f696987458ae26fe3d801faf7263b56a2b2252c2f76f" -dependencies = [ - "bare-metal", - "bitfield", - "cortex-m-macros", - "critical-section", - "embedded-hal 0.2.7", - "embedded-hal 1.0.0", - "volatile-register", -] - -[[package]] -name = "cortex-m-macros" -version = "0.7.8" +name = "const-oid" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1922be58519ad40368fc4ca595a2cefa51a7abf947be3b0c90586dc7dbd0e2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "cpufeatures" @@ -297,6 +295,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "darling" version = "0.20.11" @@ -360,6 +367,12 @@ dependencies = [ "parking_lot_core 0.9.12", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "defmt" version = "1.1.1" @@ -391,6 +404,27 @@ dependencies = [ "thiserror", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + [[package]] name = "document-features" version = "0.2.12" @@ -402,11 +436,10 @@ dependencies = [ [[package]] name = "embassy-executor" -version = "0.6.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64f84599b0f4296b92a4b6ac2109bc02340094bda47b9766c5f9ec6a318ebf8" +checksum = "f102d5e04befe3ea74b6f41a0e26218740124636eb2f59e1cc215b5839b96df2" dependencies = [ - "cortex-m", "critical-section", "document-features", "embassy-executor-macros", @@ -416,9 +449,9 @@ dependencies = [ [[package]] name = "embassy-executor-macros" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3577b1e9446f61381179a330fc5324b01d511624c55f25e3c66c9e3c626dbecf" +checksum = "dfdddc3a04226828316bf31393b6903ee162238576b1584ee2669af215d55472" dependencies = [ "darling", "proc-macro2", @@ -426,6 +459,12 @@ dependencies = [ "syn", ] +[[package]] +name = "embassy-executor-timer-queue" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc328bf943af66b80b98755db9106bf7e7471b0cf47dc8559cd9a6be504cc9c" + [[package]] name = "embassy-futures" version = "0.1.2" @@ -434,17 +473,17 @@ checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" [[package]] name = "embassy-net" -version = "0.5.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f9f2979069031c153e41075a43074c36a64492e598780b27944a605f829d23" +checksum = "71f0aa32082b7df00164f485322d6edab59122c9718b363b07ec23424c2c06a0" dependencies = [ "document-features", "embassy-net-driver", "embassy-sync", "embassy-time", - "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", "embedded-nal-async", - "heapless", + "heapless 0.8.0", "managed", "smoltcp", ] @@ -457,50 +496,56 @@ checksum = "524eb3c489760508f71360112bca70f6e53173e6fe48fc5f0efd0f5ab217751d" [[package]] name = "embassy-sync" -version = "0.6.2" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d2c8cdff05a7a51ba0087489ea44b0b1d97a296ca6b1d6d1a33ea7423d34049" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" dependencies = [ "cfg-if", "critical-section", "embedded-io-async 0.6.1", + "futures-core", "futures-sink", - "futures-util", - "heapless", + "heapless 0.8.0", ] [[package]] name = "embassy-time" -version = "0.3.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158080d48f824fad101d7b2fae2d83ac39e3f7a6fa01811034f7ab8ffc6e7309" +checksum = "592b0c143ec626e821d4d90da51a2bd91d559d6c442b7c74a47d368c9e23d97a" dependencies = [ "cfg-if", "critical-section", "document-features", "embassy-time-driver", - "embassy-time-queue-driver", + "embassy-time-queue-utils", "embedded-hal 0.2.7", "embedded-hal 1.0.0", "embedded-hal-async", - "futures-util", - "heapless", + "futures-core", + "js-sys", + "wasm-bindgen", + "wasm-timer", ] [[package]] name = "embassy-time-driver" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0c214077aaa9206958b16411c157961fb7990d4ea628120a78d1a5a28aed24" +checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" dependencies = [ "document-features", ] [[package]] -name = "embassy-time-queue-driver" -version = "0.1.0" +name = "embassy-time-queue-utils" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1177859559ebf42cd24ae7ba8fe6ee707489b01d0bf471f8827b7b12dcb0bc0" +checksum = "168297bf80aaf114b3c9ad589bf38b01b3009b9af7f97cd18086c5bbf96f5693" +dependencies = [ + "embassy-executor-timer-queue", + "heapless 0.9.3", +] [[package]] name = "embedded-hal" @@ -568,11 +613,11 @@ dependencies = [ [[package]] name = "embedded-nal-async" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76959917cd2b86f40a98c28dd5624eddd1fa69d746241c8257eac428d83cb211" +checksum = "eb5a1bd585135d302f8f6d7de329310938093da6271b37a6c94b8798795c0c6d" dependencies = [ - "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", "embedded-nal", ] @@ -591,6 +636,21 @@ dependencies = [ "embedded-storage", ] +[[package]] +name = "embedded-websocket" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527467357eb24e402fec2dc3664f0481bf457ec7344acbab19622a8b2d2d4a24" +dependencies = [ + "base64", + "byteorder", + "futures", + "heapless 0.7.17", + "httparse", + "rand_core 0.6.4", + "sha1 0.10.7", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -788,6 +848,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hash32" version = "0.3.1" @@ -818,23 +887,71 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "spin 0.9.9", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "hash32", + "hash32 0.3.1", "serde", "stable_deref_trait", ] +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1160,6 +1277,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -1243,11 +1366,11 @@ dependencies = [ [[package]] name = "rustc_version" -version = "0.2.3" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver 0.9.0", + "semver", ] [[package]] @@ -1312,9 +1435,10 @@ dependencies = [ name = "saikuro-core" version = "0.1.0" dependencies = [ - "heapless", + "heapless 0.8.0", "messagepack-serde", "portable-atomic", + "saikuro-event", "saikuro-random", "serde", "serde_bytes", @@ -1330,15 +1454,16 @@ version = "0.1.0" dependencies = [ "embedded-io-async 0.7.0", "getrandom 0.3.4", - "heapless", + "heapless 0.8.0", "messagepack-serde", "serde", "serde_bytes", "serde_json", - "spin", + "spin 0.12.2", "strum", "thiserror", "tracing", + "wasm-bindgen", "web-sys", ] @@ -1346,6 +1471,7 @@ dependencies = [ name = "saikuro-exec" version = "0.1.0" dependencies = [ + "critical-section", "embassy-executor", "embassy-futures", "embassy-net", @@ -1415,7 +1541,8 @@ dependencies = [ "saikuro-transport", "serde", "serde_json", - "spin", + "spin 0.12.2", + "talc", "tracing", "tracing-subscriber", "wasi 0.14.7+wasi-0.2.4", @@ -1436,6 +1563,7 @@ dependencies = [ name = "saikuro-storage" version = "0.1.0" dependencies = [ + "async-trait", "bytes", "dashmap 7.0.0-rc2", "embedded-storage-async", @@ -1450,7 +1578,7 @@ dependencies = [ "serde", "serde_json", "sled", - "spin", + "spin 0.12.2", "thiserror", "tokio", "tracing", @@ -1470,9 +1598,12 @@ dependencies = [ "bytes", "embassy-sync", "embedded-io-async 0.7.0", + "embedded-websocket", "futures", + "getrandom 0.3.4", "js-sys", "pin-project-lite", + "rand_core 0.6.4", "saikuro-core", "saikuro-exec", "saikuro-net", @@ -1496,27 +1627,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" -dependencies = [ - "semver-parser", -] - [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "semver-parser" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" - [[package]] name = "send_wrapper" version = "0.6.0" @@ -1586,6 +1702,28 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1642,7 +1780,7 @@ dependencies = [ "bitflags 1.3.2", "byteorder", "cfg-if", - "heapless", + "heapless 0.8.0", "managed", ] @@ -1656,6 +1794,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "spin" version = "0.12.2" @@ -1709,6 +1856,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "talc" +version = "4.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ae828aa394de34c7de08f522d1b86bd1c182c668d27da69caadda00590f26d" +dependencies = [ + "lock_api", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1873,8 +2029,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ "bytes", + "data-encoding", + "http", + "httparse", "log", "rand", + "sha1 0.11.0", "thiserror", ] @@ -1914,12 +2074,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcell" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" - [[package]] name = "version_check" version = "0.9.5" @@ -1932,15 +2086,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" -[[package]] -name = "volatile-register" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc" -dependencies = [ - "vcell", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2048,6 +2193,21 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.239.0" @@ -2057,7 +2217,7 @@ dependencies = [ "bitflags 2.13.1", "hashbrown 0.15.5", "indexmap", - "semver 1.0.28", + "semver", ] [[package]] @@ -2199,7 +2359,7 @@ dependencies = [ "id-arena", "indexmap", "log", - "semver 1.0.28", + "semver", "serde", "serde_derive", "serde_json", diff --git a/Build/Cargo.toml b/Build/Cargo.toml index 529d7947..2a334602 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -37,8 +37,8 @@ serde_bytes = { version = "0.11", default-features = false, features = [ messagepack-serde = { version = "0.2.4", default-features = false, features = [ "alloc", ] } -rmp-serde = "1.3" -bytes = "1.12" +rmp-serde = { version = "1.3", default-features = false } +bytes = { version = "1.12", default-features = false } futures = { version = "0.3", default-features = false, features = ["alloc"] } async-trait = "0.1" pin-project-lite = "0.2" @@ -58,19 +58,19 @@ heapless = { version = "0.8", default-features = false, features = ["serde"] } serde_with = "3.0" -strum = { version = "0.28", features = ["derive"] } +strum = { version = "0.28", default-features = false, features = ["derive"] } -tracing = "0.1" +tracing = { version = "0.1", default-features = false } tracing-subscriber = { version = "0.3", features = [ "env-filter", "fmt", "json", ] } -embassy-sync = { version = "0.6", default-features = false } -embassy-time = { version = "0.3", default-features = false } +embassy-sync = { version = "0.7", default-features = false } +embassy-time = { version = "0.5", default-features = false } embassy-futures = { version = "0.1", default-features = false } -embassy-net = { version = "0.5", default-features = false } +embassy-net = { version = "0.8", default-features = false } embedded-storage-async = { version = "0.4", default-features = false } sequential-storage = { version = "8", default-features = false, features = ["alloc"] } @@ -86,15 +86,16 @@ spin = { version = "0.12", default-features = false, features = [ "portable-atomic", ] } -chrono = { version = "0.4", features = ["serde", "wasmbind"] } +chrono = { version = "0.4", default-features = false, features = ["serde", "wasmbind"] } # Tokio tokio = { version = "1.53", default-features = false } tokio-util = { version = "0.7", default-features = false } # Embassy executor -embassy-executor = { version = "0.6", default-features = false } -embassy-net-driver-channel = "0.3" +embassy-executor = { version = "0.8", default-features = false } +embassy-net-driver-channel = "0.4" +critical-section = { version = "1" } # WASM wasm-bindgen = "0.2" @@ -105,11 +106,16 @@ fluvio-wasm-timer = "0.2" send_wrapper = "0.6" wasm-bindgen-test = "0.3" -# Embedded IO +# Embedded embedded-io-async = { version = "0.7", default-features = false } +# WASM Alloc +talc = { version = "4", default-features = false, features = ["lock_api"] } + # WebSocket tokio-tungstenite = { version = "0.30", default-features = false } +embedded-websocket = { version = "0.9", default-features = false } +rand_core_06 = { package = "rand_core", version = "0.6", default-features = false } # Storage backends sled = "0.34" @@ -122,7 +128,7 @@ wasip1 = "1" futures-executor = "0.3" # CLI -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } # Internal crates saikuro-core = { path = "crates/saikuro-core", default-features = false } @@ -136,3 +142,10 @@ saikuro-exec = { path = "crates/saikuro-exec", default-features = false } saikuro-random = { path = "crates/saikuro-random", default-features = false } saikuro-event = { path = "crates/saikuro-event", default-features = false } saikuro = { path = "adapters/rust", default-features = false } + +# no_std (wasm / wasi) targets cannot unwind; abort on panic everywhere. +[profile.dev] +panic = "abort" + +[profile.release] +panic = "abort" diff --git a/Build/adapters/rust/Cargo.toml b/Build/adapters/rust/Cargo.toml index 71235ce3..82787239 100644 --- a/Build/adapters/rust/Cargo.toml +++ b/Build/adapters/rust/Cargo.toml @@ -19,7 +19,8 @@ default = ["tcp", "unix", "ws", "storage", "saikuro-exec/native"] tcp = ["saikuro-transport/tcp"] unix = ["saikuro-transport/unix"] ws = ["saikuro-transport/ws"] -wasm = ["saikuro-transport/wasm", "saikuro-transport/wasm-host", "saikuro-random/wasm"] +ws-wasi = ["saikuro-transport/ws-wasi"] +wasm = ["saikuro-transport/wasm", "saikuro-transport/wasm-host", "saikuro-random/wasm", "saikuro-transport/ws"] # Storage backends: platform-agnostic factory in storage module storage = ["saikuro-storage/native"] diff --git a/Build/adapters/typescript/package-lock.json b/Build/adapters/typescript/package-lock.json index 5f5e1006..59395582 100644 --- a/Build/adapters/typescript/package-lock.json +++ b/Build/adapters/typescript/package-lock.json @@ -1903,16 +1903,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/bundle-require": { @@ -3784,9 +3784,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -3960,9 +3960,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -3980,7 +3980,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/Build/crates/saikuro-codegen/shared/mod.rs b/Build/crates/saikuro-codegen/shared/mod.rs index 07820df1..2e94de96 100644 --- a/Build/crates/saikuro-codegen/shared/mod.rs +++ b/Build/crates/saikuro-codegen/shared/mod.rs @@ -1,2 +1,4 @@ pub mod error; pub mod generator; + +pub use generator::{to_camel_case, to_pascal_case}; diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index db22c3cb..c1be30cc 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -11,10 +11,10 @@ keywords = ["ipc", "cross-language", "saikuro", "rpc", "msgpack"] [features] default = ["std", "native"] std = [] -native = ["std", "saikuro-random/native"] -no_std = ["saikuro-random/no_std"] -wasm = ["saikuro-random/wasm"] -embedded = ["saikuro-random/embedded"] +native = ["std", "saikuro-random/native", "saikuro-event/native"] +no_std = ["saikuro-random/no_std", "saikuro-event/no_std"] +wasm = ["saikuro-random/wasm", "saikuro-event/wasm"] +embedded = ["saikuro-random/embedded", "saikuro-event/embedded"] [lib] path = "lib.rs" @@ -27,6 +27,7 @@ serde_json = { workspace = true, default-features = false, features = [ serde_bytes = { workspace = true } uuid = { workspace = true } saikuro-random = { workspace = true, default-features = false } +saikuro-event = { workspace = true, default-features = false } thiserror = { workspace = true, default-features = false } strum = { workspace = true } heapless = { workspace = true } diff --git a/Build/crates/saikuro-core/protocol/schema.rs b/Build/crates/saikuro-core/protocol/schema.rs index 2609a25f..4c8386ed 100644 --- a/Build/crates/saikuro-core/protocol/schema.rs +++ b/Build/crates/saikuro-core/protocol/schema.rs @@ -132,6 +132,7 @@ impl TypeDescriptor { #[serde(rename_all = "lowercase")] pub enum Visibility { /// Callable by any namespace, including external callers. + #[default] Public, /// Callable only by functions within the same root schema. Internal, diff --git a/Build/crates/saikuro-event/Cargo.toml b/Build/crates/saikuro-event/Cargo.toml index 604478ee..89177544 100644 --- a/Build/crates/saikuro-event/Cargo.toml +++ b/Build/crates/saikuro-event/Cargo.toml @@ -13,7 +13,7 @@ default = ["std", "native", "stderr", "null", "filter"] std = [] native = ["std"] no_std = [] -wasm = [] +wasm = ["dep:wasm-bindgen"] embedded = ["dep:embedded-io-async", "dep:spin"] stderr = ["native"] tracing = ["native", "dep:tracing"] @@ -36,6 +36,7 @@ strum = { workspace = true } messagepack-serde = { workspace = true } getrandom = { workspace = true, optional = true } tracing = { workspace = true, optional = true } +wasm-bindgen = { workspace = true, optional = true } web-sys = { workspace = true, optional = true, features = ["console"] } embedded-io-async = { workspace = true, optional = true } spin = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-event/core_events/event.rs b/Build/crates/saikuro-event/core_events/event.rs index 3763ef47..4414a5e7 100644 --- a/Build/crates/saikuro-event/core_events/event.rs +++ b/Build/crates/saikuro-event/core_events/event.rs @@ -3,7 +3,7 @@ use core::fmt; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::io::{IoError, IoErrorKind}; +use crate::core_events::io::{IoError, IoErrorKind}; use crate::value::Value; /// Maximum number of structured context entries an [`ErrorDetail`] or @@ -112,6 +112,10 @@ pub enum ErrorCode { /// A batch item failed to dispatch. BatchItemFailed, + // Capacity errors + /// A fixed-capacity collection reached its compile-time limit. + CapacityExceeded, + // Catch-all /// An error category not covered by the above codes. Internal, @@ -174,6 +178,7 @@ impl fmt::Display for ErrorDetail { /// The main Rust error type for all fallible Saikuro operations. #[derive(Debug, Error)] +#[allow(missing_docs)] pub enum SaikuroError { // Schema #[error("namespace not found: {0}")] @@ -327,10 +332,10 @@ pub enum SaikuroError { // Serialisation #[error("msgpack encode error: {0}")] - MsgpackEncode(#[from] crate::codec::EncodeError), + MsgpackEncode(#[from] crate::core_events::codec::EncodeError), #[error("msgpack decode error: {0}")] - MsgpackDecode(#[from] crate::codec::DecodeError), + MsgpackDecode(#[from] crate::core_events::codec::DecodeError), // I/O #[error("I/O error: {0}")] diff --git a/Build/crates/saikuro-event/core_events/io.rs b/Build/crates/saikuro-event/core_events/io.rs index 20713c0a..e34f112e 100644 --- a/Build/crates/saikuro-event/core_events/io.rs +++ b/Build/crates/saikuro-event/core_events/io.rs @@ -1,4 +1,6 @@ -use alloc::string::{String, ToString}; +use alloc::string::String; +#[cfg(feature = "std")] +use alloc::string::ToString; use core::fmt; use serde::{Deserialize, Serialize}; diff --git a/Build/crates/saikuro-event/lib.rs b/Build/crates/saikuro-event/lib.rs index 186c1660..7d79e131 100644 --- a/Build/crates/saikuro-event/lib.rs +++ b/Build/crates/saikuro-event/lib.rs @@ -3,7 +3,7 @@ //! Unified error taxonomy, structured logging, and dynamically-typed value //! types for Saikuro. -#[cfg(not(feature = "std"))] +#[macro_use] extern crate alloc; #[cfg(feature = "std")] extern crate std; @@ -21,11 +21,19 @@ compile_error!("saikuro-event: enable exactly one engine (native / no_std / wasm #[cfg(all(feature = "no_std", feature = "std"))] compile_error!("saikuro-event: the no_std engine cannot be combined with the std toolchain"); -mod value; +/// Dynamically-typed value types used across the Saikuro wire protocol. +pub mod value; pub use value::*; mod core_events; pub use core_events::*; +/// Structured logging primitives and [`LogSink`](log::sink::LogSink) implementations. pub mod log; pub use log::*; + +#[cfg(all(feature = "native", feature = "tracing"))] +pub use log::tracing::TracingSink; + +#[cfg(feature = "console")] +pub use log::console::ConsoleSink; diff --git a/Build/crates/saikuro-event/log/embedded/mod.rs b/Build/crates/saikuro-event/log/embedded/mod.rs index b1fc0cf1..bfdb0a1e 100644 --- a/Build/crates/saikuro-event/log/embedded/mod.rs +++ b/Build/crates/saikuro-event/log/embedded/mod.rs @@ -1 +1,4 @@ +//! Embedded (no_std) logging backends. + +/// Serial-port logging sink for embedded targets. pub mod serial; diff --git a/Build/crates/saikuro-event/log/embedded/serial.rs b/Build/crates/saikuro-event/log/embedded/serial.rs index d7ed7633..ba6a85a8 100644 --- a/Build/crates/saikuro-event/log/embedded/serial.rs +++ b/Build/crates/saikuro-event/log/embedded/serial.rs @@ -1,4 +1,3 @@ -use core::fmt::Write as _; use embedded_io_async::Write; use heapless::String as HString; use spin::Mutex; diff --git a/Build/crates/saikuro-event/log/mod.rs b/Build/crates/saikuro-event/log/mod.rs index 443eb2b7..e417e394 100644 --- a/Build/crates/saikuro-event/log/mod.rs +++ b/Build/crates/saikuro-event/log/mod.rs @@ -1,5 +1,8 @@ +/// Log severity levels. pub mod level; +/// A single structured log record and its fields. pub mod record; +/// The [`LogSink`](sink::LogSink) trait and built-in sink implementations. pub mod sink; #[cfg(feature = "collector")] diff --git a/Build/crates/saikuro-event/log/native/mod.rs b/Build/crates/saikuro-event/log/native/mod.rs index f9609d65..a190a0cd 100644 --- a/Build/crates/saikuro-event/log/native/mod.rs +++ b/Build/crates/saikuro-event/log/native/mod.rs @@ -1,4 +1,6 @@ +/// Logs to the process stderr stream. pub mod stderr; #[cfg(feature = "tracing")] +/// Bridges Saikuro logging into the `tracing` ecosystem. pub mod tracing; diff --git a/Build/crates/saikuro-event/log/sink.rs b/Build/crates/saikuro-event/log/sink.rs index e88eef2f..abb5ce6b 100644 --- a/Build/crates/saikuro-event/log/sink.rs +++ b/Build/crates/saikuro-event/log/sink.rs @@ -2,6 +2,7 @@ use crate::level::LogLevel; use crate::record::LogRecord; /// A destination for [`LogRecord`]s. +#[allow(async_fn_in_trait)] pub trait LogSink { /// Emit a single log record. async fn emit(&self, record: &LogRecord); diff --git a/Build/crates/saikuro-event/log/wasm/console.rs b/Build/crates/saikuro-event/log/wasm/console.rs index 1c2065d4..48ec94ad 100644 --- a/Build/crates/saikuro-event/log/wasm/console.rs +++ b/Build/crates/saikuro-event/log/wasm/console.rs @@ -9,8 +9,15 @@ pub struct ConsoleSink; impl LogSink for ConsoleSink { async fn emit(&self, record: &LogRecord) { - if let Ok(json) = serde_json::to_string(record) { - web_sys::console::log_1(&JsValue::from_str(&json)); + #[cfg(feature = "console")] + { + if let Ok(json) = serde_json::to_string(record) { + web_sys::console::log_1(&JsValue::from_str(&json)); + } + } + #[cfg(not(feature = "console"))] + { + let _ = record; } } } diff --git a/Build/crates/saikuro-event/log/wasm/mod.rs b/Build/crates/saikuro-event/log/wasm/mod.rs index 5b9849fd..93f9aa1e 100644 --- a/Build/crates/saikuro-event/log/wasm/mod.rs +++ b/Build/crates/saikuro-event/log/wasm/mod.rs @@ -1 +1,4 @@ +//! Wasm (browser/JS) logging backends. + +/// Browser `console` logging sink. pub mod console; diff --git a/Build/crates/saikuro-event/value/mod.rs b/Build/crates/saikuro-event/value/mod.rs index 3fa475b2..7138fb2b 100644 --- a/Build/crates/saikuro-event/value/mod.rs +++ b/Build/crates/saikuro-event/value/mod.rs @@ -1,3 +1,5 @@ +/// The dynamically-typed [`Value`] and [`ValueMap`] types. +#[allow(clippy::module_inception)] pub mod value; pub use value::{Value, ValueMap}; diff --git a/Build/crates/saikuro-exec/Cargo.toml b/Build/crates/saikuro-exec/Cargo.toml index 746a84b1..1da99adb 100644 --- a/Build/crates/saikuro-exec/Cargo.toml +++ b/Build/crates/saikuro-exec/Cargo.toml @@ -16,32 +16,38 @@ std = [] native = ["std", "dep:tokio", "tokio/full", "dep:tokio-util", "futures/std"] no_std = [ "dep:embassy-executor", - "embassy-executor/alloc", + "embassy-executor/executor-thread", "embassy-executor/arch-spin", "dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures", "futures/async-await", + "dep:critical-section", ] wasm = [ "dep:embassy-executor", - "embassy-executor/alloc", + "embassy-executor/executor-thread", "embassy-executor/arch-wasm", "dep:embassy-sync", "dep:embassy-time", + "embassy-time/wasm", "dep:embassy-futures", "futures/async-await", + "dep:critical-section", + "dep:wasm-bindgen-futures", + "dep:fluvio-wasm-timer", ] embedded = [ "dep:embassy-executor", - "embassy-executor/arch-cortex-m", - "embassy-executor/task-arena-size-4096", + "embassy-executor/executor-thread", + "embassy-executor/arch-spin", "dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures", "futures/async-await", + "dep:critical-section", ] -embassy-test = ["embedded", "embassy-time/std", "embassy-time/generic-queue"] +embassy-test = ["embedded", "embassy-time/std", "embassy-time/generic-queue-8"] [dependencies] tokio = { workspace = true, optional = true } @@ -51,6 +57,7 @@ futures = { workspace = true } embassy-executor = { workspace = true, optional = true } embassy-sync = { workspace = true, optional = true } +critical-section = { workspace = true, optional = true } embassy-time = { workspace = true, optional = true } embassy-futures = { workspace = true, optional = true } embassy-net = { workspace = true, optional = true, features = [ @@ -59,7 +66,5 @@ embassy-net = { workspace = true, optional = true, features = [ "tcp", "udp", ] } - -[target.'cfg(all(target_arch = "wasm32", feature = "wasm"))'.dependencies] -wasm-bindgen-futures = { workspace = true } -fluvio-wasm-timer = { workspace = true } +wasm-bindgen-futures = { workspace = true, optional = true } +fluvio-wasm-timer = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-exec/base/exec.rs b/Build/crates/saikuro-exec/base/exec.rs index 053e6631..a76d3b08 100644 --- a/Build/crates/saikuro-exec/base/exec.rs +++ b/Build/crates/saikuro-exec/base/exec.rs @@ -1,22 +1,31 @@ -#![cfg(any(feature = "wasm", feature = "no_std"))] +#![cfg(any(feature = "wasm", feature = "no_std", feature = "embedded"))] use alloc::boxed::Box; use alloc::sync::Arc; -use core::cell::OnceCell; +use alloc::vec::Vec; use core::cell::RefCell; use core::future::Future; +#[cfg(any(feature = "wasm", feature = "no_std"))] +use core::mem::transmute; use core::pin::Pin; use core::task::{Context, Poll}; -use embassy_executor::{Executor, Spawner}; +#[cfg(feature = "no_std")] +use core::ptr::null_mut; + +use embassy_executor::Spawner; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::blocking_mutex::CriticalSectionMutex; +use embassy_sync::signal::Signal; use embassy_sync::waitqueue::MultiWakerRegistration; +use futures::stream::{FuturesUnordered, StreamExt}; use crate::shared::JoinError; -pub use crate::base::{fuse_select, sleep, timeout, yield_now}; -pub use crate::base::{mpsc, oneshot, sync, watch}; +#[cfg(feature = "no_std")] +use embassy_executor::raw::Executor as ArchExecutor; +#[cfg(feature = "wasm")] +use embassy_executor::Executor as ArchExecutor; /// Shared result slot between a spawned task and its [`JoinHandle`]. struct JoinSlot { @@ -27,33 +36,199 @@ struct JoinSlot { type JoinResultSlot = CriticalSectionMutex>>; -static EXECUTOR: OnceCell = OnceCell::new(); -static SPAWNER: OnceCell = OnceCell::new(); +#[cfg(feature = "native")] +type BoxedFuture = Pin + Send + 'static>>; +#[cfg(not(feature = "native"))] +type BoxedFuture = Pin + 'static>>; + +/// Queue of dynamically spawned futures waiting to be picked up by `task_runner`. +#[cfg(feature = "native")] +static QUEUE: CriticalSectionMutex>> = + CriticalSectionMutex::new(RefCell::new(Vec::new())); + +#[cfg(not(feature = "native"))] +struct QueueWrapper(CriticalSectionMutex>>); +#[cfg(not(feature = "native"))] +unsafe impl Sync for QueueWrapper {} +#[cfg(not(feature = "native"))] +static QUEUE: QueueWrapper = QueueWrapper(CriticalSectionMutex::new(RefCell::new(Vec::new()))); + +#[cfg(feature = "native")] +fn queue() -> &'static CriticalSectionMutex>> { + &QUEUE +} +#[cfg(not(feature = "native"))] +fn queue() -> &'static CriticalSectionMutex>> { + &QUEUE.0 +} + +/// Wakes `task_runner` when a new future is queued (or when the runner should +/// re-check the queue after draining). +static NOTIFY: Signal = Signal::new(); + +/// Launch the single multiplexing task onto the supplied `Spawner`. +pub fn start_runner(spawner: Spawner) { + spawner.spawn(task_runner()).ok(); +} + +/// The one embassy task. It multiplexes every dynamically spawned future through +/// a `FuturesUnordered`, so the embassy task set stays statically sized (just +/// this task) while concurrency is unbounded and heap-backed. +#[embassy_executor::task] +async fn task_runner() { + let mut set: FuturesUnordered = FuturesUnordered::new(); + loop { + let batch: Vec = queue().lock(|q| q.borrow_mut().drain(..).collect()); + for fut in batch { + set.push(fut); + } + // Wait until either a multiplexed future completes or a new one is queued. + embassy_futures::select::select(set.next(), NOTIFY.wait()).await; + } +} + +/// Spawn a runtime-dynamic future. The future is boxed and handed to +/// `task_runner`; its output is delivered through the returned [`JoinHandle`]. +#[cfg(feature = "native")] +pub fn spawn(fut: F) -> JoinHandle +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + let slot: Arc> = + Arc::new(CriticalSectionMutex::new(RefCell::new(JoinSlot { + value: None, + closed: false, + wakers: MultiWakerRegistration::new(), + }))); + let task_slot = slot.clone(); + let boxed: Pin + Send + 'static>> = Box::pin(async move { + let result = fut.await; + task_slot.lock(|s| { + s.borrow_mut().value = Some(result); + s.borrow_mut().wakers.wake(); + }); + }); + queue().lock(|q| q.borrow_mut().push(boxed)); + NOTIFY.signal(()); + JoinHandle { slot } +} + +/// Embedded variant: embassy-net runs single-threaded, so spawned tasks are +/// `!Send`. +#[cfg(not(feature = "native"))] +pub fn spawn(fut: F) -> JoinHandle +where + F: Future + 'static, + F::Output: 'static, +{ + let slot: Arc> = + Arc::new(CriticalSectionMutex::new(RefCell::new(JoinSlot { + value: None, + closed: false, + wakers: MultiWakerRegistration::new(), + }))); + let task_slot = slot.clone(); + let boxed: Pin + 'static>> = Box::pin(async move { + let result = fut.await; + task_slot.lock(|s| { + s.borrow_mut().value = Some(result); + s.borrow_mut().wakers.wake(); + }); + }); + queue().lock(|q| q.borrow_mut().push(boxed)); + NOTIFY.signal(()); + JoinHandle { slot } +} + +/// Run `fut` to completion on the embassy executor. Never returns on wasm +/// (the JS event loop drives the executor); loops until `fut` resolves on +/// no_std (arch-spin busy-poll) so the result can be returned. +#[cfg(feature = "no_std")] +pub fn block_on(fut: F) -> F::Output +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + let executor = static_executor(); + let spawner = executor.spawner(); + start_runner(spawner); + + let slot: Arc> = + Arc::new(CriticalSectionMutex::new(RefCell::new(JoinSlot { + value: None, + closed: false, + wakers: MultiWakerRegistration::new(), + }))); + let task_slot = slot.clone(); + let boxed: Pin + Send + 'static>> = Box::pin(async move { + let result = fut.await; + task_slot.lock(|s| { + s.borrow_mut().value = Some(result); + s.borrow_mut().wakers.wake(); + }); + }); + queue().lock(|q| q.borrow_mut().push(boxed)); + NOTIFY.signal(()); + + loop { + // SAFETY: `executor` is `&'static` (see `static_executor`) and `poll` is + // only ever called from this single owner thread. + unsafe { executor.poll() }; + if let Some(v) = slot.lock(|s| s.borrow_mut().value.take()) { + return v; + } + } +} -fn global_executor() -> &'static Executor { - EXECUTOR.get_or_init(Executor::new) +#[cfg(feature = "wasm")] +pub fn run(fut: F) { + let executor = static_executor(); + executor.start(|spawner| { + start_runner(spawner); + let boxed: Pin + Send + 'static>> = Box::pin(async move { + let _ = fut.await; + }); + queue().lock(|q| q.borrow_mut().push(boxed)); + NOTIFY.signal(()); + }); } -fn global_spawner() -> &'static Spawner { - SPAWNER.get_or_init(|| global_executor().spawner()) +#[cfg(feature = "wasm")] +pub fn block_on(fut: F) -> F::Output { + run(fut); + // `run` returns to the JS event loop, which drives the executor; for a server + // future this never completes. Unused on wasm (the entry uses `run`). + loop {} } -/// Safe wrapper around embassy-executor's `unsafe fn poll()`. The host or -/// `main` calls this in a loop. -pub fn pump() { - let executor = global_executor(); - // SAFETY: `executor` is `&'static` and initialized exactly once via - // `get_or_init`. `poll` is never called reentrantly on this executor, - // and the embassy pender (arch-spin) never calls `poll` directly. - unsafe { executor.poll() }; +/// No-op on embassy engines: the executor is driven by its arch pender (JS +/// timer on wasm, the `#[embassy_executor::main]` loop on cortex-m). The wasm +/// host entry no longer needs to pump manually. +pub fn pump() {} + +#[cfg(any(feature = "wasm", feature = "no_std"))] +fn static_executor() -> &'static mut ArchExecutor { + static mut EXECUTOR: Option = None; + #[cfg(feature = "no_std")] + let ex = unsafe { (*core::ptr::addr_of_mut!(EXECUTOR)).get_or_insert_with(|| ArchExecutor::new(null_mut())) }; + #[cfg(feature = "wasm")] + let ex = unsafe { (*core::ptr::addr_of_mut!(EXECUTOR)).get_or_insert_with(ArchExecutor::new) }; + // SAFETY: `EXECUTOR` is a `static mut` holding the sole executor instance; we + // upgrade its borrow to `'static` for the duration of the program. It is never + // moved or dropped, and `run`/`start`/`poll` are only called on this reference. + unsafe { transmute::<&mut ArchExecutor, &'static mut ArchExecutor>(ex) } } +#[cfg(any(feature = "wasm", feature = "no_std"))] pub fn new_runtime() -> Runtime { Runtime::new() } +#[cfg(any(feature = "wasm", feature = "no_std"))] pub struct Runtime; +#[cfg(any(feature = "wasm", feature = "no_std"))] impl Runtime { pub fn new() -> Self { Runtime @@ -67,21 +242,27 @@ impl Runtime { Runtime } - pub fn block_on(&self, fut: F) -> F::Output { + pub fn block_on(&self, fut: F) -> F::Output + where + F::Output: Send + 'static, + { block_on(fut) } } +#[cfg(any(feature = "wasm", feature = "no_std"))] impl Default for Runtime { fn default() -> Self { Self::new() } } +#[cfg(any(feature = "wasm", feature = "no_std"))] pub struct RuntimeBuilder { _private: (), } +#[cfg(any(feature = "wasm", feature = "no_std"))] impl RuntimeBuilder { pub fn new_multi_thread() -> Self { RuntimeBuilder { _private: () } @@ -104,55 +285,8 @@ impl RuntimeBuilder { } } -pub fn block_on(fut: F) -> F::Output { - let slot: Arc>> = - Arc::new(CriticalSectionMutex::new(RefCell::new(JoinSlot { - value: None, - closed: false, - wakers: MultiWakerRegistration::new(), - }))); - let task_slot = slot.clone(); - let token = global_executor().spawn(async move { - let result = fut.await; - task_slot.lock(|s| { - s.borrow_mut().value = Some(result); - s.borrow().wakers.wake(); - }); - }); - global_spawner().spawn(token).ok(); - loop { - pump(); - if let Some(v) = slot.lock(|s| s.borrow_mut().value.take()) { - return v; - } - } -} - -pub fn spawn(fut: F) -> JoinHandle -where - F: Future + 'static, - F::Output: 'static, -{ - let slot: Arc>> = - Arc::new(CriticalSectionMutex::new(RefCell::new(JoinSlot { - value: None, - closed: false, - wakers: MultiWakerRegistration::new(), - }))); - let task_slot = slot.clone(); - let token = global_executor().spawn(async move { - let result = fut.await; - task_slot.lock(|s| { - s.borrow_mut().value = Some(result); - s.borrow().wakers.wake(); - }); - }); - global_spawner().spawn(token).ok(); - JoinHandle { slot } -} - pub struct JoinHandle { - slot: Arc>>, + slot: Arc>, } impl JoinHandle { diff --git a/Build/crates/saikuro-exec/base/mod.rs b/Build/crates/saikuro-exec/base/mod.rs index 18b52e05..9f3c91d9 100644 --- a/Build/crates/saikuro-exec/base/mod.rs +++ b/Build/crates/saikuro-exec/base/mod.rs @@ -55,5 +55,5 @@ pub mod signal { } // Heap executor harness -#[cfg(any(feature = "wasm", feature = "no_std"))] -pub mod exec; +#[cfg(any(feature = "wasm", feature = "no_std", feature = "embedded"))] +pub(crate) mod exec; diff --git a/Build/crates/saikuro-exec/base/oneshot.rs b/Build/crates/saikuro-exec/base/oneshot.rs index b1b01b80..35827d3d 100644 --- a/Build/crates/saikuro-exec/base/oneshot.rs +++ b/Build/crates/saikuro-exec/base/oneshot.rs @@ -25,6 +25,11 @@ pub struct Sender { impl Sender { pub fn send(self, value: T) -> Result<(), T> { + self.try_send(value) + } + + /// Send a value without consuming the sender. + pub fn try_send(&self, value: T) -> Result<(), T> { self.inner.state.lock(|s| { let mut data = s.borrow_mut(); if !data.receiver_alive { @@ -38,11 +43,11 @@ impl Sender { } State::Ready(v) => { data.channel = State::Ready(v); - core::unreachable!("oneshot sender cannot send twice"); + return Err(value); } State::Closed => { data.channel = State::Closed; - core::unreachable!("oneshot sender cannot send on a closed channel"); + return Err(value); } } Ok(()) @@ -107,6 +112,13 @@ impl Future for Receiver { } } +impl Receiver { + /// Receive the single value, consuming the receiver. + pub fn recv(self) -> Receiver { + self + } +} + pub fn channel() -> (Sender, Receiver) { let inner = Arc::new(Inner { state: CriticalSectionMutex::new(RefCell::new(InnerData { diff --git a/Build/crates/saikuro-exec/embedded/exec.rs b/Build/crates/saikuro-exec/embedded/exec.rs index 90841d14..2c74dcb6 100644 --- a/Build/crates/saikuro-exec/embedded/exec.rs +++ b/Build/crates/saikuro-exec/embedded/exec.rs @@ -1,2 +1,2 @@ -pub use embassy_executor::Executor; -pub use embassy_executor::Spawner; +pub use crate::base::exec::*; +pub use embassy_executor::{Executor, Spawner}; diff --git a/Build/crates/saikuro-exec/embedded/mod.rs b/Build/crates/saikuro-exec/embedded/mod.rs index 2d6a0345..3ec5bebf 100644 --- a/Build/crates/saikuro-exec/embedded/mod.rs +++ b/Build/crates/saikuro-exec/embedded/mod.rs @@ -1,7 +1,3 @@ pub mod exec; -pub use crate::base::signal; -pub use crate::base::{fuse_select, sleep, timeout, yield_now}; -pub use crate::base::{mpsc, oneshot, sync, watch}; - pub use exec::*; diff --git a/Build/crates/saikuro-exec/lib.rs b/Build/crates/saikuro-exec/lib.rs index ca5dcf05..295f84ac 100644 --- a/Build/crates/saikuro-exec/lib.rs +++ b/Build/crates/saikuro-exec/lib.rs @@ -47,6 +47,11 @@ mod wasm; #[cfg(feature = "wasm")] pub use wasm::*; +#[cfg(feature = "no_std")] +mod no_std; +#[cfg(feature = "no_std")] +pub use no_std::*; + #[cfg(feature = "embedded")] mod embedded; #[cfg(feature = "embedded")] diff --git a/Build/crates/saikuro-exec/native/exec.rs b/Build/crates/saikuro-exec/native/exec.rs index 2e03736c..5794e58c 100644 --- a/Build/crates/saikuro-exec/native/exec.rs +++ b/Build/crates/saikuro-exec/native/exec.rs @@ -1,4 +1,5 @@ use std::future::Future; +use std::pin::Pin; use std::time::Duration; use tokio::runtime::{Builder, Runtime as TokioRuntime}; @@ -77,7 +78,7 @@ impl RuntimeBuilder { self } - pub fn build(self) -> Runtime { + pub fn build(mut self) -> Runtime { Runtime { inner: self .inner @@ -87,6 +88,12 @@ impl RuntimeBuilder { } } +/// A handle to a spawned task. Awaiting it yields the task's output or a +/// [`JoinError`] if the task was cancelled or panicked. +pub struct JoinHandle { + inner: TokioJoinHandle, +} + pub fn spawn(fut: F) -> JoinHandle where F: Future + Send + 'static, @@ -97,6 +104,11 @@ where } } +/// Run a future to completion on a dedicated multi-threaded tokio runtime. +pub fn block_on(fut: F) -> F::Output { + Runtime::new().block_on(fut) +} + impl JoinHandle { pub async fn abort(&self) { self.inner.abort(); diff --git a/Build/crates/saikuro-exec/native/mpsc.rs b/Build/crates/saikuro-exec/native/mpsc.rs index 0d4acc63..5b4d0cd5 100644 --- a/Build/crates/saikuro-exec/native/mpsc.rs +++ b/Build/crates/saikuro-exec/native/mpsc.rs @@ -19,16 +19,17 @@ impl Clone for Sender { impl Sender { pub async fn send(&self, value: T) -> Result<(), SendError> { - self.inner - .send(value) - .await - .map_err(|e| SendError(e.into_inner())) + self.inner.send(value).await.map_err(|e| SendError(e.0)) } pub fn try_send(&self, value: T) -> Result<(), TrySendError> { self.inner.try_send(value).map_err(|e| match e { - tokio::sync::mpsc::TrySendError::Full(v) => TrySendError::Full(v), - tokio::sync::mpsc::TrySendError::Closed(v) => TrySendError::Disconnected(v), + tokio::sync::mpsc::error::TrySendError::Full(v) => { + crate::shared::mpsc::TrySendError::Full(v) + } + tokio::sync::mpsc::error::TrySendError::Closed(v) => { + crate::shared::mpsc::TrySendError::Disconnected(v) + } }) } diff --git a/Build/crates/saikuro-exec/native/oneshot.rs b/Build/crates/saikuro-exec/native/oneshot.rs index 2b39aabe..5f4e714d 100644 --- a/Build/crates/saikuro-exec/native/oneshot.rs +++ b/Build/crates/saikuro-exec/native/oneshot.rs @@ -27,7 +27,8 @@ impl Future for Receiver { type Output = Result; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match self.get_mut().inner.poll_recv(cx) { + let this = self.get_mut(); + match Pin::new(&mut this.inner).poll(cx) { Poll::Ready(Ok(v)) => Poll::Ready(Ok(v)), Poll::Ready(Err(_)) => Poll::Ready(Err(RecvError)), Poll::Pending => Poll::Pending, diff --git a/Build/crates/saikuro-exec/native/sync.rs b/Build/crates/saikuro-exec/native/sync.rs index 13f9130e..1f5d9481 100644 --- a/Build/crates/saikuro-exec/native/sync.rs +++ b/Build/crates/saikuro-exec/native/sync.rs @@ -1,4 +1,3 @@ -use core::future::Future; use core::ops::{Deref, DerefMut}; pub struct Mutex { diff --git a/Build/crates/saikuro-exec/native/watch.rs b/Build/crates/saikuro-exec/native/watch.rs index c7df1884..874108a1 100644 --- a/Build/crates/saikuro-exec/native/watch.rs +++ b/Build/crates/saikuro-exec/native/watch.rs @@ -23,9 +23,7 @@ impl Clone for Sender { impl Sender { pub fn send(&self, value: T) -> Result<(), SendError> { - self.inner - .send(value) - .map_err(|e| SendError(e.into_inner())) + self.inner.send(value).map_err(|e| SendError(e.0)) } } @@ -43,7 +41,7 @@ impl Clone for Receiver { impl Receiver { pub fn borrow(&self) -> T { - self.inner.borrow() + (*self.inner.borrow()).clone() } pub fn changed(&mut self) -> ChangedFuture<'_, T> { @@ -60,8 +58,9 @@ impl Future for ChangedFuture<'_, T> { fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let this = self.get_mut(); - let mut fut = this.receiver.inner.changed(); - match Pin::new(&mut fut).poll(cx) { + let fut = this.receiver.inner.changed(); + tokio::pin!(fut); + match fut.as_mut().poll(cx) { Poll::Ready(Ok(())) => Poll::Ready(Ok(())), Poll::Ready(Err(_)) => Poll::Ready(Err(RecvError)), Poll::Pending => Poll::Pending, diff --git a/Build/crates/saikuro-exec/no_std/exec.rs b/Build/crates/saikuro-exec/no_std/exec.rs new file mode 100644 index 00000000..f40d7edf --- /dev/null +++ b/Build/crates/saikuro-exec/no_std/exec.rs @@ -0,0 +1 @@ +pub use crate::base::exec::*; diff --git a/Build/crates/saikuro-exec/no_std/mod.rs b/Build/crates/saikuro-exec/no_std/mod.rs new file mode 100644 index 00000000..3ec5bebf --- /dev/null +++ b/Build/crates/saikuro-exec/no_std/mod.rs @@ -0,0 +1,3 @@ +pub mod exec; + +pub use exec::*; diff --git a/Build/crates/saikuro-exec/shared/mod.rs b/Build/crates/saikuro-exec/shared/mod.rs index 3d87f75d..5ac2250a 100644 --- a/Build/crates/saikuro-exec/shared/mod.rs +++ b/Build/crates/saikuro-exec/shared/mod.rs @@ -63,7 +63,7 @@ impl From for usize { } // Unified error types - +#[allow(dead_code)] pub mod mpsc { use core::fmt; @@ -123,6 +123,7 @@ pub mod mpsc { impl std::error::Error for TrySendError {} } +#[allow(dead_code)] pub mod oneshot { use core::fmt; @@ -139,6 +140,7 @@ pub mod oneshot { impl std::error::Error for RecvError {} } +#[allow(dead_code)] pub mod watch { use core::fmt; diff --git a/Build/crates/saikuro-exec/wasm/mod.rs b/Build/crates/saikuro-exec/wasm/mod.rs index 2d6a0345..3ec5bebf 100644 --- a/Build/crates/saikuro-exec/wasm/mod.rs +++ b/Build/crates/saikuro-exec/wasm/mod.rs @@ -1,7 +1,3 @@ pub mod exec; -pub use crate::base::signal; -pub use crate::base::{fuse_select, sleep, timeout, yield_now}; -pub use crate::base::{mpsc, oneshot, sync, watch}; - pub use exec::*; diff --git a/Build/crates/saikuro-net/Cargo.toml b/Build/crates/saikuro-net/Cargo.toml index 953455a9..70653c3c 100644 --- a/Build/crates/saikuro-net/Cargo.toml +++ b/Build/crates/saikuro-net/Cargo.toml @@ -24,7 +24,7 @@ embedded = [ "dep:embedded-io-async", ] wasm = [] -embassy-test = ["embedded", "embassy-time/std", "embassy-time/generic-queue"] +embassy-test = ["embedded", "embassy-time/std", "embassy-time/generic-queue-8"] [dependencies] tokio = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-net/embedded/mod.rs b/Build/crates/saikuro-net/embedded/mod.rs index b7845abe..becbaba3 100644 --- a/Build/crates/saikuro-net/embedded/mod.rs +++ b/Build/crates/saikuro-net/embedded/mod.rs @@ -1,2 +1,4 @@ +/// In-memory I/O transport backends for embedded targets. pub mod io; +/// TCP networking backends for embedded targets. pub mod net; diff --git a/Build/crates/saikuro-net/native/io.rs b/Build/crates/saikuro-net/native/io.rs index c79450b5..75124c9d 100644 --- a/Build/crates/saikuro-net/native/io.rs +++ b/Build/crates/saikuro-net/native/io.rs @@ -1 +1,3 @@ -pub use tokio::io::{duplex, split, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +pub use tokio::io::{ + duplex, split, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf, ReadHalf, WriteHalf, +}; diff --git a/Build/crates/saikuro-net/native/mod.rs b/Build/crates/saikuro-net/native/mod.rs index b7845abe..d9b52946 100644 --- a/Build/crates/saikuro-net/native/mod.rs +++ b/Build/crates/saikuro-net/native/mod.rs @@ -1,2 +1,7 @@ +//! Native (tokio-backed) I/O and networking primitives for Saikuro. + +/// Async byte-stream I/O traits used by the native transport backends. pub mod io; + +/// Socket-address and network-stack types used by the native transport backends. pub mod net; diff --git a/Build/crates/saikuro-random/Cargo.toml b/Build/crates/saikuro-random/Cargo.toml index be203a97..f73dc69b 100644 --- a/Build/crates/saikuro-random/Cargo.toml +++ b/Build/crates/saikuro-random/Cargo.toml @@ -14,10 +14,10 @@ path = "lib.rs" [features] default = ["std", "native"] std = ["rand_core/std"] -native = ["std", "getrandom", "getrandom/std", "saikuro-event/getrandom"] -no_std = ["getrandom", "saikuro-event/getrandom"] -wasm = ["getrandom", "getrandom/wasm_js", "saikuro-event/getrandom"] -embedded = [] +native = ["std", "getrandom", "getrandom/std", "saikuro-event/getrandom", "saikuro-event/native"] +no_std = ["getrandom", "saikuro-event/getrandom", "saikuro-event/no_std"] +wasm = ["getrandom", "getrandom/wasm_js", "saikuro-event/getrandom", "saikuro-event/wasm"] +embedded = ["saikuro-event/embedded"] [dependencies] getrandom = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-random/lib.rs b/Build/crates/saikuro-random/lib.rs index 73d9aae2..589c1c8d 100644 --- a/Build/crates/saikuro-random/lib.rs +++ b/Build/crates/saikuro-random/lib.rs @@ -3,6 +3,9 @@ //! Randomness and entropy facade for Saikuro. +#[macro_use] +extern crate alloc; + #[cfg(any( all( feature = "native", diff --git a/Build/crates/saikuro-random/native/mod.rs b/Build/crates/saikuro-random/native/mod.rs index 14eda726..ae05683e 100644 --- a/Build/crates/saikuro-random/native/mod.rs +++ b/Build/crates/saikuro-random/native/mod.rs @@ -6,7 +6,7 @@ pub struct OsEntropy; impl EntropySource for OsEntropy { fn try_fill(&self, dest: &mut [u8]) -> Result<(), SaikuroError> { - getrandom::fill(dest).map_err(|e| SaikuroError::from(e)) + getrandom::fill(dest).map_err(SaikuroError::from) } } diff --git a/Build/crates/saikuro-random/shared/mod.rs b/Build/crates/saikuro-random/shared/mod.rs index 7c240933..f8fa2317 100644 --- a/Build/crates/saikuro-random/shared/mod.rs +++ b/Build/crates/saikuro-random/shared/mod.rs @@ -39,10 +39,10 @@ fn keystream_block( // chacha20 seeks by byte offset, not by block index. let pos = index .checked_mul(BLOCK_LEN as u64) - .ok_or(SaikuroError::Entropy(format!("DRBG keystream exhausted")))?; + .ok_or(SaikuroError::Entropy("DRBG keystream exhausted".into()))?; cipher .try_seek(pos) - .map_err(|_| SaikuroError::Entropy(format!("DRBG keystream exhausted")))?; + .map_err(|_| SaikuroError::Entropy("DRBG keystream exhausted".into()))?; let mut block = [0u8; BLOCK_LEN]; cipher.apply_keystream(&mut block); Ok(block) @@ -86,7 +86,7 @@ impl Drbg { let end = start .checked_add(block_count) .filter(|&end| end <= MAX_BLOCKS) - .ok_or(SaikuroError::Entropy(format!("DRBG keystream exhausted")))?; + .ok_or(SaikuroError::Entropy("DRBG keystream exhausted".into()))?; self.counter = end; for i in 0..blocks { let block = keystream_block(&self.key, &self.nonce, start + i as u64)?; @@ -182,9 +182,7 @@ pub fn seed_from_slice(seed: &[u8]) -> Result<(), SaikuroError> { .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { - return Err(SaikuroError::Entropy(format!( - "DRBG has already been seeded" - ))); + return Err(SaikuroError::Entropy("DRBG has already been seeded".into())); } for (i, word) in SEED.iter().enumerate() { let mut bytes = [0u8; 8]; @@ -229,19 +227,14 @@ fn read_seed() -> ([u8; KEY_LEN], [u8; NONCE_LEN]) { /// Fill `dest` with cryptographically secure random bytes from the /// process-wide DRBG. -/// -/// On first use, entropy-backed engines (`native`, `wasm`, `no_std`) seed the -/// DRBG automatically from their platform source, so hosted binaries can call -/// this without explicit setup. The `embedded` engine has no default source -/// and returns a [`SaikuroError::Entropy`] until the application calls -/// [`init_from`]. +#[allow(dead_code)] pub fn fill(dest: &mut [u8]) -> Result<(), SaikuroError> { if !is_seeded() { crate::try_auto_seed()?; if !is_seeded() { - return Err(SaikuroError::Entropy(format!( - "DRBG used before being seeded" - ))); + return Err(SaikuroError::Entropy( + "DRBG used before being seeded".into(), + )); } } let (key, nonce) = read_seed(); @@ -295,7 +288,7 @@ fn reserve_blocks(blocks: u64) -> Result { let next = current .checked_add(blocks) .filter(|&next| next <= MAX_BLOCKS) - .ok_or(SaikuroError::Entropy(format!("DRBG keystream exhausted")))?; + .ok_or(SaikuroError::Entropy("DRBG keystream exhausted".into()))?; match COUNTER.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) { Ok(_) => return Ok(current), Err(observed) => current = observed, diff --git a/Build/crates/saikuro-router/lib.rs b/Build/crates/saikuro-router/lib.rs index 0538a747..73fb44b9 100644 --- a/Build/crates/saikuro-router/lib.rs +++ b/Build/crates/saikuro-router/lib.rs @@ -1,10 +1,16 @@ #![cfg_attr(not(feature = "std"), no_std)] #![deny(missing_docs)] +//! Invocation routing, provider registry, and stream-delivery state for the +//! Saikuro runtime. + extern crate alloc; +/// Provider registry and handle abstractions. pub mod provider; +/// The core invocation router. pub mod router; +/// Stream and channel delivery state. pub mod stream_state; pub use provider::{Provider, ProviderHandle, ProviderRegistry}; @@ -12,10 +18,13 @@ pub use router::{InvocationRouter, RouterConfig}; pub use stream_state::{ChannelState, StreamState, StreamStateStore}; // Default log sink per engine. +/// The default [`LogSink`](saikuro_event::log::LogSink) for the native engine. #[cfg(feature = "native")] pub type DefaultRouterSink = saikuro_event::TracingSink; +/// The default [`LogSink`](saikuro_event::log::LogSink) for the wasm engine. #[cfg(feature = "wasm")] pub type DefaultRouterSink = saikuro_event::ConsoleSink; +/// The default [`LogSink`](saikuro_event::log::LogSink) for no_std/embedded engines. #[cfg(any(feature = "no_std", feature = "embedded"))] pub type DefaultRouterSink = saikuro_event::NullSink; diff --git a/Build/crates/saikuro-router/provider/mod.rs b/Build/crates/saikuro-router/provider/mod.rs index ca0c1ad4..51bfbb0f 100644 --- a/Build/crates/saikuro-router/provider/mod.rs +++ b/Build/crates/saikuro-router/provider/mod.rs @@ -1,2 +1,3 @@ +#[allow(clippy::module_inception)] mod provider; pub use provider::*; diff --git a/Build/crates/saikuro-router/provider/provider.rs b/Build/crates/saikuro-router/provider/provider.rs index 5571f68d..f8cd7d71 100644 --- a/Build/crates/saikuro-router/provider/provider.rs +++ b/Build/crates/saikuro-router/provider/provider.rs @@ -11,6 +11,7 @@ use saikuro_event::{Result, SaikuroError}; // Pending call tracker /// A one-shot channel waiting for the response to a single Call invocation. pub type PendingCallSender = oneshot::Sender; +/// Receiver half of a pending `Call` response channel. pub type PendingCallReceiver = oneshot::Receiver; // Provider trait @@ -42,7 +43,9 @@ pub trait Provider: Send + Sync + 'static { /// Work item sent through the provider's dispatch channel. pub struct ProviderWorkItem { + /// Invocation envelope to deliver to the provider. pub envelope: Envelope, + /// Optional oneshot sender used to complete a `Call` invocation. pub response_tx: Option, } @@ -56,6 +59,7 @@ pub struct ProviderHandle { } impl ProviderHandle { + /// Build a provider handle with a freshly generated registration token. pub fn new( id: impl Into, namespaces: Vec, @@ -116,11 +120,19 @@ impl Provider for ProviderHandle { // ProviderRegistry /// Thread-safe registry mapping namespace names to provider handles. -#[derive(Clone, Default)] +#[derive(Clone)] pub struct ProviderRegistry { inner: Arc>, } +impl Default for ProviderRegistry { + fn default() -> Self { + Self { + inner: Arc::new(RwLock::new(RegistryState::default())), + } + } +} + #[derive(Default)] struct RegistryState { /// namespace -> provider handle @@ -130,6 +142,7 @@ struct RegistryState { } impl ProviderRegistry { + /// Create an empty provider registry. pub fn new() -> Self { Self::default() } @@ -168,15 +181,16 @@ impl ProviderRegistry { for ns in &namespaces { match state.by_namespace.insert(ns.clone(), handle.clone()) { - Some(old) => { - if old.id() != provider_id || old.registration_token() != registration_token { - let old_key = (old.id().to_owned(), old.registration_token()); - if let Some(old_ns_list) = state.by_provider.get_mut(&old_key) { - old_ns_list.retain(|n| n != ns); - } + Some(old) + if old.id() != provider_id + || old.registration_token() != registration_token => + { + let old_key = (old.id().to_owned(), old.registration_token()); + if let Some(old_ns_list) = state.by_provider.get_mut(&old_key) { + old_ns_list.retain(|n| n != ns); } } - None => {} + _ => {} } } state.by_provider.insert(provider_key, namespaces); @@ -209,6 +223,7 @@ impl ProviderRegistry { pub async fn has_live_provider(&self, namespace: &str) -> bool { self.inner .read() + .await .by_namespace .get(namespace) .map(|h| h.is_alive()) diff --git a/Build/crates/saikuro-router/router/mod.rs b/Build/crates/saikuro-router/router/mod.rs index 791f2479..4d0905ca 100644 --- a/Build/crates/saikuro-router/router/mod.rs +++ b/Build/crates/saikuro-router/router/mod.rs @@ -1,2 +1,3 @@ +#[allow(clippy::module_inception)] mod router; pub use router::*; diff --git a/Build/crates/saikuro-router/router/router.rs b/Build/crates/saikuro-router/router/router.rs index b8d5dfdd..8bdcac8a 100644 --- a/Build/crates/saikuro-router/router/router.rs +++ b/Build/crates/saikuro-router/router/router.rs @@ -67,6 +67,7 @@ impl Clone for InvocationRouter { } impl InvocationRouter { + /// Create a router using the default engine log sink. pub fn new(providers: ProviderRegistry, config: RouterConfig) -> Self { Self::with_log_sink(providers, config, default_sink()) } @@ -93,6 +94,7 @@ impl InvocationRouter { } // State store access + /// Return a reference to the router's stream/channel state store. pub fn streams(&self) -> &StreamStateStore { &self.streams } @@ -352,11 +354,8 @@ impl InvocationRouter { let id = envelope.id; // args[0] is the LogRecord as a Value::Map. - let record = envelope - .args - .into_iter() - .next() - .and_then(|v| match LogRecord::try_from(v) { + let record = match envelope.args.into_iter().next() { + Some(v) => match LogRecord::try_from(v) { Ok(r) => Some(r), Err(e) => { self.log_sink @@ -372,7 +371,9 @@ impl InvocationRouter { .await; None } - }); + }, + None => None, + }; match record { Some(r) => { @@ -429,6 +430,7 @@ impl InvocationRouter { } } + /// Route a client-to-provider channel item (inbound direction). pub async fn route_channel_inbound(&self, response: ResponseEnvelope) -> Result<()> { self.route_channel_item(response, true).await } diff --git a/Build/crates/saikuro-router/stream_state/mod.rs b/Build/crates/saikuro-router/stream_state/mod.rs index d5fb8840..93a90359 100644 --- a/Build/crates/saikuro-router/stream_state/mod.rs +++ b/Build/crates/saikuro-router/stream_state/mod.rs @@ -1,2 +1,3 @@ +#[allow(clippy::module_inception)] mod stream_state; pub use stream_state::*; diff --git a/Build/crates/saikuro-router/stream_state/stream_state.rs b/Build/crates/saikuro-router/stream_state/stream_state.rs index b23dfbb1..90695e16 100644 --- a/Build/crates/saikuro-router/stream_state/stream_state.rs +++ b/Build/crates/saikuro-router/stream_state/stream_state.rs @@ -9,9 +9,13 @@ use saikuro_exec::{ /// Result of attempting to deliver one frame. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeliveryOutcome { + /// The item was delivered to its destination queue. Delivered, + /// The stream ended; no further items are accepted. Terminal, + /// The stream or connection is closed. Closed, + /// The item arrived out of expected sequence order. OutOfOrder, } @@ -29,6 +33,7 @@ pub struct StreamState { } impl StreamState { + /// Build a new stream state that forwards delivered items to `item_tx`. pub fn new(item_tx: mpsc::Sender) -> Arc { Arc::new(Self { lifecycle: Mutex::new(Lifecycle::default()), @@ -36,6 +41,7 @@ impl StreamState { }) } + /// Deliver a response on this server-to-client stream. pub async fn deliver(&self, response: ResponseEnvelope) -> DeliveryOutcome { let mut lifecycle = self.lifecycle.lock().await; let expected_seq = lifecycle.inbound_seq; @@ -62,6 +68,7 @@ pub struct ChannelState { } impl ChannelState { + /// Build a new bidirectional channel state with inbound/outbound item sinks. pub fn new( inbound_tx: mpsc::Sender, outbound_tx: mpsc::Sender, @@ -73,6 +80,7 @@ impl ChannelState { }) } + /// Deliver a response on this channel in the requested direction. pub async fn deliver(&self, response: ResponseEnvelope, inbound: bool) -> DeliveryOutcome { let mut lifecycle = self.lifecycle.lock().await; let (expected_seq, tx) = if inbound { @@ -130,12 +138,21 @@ async fn deliver_locked( } /// Thread-safe store for all open stream and channel states. -#[derive(Clone, Default)] +#[derive(Clone)] pub struct StreamStateStore { streams: Arc>>, channels: Arc>>, } +impl Default for StreamStateStore { + fn default() -> Self { + Self { + streams: Arc::new(RwLock::new(BTreeMap::new())), + channels: Arc::new(RwLock::new(BTreeMap::new())), + } + } +} + struct StreamEntry { state: Arc, receiver: Option>, @@ -148,10 +165,12 @@ struct ChannelEntry { } impl StreamStateStore { + /// Create an empty stream/channel state store. pub fn new() -> Self { Self::default() } + /// Insert a new server-to-client stream state. pub async fn insert_stream( &self, id: InvocationId, @@ -167,6 +186,7 @@ impl StreamStateStore { ); } + /// Look up the stream state for an invocation id. pub async fn get_stream(&self, id: &InvocationId) -> Option> { self.streams .read() @@ -175,6 +195,7 @@ impl StreamStateStore { .map(|entry| entry.state.clone()) } + /// Remove a stream state, returning it if present. pub async fn remove_stream(&self, id: &InvocationId) -> Option> { self.streams .write() @@ -183,6 +204,7 @@ impl StreamStateStore { .map(|entry| entry.state) } + /// Remove the stream only if it still matches the given state. pub async fn remove_stream_if(&self, id: &InvocationId, state: &Arc) -> bool { let mut streams = self.streams.write().await; if streams @@ -196,16 +218,19 @@ impl StreamStateStore { } } + /// Take the item receiver half out of the stream state, if present. pub async fn take_stream_receiver( &self, id: &InvocationId, ) -> Option> { self.streams .write() + .await .get_mut(id) .and_then(|entry| entry.receiver.take()) } + /// Insert a new bidirectional channel state. pub async fn insert_channel( &self, id: InvocationId, @@ -213,7 +238,7 @@ impl StreamStateStore { inbound_rx: mpsc::Receiver, outbound_rx: mpsc::Receiver, ) { - self.channels.write().insert( + self.channels.write().await.insert( id, ChannelEntry { state, @@ -223,19 +248,27 @@ impl StreamStateStore { ); } + /// Look up the channel state for an invocation id. pub async fn get_channel(&self, id: &InvocationId) -> Option> { self.channels .read() + .await .get(id) .map(|entry| entry.state.clone()) } + /// Remove a channel state, returning it if present. pub async fn remove_channel(&self, id: &InvocationId) -> Option> { - self.channels.write().remove(id).map(|entry| entry.state) + self.channels + .write() + .await + .remove(id) + .map(|entry| entry.state) } + /// Remove the channel only if it still matches the given state. pub async fn remove_channel_if(&self, id: &InvocationId, state: &Arc) -> bool { - let mut channels = self.channels.write(); + let mut channels = self.channels.write().await; if channels .get(id) .is_some_and(|entry| Arc::ptr_eq(&entry.state, state)) @@ -247,22 +280,26 @@ impl StreamStateStore { } } + /// Take the inbound receiver half out of the channel state, if present. pub async fn take_channel_inbound_receiver( &self, id: &InvocationId, ) -> Option> { self.channels .write() + .await .get_mut(id) .and_then(|entry| entry.inbound_receiver.take()) } + /// Take the outbound receiver half out of the channel state, if present. pub async fn take_channel_outbound_receiver( &self, id: &InvocationId, ) -> Option> { self.channels .write() + .await .get_mut(id) .and_then(|entry| entry.outbound_receiver.take()) } diff --git a/Build/crates/saikuro-runtime/Cargo.toml b/Build/crates/saikuro-runtime/Cargo.toml index 4dff8cec..cc12ae13 100644 --- a/Build/crates/saikuro-runtime/Cargo.toml +++ b/Build/crates/saikuro-runtime/Cargo.toml @@ -8,24 +8,23 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "runtime", "async"] +[lib] +path = "lib.rs" +crate-type = ["cdylib", "rlib"] + [[bin]] name = "saikuro-runtime" -path = "src/main.rs" +path = "main.rs" required-features = ["native"] [[bin]] name = "saikuro-runtime-embedded" -path = "src/bin/embedded.rs" +path = "bin/embedded.rs" required-features = ["embedded", "tcp"] -[[bin]] -name = "saikuro-runtime-wasm" -path = "src/bin/wasm.rs" -required-features = ["wasm"] - [[bin]] name = "saikuro-runtime-wasi" -path = "src/bin/wasi.rs" +path = "bin/wasi.rs" required-features = ["no_std"] [features] @@ -43,6 +42,9 @@ native = [ "saikuro-random/native", "saikuro-event/native", "saikuro-event/stderr", + "dep:anyhow", + "dep:clap", + "dep:tracing-subscriber", ] no_std = [ "saikuro-core/no_std", @@ -83,6 +85,7 @@ embedded = [ tcp = ["saikuro-transport/tcp"] unix = ["saikuro-transport/unix"] ws = ["saikuro-transport/ws"] +ws-wasi = ["saikuro-transport/ws-wasi"] wasm-host = ["saikuro-transport/wasm-host"] wasi-tcp = ["saikuro-transport/wasi-tcp"] wasi-host = ["saikuro-transport/wasi-host"] @@ -92,29 +95,32 @@ wasi-preview1 = ["saikuro-transport/wasi-preview1"] wasi-preview2 = ["saikuro-transport/wasi-preview2", "dep:wasi"] [dependencies] -saikuro-core = { path = "../saikuro-core", default-features = false } -saikuro-schema = { path = "../saikuro-schema", default-features = false } +saikuro-core = { path = "../saikuro-core", default-features = false } +saikuro-schema = { path = "../saikuro-schema", default-features = false } saikuro-transport = { path = "../saikuro-transport", default-features = false } -saikuro-router = { path = "../saikuro-router", default-features = false } -saikuro-exec = { path = "../saikuro-exec", default-features = false } -saikuro-random = { path = "../saikuro-random", default-features = false } -saikuro-event = { path = "../saikuro-event", default-features = false } +saikuro-router = { path = "../saikuro-router", default-features = false } +saikuro-exec = { path = "../saikuro-exec", default-features = false } +saikuro-random = { path = "../saikuro-random", default-features = false } +saikuro-event = { path = "../saikuro-event", default-features = false } -serde = { workspace = true } -serde_json = { workspace = true, features = ["alloc"] } -bytes = { workspace = true, default-features = false } +serde = { workspace = true } +serde_json = { workspace = true, features = ["alloc"] } +bytes = { workspace = true, default-features = false } async-trait = { workspace = true } -futures = { workspace = true } -tracing = { workspace = true, default-features = false, features = ["log", "attributes"] } -spin = { workspace = true } +futures = { workspace = true } +tracing = { workspace = true, default-features = false, features = [ + "log", + "attributes", +] } +spin = { workspace = true } portable-atomic = { workspace = true } wasi = { workspace = true, optional = true } embassy-executor = { workspace = true, optional = true } saikuro-net = { path = "../saikuro-net", default-features = false, optional = true } wasm-bindgen = { workspace = true, optional = true } +talc = { workspace = true } -[target.'cfg(feature = "native")'.dependencies] -anyhow = { workspace = true } -clap = { workspace = true, features = ["env"] } -tracing-subscriber = { workspace = true } +anyhow = { workspace = true, optional = true } +clap = { workspace = true, optional = true } +tracing-subscriber = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-runtime/src/bin/embedded.rs b/Build/crates/saikuro-runtime/bin/embedded.rs similarity index 51% rename from Build/crates/saikuro-runtime/src/bin/embedded.rs rename to Build/crates/saikuro-runtime/bin/embedded.rs index baa484d3..537f1ed3 100644 --- a/Build/crates/saikuro-runtime/src/bin/embedded.rs +++ b/Build/crates/saikuro-runtime/bin/embedded.rs @@ -2,10 +2,8 @@ extern crate alloc; -use saikuro_exec::watch; -use saikuro_net::net::Stack; -use saikuro_runtime::SaikuroRuntime; -use saikuro_transport::embedded::tcp::TcpTransportListener; +use saikuro_exec::start_runner; +use saikuro_runtime::embedded::run; /// Host-provided board support. mod board { @@ -23,16 +21,7 @@ mod board { } #[embassy_executor::main] -async fn main() { - let stack = board::stack(); - let runtime = SaikuroRuntime::builder().build(); - - let (_shutdown_tx, shutdown_rx) = watch::channel(false); - - runtime - .serve( - vec![TcpTransportListener::new(stack, board::endpoint())], - shutdown_rx, - ) - .await; +async fn main(spawner: embassy_executor::Spawner) { + start_runner(spawner); + run(board::stack(), board::endpoint()).await; } diff --git a/Build/crates/saikuro-runtime/src/bin/wasi.rs b/Build/crates/saikuro-runtime/bin/wasi.rs similarity index 74% rename from Build/crates/saikuro-runtime/src/bin/wasi.rs rename to Build/crates/saikuro-runtime/bin/wasi.rs index 6478d541..886912d5 100644 --- a/Build/crates/saikuro-runtime/src/bin/wasi.rs +++ b/Build/crates/saikuro-runtime/bin/wasi.rs @@ -1,6 +1,8 @@ #![cfg(feature = "no_std")] #![no_std] +#![no_main] +#[macro_use] extern crate alloc; use alloc::sync::Arc; @@ -14,16 +16,20 @@ use saikuro_transport::wasi::tcp::WasiTcpListener; /// WASI command entry point. Returns a process exit code. #[no_mangle] pub extern "C" fn _start() -> i32 { - let runtime = Arc::new(SaikuroRuntime::builder().build()); - let (_shutdown_tx, shutdown_rx) = watch::channel(false); + #[cfg(all(not(feature = "std"), not(feature = "embedded")))] + saikuro_runtime::init_heap(); - let tcp = match WasiTcpListener::new("0.0.0.0:7700") { - Ok(listener) => LocalRuntimeListener::new(listener), - Err(_) => return 1, - }; + let builder = SaikuroRuntime::builder(); + let (_shutdown_tx, shutdown_rx) = watch::channel(false); let pipe = HostPipeListener::::new("saikuro"); saikuro_exec::block_on(async move { + let tcp = match WasiTcpListener::new("0.0.0.0:7700") { + Ok(listener) => LocalRuntimeListener::new(listener), + Err(_) => return, + }; + + let runtime = Arc::new(builder.build().await); let mut rx1 = shutdown_rx.clone(); let mut rx2 = shutdown_rx.clone(); diff --git a/Build/crates/saikuro-runtime/embedded/mod.rs b/Build/crates/saikuro-runtime/embedded/mod.rs new file mode 100644 index 00000000..d025814b --- /dev/null +++ b/Build/crates/saikuro-runtime/embedded/mod.rs @@ -0,0 +1,17 @@ +use crate::SaikuroRuntime; +use saikuro_exec::watch; +use saikuro_net::net::Stack; +use saikuro_transport::embedded::tcp::TcpTransportListener; + +/// Run the runtime against a host-provided network stack. The firmware is +/// responsible for supplying the stack and endpoint (see `board` integration). +pub async fn run(stack: &'static Stack<'static>, endpoint: saikuro_net::net::IpEndpoint) { + let runtime = SaikuroRuntime::builder().build().await; + let (_shutdown_tx, shutdown_rx) = watch::channel(false); + runtime + .serve( + vec![TcpTransportListener::new(stack, endpoint)], + shutdown_rx, + ) + .await; +} diff --git a/Build/crates/saikuro-runtime/lib.rs b/Build/crates/saikuro-runtime/lib.rs new file mode 100644 index 00000000..eabb9801 --- /dev/null +++ b/Build/crates/saikuro-runtime/lib.rs @@ -0,0 +1,87 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +#[macro_use] +extern crate alloc; + +// Exactly one engine must be selected; `native` requires `std`; the `no_std` +// engine must not enable `std`. +#[cfg(all(feature = "native", feature = "no_std"))] +compile_error!("engine conflict: native and no_std are mutually exclusive"); +#[cfg(all(feature = "native", feature = "wasm"))] +compile_error!("engine conflict: native and wasm are mutually exclusive"); +#[cfg(all(feature = "native", feature = "embedded"))] +compile_error!("engine conflict: native and embedded are mutually exclusive"); +#[cfg(all(feature = "no_std", feature = "wasm"))] +compile_error!("engine conflict: no_std and wasm are mutually exclusive"); +#[cfg(all(feature = "no_std", feature = "embedded"))] +compile_error!("engine conflict: no_std and embedded are mutually exclusive"); +#[cfg(all(feature = "wasm", feature = "embedded"))] +compile_error!("engine conflict: wasm and embedded are mutually exclusive"); +#[cfg(not(any( + feature = "native", + feature = "no_std", + feature = "wasm", + feature = "embedded" +)))] +compile_error!("exactly one engine must be selected: native | no_std | wasm | embedded"); +#[cfg(all(feature = "native", not(feature = "std")))] +compile_error!("native engine requires the std toolchain"); + +mod shared; +pub use shared::*; + +#[cfg(feature = "embedded")] +pub mod embedded; +#[cfg(feature = "native")] +pub mod native; +#[cfg(feature = "wasm")] +pub mod wasm; + +#[cfg(feature = "embedded")] +pub use embedded::*; +#[cfg(feature = "native")] +pub use native::*; +#[cfg(feature = "wasm")] +pub use wasm::*; + +// The no_std (wasm / wasi) engine has no allocator from the toolchain, so the +// runtime must provide one. +#[cfg(all( + not(feature = "std"), + not(feature = "embedded"), + target_family = "wasm" +))] +#[global_allocator] +static HEAP: talc::TalckWasm = unsafe { talc::TalckWasm::new_global() }; + +/// Prepare the no_std heap. +#[cfg(all( + not(feature = "std"), + not(feature = "embedded"), + target_family = "wasm" +))] +#[allow(dead_code)] +pub fn init_heap() {} + +#[cfg(all( + not(feature = "std"), + any(all(target_os = "wasi", feature = "wasi-preview1"), target_os = "none",) +))] +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + #[cfg(target_arch = "wasm32")] + { + // SAFETY: terminating the wasm instance is the only valid action on + // panic for an embedded-wasm target. `core::arch::wasm32::unreachable` + // is an `unsafe fn` under bare wasm32 but a safe intrinsic under wasi, + // so the inner `unsafe` block is only conditionally required. + #[allow(unused_unsafe)] + unsafe { + core::arch::wasm32::unreachable(); + } + } + #[cfg(not(target_arch = "wasm32"))] + { + loop {} + } +} diff --git a/Build/crates/saikuro-runtime/main.rs b/Build/crates/saikuro-runtime/main.rs new file mode 100644 index 00000000..da66c36d --- /dev/null +++ b/Build/crates/saikuro-runtime/main.rs @@ -0,0 +1,5 @@ +//! Saikuro Runtime Server (native binary) + +fn main() -> anyhow::Result<()> { + saikuro_runtime::native::run() +} diff --git a/Build/crates/saikuro-runtime/src/main.rs b/Build/crates/saikuro-runtime/native/mod.rs similarity index 82% rename from Build/crates/saikuro-runtime/src/main.rs rename to Build/crates/saikuro-runtime/native/mod.rs index 8ac0ec8e..05f4a562 100644 --- a/Build/crates/saikuro-runtime/src/main.rs +++ b/Build/crates/saikuro-runtime/native/mod.rs @@ -1,43 +1,14 @@ -//! Saikuro Runtime Server (native binary) -//! -//! Standalone process that accepts connections from Saikuro adapters over TCP, -//! WebSocket, and Unix domain sockets. It acts as the central message broker: -//! adapters announce their capabilities and the runtime routes invocations -//! among them. -//! -//! The engine-agnostic orchestration lives in the `saikuro-runtime` library; -//! this binary only handles native concerns (CLI, `std::fs` schema loading, -//! OS signal handling) and drives [`SaikuroRuntime::serve`]. -//! -//! # Usage -//! -//! ```text -//! saikuro-runtime [OPTIONS] -//! -//! Options: -//! --schema Load a frozen schema JSON at startup -//! --tcp-port Listen for TCP connections (default: 7700) -//! --ws-port Listen for WebSocket connections (default: 7701) -//! --unix Listen on a Unix domain socket -//! --mode Runtime mode: development | production (default: development) -//! --log-level Log level: error | warn | info | debug | trace (default: info) -//! --json-logs Emit logs as JSON (useful for log aggregation) -//! --no-tcp Disable TCP listener -//! --no-ws Disable WebSocket listener -//! ``` - use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; +use crate::config::RuntimeMode; +use crate::SaikuroRuntime; use anyhow::{Context, Result}; use clap::Parser; use saikuro_exec::{signal, spawn, timeout, watch}; -use saikuro_runtime::config::RuntimeMode; -use saikuro_runtime::SaikuroRuntime; use tracing::{error, info, warn}; // CLI - #[derive(Debug, Parser)] #[command( name = "saikuro-runtime", @@ -108,7 +79,9 @@ impl From for RuntimeMode { // Main -fn main() -> Result<()> { +/// Build the runtime, spawn a serve task per enabled listener, and run until a +/// shutdown signal arrives. Returns the process exit result. +pub fn run() -> Result<()> { saikuro_exec::block_on(async_main()) } @@ -137,7 +110,7 @@ async fn async_main() -> Result<()> { info!(path = %schema_path.display(), "loaded static schema"); } - let runtime = Arc::new(builder.build()); + let runtime = Arc::new(builder.build().await); // Set up graceful shutdown channel. let (shutdown_tx, shutdown_rx) = watch::channel(false); @@ -154,7 +127,7 @@ async fn async_main() -> Result<()> { Ok(listener) => { info!(addr = %listener.local_addr(), "TCP listener ready"); let rt = runtime.clone(); - let mut rx = shutdown_rx.clone(); + let rx = shutdown_rx.clone(); serve_tasks.push(spawn(async move { rt.serve(vec![listener], rx).await; })); @@ -175,7 +148,7 @@ async fn async_main() -> Result<()> { Ok(listener) => { info!(addr = %listener.local_addr(), "WebSocket listener ready"); let rt = runtime.clone(); - let mut rx = shutdown_rx.clone(); + let rx = shutdown_rx.clone(); serve_tasks.push(spawn(async move { rt.serve(vec![listener], rx).await; })); @@ -195,7 +168,7 @@ async fn async_main() -> Result<()> { Ok(listener) => { info!(path = %unix_path.display(), "Unix socket listener ready"); let rt = runtime.clone(); - let mut rx = shutdown_rx.clone(); + let rx = shutdown_rx.clone(); serve_tasks.push(spawn(async move { rt.serve(vec![listener], rx).await; })); diff --git a/Build/crates/saikuro-runtime/src/config.rs b/Build/crates/saikuro-runtime/shared/config.rs similarity index 94% rename from Build/crates/saikuro-runtime/src/config.rs rename to Build/crates/saikuro-runtime/shared/config.rs index 25139c4f..08753678 100644 --- a/Build/crates/saikuro-runtime/src/config.rs +++ b/Build/crates/saikuro-runtime/shared/config.rs @@ -41,6 +41,11 @@ pub struct RuntimeConfig { /// Enable structured JSON logging via `tracing-subscriber`. #[serde(default)] pub json_logs: bool, + + /// Baked-in schema bytes supplied by an engine entry point (native `--schema`, + /// or the embedded/wasm/WASI baked schema). Not part of the serialised config. + #[serde(skip)] + pub schema_bytes: Option<&'static [u8]>, } impl RuntimeConfig { @@ -63,6 +68,7 @@ impl Default for RuntimeConfig { max_message_size: default_max_message_size(), stream_buffer_capacity: default_stream_capacity(), json_logs: false, + schema_bytes: None, } } } diff --git a/Build/crates/saikuro-runtime/src/connection.rs b/Build/crates/saikuro-runtime/shared/connection.rs similarity index 95% rename from Build/crates/saikuro-runtime/src/connection.rs rename to Build/crates/saikuro-runtime/shared/connection.rs index dd7d6c9f..04bdac7d 100644 --- a/Build/crates/saikuro-runtime/src/connection.rs +++ b/Build/crates/saikuro-runtime/shared/connection.rs @@ -1,5 +1,7 @@ +use alloc::borrow::ToOwned; +use alloc::boxed::Box; use alloc::collections::BTreeMap; -use alloc::string::String; +use alloc::string::{String, ToString}; use alloc::sync::Arc; use alloc::vec::Vec; @@ -148,9 +150,11 @@ where // Clean up: deregister any provider the peer announced. self.provider_registry - .deregister(&self.peer_id, self.registration_token); + .deregister(&self.peer_id, self.registration_token) + .await; self.schema_registry - .deregister_provider(&self.peer_id, self.registration_token); + .deregister_provider(&self.peer_id, self.registration_token) + .await; info!(peer = %self.peer_id, "connection handler exiting"); } @@ -180,11 +184,11 @@ where // 2. Handle system envelopes before schema validation. match envelope.invocation_type { InvocationType::Announce => { - let response = self.handle_announce(envelope, pending, forward_tx); + let response = self.handle_announce(envelope, pending, forward_tx).await; // If sandbox mode is on and the announce succeeded, build the // filtered schema to push back to the peer. let sandbox_schema = if self.capability_engine.is_sandboxed() && response.ok { - self.build_filtered_schema() + self.build_filtered_schema().await } else { None }; @@ -198,7 +202,7 @@ where } // 3. Validate the envelope against the schema. - let validation = match self.validator.validate(&envelope) { + let validation = match self.validator.validate(&envelope).await { Ok(report) => report, Err(e) => { return Some(( @@ -314,7 +318,7 @@ where } /// Handle a schema-announcement envelope. - fn handle_announce( + async fn handle_announce( &self, envelope: Envelope, pending: &PendingCalls, @@ -332,11 +336,11 @@ where let ns_count = s.namespaces.len(); let namespaces: Vec = s.namespaces.keys().cloned().collect(); - match self.schema_registry.merge_schema_with_token( - s, - &self.peer_id, - self.registration_token, - ) { + match self + .schema_registry + .merge_schema_with_token(s, &self.peer_id, self.registration_token) + .await + { Ok(()) => { info!( peer = %self.peer_id, @@ -346,7 +350,8 @@ where // Register a wire-forwarding provider handle so the // router can dispatch calls to this peer. - self.register_wire_provider(namespaces, pending, forward_tx); + self.register_wire_provider(namespaces, pending, forward_tx) + .await; ResponseEnvelope::ok_empty(id) } @@ -377,7 +382,7 @@ where /// Create and register a [`ProviderHandle`] that forwards invocations to /// the connected peer over the wire. - fn register_wire_provider( + async fn register_wire_provider( &self, namespaces: Vec, pending: &PendingCalls, @@ -391,7 +396,7 @@ where namespaces, work_tx, ); - self.provider_registry.register(handle); + self.provider_registry.register(handle).await; let pending_clone = pending.clone(); let forward_tx_clone = forward_tx.clone(); @@ -438,8 +443,8 @@ where /// /// Only namespaces and functions visible to `peer_capabilities` (and not /// `Internal` or `Private`) are included. - fn build_filtered_schema(&self) -> Option { - let full = match self.schema_registry.snapshot() { + async fn build_filtered_schema(&self) -> Option { + let full = match self.schema_registry.snapshot().await { Ok(schema) => schema, Err(e) => { error!(peer = %self.peer_id, error = %e, "schema snapshot capacity exceeded"); diff --git a/Build/crates/saikuro-runtime/src/handle.rs b/Build/crates/saikuro-runtime/shared/handle.rs similarity index 82% rename from Build/crates/saikuro-runtime/src/handle.rs rename to Build/crates/saikuro-runtime/shared/handle.rs index 4a95616c..a63e2bdd 100644 --- a/Build/crates/saikuro-runtime/src/handle.rs +++ b/Build/crates/saikuro-runtime/shared/handle.rs @@ -1,4 +1,4 @@ -use alloc::string::String; +use alloc::string::{String, ToString}; use alloc::sync::Arc; use alloc::vec::Vec; @@ -8,12 +8,11 @@ use saikuro_core::{ }; use saikuro_exec::mpsc; use saikuro_router::{ - provider::{ProviderHandle, ProviderWorkItem}, + provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, router::InvocationRouter, }; use saikuro_schema::{ capability_engine::CapabilityEngine, - provider::ProviderRegistry, registry::{NamespaceRegistration, SchemaRegistry}, validator::InvocationValidator, }; @@ -41,14 +40,19 @@ impl RuntimeHandle { // Schema /// Register or merge a schema document from a newly-connected provider. - pub fn register_schema(&self, schema: Schema, provider_id: impl Into) -> Result<()> { + pub async fn register_schema( + &self, + schema: Schema, + provider_id: impl Into, + ) -> Result<()> { self.schema_registry .merge_schema(schema, provider_id) + .await .map_err(Into::into) } /// Register or merge a schema under an existing provider registration. - pub fn register_schema_with_token( + pub async fn register_schema_with_token( &self, schema: Schema, provider_id: impl Into, @@ -56,42 +60,50 @@ impl RuntimeHandle { ) -> Result<()> { self.schema_registry .merge_schema_with_token(schema, provider_id, registration_token) + .await .map_err(Into::into) } /// Register a single namespace from a provider. - pub fn register_namespace(&self, reg: NamespaceRegistration) -> Result<()> { - self.schema_registry.register(reg).map_err(Into::into) + pub async fn register_namespace(&self, reg: NamespaceRegistration) -> Result<()> { + self.schema_registry.register(reg).await.map_err(Into::into) } /// Deregister all schemas owned by a provider (called on disconnect). - pub fn deregister_provider_schema( + pub async fn deregister_provider_schema( &self, provider_id: &str, registration_token: RegistrationToken, ) { self.schema_registry - .deregister_provider(provider_id, registration_token); + .deregister_provider(provider_id, registration_token) + .await; } /// Export a snapshot of the current schema state. - pub fn schema_snapshot(&self) -> Result { - self.schema_registry.snapshot().map_err(Into::into) + pub async fn schema_snapshot(&self) -> Result { + self.schema_registry.snapshot().await.map_err(Into::into) } // Providers /// Register a provider handle so the router can dispatch to it. - pub fn register_provider(&self, handle: ProviderHandle) { - self.provider_registry.register(handle); + pub async fn register_provider(&self, handle: ProviderHandle) { + self.provider_registry.register(handle).await; } /// Deregister one provider generation from routing and schema ownership. - pub fn deregister_provider(&self, provider_id: &str, registration_token: RegistrationToken) { + pub async fn deregister_provider( + &self, + provider_id: &str, + registration_token: RegistrationToken, + ) { self.provider_registry - .deregister(provider_id, registration_token); + .deregister(provider_id, registration_token) + .await; self.schema_registry - .deregister_provider(provider_id, registration_token); + .deregister_provider(provider_id, registration_token) + .await; } // Dispatch @@ -109,7 +121,7 @@ impl RuntimeHandle { let router = self.build_router(); // Validate - let validation = match validator.validate(&envelope) { + let validation = match validator.validate(&envelope).await { Ok(r) => r, Err(e) => { return ResponseEnvelope::err( @@ -146,7 +158,7 @@ impl RuntimeHandle { /// /// `peer_caps` are the capabilities granted to this peer; they are checked /// on every invocation it sends. - pub fn accept_transport( + pub fn accept_transport( &self, transport: T, peer_id: impl Into, @@ -180,15 +192,15 @@ impl RuntimeHandle { /// [`ResponseEnvelope`]. It runs in a spawned task for each invocation. /// /// This is the primary API for writing Rust-native providers. - pub fn register_fn_provider( + pub async fn register_fn_provider( &self, provider_id: impl Into, namespaces: Vec, handler: F, ) -> RegistrationToken where - F: Fn(Envelope) -> Fut + 'static, - Fut: core::future::Future + 'static, + F: Fn(Envelope) -> Fut + Send + Sync + 'static, + Fut: core::future::Future + Send + 'static, { let provider_id = provider_id.into(); let registration_token = RegistrationToken::new(); @@ -201,7 +213,7 @@ impl RuntimeHandle { namespaces.clone(), work_tx, ); - self.provider_registry.register(handle); + self.provider_registry.register(handle).await; let handler = Arc::new(handler); diff --git a/Build/crates/saikuro-runtime/src/lib.rs b/Build/crates/saikuro-runtime/shared/mod.rs similarity index 60% rename from Build/crates/saikuro-runtime/src/lib.rs rename to Build/crates/saikuro-runtime/shared/mod.rs index cdb4793e..64eaf784 100644 --- a/Build/crates/saikuro-runtime/src/lib.rs +++ b/Build/crates/saikuro-runtime/shared/mod.rs @@ -1,11 +1,3 @@ -#![cfg_attr(not(feature = "std"), no_std)] - -#[cfg(not(feature = "std"))] -extern crate alloc; - -#[macro_use] -extern crate alloc; - pub mod config; pub mod connection; pub mod handle; diff --git a/Build/crates/saikuro-runtime/src/runtime.rs b/Build/crates/saikuro-runtime/shared/runtime.rs similarity index 95% rename from Build/crates/saikuro-runtime/src/runtime.rs rename to Build/crates/saikuro-runtime/shared/runtime.rs index 428e0261..d7bfc080 100644 --- a/Build/crates/saikuro-runtime/src/runtime.rs +++ b/Build/crates/saikuro-runtime/shared/runtime.rs @@ -1,4 +1,5 @@ use alloc::sync::Arc; +use alloc::vec::Vec; use core::sync::atomic::Ordering; use core::time::Duration; @@ -72,8 +73,8 @@ impl RuntimeBuilder { /// Build the runtime. This does not start any listener loops; use /// [`RuntimeHandle`] methods to attach transports, or [`SaikuroRuntime::serve`] /// to run a set of listeners until shutdown. - pub fn build(self) -> SaikuroRuntime { - SaikuroRuntime::from_config(self.config) + pub async fn build(self) -> SaikuroRuntime { + SaikuroRuntime::from_config(self.config).await } } @@ -94,11 +95,11 @@ impl SaikuroRuntime { RuntimeBuilder::new() } - fn from_config(config: RuntimeConfig) -> Self { + async fn from_config(config: RuntimeConfig) -> Self { let schema_bytes = config.schema_bytes; let schema_registry = SchemaRegistry::new(); - let mut runtime = Self { + let runtime = Self { config, schema_registry, provider_registry: ProviderRegistry::new(), @@ -111,7 +112,7 @@ impl SaikuroRuntime { if let Some(bytes) = schema_bytes { match serde_json::from_slice::(bytes) { Ok(schema) => { - if let Err(e) = runtime.schema_registry.merge_schema(schema, "static") { + if let Err(e) = runtime.schema_registry.merge_schema(schema, "static").await { error!(error = %e, "failed to merge static schema"); } } @@ -120,7 +121,7 @@ impl SaikuroRuntime { } if runtime.config.mode == crate::config::RuntimeMode::Production { - runtime.schema_registry.freeze(); + runtime.schema_registry.freeze().await; } runtime @@ -169,7 +170,7 @@ impl SaikuroRuntime { } /// Run a set of listeners until the host signals shutdown via `shutdown`. - pub async fn serve( + pub async fn serve( &self, listeners: Vec, mut shutdown: watch::Receiver, diff --git a/Build/crates/saikuro-runtime/shared/transport_adapter.rs b/Build/crates/saikuro-runtime/shared/transport_adapter.rs new file mode 100644 index 00000000..fae548bf --- /dev/null +++ b/Build/crates/saikuro-runtime/shared/transport_adapter.rs @@ -0,0 +1,285 @@ +use alloc::boxed::Box; +#[cfg(not(feature = "native"))] +use alloc::string::String; +use async_trait::async_trait; +use bytes::Bytes; + +use saikuro_transport::shared::error::Result; +#[cfg(not(feature = "native"))] +use saikuro_transport::shared::host::{HostPipeFactory, Role, WasmHostTransport}; +#[cfg(not(feature = "native"))] +use saikuro_transport::shared::traits::{ + LocalTransport, LocalTransportListener, LocalTransportReceiver, LocalTransportSender, +}; +use saikuro_transport::shared::traits::{ + Transport, TransportListener, TransportReceiver, TransportSender, +}; + +#[cfg(feature = "native")] +mod send_runtime_traits { + use super::*; + + #[async_trait] + pub trait RuntimeSender: Send { + async fn send(&mut self, frame: Bytes) -> Result<()>; + async fn close(&mut self) -> Result<()>; + } + #[async_trait] + pub trait RuntimeReceiver: Send { + async fn recv(&mut self) -> Result>; + } + #[async_trait] + pub trait RuntimeTransport: Send { + type Sender: RuntimeSender + Send + Sync + 'static; + type Receiver: RuntimeReceiver + Send + Sync + 'static; + fn split(self) -> (Self::Sender, Self::Receiver); + fn description(&self) -> &str; + } + #[async_trait] + pub trait RuntimeListener: Send { + type Output: RuntimeTransport + 'static; + async fn accept(&mut self) -> Result>; + async fn close(&mut self) -> Result<()>; + } +} + +#[cfg(not(feature = "native"))] +mod nosend_runtime_traits { + use super::*; + + #[async_trait(?Send)] + pub trait RuntimeSender { + async fn send(&mut self, frame: Bytes) -> Result<()>; + async fn close(&mut self) -> Result<()>; + } + #[async_trait(?Send)] + pub trait RuntimeReceiver { + async fn recv(&mut self) -> Result>; + } + #[async_trait(?Send)] + pub trait RuntimeTransport { + type Sender: RuntimeSender + 'static; + type Receiver: RuntimeReceiver + 'static; + fn split(self) -> (Self::Sender, Self::Receiver); + fn description(&self) -> &str; + } + #[async_trait(?Send)] + pub trait RuntimeListener { + type Output: RuntimeTransport + 'static; + async fn accept(&mut self) -> Result>; + async fn close(&mut self) -> Result<()>; + } +} + +#[cfg(feature = "native")] +pub use send_runtime_traits::*; + +#[cfg(not(feature = "native"))] +pub use nosend_runtime_traits::*; + +// Blanket impls forwarding the `Transport*` family to the runtime traits. +// Native needs `Send` bounds (tokio tasks); non-native engines are `?Send`. +#[cfg(feature = "native")] +#[async_trait] +impl RuntimeSender for T { + async fn send(&mut self, frame: Bytes) -> Result<()> { + T::send(self, frame).await + } + async fn close(&mut self) -> Result<()> { + T::close(self).await + } +} + +#[cfg(feature = "native")] +#[async_trait] +impl RuntimeReceiver for T { + async fn recv(&mut self) -> Result> { + T::recv(self).await + } +} + +#[cfg(feature = "native")] +#[async_trait] +impl RuntimeTransport for T { + type Sender = T::Sender; + type Receiver = T::Receiver; + fn split(self) -> (Self::Sender, Self::Receiver) { + T::split(self) + } + fn description(&self) -> &str { + T::description(self) + } +} + +#[cfg(feature = "native")] +#[async_trait] +impl RuntimeListener for T { + type Output = T::Output; + async fn accept(&mut self) -> Result> { + T::accept(self).await + } + async fn close(&mut self) -> Result<()> { + T::close(self).await + } +} + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeSender for T { + async fn send(&mut self, frame: Bytes) -> Result<()> { + T::send(self, frame).await + } + async fn close(&mut self) -> Result<()> { + T::close(self).await + } +} + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeReceiver for T { + async fn recv(&mut self) -> Result> { + T::recv(self).await + } +} + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeTransport for T { + type Sender = T::Sender; + type Receiver = T::Receiver; + fn split(self) -> (Self::Sender, Self::Receiver) { + T::split(self) + } + fn description(&self) -> &str { + T::description(self) + } +} + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeListener for T { + type Output = T::Output; + async fn accept(&mut self) -> Result> { + T::accept(self).await + } + async fn close(&mut self) -> Result<()> { + T::close(self).await + } +} + +// Non-native engines adapt the `LocalTransport*` family into the runtime traits +// via these wrappers. Native does not use them: it forwards `Transport*` above. +#[cfg(not(feature = "native"))] +pub struct LocalRuntimeSender(S); + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeSender for LocalRuntimeSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + self.0.send(frame).await + } + async fn close(&mut self) -> Result<()> { + self.0.close().await + } +} + +#[cfg(not(feature = "native"))] +pub struct LocalRuntimeReceiver(R); + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeReceiver for LocalRuntimeReceiver { + async fn recv(&mut self) -> Result> { + self.0.recv().await + } +} + +#[cfg(not(feature = "native"))] +pub struct LocalRuntimeTransport(T); + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeTransport for LocalRuntimeTransport +where + T::Sender: 'static, + T::Receiver: 'static, +{ + type Sender = LocalRuntimeSender; + type Receiver = LocalRuntimeReceiver; + fn split(self) -> (Self::Sender, Self::Receiver) { + let (sender, receiver) = self.0.split(); + (LocalRuntimeSender(sender), LocalRuntimeReceiver(receiver)) + } + fn description(&self) -> &str { + self.0.description() + } +} + +#[cfg(not(feature = "native"))] +pub struct LocalRuntimeListener(L); + +#[cfg(not(feature = "native"))] +impl LocalRuntimeListener { + /// Wrap a `LocalTransportListener` so it satisfies [`RuntimeListener`]. + pub fn new(listener: L) -> Self { + Self(listener) + } +} + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeListener for LocalRuntimeListener +where + L::Output: 'static, + ::Sender: 'static, + ::Receiver: 'static, +{ + type Output = LocalRuntimeTransport; + async fn accept(&mut self) -> Result> { + match L::accept(&mut self.0).await? { + Some(transport) => Ok(Some(LocalRuntimeTransport(transport))), + None => Ok(None), + } + } + async fn close(&mut self) -> Result<()> { + L::close(&mut self.0).await + } +} + +/// Adapts a `HostPipeFactory` (BroadcastChannel / WASI loopback) into a +/// [`RuntimeListener`]-compatible listener. Only meaningful for the wasm / wasi +/// engines; the embedded engine uses its own embassy-net listener. +#[cfg(not(feature = "native"))] +pub struct HostPipeListener { + channel: String, + _marker: core::marker::PhantomData F>, +} + +#[cfg(not(feature = "native"))] +impl HostPipeListener { + /// Start listening for a rendezvous connection on `channel`. + pub fn new(channel: impl Into) -> Self { + Self { + channel: channel.into(), + _marker: core::marker::PhantomData, + } + } +} + +#[cfg(not(feature = "native"))] +#[async_trait(?Send)] +impl RuntimeListener for HostPipeListener +where + F::Send: 'static, + F::Recv: 'static, +{ + type Output = LocalRuntimeTransport>; + async fn accept(&mut self) -> Result> { + let (send, recv) = F::open(&self.channel, Role::Accept).await?; + let transport = WasmHostTransport::new(send, recv); + Ok(Some(LocalRuntimeTransport(transport))) + } + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/Build/crates/saikuro-runtime/src/bin/wasm.rs b/Build/crates/saikuro-runtime/src/bin/wasm.rs deleted file mode 100644 index 672fd9af..00000000 --- a/Build/crates/saikuro-runtime/src/bin/wasm.rs +++ /dev/null @@ -1,27 +0,0 @@ -#![cfg(feature = "wasm")] - -use saikuro_exec::watch; -use saikuro_runtime::transport_adapter::HostPipeListener; -use saikuro_runtime::SaikuroRuntime; -use saikuro_transport::wasm::host_browser::BroadcastChannelPipe; - -/// Start the runtime, listening for adapters that rendezvous on `channel`. -#[wasm_bindgen::prelude::wasm_bindgen] -pub fn start(channel: String) { - let runtime = SaikuroRuntime::builder().build(); - let (_shutdown_tx, shutdown_rx) = watch::channel(false); - saikuro_exec::spawn(async move { - runtime - .serve( - vec![HostPipeListener::::new(channel)], - shutdown_rx, - ) - .await; - }); -} - -/// Pump the executor once. Call from the browser event loop. -#[wasm_bindgen::prelude::wasm_bindgen] -pub fn pump() { - saikuro_exec::pump(); -} diff --git a/Build/crates/saikuro-runtime/src/transport_adapter.rs b/Build/crates/saikuro-runtime/src/transport_adapter.rs deleted file mode 100644 index ccce2b76..00000000 --- a/Build/crates/saikuro-runtime/src/transport_adapter.rs +++ /dev/null @@ -1,215 +0,0 @@ -use alloc::boxed::Box; -use alloc::string::String; -use async_trait::async_trait; -use bytes::Bytes; - -use saikuro_transport::shared::error::Result; -use saikuro_transport::shared::host::{HostPipeFactory, Role, WasmHostTransport}; -use saikuro_transport::shared::traits::{ - LocalTransport, LocalTransportListener, LocalTransportReceiver, LocalTransportSender, - Transport, TransportListener, TransportReceiver, TransportSender, -}; - -macro_rules! define_runtime_traits { - (SEND) => { - #[async_trait] - pub trait RuntimeSender: Send + Sync { - async fn send(&mut self, frame: Bytes) -> Result<()>; - async fn close(&mut self) -> Result<()>; - } - #[async_trait] - pub trait RuntimeReceiver: Send + Sync { - async fn recv(&mut self) -> Result>; - } - #[async_trait] - pub trait RuntimeTransport: Send + Sync { - type Sender: RuntimeSender; - type Receiver: RuntimeReceiver; - fn split(self) -> (Self::Sender, Self::Receiver); - fn description(&self) -> &str; - } - #[async_trait] - pub trait RuntimeListener: Send + Sync { - /// The concrete transport produced by a successful accept. - type Output: RuntimeTransport; - async fn accept(&mut self) -> Result>; - async fn close(&mut self) -> Result<()>; - } - }; - (NOSEND) => { - #[async_trait(?Send)] - pub trait RuntimeSender { - async fn send(&mut self, frame: Bytes) -> Result<()>; - async fn close(&mut self) -> Result<()>; - } - #[async_trait(?Send)] - pub trait RuntimeReceiver { - async fn recv(&mut self) -> Result>; - } - #[async_trait(?Send)] - pub trait RuntimeTransport { - type Sender: RuntimeSender; - type Receiver: RuntimeReceiver; - fn split(self) -> (Self::Sender, Self::Receiver); - fn description(&self) -> &str; - } - #[async_trait(?Send)] - pub trait RuntimeListener { - type Output: RuntimeTransport; - async fn accept(&mut self) -> Result>; - async fn close(&mut self) -> Result<()>; - } - }; -} - -#[cfg(feature = "native")] -define_runtime_traits!(SEND); -#[cfg(not(feature = "native"))] -define_runtime_traits!(NOSEND); - -// Blanket impls for the boxed (native) family. - -#[cfg_attr(feature = "native", async_trait)] -#[cfg_attr(not(feature = "native"), async_trait(?Send))] -impl RuntimeSender for T { - async fn send(&mut self, frame: Bytes) -> Result<()> { - T::send(self, frame).await - } - async fn close(&mut self) -> Result<()> { - T::close(self).await - } -} - -#[cfg_attr(feature = "native", async_trait)] -#[cfg_attr(not(feature = "native"), async_trait(?Send))] -impl RuntimeReceiver for T { - async fn recv(&mut self) -> Result> { - T::recv(self).await - } -} - -#[cfg_attr(feature = "native", async_trait)] -#[cfg_attr(not(feature = "native"), async_trait(?Send))] -impl RuntimeTransport for T { - type Sender = T::Sender; - type Receiver = T::Receiver; - fn split(self) -> (Self::Sender, Self::Receiver) { - (*self).split() - } - fn description(&self) -> &str { - T::description(self) - } -} - -#[cfg_attr(feature = "native", async_trait)] -#[cfg_attr(not(feature = "native"), async_trait(?Send))] -impl RuntimeListener for T { - type Output = T::Output; - async fn accept(&mut self) -> Result> { - T::accept(self).await - } - async fn close(&mut self) -> Result<()> { - T::close(self).await - } -} - -#[cfg(not(feature = "native"))] -pub struct LocalRuntimeSender(S); - -#[cfg(not(feature = "native"))] -#[async_trait(?Send)] -impl RuntimeSender for LocalRuntimeSender { - async fn send(&mut self, frame: Bytes) -> Result<()> { - self.0.send(frame).await - } - async fn close(&mut self) -> Result<()> { - self.0.close().await - } -} - -#[cfg(not(feature = "native"))] -pub struct LocalRuntimeReceiver(R); - -#[cfg(not(feature = "native"))] -#[async_trait(?Send)] -impl RuntimeReceiver for LocalRuntimeReceiver { - async fn recv(&mut self) -> Result> { - self.0.recv().await - } -} - -#[cfg(not(feature = "native"))] -pub struct LocalRuntimeTransport(T); - -#[cfg(not(feature = "native"))] -#[async_trait(?Send)] -impl RuntimeTransport for LocalRuntimeTransport { - type Sender = LocalRuntimeSender; - type Receiver = LocalRuntimeReceiver; - fn split(self) -> (Self::Sender, Self::Receiver) { - let (sender, receiver) = (*self).0.split(); - (LocalRuntimeSender(sender), LocalRuntimeReceiver(receiver)) - } - fn description(&self) -> &str { - self.0.description() - } -} - -#[cfg(not(feature = "native"))] -pub struct LocalRuntimeListener(L); - -#[cfg(not(feature = "native"))] -impl LocalRuntimeListener { - /// Wrap a `LocalTransportListener` so it satisfies [`RuntimeListener`]. - pub fn new(listener: L) -> Self { - Self(listener) - } -} - -#[cfg(not(feature = "native"))] -#[async_trait(?Send)] -impl RuntimeListener for LocalRuntimeListener { - type Output = LocalRuntimeTransport; - async fn accept(&mut self) -> Result> { - match L::accept(&mut self.0).await? { - Some(transport) => Ok(Some(LocalRuntimeTransport(transport))), - None => Ok(None), - } - } - async fn close(&mut self) -> Result<()> { - L::close(&mut self.0).await - } -} - -/// Adapts a `HostPipeFactory` (BroadcastChannel / WASI loopback) into a -/// [`RuntimeListener`]. -#[cfg(not(feature = "native"))] -pub struct HostPipeListener { - channel: String, - _marker: core::marker::PhantomData F>, -} - -#[cfg(not(feature = "native"))] -impl HostPipeListener { - /// Start listening for a rendezvous connection on `channel`. - pub fn new(channel: impl Into) -> Self { - Self { - channel: channel.into(), - _marker: core::marker::PhantomData, - } - } -} - -#[cfg(not(feature = "native"))] -#[async_trait(?Send)] -impl RuntimeListener for HostPipeListener { - type Output = LocalRuntimeTransport>; - async fn accept(&mut self) -> Result> { - let (send, recv) = F::open(&self.channel, Role::Accept).await?; - let transport = WasmHostTransport::new(send, recv); - Ok(Some(LocalRuntimeTransport(transport))) - } - async fn close(&mut self) -> Result<()> { - Ok(()) - } -} diff --git a/Build/crates/saikuro-runtime/wasm/mod.rs b/Build/crates/saikuro-runtime/wasm/mod.rs new file mode 100644 index 00000000..4c8e879f --- /dev/null +++ b/Build/crates/saikuro-runtime/wasm/mod.rs @@ -0,0 +1,42 @@ +use alloc::string::String; + +use wasm_bindgen::prelude::wasm_bindgen; + +use crate::transport_adapter::HostPipeListener; +use crate::SaikuroRuntime; +use saikuro_exec::watch; +use saikuro_transport::wasm::host_browser::BroadcastChannelPipe; + +/// Start the runtime, listening for adapters that rendezvous on `channel`. +pub fn start_runtime(channel: String) { + #[cfg(all(not(feature = "std"), not(feature = "embedded")))] + crate::init_heap(); + + saikuro_exec::run(async move { + let runtime = SaikuroRuntime::builder().build().await; + let (_shutdown_tx, shutdown_rx) = watch::channel(false); + runtime + .serve( + vec![HostPipeListener::::new(channel)], + shutdown_rx, + ) + .await; + }); +} + +/// Pump the executor once. Call from the browser event loop. +pub fn pump_executor() { + saikuro_exec::pump(); +} + +/// JS entry point: start the runtime on `channel`. +#[wasm_bindgen] +pub fn start(channel: String) { + start_runtime(channel); +} + +/// JS entry point: pump the executor once from the browser event loop. +#[wasm_bindgen] +pub fn pump() { + pump_executor(); +} diff --git a/Build/crates/saikuro-schema/capability/engine.rs b/Build/crates/saikuro-schema/capability/engine.rs index 29bdabc2..568207f4 100644 --- a/Build/crates/saikuro-schema/capability/engine.rs +++ b/Build/crates/saikuro-schema/capability/engine.rs @@ -12,7 +12,10 @@ pub enum CapabilityOutcome { /// All required capabilities are held by the caller. Granted, /// The caller is missing this specific required capability. - Denied { missing: CapabilityToken }, + Denied { + /// The capability token the caller was missing. + missing: CapabilityToken, + }, } /// Engine that enforces capability requirements on invocations. diff --git a/Build/crates/saikuro-schema/capability/mod.rs b/Build/crates/saikuro-schema/capability/mod.rs index 807da8f2..b5ccf259 100644 --- a/Build/crates/saikuro-schema/capability/mod.rs +++ b/Build/crates/saikuro-schema/capability/mod.rs @@ -1,2 +1,3 @@ -mod engine; +/// Capability token enforcement. +pub mod engine; pub use engine::*; diff --git a/Build/crates/saikuro-schema/lib.rs b/Build/crates/saikuro-schema/lib.rs index 8e151b24..c534b407 100644 --- a/Build/crates/saikuro-schema/lib.rs +++ b/Build/crates/saikuro-schema/lib.rs @@ -1,18 +1,31 @@ #![cfg_attr(not(feature = "std"), no_std)] #![deny(missing_docs)] +//! Schema, capability, and invocation-validation types for the Saikuro runtime. + +#[macro_use] extern crate alloc; -pub mod engine; +/// Capability enforcement engine. +pub mod capability; +/// Capability enforcement engine (re-exported module path). +pub use capability::engine as capability_engine; +/// Schema registry and namespace management. pub mod registry; +/// Invocation validator. pub mod validator; -pub use engine::CapabilityEngine; -pub use registry::{ NamespaceRegistration, SchemaRegistry }; -pub use validator::{ InvocationValidator, ValidationReport }; +pub use capability::engine::CapabilityEngine; +pub use registry::{NamespaceRegistration, SchemaRegistry}; +pub use validator::{InvocationValidator, ValidationReport}; // Compilation guard: exactly one engine backend must be selected. -#[cfg(not(any(feature = "native", feature = "no_std", feature = "wasm", feature = "embedded")))] +#[cfg(not(any( + feature = "native", + feature = "no_std", + feature = "wasm", + feature = "embedded" +)))] compile_error!( "saikuro-schema: enable exactly one engine feature: native, no_std, wasm, or embedded" ); diff --git a/Build/crates/saikuro-schema/registry/mod.rs b/Build/crates/saikuro-schema/registry/mod.rs index f61c4754..88a5b29c 100644 --- a/Build/crates/saikuro-schema/registry/mod.rs +++ b/Build/crates/saikuro-schema/registry/mod.rs @@ -1,2 +1,3 @@ +#[allow(clippy::module_inception)] mod registry; pub use registry::*; diff --git a/Build/crates/saikuro-schema/registry/registry.rs b/Build/crates/saikuro-schema/registry/registry.rs index 645f7c9b..fd66af29 100644 --- a/Build/crates/saikuro-schema/registry/registry.rs +++ b/Build/crates/saikuro-schema/registry/registry.rs @@ -3,12 +3,11 @@ use saikuro_core::schema::{ FunctionSchema, NamespaceSchema, Schema, TypeDefinition, SCHEMA_NAMESPACES_CAPACITY, SCHEMA_TYPES_CAPACITY, }; -use saikuro_exec::sync::RwLock; use saikuro_core::RegistrationToken; +use saikuro_exec::sync::RwLock; use saikuro_event::SaikuroError; - /// Whether the registry accepts dynamic schema updates. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RegistryMode { @@ -126,7 +125,8 @@ impl SchemaRegistry { schema: Schema, provider_id: impl Into, ) -> Result<(), SaikuroError> { - self.merge_schema_with_token(schema, provider_id, RegistrationToken::new()).await + self.merge_schema_with_token(schema, provider_id, RegistrationToken::new()) + .await } /// Merge a schema document under an existing provider registration. @@ -182,15 +182,17 @@ impl SchemaRegistry { } /// Remove namespaces owned by one specific provider registration. - pub async fn deregister_provider(&self, provider_id: &str, registration_token: RegistrationToken) { + pub async fn deregister_provider( + &self, + provider_id: &str, + registration_token: RegistrationToken, + ) { let mut schemata = self.inner.write().await; if schemata.mode == RegistryMode::Production { return; } schemata.namespaces.retain(|_ns, entry| { - let keep = - entry.provider_id != provider_id || entry.registration_token != registration_token; - keep + entry.provider_id != provider_id || entry.registration_token != registration_token }); } @@ -224,6 +226,7 @@ impl SchemaRegistry { pub async fn provider_for_namespace(&self, namespace: &str) -> Option { self.inner .read() + .await .namespaces .get(namespace) .map(|e| e.provider_id.clone()) @@ -278,9 +281,13 @@ impl Default for SchemaRegistry { /// A fully-resolved reference to a function schema plus its owning provider. #[derive(Debug, Clone)] pub struct FunctionRef { + /// Namespace that owns the function. pub namespace: String, + /// Function name within the namespace. pub function: String, + /// Resolved function schema. pub schema: FunctionSchema, + /// Provider that registered the namespace. pub provider_id: String, } diff --git a/Build/crates/saikuro-schema/validator/mod.rs b/Build/crates/saikuro-schema/validator/mod.rs index e727ec61..520c2dd4 100644 --- a/Build/crates/saikuro-schema/validator/mod.rs +++ b/Build/crates/saikuro-schema/validator/mod.rs @@ -1,2 +1,3 @@ +#[allow(clippy::module_inception)] mod validator; pub use validator::*; diff --git a/Build/crates/saikuro-schema/validator/validator.rs b/Build/crates/saikuro-schema/validator/validator.rs index 839eb0f9..dc206534 100644 --- a/Build/crates/saikuro-schema/validator/validator.rs +++ b/Build/crates/saikuro-schema/validator/validator.rs @@ -30,6 +30,7 @@ pub struct InvocationValidator { } impl InvocationValidator { + /// Build a validator that enforces `Public` visibility only. pub fn new(registry: SchemaRegistry) -> Self { Self { registry, @@ -130,11 +131,12 @@ impl InvocationValidator { // Validate each item; collect the first error with its index. for (index, item) in items.iter().enumerate() { - self.validate(item).await - .map_err(|source| SaikuroError::BatchItemFailed { + Box::pin(self.validate(item)).await.map_err(|source| { + SaikuroError::BatchItemFailed { index, reason: source.to_string(), - })?; + } + })?; } // For batch we return a synthetic report. The router will dispatch each @@ -147,11 +149,7 @@ impl InvocationValidator { } // Helpers - fn check_visibility( - &self, - target: &str, - visibility: &Visibility, - ) -> Result<(), SaikuroError> { + fn check_visibility(&self, target: &str, visibility: &Visibility) -> Result<(), SaikuroError> { match visibility { Visibility::Public => Ok(()), Visibility::Internal if self.allow_internal => Ok(()), diff --git a/Build/crates/saikuro-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index 9ad7423c..458592d3 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -78,6 +78,7 @@ bytes = { workspace = true } futures = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +async-trait = { workspace = true } dashmap = { workspace = true, optional = true } tokio = { workspace = true, optional = true, features = ["sync", "rt", "rt-multi-thread"] } diff --git a/Build/crates/saikuro-storage/native/fs.rs b/Build/crates/saikuro-storage/native/fs.rs index c61a2dd6..1db59ee1 100644 --- a/Build/crates/saikuro-storage/native/fs.rs +++ b/Build/crates/saikuro-storage/native/fs.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use bytes::Bytes; use std::path::{Component, Path, PathBuf}; use tokio::task::spawn_blocking; @@ -238,6 +239,7 @@ impl KeyValueBackend for FilesystemStorage { } } +#[async_trait(?Send)] impl FileBackend for FilesystemStorage { async fn read_file(&self, path: &str) -> Result { let full = safe_join(&self.files_root, path)?; diff --git a/Build/crates/saikuro-storage/shared/traits/file.rs b/Build/crates/saikuro-storage/shared/traits/file.rs index a2a10943..8cf31cf9 100644 --- a/Build/crates/saikuro-storage/shared/traits/file.rs +++ b/Build/crates/saikuro-storage/shared/traits/file.rs @@ -1,10 +1,11 @@ use alloc::string::String; use alloc::vec::Vec; +use async_trait::async_trait; use bytes::Bytes; use saikuro_event::Result; /// A file-like storage interface for hierarchical storage. -#[allow(async_fn_in_trait)] +#[async_trait(?Send)] pub trait FileBackend: 'static { /// Read a file's contents. async fn read_file(&self, path: &str) -> Result; diff --git a/Build/crates/saikuro-storage/wasi/preview1.rs b/Build/crates/saikuro-storage/wasi/preview1.rs index aa5f6e1a..3c407149 100644 --- a/Build/crates/saikuro-storage/wasi/preview1.rs +++ b/Build/crates/saikuro-storage/wasi/preview1.rs @@ -1,7 +1,7 @@ use alloc::string::{String, ToString}; use alloc::vec; use alloc::vec::Vec; - +use async_trait::async_trait; use bytes::Bytes; use crate::shared::config::StorageConfig; @@ -365,6 +365,7 @@ impl Default for WasiFileStore { } } +#[async_trait(?Send)] impl FileBackend for WasiFileStore { async fn read_file(&self, path: &str) -> Result { let fd = open_file(path, false, false)?; diff --git a/Build/crates/saikuro-storage/wasi/preview2.rs b/Build/crates/saikuro-storage/wasi/preview2.rs index 4837393a..ad66b3ca 100644 --- a/Build/crates/saikuro-storage/wasi/preview2.rs +++ b/Build/crates/saikuro-storage/wasi/preview2.rs @@ -1,8 +1,7 @@ use alloc::string::{String, ToString}; use alloc::vec::Vec; - +use async_trait::async_trait; use bytes::Bytes; - use wasi::filesystem::{ self, Descriptor, DescriptorFlags, DirectoryEntry, Error as FsError, OpenFlags, PathFlags, }; @@ -176,6 +175,7 @@ impl Default for WasiFileStore { } } +#[async_trait(?Send)] impl FileBackend for WasiFileStore { async fn read_file(&self, path: &str) -> Result { let root = self.root()?; diff --git a/Build/crates/saikuro-storage/wasm/fs_access.rs b/Build/crates/saikuro-storage/wasm/fs_access.rs index 005ccdf9..abf2fa9e 100644 --- a/Build/crates/saikuro-storage/wasm/fs_access.rs +++ b/Build/crates/saikuro-storage/wasm/fs_access.rs @@ -1,5 +1,6 @@ #![cfg(target_arch = "wasm32")] +use async_trait::async_trait; use bytes::Bytes; use js_sys::{ArrayBuffer, Uint8Array}; use std::cell::RefCell; @@ -409,6 +410,7 @@ impl KeyValueBackend for FsAccessStorage { } } +#[async_trait(?Send)] impl FileBackend for FsAccessStorage { async fn read_file(&self, path: &str) -> Result { let (dirs, file_name) = navigate_path(path); diff --git a/Build/crates/saikuro-storage/wasm/opfs.rs b/Build/crates/saikuro-storage/wasm/opfs.rs index 7234fa84..c510d946 100644 --- a/Build/crates/saikuro-storage/wasm/opfs.rs +++ b/Build/crates/saikuro-storage/wasm/opfs.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use bytes::Bytes; use js_sys::{ArrayBuffer, Uint8Array}; use std::cell::RefCell; @@ -391,6 +392,7 @@ impl KeyValueBackend for OpfsStorage { } } +#[async_trait(?Send)] impl FileBackend for OpfsStorage { async fn read_file(&self, path: &str) -> Result { let (dirs, file_name) = navigate_path(path); diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index 92f1a51f..4a5704ee 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -22,7 +22,16 @@ native = [ "dep:tokio-tungstenite", ] no_std = ["saikuro-core/no_std", "saikuro-net/no_std", "saikuro-exec/no_std"] -wasm = ["saikuro-core/wasm", "saikuro-net/wasm", "saikuro-exec/wasm"] +wasm = [ + "saikuro-core/wasm", + "saikuro-net/wasm", + "saikuro-exec/wasm", + "dep:wasm-bindgen", + "dep:js-sys", + "dep:web-sys", + "dep:send_wrapper", + "dep:wasm-bindgen-futures", +] embedded = [ "saikuro-core/embedded", "saikuro-net/embedded", @@ -30,15 +39,17 @@ embedded = [ "dep:embedded-io-async", "dep:embassy-sync", ] - tcp = [] unix = [] -ws = [] +ws = ["ws-native"] +ws-native = ["tokio-tungstenite?/connect", "tokio-tungstenite?/handshake"] +ws-wasm = [] +ws-wasi = ["dep:embedded-websocket", "dep:rand_core_06", "dep:getrandom", "wasi-tcp", "ws"] wasm-host = ["wasm"] -wasi-tcp = ["no_std", "saikuro-exec/no_std", "dep:embedded-io-async"] +wasi-tcp = ["saikuro-exec/no_std", "dep:embedded-io-async"] wasi-host = ["no_std", "saikuro-exec/no_std", "wasi-tcp"] wasi-preview2 = ["dep:wasi"] -wasi-preview1 = [] +wasi-preview1 = ["dep:wasip1"] [dependencies] saikuro-core = { path = "../saikuro-core", default-features = false } @@ -54,16 +65,16 @@ thiserror = { workspace = true } tracing = { workspace = true, default-features = false, features = ["log", "attributes"] } embedded-io-async = { workspace = true, optional = true } embassy-sync = { workspace = true, optional = true } - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio-tungstenite = { workspace = true, optional = true } +embedded-websocket = { workspace = true, optional = true } +rand_core_06 = { workspace = true, optional = true } +getrandom = { workspace = true, optional = true } -[target.'cfg(all(target_arch = "wasm32", feature = "wasm"))'.dependencies] -send_wrapper = { workspace = true } -wasm-bindgen = { workspace = true } -js-sys = { workspace = true } -wasm-bindgen-futures = { workspace = true } -web-sys = { workspace = true, features = [ +send_wrapper = { workspace = true, optional = true } +wasm-bindgen = { workspace = true, optional = true } +js-sys = { workspace = true, optional = true } +wasm-bindgen-futures = { workspace = true, optional = true } +web-sys = { workspace = true, optional = true, features = [ "BroadcastChannel", "Crypto", "MessageEvent", @@ -74,7 +85,6 @@ web-sys = { workspace = true, features = [ "BinaryType", ] } -[target.'cfg(target_arch = "wasm32")'.dependencies] wasi = { workspace = true, optional = true } wasip1 = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-transport/embedded/io_transport.rs b/Build/crates/saikuro-transport/embedded/io_transport.rs index 5d8dfaef..82dbf1b4 100644 --- a/Build/crates/saikuro-transport/embedded/io_transport.rs +++ b/Build/crates/saikuro-transport/embedded/io_transport.rs @@ -1,9 +1,10 @@ -use alloc::string::ToString; -use bytes::{Bytes, BytesMut}; +use alloc::boxed::Box; +use async_trait::async_trait; +use bytes::Bytes; use embedded_io_async::{Read, Write}; -use crate::shared::framed::{read_exact, read_first_byte, write_all, HEADER_LEN}; use crate::shared::error::{Result, TransportError}; +use crate::shared::framing::{read_frame, write_frame}; use crate::shared::traits::{LocalTransportReceiver, LocalTransportSender}; /// A framed transport composed from independently owned reader and writer halves. @@ -22,7 +23,6 @@ pub struct EmbeddedIoSender { /// The reader half of [`EmbeddedIoTransport`]. pub struct EmbeddedIoReceiver { reader: R, - max_frame_size: usize, } impl EmbeddedIoTransport { @@ -53,13 +53,13 @@ impl EmbeddedIoTransport { }, EmbeddedIoReceiver { reader: self.reader, - max_frame_size: self.max_frame_size, }, ) } } -impl LocalTransportSender for EmbeddedIoSender { +#[async_trait(?Send)] +impl LocalTransportSender for EmbeddedIoSender { async fn send(&mut self, frame: Bytes) -> Result<()> { if frame.len() > self.max_frame_size { return Err(TransportError::MessageTooLarge { @@ -67,50 +67,17 @@ impl LocalTransportSender for EmbeddedIoSender { limit: self.max_frame_size, }); } - let mut header = [0u8; HEADER_LEN]; - header.copy_from_slice(&(frame.len() as u32).to_be_bytes()); - write_all(&mut self.writer, &header).await?; - write_all(&mut self.writer, &frame).await?; - self.writer - .flush() - .await - .map_err(|error| TransportError::SendFailed(error.kind().to_string())) + write_frame(&mut self.writer, &frame).await } async fn close(&mut self) -> Result<()> { - self.writer - .flush() - .await - .map_err(|error| TransportError::SendFailed(error.kind().to_string())) + Ok(()) } } -impl LocalTransportReceiver for EmbeddedIoReceiver { +#[async_trait(?Send)] +impl LocalTransportReceiver for EmbeddedIoReceiver { async fn recv(&mut self) -> Result> { - let mut header = [0u8; HEADER_LEN]; - if read_first_byte(&mut self.reader, &mut header[0]).await? == 0 { - return Ok(None); - } - read_exact( - &mut self.reader, - &mut header[1..], - "connection closed during frame header", - ) - .await?; - let frame_len = u32::from_be_bytes(header) as usize; - if frame_len > self.max_frame_size { - return Err(TransportError::MessageTooLarge { - size: frame_len, - limit: self.max_frame_size, - }); - } - let mut payload = BytesMut::zeroed(frame_len); - read_exact( - &mut self.reader, - &mut payload, - "connection closed during frame payload", - ) - .await?; - Ok(Some(payload.freeze())) + read_frame(&mut self.reader).await } } diff --git a/Build/crates/saikuro-transport/embedded/mod.rs b/Build/crates/saikuro-transport/embedded/mod.rs index 76ac8a88..00621f03 100644 --- a/Build/crates/saikuro-transport/embedded/mod.rs +++ b/Build/crates/saikuro-transport/embedded/mod.rs @@ -1,4 +1,3 @@ -pub mod framed; pub mod io_transport; #[cfg(feature = "tcp")] diff --git a/Build/crates/saikuro-transport/embedded/tcp.rs b/Build/crates/saikuro-transport/embedded/tcp.rs index 6fe1a09f..658cd372 100644 --- a/Build/crates/saikuro-transport/embedded/tcp.rs +++ b/Build/crates/saikuro-transport/embedded/tcp.rs @@ -1,31 +1,45 @@ +use alloc::boxed::Box; +use alloc::sync::Arc; use async_trait::async_trait; -use bytes::{Bytes, BytesMut}; -use core::sync::Arc; -use embassy_sync::blocking_mutex::{CriticalSectionRawMutex, Mutex as BlockingMutex}; +use bytes::Bytes; + +use embassy_sync::blocking_mutex::raw::NoopRawMutex; +use embassy_sync::mutex::Mutex as AsyncMutex; use tracing::debug; -use saikuro_net::net::{IpEndpoint, IpListenEndpoint, Stack, TcpSocket}; +use saikuro_net::net::tcp::TcpSocket; +use saikuro_net::net::{IpEndpoint, IpListenEndpoint, Stack}; -use crate::shared::framed::{read_exact, read_first_byte, write_all, HEADER_LEN}; use crate::shared::error::{Result, TransportError}; +use crate::shared::framing::{read_frame, write_frame}; use crate::shared::traits::{ Transport, TransportConnector, TransportListener, TransportReceiver, TransportSender, }; -type SharedSocket = Arc>>; +const SOCKET_TX_SZ: usize = 1024; +const SOCKET_RX_SZ: usize = 1024; + +// A listener accepts one connection at a time, so a single pair of static +// buffers is sufficient for both client and server sockets. The socket borrows +// these for its lifetime, so they are kept `'static` and the transport types +// stay `Send`/`'static`. +static mut CLIENT_RX: [u8; SOCKET_RX_SZ] = [0; SOCKET_RX_SZ]; +static mut CLIENT_TX: [u8; SOCKET_TX_SZ] = [0; SOCKET_TX_SZ]; +static mut LISTENER_RX: [u8; SOCKET_RX_SZ] = [0; SOCKET_RX_SZ]; +static mut LISTENER_TX: [u8; SOCKET_TX_SZ] = [0; SOCKET_TX_SZ]; + +type SharedSocket = Arc>>; /// A TCP transport connection (embedded / embassy-net). pub struct TcpTransport { socket: SharedSocket, - peer: IpEndpoint, } impl TcpTransport { /// Wrap an already-connected embassy-net [`TcpSocket`]. - pub fn new(socket: TcpSocket<'static>, peer: IpEndpoint) -> Self { + pub fn new(socket: TcpSocket<'static>) -> Self { Self { - socket: Arc::new(BlockingMutex::new(socket)), - peer, + socket: Arc::new(AsyncMutex::new(socket)), } } } @@ -39,12 +53,8 @@ impl Transport for TcpTransport { ( TcpSender { socket: socket.clone(), - peer: self.peer, - }, - TcpReceiver { - socket, - peer: self.peer, }, + TcpReceiver { socket }, ) } @@ -56,24 +66,13 @@ impl Transport for TcpTransport { /// Sending half of an embedded TCP transport. pub struct TcpSender { socket: SharedSocket, - peer: IpEndpoint, } -#[async_trait] +#[async_trait(?Send)] impl TransportSender for TcpSender { async fn send(&mut self, frame: Bytes) -> Result<()> { - if frame.len() > crate::MAX_FRAME_SIZE { - return Err(TransportError::MessageTooLarge { - size: frame.len(), - limit: crate::MAX_FRAME_SIZE, - }); - } - let mut header = [0u8; HEADER_LEN]; - header.copy_from_slice(&(frame.len() as u32).to_be_bytes()); - let mut socket = self.socket.lock(); - write_all(&mut *socket, &header).await?; - write_all(&mut *socket, &frame).await?; - Ok(()) + let mut socket = self.socket.lock().await; + write_frame(&mut *socket, &frame).await } async fn close(&mut self) -> Result<()> { @@ -84,38 +83,13 @@ impl TransportSender for TcpSender { /// Receiving half of an embedded TCP transport. pub struct TcpReceiver { socket: SharedSocket, - peer: IpEndpoint, } -#[async_trait] +#[async_trait(?Send)] impl TransportReceiver for TcpReceiver { async fn recv(&mut self) -> Result> { - let mut socket = self.socket.lock(); - let mut header = [0u8; HEADER_LEN]; - if read_first_byte(&mut *socket, &mut header[0]).await? == 0 { - return Ok(None); - } - read_exact( - &mut *socket, - &mut header[1..], - "connection closed during frame header", - ) - .await?; - let frame_len = u32::from_be_bytes(header) as usize; - if frame_len > crate::MAX_FRAME_SIZE { - return Err(TransportError::MessageTooLarge { - size: frame_len, - limit: crate::MAX_FRAME_SIZE, - }); - } - let mut payload = BytesMut::zeroed(frame_len); - read_exact( - &mut *socket, - &mut payload, - "connection closed during frame payload", - ) - .await?; - Ok(Some(payload.freeze())) + let mut socket = self.socket.lock().await; + read_frame(&mut *socket).await } } @@ -132,23 +106,27 @@ impl TcpConnector { } } -#[async_trait] +#[async_trait(?Send)] impl TransportConnector for TcpConnector { type Output = TcpTransport; async fn connect(&self) -> Result { debug!(remote = ?self.remote, "embedded tcp connecting"); - let socket = TcpSocket::connect(self.stack, self.remote) + let rx = unsafe { &mut *core::ptr::addr_of_mut!(CLIENT_RX) }; + let tx = unsafe { &mut *core::ptr::addr_of_mut!(CLIENT_TX) }; + let mut socket = TcpSocket::new(*self.stack, rx, tx); + socket + .connect(self.remote) .await - .map_err(|e| TransportError::ConnectionRefused(format!("tcp connect failed: {e:?}")))?; - let peer = socket.remote_endpoint().unwrap_or(self.remote); - Ok(TcpTransport::new(socket, peer)) + .map_err(|e| TransportError::ConnectionRefused(alloc::format!("{:?}", e)))?; + Ok(TcpTransport::new(socket)) } } /// Accepts incoming TCP connections over embassy-net. /// -/// Each accepted connection yields a fresh [`TcpSocket`] on the shared stack. +/// embassy-net has no `TcpListener`: spin up a socket, put it in listening mode, +/// and await the single connection it accepts. pub struct TcpTransportListener { stack: &'static Stack<'static>, local: IpEndpoint, @@ -166,24 +144,23 @@ impl TcpTransportListener { } } -#[async_trait] +#[async_trait(?Send)] impl TransportListener for TcpTransportListener { type Output = TcpTransport; async fn accept(&mut self) -> Result> { - // embassy-net has no TcpListener: spin up a socket, put it in - // listening mode, and await the single connection it accepts. - let mut socket = TcpSocket::new(self.stack); + let rx = unsafe { &mut *core::ptr::addr_of_mut!(LISTENER_RX) }; + let tx = unsafe { &mut *core::ptr::addr_of_mut!(LISTENER_TX) }; + let mut socket = TcpSocket::new(*self.stack, rx, tx); socket .accept(IpListenEndpoint { addr: Some(self.local.addr), port: self.local.port, }) .await - .map_err(|e| TransportError::ConnectionRefused(format!("tcp accept failed: {e:?}")))?; - let peer = socket.remote_endpoint().unwrap_or(self.local); - debug!(peer = ?peer, "embedded tcp accepted connection"); - Ok(Some(TcpTransport::new(socket, peer))) + .map_err(|e| TransportError::ConnectionRefused(alloc::format!("{:?}", e)))?; + debug!(local = ?self.local, "embedded tcp accepted connection"); + Ok(Some(TcpTransport::new(socket))) } async fn close(&mut self) -> Result<()> { diff --git a/Build/crates/saikuro-transport/lib.rs b/Build/crates/saikuro-transport/lib.rs index 989cdb00..ba51ce91 100644 --- a/Build/crates/saikuro-transport/lib.rs +++ b/Build/crates/saikuro-transport/lib.rs @@ -1,6 +1,7 @@ //! Pluggable, backend-agnostic transports for Saikuro. #![cfg_attr(not(feature = "std"), no_std)] +#![allow(async_fn_in_trait)] #[macro_use] extern crate alloc; @@ -32,10 +33,32 @@ compile_error!( "saikuro-transport: the no_std engine cannot be combined with the std toolchain feature" ); +#[cfg(all(feature = "wasm", feature = "ws", not(feature = "std")))] +compile_error!( + "saikuro-transport: browser wasm (no_std) has no WebSocket socket API; use WASI \ + (wasm32-wasip1/wasip2) with the `ws-wasi` feature for wasm-no_std WebSocket clients, \ + or enable `std` on the wasm engine." +); + pub mod shared; #[cfg(feature = "native")] pub mod native; +#[cfg(all(feature = "native", feature = "tcp"))] +pub use native::tcp; +#[cfg(all(feature = "native", feature = "unix"))] +pub use native::unix; +#[cfg(all(feature = "native", feature = "ws"))] +pub use native::websocket; + +/// Transport selection and configuration types. +#[cfg(any( + feature = "native", + feature = "no_std", + feature = "wasm", + feature = "embedded" +))] +pub use shared::selector; #[cfg(feature = "embedded")] pub mod embedded; @@ -47,6 +70,10 @@ pub mod wasm; pub mod wasi; pub use shared::error::TransportError; +pub use shared::host::{ + HostPipeFactory, HostPipeRecv, HostPipeSend, Role, WasmHostConnector, WasmHostListener, + WasmHostTransport, +}; pub use shared::memory::MemoryTransport; pub use shared::selector::{TransportConfig, TransportKind, TransportSelector}; pub use shared::traits::{ @@ -54,10 +81,6 @@ pub use shared::traits::{ LocalTransportSender, Transport, TransportConnector, TransportListener, TransportReceiver, TransportSender, }; -pub use shared::host::{ - HostPipeFactory, HostPipeRecv, HostPipeSend, Role, WasmHostConnector, WasmHostListener, - WasmHostTransport, -}; #[cfg(all(feature = "native", feature = "tcp"))] pub use native::tcp::TcpTransport; @@ -66,32 +89,33 @@ pub use native::unix::UnixTransport; #[cfg(all(feature = "native", feature = "ws"))] pub use native::websocket::{WebSocketTransport, WsTransportListener}; -#[cfg(all(feature = "embedded", feature = "tcp"))] -pub use embedded::tcp::TcpTransport; #[cfg(feature = "embedded")] pub use embedded::io_transport::{EmbeddedIoReceiver, EmbeddedIoSender, EmbeddedIoTransport}; +#[cfg(all(feature = "embedded", feature = "tcp"))] +pub use embedded::tcp::TcpTransport; -#[cfg(all(feature = "wasm", feature = "ws"))] -pub use wasm::websocket::WebSocketTransport; #[cfg(all(feature = "wasm", feature = "wasm-host"))] pub use wasm::host_browser::{BroadcastChannelPipe, WasmHost}; +#[cfg(all(feature = "wasm", feature = "ws", feature = "std"))] +pub use wasm::websocket::WebSocketTransport; + +#[cfg(all(feature = "no_std", feature = "ws-wasi"))] +pub use wasi::websocket; +#[cfg(all(feature = "no_std", feature = "wasi-host"))] +pub use wasi::host::{WasiHostRecv, WasiHostSend, WasiPipe}; #[cfg(all(feature = "no_std", feature = "wasi-tcp"))] pub use wasi::tcp::{WasiTcpConnector, WasiTcpListener, WasiTcpTransport}; -#[cfg(all(feature = "no_std", feature = "wasi-host"))] -pub use wasi::host::{WasiHost, WasiHostConnector, WasiHostListener}; /// Maximum allowed frame size (16 MiB). Frames larger than this are rejected /// to prevent memory exhaustion from malformed or malicious peers. pub const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024; /// Default capacity of internal transport channels. -pub const DEFAULT_CHANNEL_CAPACITY: saikuro_exec::ChannelCapacity = saikuro_exec::ChannelCapacity::MAX; +pub const DEFAULT_CHANNEL_CAPACITY: saikuro_exec::ChannelCapacity = + saikuro_exec::ChannelCapacity::MAX; /// Implements [`TransportSender`] for a native transport's sending half. -/// -/// The target struct must have an `inner` field that is an `futures` -/// `SplitSink` over `bytes::Bytes` whose `Error` is [`TransportError`]. #[macro_export] macro_rules! impl_native_sender { ($ty:ty, $addr:ident, $desc:literal) => { @@ -99,21 +123,18 @@ macro_rules! impl_native_sender { impl $crate::shared::traits::TransportSender for $ty { async fn send(&mut self, frame: ::bytes::Bytes) -> $crate::shared::error::Result<()> { tracing::trace!($addr = ?self.$addr, bytes = frame.len(), concat!($desc, " send")); - futures::SinkExt::send(&mut self.inner, frame).await + $crate::shared::framing::write_frame(&mut self.inner, &frame).await } async fn close(&mut self) -> $crate::shared::error::Result<()> { tracing::debug!($addr = ?self.$addr, concat!($desc, " sender closing")); - futures::SinkExt::close(&mut self.inner).await + $crate::shared::framing::AsyncByteWrite::flush(&mut self.inner).await } } }; } /// Implements [`TransportReceiver`] for a native transport's receiving half. -/// -/// The target struct must have an `inner` field that is an `futures` -/// `SplitStream` whose `Item` is `Result`. #[macro_export] macro_rules! impl_native_receiver { ($ty:ty, $addr:ident, $desc:literal) => { @@ -122,23 +143,22 @@ macro_rules! impl_native_receiver { async fn recv( &mut self, ) -> $crate::shared::error::Result> { - match futures::StreamExt::next(&mut self.inner).await { - Some(Ok(bytes)) => { - tracing::trace!( - $addr = ?self.$addr, - bytes = bytes.len(), - concat!($desc, " recv") - ); - Ok(Some(bytes)) - } - Some(Err(e)) => Err(e), - None => { - tracing::debug!( - $addr = ?self.$addr, - concat!($desc, " connection closed by peer") - ); - Ok(None) + match $crate::shared::framing::read_frame(&mut self.inner).await { + Ok(bytes) => { + match &bytes { + Some(b) => tracing::trace!( + $addr = ?self.$addr, + bytes = b.len(), + concat!($desc, " recv") + ), + None => tracing::debug!( + $addr = ?self.$addr, + concat!($desc, " connection closed by peer") + ), + } + Ok(bytes) } + Err(e) => Err(e), } } } diff --git a/Build/crates/saikuro-transport/native/framed.rs b/Build/crates/saikuro-transport/native/framed.rs index 0b8b2e08..7e4a3479 100644 --- a/Build/crates/saikuro-transport/native/framed.rs +++ b/Build/crates/saikuro-transport/native/framed.rs @@ -1,181 +1,29 @@ -use core::pin::Pin; -use core::task::{Context, Poll}; +//! Native (tokio) adapter for the shared framing core. +use saikuro_net::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use bytes::{Buf, BufMut}; -use futures::{ready, Sink, Stream}; -use pin_project_lite::pin_project; -use saikuro_net::io::{AsyncRead, AsyncWrite, ReadBuf}; +use crate::shared::error::TransportError; +use crate::shared::framing::{AsyncByteRead, AsyncByteWrite}; -use crate::shared::error::{Result, TransportError}; -use crate::shared::framing::LengthPrefixedCodec; - -/// Minimum capacity to make available for each read when no frame is -/// pending. Large enough to amortize syscalls without over-committing -/// memory on small frames; when a frame is pending, decode reserves the -/// exact remaining frame bytes so the read spans the whole frame. -const READ_CHUNK: usize = 4096; - -pin_project! { - pub struct FramedStream { - #[pin] - inner: S, - codec: LengthPrefixedCodec, - read_buf: bytes::BytesMut, - write_buf: bytes::BytesMut, - // Set once a framing, I/O, or truncation error is surfaced so the - // stream stays terminal and later polls report the end instead of - // resuming on an unaligned byte stream. - failed: bool, +impl AsyncByteRead for R { + async fn read(&mut self, buf: &mut [u8]) -> Result { + AsyncReadExt::read(self, buf) + .await + .map_err(|e| TransportError::ConnectionLost(e.to_string())) } } -impl FramedStream { - pub fn new(inner: S) -> Self { - Self { - inner, - codec: LengthPrefixedCodec::new(), - read_buf: bytes::BytesMut::new(), - write_buf: bytes::BytesMut::new(), - failed: false, - } - } - - /// Split into a sink (write half) and a stream (read half). - /// - /// `StreamExt::split` produces both halves from a single underlying - /// `BiLock` so they stay safely paired. - pub fn split( - self, - ) -> ( - futures::stream::SplitSink, - futures::stream::SplitStream, - ) { - futures::StreamExt::split::(self) +impl AsyncByteWrite for W { + async fn write(&mut self, buf: &[u8]) -> Result { + AsyncWriteExt::write(self, buf) + .await + .map_err(|e| TransportError::ConnectionLost(e.to_string())) } -} - -impl Stream for FramedStream { - type Item = Result; - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut this = self.project(); - - if *this.failed { - return Poll::Ready(None); - } - - loop { - // decode any complete frames already buffered. - match this.codec.decode(this.read_buf) { - Ok(Some(frame)) => return Poll::Ready(Some(Ok(frame))), - Ok(None) => {} - Err(e) => { - // Corrupt or oversized frame; the byte stream is no - // longer aligned, so surface the error and terminate. - *this.failed = true; - return Poll::Ready(Some(Err(e))); - } - } - - // Read directly into the uninitialized tail of read_buf. When - // a frame is pending, decode already reserved the remaining - // frame bytes so chunk_mut spans the whole frame; otherwise - // reserve the chunk size so the read still has a writable - // target. advance_mut only appends the filled bytes, so a - // Pending read leaves no phantom bytes behind. - this.read_buf.reserve(READ_CHUNK); - let filled = { - let dst = this.read_buf.chunk_mut(); - // SAFETY: chunk_mut borrows the uninitialized tail of the - // buffer; the slice is only filled by poll_read below - // before we advance_mut by the filled length. - let dst = unsafe { dst.as_uninit_slice_mut() }; - let mut read_buf = ReadBuf::uninit(dst); - match ready!(this.inner.as_mut().poll_read(cx, &mut read_buf)) { - Ok(()) => read_buf.filled().len(), - Err(e) => { - *this.failed = true; - return Poll::Ready(Some(Err(TransportError::from(e)))); - } - } - }; - // SAFETY: poll_read initialized the first `filled` bytes. - unsafe { this.read_buf.advance_mut(filled) }; - - if filled == 0 { - // EOF from the peer. A clean close happens only at a - // frame boundary; leftover bytes mean a truncated frame. - if this.read_buf.is_empty() && !this.codec.has_pending_frame() { - return Poll::Ready(None); - } - *this.failed = true; - return Poll::Ready(Some(Err(TransportError::FramingError( - "connection closed mid-frame".into(), - )))); - } - - // More bytes arrived; loop back to decode them. - } + async fn flush(&mut self) -> Result<(), TransportError> { + AsyncWriteExt::flush(self) + .await + .map_err(|e| TransportError::ConnectionLost(e.to_string())) } } -impl Sink for FramedStream { - type Error = TransportError; - - fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - if self.as_ref().project_ref().write_buf.is_empty() { - return Poll::Ready(Ok(())); - } - self.poll_flush(cx) - } - - fn start_send(self: Pin<&mut Self>, item: bytes::Bytes) -> Result<()> { - let this = self.project(); - this.codec.encode(item, this.write_buf)?; - Ok(()) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - ready!(flush_write_buf(self.as_mut(), cx))?; - self.project() - .inner - .poll_flush(cx) - .map_err(TransportError::from) - } - - fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - ready!(flush_write_buf(self.as_mut(), cx))?; - let flushed = ready!(self.as_mut().project().inner.poll_flush(cx)); - match flushed { - Err(e) => Poll::Ready(Err(TransportError::from(e))), - Ok(()) => self - .project() - .inner - .poll_shutdown(cx) - .map_err(TransportError::from), - } - } -} - -/// Drain `write_buf` into the underlying stream until it is empty. -fn flush_write_buf( - mut stream: Pin<&mut FramedStream>, - cx: &mut Context<'_>, -) -> Poll> { - while !stream.as_ref().project_ref().write_buf.is_empty() { - let this = stream.as_mut().project(); - let n = match ready!(this.inner.poll_write(cx, this.write_buf)) { - Ok(n) => n, - Err(e) => return Poll::Ready(Err(TransportError::from(e))), - }; - if n == 0 { - // The stream refuses to take bytes; treat as a write failure - // rather than spinning forever. - return Poll::Ready(Err(TransportError::FramingError( - "write made no progress".into(), - ))); - } - this.write_buf.advance(n); - } - Poll::Ready(Ok(())) -} +pub use crate::shared::framing::{read_frame, write_frame}; diff --git a/Build/crates/saikuro-transport/native/tcp.rs b/Build/crates/saikuro-transport/native/tcp.rs index 6b95a97d..e8673e05 100644 --- a/Build/crates/saikuro-transport/native/tcp.rs +++ b/Build/crates/saikuro-transport/native/tcp.rs @@ -1,19 +1,18 @@ use crate::{impl_native_receiver, impl_native_sender}; use async_trait::async_trait; -use bytes::Bytes; +use saikuro_net::io::{split, ReadHalf, WriteHalf}; use saikuro_net::net::{TcpListener, TcpStream}; use std::net::SocketAddr; use tracing::debug; use crate::shared::{ error::Result, - framing::FramedStream, traits::{Transport, TransportConnector, TransportListener}, }; /// A TCP transport connection. pub struct TcpTransport { - framed: FramedStream, + stream: TcpStream, peer_addr: SocketAddr, } @@ -24,10 +23,7 @@ impl TcpTransport { // Disable Nagle's algorithm: Saikuro sends complete frames and latency // matters more than segment coalescing. stream.set_nodelay(true)?; - Ok(Self { - framed: FramedStream::new(stream), - peer_addr, - }) + Ok(Self { stream, peer_addr }) } } @@ -36,15 +32,15 @@ impl Transport for TcpTransport { type Receiver = TcpReceiver; fn split(self) -> (Self::Sender, Self::Receiver) { + let (read, write) = split(self.stream); let peer = self.peer_addr; - let (sink, stream) = self.framed.split(); ( TcpSender { - inner: sink, + inner: write, peer_addr: peer, }, TcpReceiver { - inner: stream, + inner: read, peer_addr: peer, }, ) @@ -57,14 +53,14 @@ impl Transport for TcpTransport { // Sender / Receiver pub struct TcpSender { - inner: futures::stream::SplitSink, Bytes>, + inner: WriteHalf, peer_addr: SocketAddr, } impl_native_sender!(TcpSender, peer_addr, "tcp"); pub struct TcpReceiver { - inner: futures::stream::SplitStream>, + inner: ReadHalf, peer_addr: SocketAddr, } diff --git a/Build/crates/saikuro-transport/native/unix.rs b/Build/crates/saikuro-transport/native/unix.rs index d79e44c8..f70c3f99 100644 --- a/Build/crates/saikuro-transport/native/unix.rs +++ b/Build/crates/saikuro-transport/native/unix.rs @@ -1,29 +1,25 @@ use crate::{impl_native_receiver, impl_native_sender}; use async_trait::async_trait; -use bytes::Bytes; +use saikuro_net::io::{split, ReadHalf, WriteHalf}; use saikuro_net::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use tracing::debug; use crate::shared::{ error::Result, - framing::FramedStream, traits::{Transport, TransportConnector, TransportListener}, }; /// A Unix domain socket transport connection. pub struct UnixTransport { - framed: FramedStream, + stream: UnixStream, path: PathBuf, } impl UnixTransport { /// Wrap an already-connected [`UnixStream`]. pub fn new(stream: UnixStream, path: PathBuf) -> Self { - Self { - framed: FramedStream::new(stream), - path, - } + Self { stream, path } } } @@ -32,17 +28,14 @@ impl Transport for UnixTransport { type Receiver = UnixReceiver; fn split(self) -> (Self::Sender, Self::Receiver) { + let (read, write) = split(self.stream); let path = self.path.clone(); - let (sink, stream) = self.framed.split(); ( UnixSender { - inner: sink, + inner: write, path: path.clone(), }, - UnixReceiver { - inner: stream, - path, - }, + UnixReceiver { inner: read, path }, ) } @@ -53,14 +46,14 @@ impl Transport for UnixTransport { // Sender / Receiver pub struct UnixSender { - inner: futures::stream::SplitSink, Bytes>, + inner: WriteHalf, path: PathBuf, } impl_native_sender!(UnixSender, path, "unix"); pub struct UnixReceiver { - inner: futures::stream::SplitStream>, + inner: ReadHalf, path: PathBuf, } diff --git a/Build/crates/saikuro-transport/native/websocket.rs b/Build/crates/saikuro-transport/native/websocket.rs index d1887e4c..e73f5f42 100644 --- a/Build/crates/saikuro-transport/native/websocket.rs +++ b/Build/crates/saikuro-transport/native/websocket.rs @@ -24,9 +24,9 @@ impl WebSocketTransport { pub async fn connect(url: impl Into) -> Result { let url = url.into(); debug!(%url, "websocket connecting"); - let (ws, _response) = connect_async(&url) - .await - .map_err(|e| TransportError::ConnectionRefused(format!("ws connect to {url} failed: {e}")))?; + let (ws, _response) = connect_async(&url).await.map_err(|e| { + TransportError::ConnectionRefused(format!("ws connect to {url} failed: {e}")) + })?; Ok(Self { inner: ws, url }) } @@ -126,7 +126,7 @@ impl TransportSender for WebSocketSender { async fn send(&mut self, frame: Bytes) -> Result<()> { trace!(url = %self.url, bytes = frame.len(), "ws send"); self.inner - .send(Message::Binary(frame.to_vec())) + .send(Message::Binary(frame)) .await .map_err(|e| TransportError::SendFailed(e.to_string())) } @@ -153,7 +153,7 @@ impl TransportReceiver for WebSocketReceiver { match self.inner.next().await { Some(Ok(Message::Binary(data))) => { trace!(url = %self.url, bytes = data.len(), "ws recv binary"); - return Ok(Some(Bytes::from(data))); + return Ok(Some(data)); } Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => { continue; diff --git a/Build/crates/saikuro-transport/shared/framed.rs b/Build/crates/saikuro-transport/shared/framed.rs deleted file mode 100644 index f1f6fc5f..00000000 --- a/Build/crates/saikuro-transport/shared/framed.rs +++ /dev/null @@ -1,36 +0,0 @@ -#![cfg(any(feature = "embedded", feature = "wasi-tcp"))] - -use alloc::string::ToString; -use embedded_io_async::{Read, Write}; - -use crate::shared::error::{Result, TransportError}; - -/// Number of big-endian length bytes that prefix every frame. -pub(crate) const HEADER_LEN: usize = 4; - -/// Read a single byte from `reader`, writing it to `first` and returning it. -pub(crate) async fn read_first_byte(reader: &mut R, first: &mut u8) -> Result { - let mut byte = [0u8; 1]; - reader - .read_exact(&mut byte) - .await - .map_err(|e| TransportError::ConnectionLost(e.to_string()))?; - *first = byte[0]; - Ok(byte[0]) -} - -/// Read exactly `buf.len()` bytes from `reader`, or fail with `msg`. -pub(crate) async fn read_exact(reader: &mut R, buf: &mut [u8], msg: &str) -> Result<()> { - reader - .read_exact(buf) - .await - .map_err(|_| TransportError::ConnectionLost(msg.into())) -} - -/// Write every byte of `buf` to `writer`. -pub(crate) async fn write_all(writer: &mut W, buf: &[u8]) -> Result<()> { - writer - .write_all(buf) - .await - .map_err(|e| TransportError::ConnectionLost(e.to_string())) -} diff --git a/Build/crates/saikuro-transport/shared/framing.rs b/Build/crates/saikuro-transport/shared/framing.rs index 239105ef..efbd224a 100644 --- a/Build/crates/saikuro-transport/shared/framing.rs +++ b/Build/crates/saikuro-transport/shared/framing.rs @@ -1,101 +1,133 @@ -use bytes::{Buf, BufMut, Bytes, BytesMut}; +//! Length-prefixed framing for every Saikuro byte-stream transport. +//! +//! Wire format: a 4-byte big-endian length prefix followed by the payload, with +//! a 16 MiB maximum. + +use bytes::{Bytes, BytesMut}; -use crate::MAX_FRAME_SIZE; use crate::shared::error::{Result, TransportError}; +use crate::MAX_FRAME_SIZE; -/// Codec that frames a byte stream into discrete length-prefixed messages. -#[derive(Debug, Clone, Default)] -pub struct LengthPrefixedCodec { - /// Once we've read the length header we cache it here to avoid re-parsing. - pending_len: Option, - /// Payload bytes still owed for a frame whose length header exceeded - /// [`MAX_FRAME_SIZE`]. The header is consumed but the declared payload - /// must be swallowed so it is not misinterpreted as a fresh header. - discard_remaining: u64, -} +/// Number of big-endian length bytes that prefix every frame. +pub(crate) const HEADER_LEN: usize = 4; -impl LengthPrefixedCodec { - pub fn new() -> Self { - Self::default() +fn message_too_large(size: usize) -> TransportError { + TransportError::MessageTooLarge { + size, + limit: MAX_FRAME_SIZE, } +} - /// Return whether decoding has consumed a header and still expects bytes. - #[cfg(any(feature = "tcp", feature = "unix"))] - pub(crate) fn has_pending_frame(&self) -> bool { - self.pending_len.is_some() || self.discard_remaining != 0 - } +/// Backend-agnostic async source of bytes. Every transport adapts its own +/// I/O to this trait so the framing codec is implemented only once. +pub trait AsyncByteRead { + /// Read into `buf`, returning the number of bytes read. `0` signals a + /// clean end-of-stream (the peer closed the connection). + async fn read(&mut self, buf: &mut [u8]) -> Result; +} - /// Decode the next complete frame from `src`, returning `Ok(None)` until a - /// full frame is buffered. - pub fn decode(&mut self, src: &mut BytesMut) -> Result> { - // Swallow any payload owed by a rejected oversized frame before - // touching normal framing state. - if self.discard_remaining > 0 { - let take = core::cmp::min(self.discard_remaining, src.len() as u64); - src.advance(take as usize); - self.discard_remaining -= take; - if self.discard_remaining > 0 { - return Ok(None); - } - } +/// Backend-agnostic async sink of bytes. +pub trait AsyncByteWrite { + /// Write `buf`, returning the number of bytes accepted. + async fn write(&mut self, buf: &[u8]) -> Result; + /// Flush any buffered bytes to the underlying transport. + async fn flush(&mut self) -> Result<()>; +} - // Phase 1: read the 4-byte length header if we don't have it yet. - let frame_len = match self.pending_len { - Some(len) => len, - None => { - if src.len() < 4 { - // Not enough bytes yet; ask for more. - return Ok(None); - } - let len = u32::from_be_bytes([src[0], src[1], src[2], src[3]]); - src.advance(4); - self.pending_len = Some(len); - len - } - }; +#[cfg(feature = "embedded")] +impl AsyncByteRead for R { + async fn read(&mut self, buf: &mut [u8]) -> Result { + self.read(buf) + .await + .map_err(|e| TransportError::ConnectionLost(alloc::format!("{:?}", e))) + } +} - let frame_len = - usize::try_from(frame_len).map_err(|_| message_too_large(frame_len as usize))?; +#[cfg(feature = "embedded")] +impl AsyncByteWrite for W { + async fn write(&mut self, buf: &[u8]) -> Result { + self.write(buf) + .await + .map_err(|e| TransportError::ConnectionLost(alloc::format!("{:?}", e))) + } - if frame_len > MAX_FRAME_SIZE { - // The declared payload will never be decoded, so count it against - // the discard budget instead of resetting and letting the next - // call misread payload bytes as a length header. - self.pending_len = None; - self.discard_remaining = frame_len as u64; - return Err(message_too_large(frame_len)); - } + async fn flush(&mut self) -> Result<()> { + embedded_io_async::Write::flush(self) + .await + .map_err(|e| TransportError::ConnectionLost(alloc::format!("{:?}", e))) + } +} - // Phase 2: wait until the full payload has arrived. - if src.len() < frame_len { - // Reserve exactly the bytes we still need to avoid churn. - src.reserve(frame_len - src.len()); - return Ok(None); +/// Read exactly `buf.len()` bytes, or fail if the peer closes first. +async fn read_exact(reader: &mut R, buf: &mut [u8]) -> Result<()> { + let mut filled = 0; + while filled < buf.len() { + let n = reader.read(&mut buf[filled..]).await?; + if n == 0 { + return Err(TransportError::ConnectionLost( + "connection closed mid-frame".into(), + )); } - - // We have a complete frame. - self.pending_len = None; - let payload = src.split_to(frame_len).freeze(); - Ok(Some(payload)) + filled += n; } + Ok(()) +} - /// Encode `item` as a length-prefixed frame appended to `dst`. - pub fn encode(&mut self, item: Bytes, dst: &mut BytesMut) -> Result<()> { - let len = item.len(); - if len > MAX_FRAME_SIZE { - return Err(message_too_large(len)); +/// Read the 4-byte header. Returns `Ok(true)` if the peer closed cleanly at a +/// frame boundary (no bytes were read), `Ok(false)` once the header is full. +async fn read_header_or_eof( + reader: &mut R, + header: &mut [u8; HEADER_LEN], +) -> Result { + let mut filled = 0; + while filled < HEADER_LEN { + let n = reader.read(&mut header[filled..]).await?; + if n == 0 { + if filled == 0 { + return Ok(true); + } + return Err(TransportError::ConnectionLost( + "connection closed mid-frame header".into(), + )); } + filled += n; + } + Ok(false) +} - dst.reserve(4 + len); - dst.put_u32(u32::try_from(len).map_err(|_| message_too_large(len))?); - dst.put(item); - Ok(()) +/// Receive one length-prefixed frame, or `None` on a clean peer close. +pub async fn read_frame(reader: &mut R) -> Result> { + let mut header = [0u8; HEADER_LEN]; + if read_header_or_eof(reader, &mut header).await? { + return Ok(None); + } + let frame_len = decode_length_prefix(&header); + if frame_len > MAX_FRAME_SIZE { + return Err(message_too_large(frame_len)); } + let mut payload = BytesMut::zeroed(frame_len); + read_exact(reader, &mut payload).await?; + Ok(Some(payload.freeze())) } -fn message_too_large(size: usize) -> TransportError { - TransportError::MessageTooLarge { - size, - limit: MAX_FRAME_SIZE, +/// Send one length-prefixed frame. +pub async fn write_frame(writer: &mut W, frame: &[u8]) -> Result<()> { + if frame.len() > MAX_FRAME_SIZE { + return Err(message_too_large(frame.len())); } + let header = encode_length_prefix(frame.len()); + writer.write(&header).await?; + writer.write(frame).await?; + writer.flush().await?; + Ok(()) +} + +/// Encode a frame length as the 4-byte big-endian wire header. +pub const fn encode_length_prefix(len: usize) -> [u8; HEADER_LEN] { + (len as u32).to_be_bytes() +} + +/// Decode a 4-byte big-endian wire header into a frame length. +pub fn decode_length_prefix(header: &[u8; HEADER_LEN]) -> usize { + u32::from_be_bytes(*header) as usize } diff --git a/Build/crates/saikuro-transport/shared/host.rs b/Build/crates/saikuro-transport/shared/host.rs index 0ed4b978..3a0225f3 100644 --- a/Build/crates/saikuro-transport/shared/host.rs +++ b/Build/crates/saikuro-transport/shared/host.rs @@ -1,5 +1,7 @@ +use alloc::boxed::Box; use alloc::string::String; use alloc::vec::Vec; +use async_trait::async_trait; use bytes::Bytes; use core::marker::PhantomData; @@ -18,29 +20,6 @@ pub enum Role { Accept, } -/// The sending half of a host message bus, abstracted over its backend. -pub trait HostPipeSend { - /// Send a single binary frame over the bus. - async fn send(&mut self, frame: &[u8]) -> Result<()>; -} - -/// The receiving half of a host message bus, abstracted over its backend. -pub trait HostPipeRecv { - /// Receive the next binary frame, or `None` when the peer closed cleanly. - async fn recv(&mut self) -> Result>>; -} - -/// A host message bus that can be opened as a connected, framed pipe. -pub trait HostPipeFactory { - /// The sending half produced by [`open`](HostPipeFactory::open). - type Send: HostPipeSend; - /// The receiving half produced by [`open`](HostPipeFactory::open). - type Recv: HostPipeRecv; - - /// Open a connected pipe on `channel` playing `role`. - async fn open(channel: &str, role: Role) -> Result<(Self::Send, Self::Recv)>; -} - /// A transport backed by a host message bus, generic over its pipe backend. pub struct WasmHostTransport { sender: S, @@ -60,9 +39,7 @@ impl LocalTransport for WasmHostTransport (Self::Sender, Self::Receiver) { ( - WasmHostSender { - pipe: self.sender, - }, + WasmHostSender { pipe: self.sender }, WasmHostReceiver { pipe: self.receiver, }, @@ -79,30 +56,11 @@ pub struct WasmHostSender { pipe: S, } -impl LocalTransportSender for WasmHostSender { - async fn send(&mut self, frame: Bytes) -> Result<()> { - self.pipe.send(&frame).await - } - - async fn close(&mut self) -> Result<()> { - Ok(()) - } -} - /// Receiving half of a [`WasmHostTransport`]. pub struct WasmHostReceiver { pipe: R, } -impl LocalTransportReceiver for WasmHostReceiver { - async fn recv(&mut self) -> Result> { - match self.pipe.recv().await? { - Some(bytes) => Ok(Some(Bytes::from(bytes))), - None => Ok(None), - } - } -} - /// Connects to a peer over a host message bus. pub struct WasmHostConnector { channel: String, @@ -119,15 +77,6 @@ impl WasmHostConnector { } } -impl LocalTransportConnector for WasmHostConnector { - type Output = WasmHostTransport; - - async fn connect(&self) -> Result { - let (sender, receiver) = F::open(&self.channel, Role::Connect).await?; - Ok(WasmHostTransport::new(sender, receiver)) - } -} - /// Accepts inbound connections over a host message bus. pub struct WasmHostListener { channel: String, @@ -144,15 +93,162 @@ impl WasmHostListener { } } -impl LocalTransportListener for WasmHostListener { - type Output = WasmHostTransport; +// Exactly one engine is active per build, so the host-pipe traits carry +// different auto-trait bounds per engine (matching `shared::traits`). +#[cfg(feature = "native")] +mod send_traits { + use super::*; + + /// The sending half of a host message bus, abstracted over its backend. + #[async_trait] + pub trait HostPipeSend: Send + 'static { + /// Send a single binary frame over the bus. + async fn send(&mut self, frame: &[u8]) -> Result<()>; + } + + /// The receiving half of a host message bus, abstracted over its backend. + #[async_trait] + pub trait HostPipeRecv: Send + 'static { + /// Receive the next binary frame, or `None` on a clean peer close. + async fn recv(&mut self) -> Result>>; + } + + /// A host message bus that can be opened as a connected, framed pipe. + #[async_trait] + pub trait HostPipeFactory: Send + 'static { + /// The sending half produced by [`open`](HostPipeFactory::open). + type Send: HostPipeSend; + /// The receiving half produced by [`open`](HostPipeFactory::open). + type Recv: HostPipeRecv; + + /// Open a connected pipe on `channel` playing `role`. + async fn open(channel: &str, role: Role) -> Result<(Self::Send, Self::Recv)>; + } + + #[async_trait] + impl LocalTransportSender for WasmHostSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + self.pipe.send(&frame).await + } - async fn accept(&mut self) -> Result> { - let (sender, receiver) = F::open(&self.channel, Role::Accept).await?; - Ok(Some(WasmHostTransport::new(sender, receiver))) + async fn close(&mut self) -> Result<()> { + Ok(()) + } } - async fn close(&mut self) -> Result<()> { - Ok(()) + #[async_trait] + impl LocalTransportReceiver for WasmHostReceiver { + async fn recv(&mut self) -> Result> { + match self.pipe.recv().await? { + Some(bytes) => Ok(Some(Bytes::from(bytes))), + None => Ok(None), + } + } + } + + #[async_trait] + impl LocalTransportConnector for WasmHostConnector { + type Output = WasmHostTransport; + + async fn connect(&self) -> Result { + let (sender, receiver) = F::open(&self.channel, Role::Connect).await?; + Ok(WasmHostTransport::new(sender, receiver)) + } + } + + #[async_trait] + impl LocalTransportListener for WasmHostListener { + type Output = WasmHostTransport; + + async fn accept(&mut self) -> Result> { + let (sender, receiver) = F::open(&self.channel, Role::Accept).await?; + Ok(Some(WasmHostTransport::new(sender, receiver))) + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } } } + +#[cfg(not(feature = "native"))] +mod nosend_traits { + use super::*; + + /// The sending half of a host message bus, abstracted over its backend. + #[async_trait(?Send)] + pub trait HostPipeSend: 'static { + /// Send a single binary frame over the bus. + async fn send(&mut self, frame: &[u8]) -> Result<()>; + } + + /// The receiving half of a host message bus, abstracted over its backend. + #[async_trait(?Send)] + pub trait HostPipeRecv: 'static { + /// Receive the next binary frame, or `None` on a clean peer close. + async fn recv(&mut self) -> Result>>; + } + + /// A host message bus that can be opened as a connected, framed pipe. + #[async_trait(?Send)] + pub trait HostPipeFactory: 'static { + /// The sending half produced by [`open`](HostPipeFactory::open). + type Send: HostPipeSend; + /// The receiving half produced by [`open`](HostPipeFactory::open). + type Recv: HostPipeRecv; + + /// Open a connected pipe on `channel` playing `role`. + async fn open(channel: &str, role: Role) -> Result<(Self::Send, Self::Recv)>; + } + + #[async_trait(?Send)] + impl LocalTransportSender for WasmHostSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + self.pipe.send(&frame).await + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } + } + + #[async_trait(?Send)] + impl LocalTransportReceiver for WasmHostReceiver { + async fn recv(&mut self) -> Result> { + match self.pipe.recv().await? { + Some(bytes) => Ok(Some(Bytes::from(bytes))), + None => Ok(None), + } + } + } + + #[async_trait(?Send)] + impl LocalTransportConnector for WasmHostConnector { + type Output = WasmHostTransport; + + async fn connect(&self) -> Result { + let (sender, receiver) = F::open(&self.channel, Role::Connect).await?; + Ok(WasmHostTransport::new(sender, receiver)) + } + } + + #[async_trait(?Send)] + impl LocalTransportListener for WasmHostListener { + type Output = WasmHostTransport; + + async fn accept(&mut self) -> Result> { + let (sender, receiver) = F::open(&self.channel, Role::Accept).await?; + Ok(Some(WasmHostTransport::new(sender, receiver))) + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } + } +} + +#[cfg(feature = "native")] +pub use send_traits::*; + +#[cfg(not(feature = "native"))] +pub use nosend_traits::*; diff --git a/Build/crates/saikuro-transport/shared/memory.rs b/Build/crates/saikuro-transport/shared/memory.rs index 6fc67ddd..47d2ede4 100644 --- a/Build/crates/saikuro-transport/shared/memory.rs +++ b/Build/crates/saikuro-transport/shared/memory.rs @@ -80,41 +80,78 @@ pub struct MemorySender { label: String, } -#[async_trait] -impl TransportSender for MemorySender { - async fn send(&mut self, frame: Bytes) -> Result<()> { - trace!(label = %self.label, bytes = frame.len(), "memory send"); - self.inner.send(frame).await.map_err(|_| { - TransportError::ConnectionLost(format!( - "in-memory receiver dropped for '{}'", - self.label - )) - }) - } - - async fn close(&mut self) -> Result<()> { - // Dropping the sender closes the channel; the receiver will see None. - // There is nothing explicit to do here: the sender will be dropped - // when this struct is dropped. - trace!(label = %self.label, "memory sender closing"); - Ok(()) - } -} - /// Receiving half of a [`MemoryTransport`]. pub struct MemoryReceiver { inner: mpsc::Receiver, label: String, } -#[async_trait] -impl TransportReceiver for MemoryReceiver { - async fn recv(&mut self) -> Result> { - let result = self.inner.recv().await; - match &result { - Some(bytes) => trace!(label = %self.label, bytes = bytes.len(), "memory recv"), - None => trace!(label = %self.label, "memory channel closed"), +#[cfg(feature = "native")] +mod send_impls { + use super::*; + + #[async_trait] + impl TransportSender for MemorySender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + trace!(label = %self.label, bytes = frame.len(), "memory send"); + self.inner.send(frame).await.map_err(|_| { + TransportError::ConnectionLost(format!( + "in-memory receiver dropped for '{}'", + self.label + )) + }) + } + + async fn close(&mut self) -> Result<()> { + trace!(label = %self.label, "memory sender closing"); + Ok(()) + } + } + + #[async_trait] + impl TransportReceiver for MemoryReceiver { + async fn recv(&mut self) -> Result> { + let result = self.inner.recv().await; + match &result { + Some(bytes) => trace!(label = %self.label, bytes = bytes.len(), "memory recv"), + None => trace!(label = %self.label, "memory channel closed"), + } + Ok(result) + } + } +} + +#[cfg(not(feature = "native"))] +mod nosend_impls { + use super::*; + + #[async_trait(?Send)] + impl TransportSender for MemorySender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + trace!(label = %self.label, bytes = frame.len(), "memory send"); + self.inner.send(frame).await.map_err(|_| { + TransportError::ConnectionLost(format!( + "in-memory receiver dropped for '{}'", + self.label + )) + }) + } + + async fn close(&mut self) -> Result<()> { + trace!(label = %self.label, "memory sender closing"); + Ok(()) + } + } + + #[async_trait(?Send)] + impl TransportReceiver for MemoryReceiver { + async fn recv(&mut self) -> Result> { + let result = self.inner.recv().await; + match &result { + Some(bytes) => trace!(label = %self.label, bytes = bytes.len(), "memory recv"), + None => trace!(label = %self.label, "memory channel closed"), + } + Ok(result) } - Ok(result) } } diff --git a/Build/crates/saikuro-transport/shared/mod.rs b/Build/crates/saikuro-transport/shared/mod.rs index dbb79dfc..e5ea6ab1 100644 --- a/Build/crates/saikuro-transport/shared/mod.rs +++ b/Build/crates/saikuro-transport/shared/mod.rs @@ -1,5 +1,4 @@ pub mod error; -pub mod framed; pub mod framing; pub mod host; pub mod memory; diff --git a/Build/crates/saikuro-transport/shared/traits.rs b/Build/crates/saikuro-transport/shared/traits.rs index 3d234671..a126445b 100644 --- a/Build/crates/saikuro-transport/shared/traits.rs +++ b/Build/crates/saikuro-transport/shared/traits.rs @@ -4,131 +4,221 @@ use bytes::Bytes; use crate::shared::error::Result; -/// A bidirectional message transport. -/// -/// The runtime creates a [`Transport`] and splits it into a -/// [`TransportSender`] and [`TransportReceiver`] pair, each of which can be -/// moved to a separate task. Messages are raw byte frames; framing/length -/// prefixing is handled inside the concrete implementation. -/// -/// ## Implementation contract -/// - Implementations MUST guarantee ordered delivery within a connection. -/// - Implementations MUST be binary-safe (no newline stripping, etc.). -/// - Implementations SHOULD apply backpressure when internal send buffers fill. -/// - Implementations MUST be cancellation-safe on `send` and `recv`. -#[async_trait] -pub trait Transport: Send + Sync + 'static { - /// The sender half type produced by [`split`](Transport::split). - type Sender: TransportSender; - /// The receiver half type produced by [`split`](Transport::split). - type Receiver: TransportReceiver; - - /// Split this transport into a sender and receiver that can be used - /// concurrently from separate tasks. - fn split(self) -> (Self::Sender, Self::Receiver); - - /// A human-readable description of the transport for logging. - fn description(&self) -> &str; -} +// Exactly one engine is active per build (enforced by the facade crates), so we +// can define the transport traits with different auto-trait bounds per engine. +#[cfg(feature = "native")] +mod send_traits { + use super::*; -/// The sending half of a [`Transport`]. -#[async_trait] -pub trait TransportSender: Send + Sync + 'static { - /// Send a single binary frame to the remote peer. + /// A bidirectional message transport. /// - /// This method applies backpressure: if the send buffer is full it will - /// yield the async task until space is available. - async fn send(&mut self, frame: Bytes) -> Result<()>; - - /// Close the sending side gracefully. Any frames already buffered will - /// be flushed before the connection is terminated. - async fn close(&mut self) -> Result<()>; -} - -/// The receiving half of a [`Transport`]. -#[async_trait] -pub trait TransportReceiver: Send + Sync + 'static { - /// Wait for and return the next binary frame from the remote peer. + /// The runtime creates a [`Transport`] and splits it into a + /// [`TransportSender`] and [`TransportReceiver`] pair, each of which can be + /// moved to a separate task. Messages are raw byte frames; framing/length + /// prefixing is handled inside the concrete implementation. /// - /// Returns `Ok(None)` when the remote peer has closed the connection - /// cleanly. Returns `Err(_)` on unrecoverable transport errors. - async fn recv(&mut self) -> Result>; -} - -/// A factory that can produce new [`Transport`] connections to a given peer. -/// -/// This is the interface the runtime uses when it needs to connect to a -/// remote provider for the first time, or reconnect after a failure. -#[async_trait] -pub trait TransportConnector: Send + Sync + 'static { - type Output: Transport; - - /// Establish a new connection, returning a ready [`Transport`]. - async fn connect(&self) -> Result; -} - -/// A listener that accepts inbound connections and produces transports. -/// -/// This is used by provider adapters and the runtime's listener loop. -#[async_trait] -pub trait TransportListener: Send + Sync + 'static { - type Output: Transport; - - /// Accept the next inbound connection. + /// ## Implementation contract + /// - Implementations MUST guarantee ordered delivery within a connection. + /// - Implementations MUST be binary-safe (no newline stripping, etc.). + /// - Implementations SHOULD apply backpressure when internal send buffers fill. + /// - Implementations MUST be cancellation-safe on `send` and `recv`. + #[async_trait] + pub trait Transport: Send + Sync + 'static { + /// The sender half type produced by [`split`](Transport::split). + type Sender: TransportSender; + /// The receiver half type produced by [`split`](Transport::split). + type Receiver: TransportReceiver; + + /// Split this transport into a sender and receiver that can be used + /// concurrently from separate tasks. + fn split(self) -> (Self::Sender, Self::Receiver); + + /// A human-readable description of the transport for logging. + fn description(&self) -> &str; + } + + /// The sending half of a [`Transport`]. + #[async_trait] + pub trait TransportSender: Send + Sync + 'static { + /// Send a single binary frame to the remote peer. + /// + /// This method applies backpressure: if the send buffer is full it will + /// yield the async task until space is available. + async fn send(&mut self, frame: Bytes) -> Result<()>; + + /// Close the sending side gracefully. Any frames already buffered will + /// be flushed before the connection is terminated. + async fn close(&mut self) -> Result<()>; + } + + /// The receiving half of a [`Transport`]. + #[async_trait] + pub trait TransportReceiver: Send + Sync + 'static { + /// Wait for and return the next binary frame from the remote peer. + /// + /// Returns `Ok(None)` when the remote peer has closed the connection + /// cleanly. Returns `Err(_)` on unrecoverable transport errors. + async fn recv(&mut self) -> Result>; + } + + /// A factory that can produce new [`Transport`] connections to a given peer. /// - /// Returns `Ok(None)` when the listener has been shut down. - async fn accept(&mut self) -> Result>; - - /// Stop accepting new connections. - async fn close(&mut self) -> Result<()>; -} - -/// Mirrors [`TransportSender`] but uses native `async fn` (return-position `impl -/// Trait`) instead of a boxed, `Send + Sync` `async_trait` future. -pub trait LocalTransportSender { - /// Send a single binary frame to the remote peer. - fn send(&mut self, frame: Bytes) -> impl core::future::Future> + '_; - /// Close the sending side gracefully. - fn close(&mut self) -> impl core::future::Future> + '_; -} - -/// A local, statically-dispatched receiving half of a transport. -pub trait LocalTransportReceiver { - /// Wait for and return the next binary frame, or `None` on clean close. - fn recv(&mut self) -> impl core::future::Future>> + '_; -} - -/// A local, statically-dispatched bidirectional transport. -pub trait LocalTransport { - /// The sender half type produced by [`split`](LocalTransport::split). - type Sender: LocalTransportSender; - /// The receiver half type produced by [`split`](LocalTransport::split). - type Receiver: LocalTransportReceiver; - - /// Split into concurrently-usable sender and receiver halves. - fn split(self) -> (Self::Sender, Self::Receiver); - - /// A human-readable description of the transport for logging. - fn description(&self) -> &str; + /// This is the interface the runtime uses when it needs to connect to a + /// remote provider for the first time, or reconnect after a failure. + #[async_trait] + pub trait TransportConnector: Send + Sync + 'static { + /// The ready transport produced by [`connect`](TransportConnector::connect). + type Output: Transport; + + /// Establish a new connection, returning a ready [`Transport`]. + async fn connect(&self) -> Result; + } + + /// A listener that accepts inbound connections and produces transports. + /// + /// This is used by provider adapters and the runtime's listener loop. + #[async_trait] + pub trait TransportListener: Send + Sync + 'static { + /// The ready transport produced by [`accept`](TransportListener::accept). + type Output: Transport; + + /// Accept the next inbound connection. + /// + /// Returns `Ok(None)` when the listener has been shut down. + async fn accept(&mut self) -> Result>; + + /// Stop accepting new connections. + async fn close(&mut self) -> Result<()>; + } + + /// Mirrors [`TransportSender`] but uses statically-dispatched local transports. + #[async_trait] + pub trait LocalTransportSender: Send { + /// Send a single binary frame to the remote peer. + async fn send(&mut self, frame: Bytes) -> Result<()>; + /// Close the sending side gracefully. + async fn close(&mut self) -> Result<()>; + } + + /// A local, statically-dispatched receiving half of a transport. + #[async_trait] + pub trait LocalTransportReceiver: Send { + /// Wait for and return the next binary frame, or `None` on clean close. + async fn recv(&mut self) -> Result>; + } + + /// A local, statically-dispatched bidirectional transport. + #[async_trait] + pub trait LocalTransport: Send { + /// The sender half type produced by [`split`](LocalTransport::split). + type Sender: LocalTransportSender; + /// The receiver half type produced by [`split`](LocalTransport::split). + type Receiver: LocalTransportReceiver; + + /// Split into concurrently-usable sender and receiver halves. + fn split(self) -> (Self::Sender, Self::Receiver); + + /// A human-readable description of the transport for logging. + fn description(&self) -> &str; + } + + /// A local factory that connects to a peer. + #[async_trait] + pub trait LocalTransportConnector: Send { + /// The ready transport produced by [`connect`](LocalTransportConnector::connect). + type Output: LocalTransport; + + /// Establish a new connection. + async fn connect(&self) -> Result; + } + + /// A local listener that accepts inbound connections. + #[async_trait] + pub trait LocalTransportListener: Send { + /// The ready transport produced by [`accept`](LocalTransportListener::accept). + type Output: LocalTransport; + + /// Accept the next inbound connection, or `None` when shut down. + async fn accept(&mut self) -> Result>; + + /// Stop accepting new connections. + async fn close(&mut self) -> Result<()>; + } } -/// A local factory that connects to a peer. -pub trait LocalTransportConnector { - /// The ready transport produced by [`connect`](LocalTransportConnector::connect). - type Output: LocalTransport; - - /// Establish a new connection. - fn connect(&self) -> impl core::future::Future> + '_; +#[cfg(not(feature = "native"))] +mod nosend_traits { + use super::*; + + /// A bidirectional message transport (embedded, single-threaded, `!Send`). + #[async_trait(?Send)] + pub trait Transport: 'static { + type Sender: TransportSender; + type Receiver: TransportReceiver; + + fn split(self) -> (Self::Sender, Self::Receiver); + fn description(&self) -> &str; + } + + #[async_trait(?Send)] + pub trait TransportSender: 'static { + async fn send(&mut self, frame: Bytes) -> Result<()>; + async fn close(&mut self) -> Result<()>; + } + + #[async_trait(?Send)] + pub trait TransportReceiver: 'static { + async fn recv(&mut self) -> Result>; + } + + #[async_trait(?Send)] + pub trait TransportConnector: 'static { + type Output: Transport; + async fn connect(&self) -> Result; + } + + #[async_trait(?Send)] + pub trait TransportListener: 'static { + type Output: Transport; + async fn accept(&mut self) -> Result>; + async fn close(&mut self) -> Result<()>; + } + + #[async_trait(?Send)] + pub trait LocalTransportSender: 'static { + async fn send(&mut self, frame: Bytes) -> Result<()>; + async fn close(&mut self) -> Result<()>; + } + + #[async_trait(?Send)] + pub trait LocalTransportReceiver: 'static { + async fn recv(&mut self) -> Result>; + } + + #[async_trait(?Send)] + pub trait LocalTransport: 'static { + type Sender: LocalTransportSender; + type Receiver: LocalTransportReceiver; + fn split(self) -> (Self::Sender, Self::Receiver); + fn description(&self) -> &str; + } + + #[async_trait(?Send)] + pub trait LocalTransportConnector: 'static { + type Output: LocalTransport; + async fn connect(&self) -> Result; + } + + #[async_trait(?Send)] + pub trait LocalTransportListener: 'static { + type Output: LocalTransport; + async fn accept(&mut self) -> Result>; + async fn close(&mut self) -> Result<()>; + } } -/// A local listener that accepts inbound connections. -pub trait LocalTransportListener { - /// The ready transport produced by [`accept`](LocalTransportListener::accept). - type Output: LocalTransport; +#[cfg(feature = "native")] +pub use send_traits::*; - /// Accept the next inbound connection, or `None` when shut down. - fn accept(&mut self) -> impl core::future::Future>> + '_; - - /// Stop accepting new connections. - fn close(&mut self) -> impl core::future::Future> + '_; -} +#[cfg(not(feature = "native"))] +pub use nosend_traits::*; diff --git a/Build/crates/saikuro-transport/wasi/host.rs b/Build/crates/saikuro-transport/wasi/host.rs index 627feb6f..a32c11bc 100644 --- a/Build/crates/saikuro-transport/wasi/host.rs +++ b/Build/crates/saikuro-transport/wasi/host.rs @@ -1,10 +1,17 @@ +use alloc::boxed::Box; use alloc::string::String; +use alloc::vec::Vec; +use async_trait::async_trait; use bytes::Bytes; use crate::shared::error::{Result, TransportError}; use crate::shared::host::{HostPipeFactory, HostPipeRecv, HostPipeSend, Role}; -use crate::wasi::tcp::{backend, WasiTcpConnector, WasiTcpListener, WasiTcpReceiver, WasiTcpSender}; +use crate::shared::traits::{ + LocalTransport, LocalTransportConnector, LocalTransportListener, LocalTransportReceiver, + LocalTransportSender, +}; +use crate::wasi::tcp::{WasiTcpConnector, WasiTcpListener, WasiTcpReceiver, WasiTcpSender}; /// Base of the deterministic loopback rendezvous port range. const PIPE_PORT_BASE: u16 = 0xC000; @@ -15,17 +22,19 @@ const PIPE_PORT_SPAN: u16 = 0x1000; pub struct WasiPipe; /// Sending half of a [`WasiPipe`] connection. -pub struct WasiHostSend(pub WasiTcpSender); +pub struct WasiHostSend(pub WasiTcpSender); /// Receiving half of a [`WasiPipe`] connection. -pub struct WasiHostRecv(pub WasiTcpReceiver); +pub struct WasiHostRecv(pub WasiTcpReceiver); +#[async_trait(?Send)] impl HostPipeSend for WasiHostSend { async fn send(&mut self, frame: &[u8]) -> Result<()> { self.0.send(Bytes::copy_from_slice(frame)).await } } +#[async_trait(?Send)] impl HostPipeRecv for WasiHostRecv { async fn recv(&mut self) -> Result>> { match self.0.recv().await? { @@ -35,6 +44,7 @@ impl HostPipeRecv for WasiHostRecv { } } +#[async_trait(?Send)] impl HostPipeFactory for WasiPipe { type Send = WasiHostSend; type Recv = WasiHostRecv; @@ -58,10 +68,9 @@ impl HostPipeFactory for WasiPipe { } Role::Accept => { let mut listener = WasiTcpListener::new(addr)?; - let transport = listener - .accept() - .await? - .ok_or_else(|| TransportError::ConnectionLost("wasi-host listener closed".into()))?; + let transport = listener.accept().await?.ok_or_else(|| { + TransportError::ConnectionLost("wasi-host listener closed".into()) + })?; let (mut tx, mut rx) = transport.split(); match rx.recv().await? { Some(bytes) if bytes.as_ref() == b"connect" => {} diff --git a/Build/crates/saikuro-transport/wasi/mod.rs b/Build/crates/saikuro-transport/wasi/mod.rs index 8a721cb3..3d5d2495 100644 --- a/Build/crates/saikuro-transport/wasi/mod.rs +++ b/Build/crates/saikuro-transport/wasi/mod.rs @@ -1,9 +1,10 @@ -pub mod framed; -#[cfg(feature = "wasi-preview2")] -pub mod preview2; +#[cfg(feature = "wasi-host")] +pub mod host; #[cfg(feature = "wasi-preview1")] pub mod preview1; +#[cfg(feature = "wasi-preview2")] +pub mod preview2; #[cfg(feature = "wasi-tcp")] pub mod tcp; -#[cfg(feature = "wasi-host")] -pub mod host; +#[cfg(feature = "ws-wasi")] +pub mod websocket; diff --git a/Build/crates/saikuro-transport/wasi/preview1.rs b/Build/crates/saikuro-transport/wasi/preview1.rs index c8203afe..7d218f6e 100644 --- a/Build/crates/saikuro-transport/wasi/preview1.rs +++ b/Build/crates/saikuro-transport/wasi/preview1.rs @@ -1,9 +1,7 @@ -use alloc::rc::Rc; - -use embedded_io_async::{ErrorKind, Read, Write}; +use alloc::sync::Arc; use crate::shared::error::{Result, TransportError}; -use crate::wasi::tcp::{parse_addr, parse_ipv4}; +use crate::wasi::tcp::{parse_addr, parse_ipv4, WasiConn}; const AF_INET: u8 = 0; // witx address-family::inet4 const SOCK_STREAM: u8 = 1; // witx socket-type::stream @@ -46,8 +44,8 @@ extern "C" { fn fd_close(fd: u32) -> u16; } -/// An open preview1 socket. Owns the fd: the last `Rc` dropping closes it. -struct Connection { +/// An open preview1 socket. Owns the fd: the last `Arc` dropping closes it. +pub struct Connection { fd: u32, } @@ -60,50 +58,6 @@ impl Drop for Connection { } } -/// A readable socket half. Shares ownership of the underlying fd. -pub struct Reader(Rc); - -/// A writable socket half. Shares ownership of the underlying fd. -pub struct Writer(Rc); - -impl Read for Reader { - async fn read(&mut self, buf: &mut [u8]) -> Result { - let iov = Ciovec { - buf: buf.as_ptr(), - len: buf.len(), - }; - let mut ret = RecvRet { len: 0, roflags: 0 }; - // SAFETY: iov aliases buf for the duration of the call and ret is written - // by the host. The fd is a valid open socket. - let rc = unsafe { sock_recv(self.0.fd, &iov, 0, &mut ret) }; - if rc != 0 { - return Err(ErrorKind::Other); - } - Ok(ret.len as usize) - } -} - -impl Write for Writer { - async fn write(&mut self, buf: &[u8]) -> Result { - let iov = Iovec { - buf: buf.as_ptr(), - len: buf.len(), - }; - let mut n = 0u32; - // SAFETY: iov aliases buf for the duration of the call and n is written - // by the host. The fd is a valid open socket. - let rc = unsafe { sock_send(self.0.fd, &iov, 0, &mut n) }; - if rc != 0 { - return Err(ErrorKind::Other); - } - Ok(n as usize) - } - - async fn flush(&mut self) -> Result<(), ErrorKind> { - Ok(()) - } -} - /// A listening preview1 socket. pub struct Listener { fd: u32, @@ -134,8 +88,58 @@ fn sockaddr_in(octets: [u8; 4], port: u16) -> SockaddrIn { } } -/// Dial `addr` (host:port) and return the connected read/write halves. -pub fn connect(addr: &str) -> Result<(Reader, Writer)> { +/// Receive up to `buf.len()` bytes into `buf`; returns the count read. +/// A return of `0` indicates a clean EOF. +fn recv_raw(fd: u32, buf: &mut [u8]) -> Result { + let iov = Ciovec { + buf: buf.as_ptr(), + len: buf.len(), + }; + let mut ret = RecvRet { len: 0, roflags: 0 }; + // SAFETY: iov aliases buf for the duration of the call and ret is written + // by the host. The fd is a valid open socket. + let rc = unsafe { sock_recv(fd, &iov, 0, &mut ret) }; + if !errno_ok(rc) { + return Err(TransportError::ReceiveFailed(format!("sock_recv: {rc}"))); + } + Ok(ret.len as usize) +} + +impl WasiConn for Connection { + fn read_bytes(&self, buf: &mut [u8]) -> Result { + recv_raw(self.fd, buf) + } + + fn write_bytes(&self, buf: &[u8]) -> Result<()> { + send_frame(self, buf) + } +} + +/// Send one length-prefixed frame over `conn`. +pub fn send_frame(conn: &Connection, frame: &[u8]) -> Result<()> { + let mut offset = 0; + while offset < frame.len() { + let iov = Iovec { + buf: frame[offset..].as_ptr(), + len: frame.len() - offset, + }; + let mut n = 0u32; + // SAFETY: iov aliases frame for the duration of the call; n is written + // by the host. The fd is a valid open socket. + let rc = unsafe { sock_send(conn.fd, &iov, 0, &mut n) }; + if !errno_ok(rc) { + return Err(TransportError::SendFailed(format!("sock_send: {rc}"))); + } + if n == 0 { + return Err(TransportError::SendFailed("sock_send wrote 0 bytes".into())); + } + offset += n as usize; + } + Ok(()) +} + +/// Dial `addr` (host:port) and return the connected socket. +pub fn connect(addr: &str) -> Result> { let (host, port) = parse_addr(addr)?; let octets = parse_ipv4(&host) .ok_or_else(|| TransportError::ConnectionRefused(format!("unresolved host {host}")))?; @@ -144,16 +148,20 @@ pub fn connect(addr: &str) -> Result<(Reader, Writer)> { // SAFETY: sock_open writes exactly one fd to ret_area on success. let rc = unsafe { sock_open(AF_INET, SOCK_STREAM, &mut fd) }; if !errno_ok(rc) { - return Err(TransportError::ConnectionRefused(format!("sock_open: {rc}"))); + return Err(TransportError::ConnectionRefused(format!( + "sock_open: {rc}" + ))); } - let conn = Rc::new(Connection { fd }); + let conn = Arc::new(Connection { fd }); let sa = sockaddr_in(octets, port); // SAFETY: sa points to a valid SockaddrIn for the duration of the call. let rc = unsafe { sock_connect(conn.fd, &sa, core::mem::size_of::() as u32) }; if !errno_ok(rc) { - return Err(TransportError::ConnectionRefused(format!("sock_connect: {rc}"))); + return Err(TransportError::ConnectionRefused(format!( + "sock_connect: {rc}" + ))); } - Ok((Reader(Rc::clone(&conn)), Writer(conn))) + Ok(conn) } /// Bind and listen on `port` on all interfaces. @@ -161,7 +169,9 @@ pub fn listen(port: u16) -> Result { let mut fd = 0u32; let rc = unsafe { sock_open(AF_INET, SOCK_STREAM, &mut fd) }; if !errno_ok(rc) { - return Err(TransportError::ConnectionRefused(format!("sock_open: {rc}"))); + return Err(TransportError::ConnectionRefused(format!( + "sock_open: {rc}" + ))); } let sa = sockaddr_in([0, 0, 0, 0], port); let rc = unsafe { sock_bind(fd, &sa, core::mem::size_of::() as u32) }; @@ -170,7 +180,9 @@ pub fn listen(port: u16) -> Result { unsafe { let _ = fd_close(fd); } - return Err(TransportError::ConnectionRefused(format!("sock_bind: {rc}"))); + return Err(TransportError::ConnectionRefused(format!( + "sock_bind: {rc}" + ))); } let rc = unsafe { sock_listen(fd, 16) }; if !errno_ok(rc) { @@ -178,22 +190,25 @@ pub fn listen(port: u16) -> Result { unsafe { let _ = fd_close(fd); } - return Err(TransportError::ConnectionRefused(format!("sock_listen: {rc}"))); + return Err(TransportError::ConnectionRefused(format!( + "sock_listen: {rc}" + ))); } Ok(Listener { fd }) } impl Listener { - /// Accept one inbound connection and return its read/write halves. - pub fn accept(&self) -> Result<(Reader, Writer)> { + /// Accept one inbound connection and return its socket. + pub fn accept(&self) -> Result> { let mut flags = 0u16; let mut fd = 0u32; // SAFETY: host writes the accepted fd to ret_area; flags is read by host. let rc = unsafe { sock_accept(self.fd, &mut flags, &mut fd) }; if !errno_ok(rc) { - return Err(TransportError::ConnectionRefused(format!("sock_accept: {rc}"))); + return Err(TransportError::ConnectionRefused(format!( + "sock_accept: {rc}" + ))); } - let conn = Rc::new(Connection { fd }); - Ok((Reader(Rc::clone(&conn)), Writer(conn))) + Ok(Arc::new(Connection { fd })) } } diff --git a/Build/crates/saikuro-transport/wasi/preview2.rs b/Build/crates/saikuro-transport/wasi/preview2.rs index 926bf737..0ec05ea1 100644 --- a/Build/crates/saikuro-transport/wasi/preview2.rs +++ b/Build/crates/saikuro-transport/wasi/preview2.rs @@ -1,105 +1,97 @@ -use alloc::string::String; -use alloc::vec::Vec; +use alloc::sync::Arc; -use embedded_io_async::{ErrorKind, Read, Write}; use wasi::io::streams::{InputStream, OutputStream}; use wasi::sockets::instance_network::instance_network; -use wasi::sockets::ip::{ - IpAddress, IpAddressFamily, IpSocketAddress, Ipv4Address, Ipv4SocketAddress, Ipv6Address, - Ipv6SocketAddress, +use wasi::sockets::network::{ + ErrorCode, IpAddressFamily, IpSocketAddress, Ipv4SocketAddress, Network, }; -use wasi::sockets::network::Network; -use wasi::sockets::tcp::{TcpSocket, TcpSocketType}; +use wasi::sockets::tcp::TcpSocket; +use wasi::sockets::tcp_create_socket::create_tcp_socket; use crate::shared::error::{Result, TransportError}; -use crate::wasi::tcp::{parse_addr}; +use crate::wasi::tcp::{parse_addr, parse_ipv4, WasiConn}; -/// A readable socket half. -pub struct Reader(InputStream); - -/// A writable socket half. -pub struct Writer(OutputStream); - -impl Read for Reader { - async fn read(&mut self, buf: &mut [u8]) -> Result { - self.0 - .blocking_read(buf) - .map(|n| n as usize) - .map_err(|_| ErrorKind::Other) - } -} - -impl Write for Writer { - async fn write(&mut self, buf: &[u8]) -> Result { - self.0 - .blocking_write(buf) - .map(|_| buf.len()) - .map_err(|_| ErrorKind::Other) - } - - async fn flush(&mut self) -> Result<(), ErrorKind> { - self.0.blocking_flush().map_err(|_| ErrorKind::Other) - } +/// An open preview2 socket: holds the input/output streams. Dropping the +/// streams closes the connection on the host (the generated resource handles +/// implement `Drop`). +pub struct Connection { + input: InputStream, + output: OutputStream, } -/// A listening preview2 TCP socket. +/// A listening preview2 socket. pub struct Listener { socket: TcpSocket, } /// Map a preview2 socket error code into a transport error. -fn to_err(code: wasi::sockets::network::ErrorCode) -> TransportError { +fn to_err(code: ErrorCode) -> TransportError { TransportError::ConnectionRefused(format!("{code:?}")) } -/// Build an `IpSocketAddress` from a resolved IP and port. -fn socket_addr(ip: IpAddress, port: u16) -> IpSocketAddress { - match ip { - IpAddress::Ipv4(v4) => IpSocketAddress::Ipv4(Ipv4SocketAddress { - port, - address: v4, - }), - IpAddress::Ipv6(v6) => IpSocketAddress::Ipv6(Ipv6SocketAddress { - port, - address: v6, - flow_info: 0, - scope_id: 0, - }), +/// Build an `Ipv4SocketAddress` from a literal octet quad and port. DNS is not +/// performed; only numeric IPv4 peers are supported, matching the preview1 path. +fn ipv4_socket_addr(octets: [u8; 4], port: u16) -> IpSocketAddress { + IpSocketAddress::Ipv4(Ipv4SocketAddress { + port, + address: (octets[0], octets[1], octets[2], octets[3]), + }) +} + +impl WasiConn for Connection { + fn read_bytes(&self, buf: &mut [u8]) -> Result { + let chunk = self + .input + .blocking_read(buf.len() as u64) + .map_err(|e| TransportError::ReceiveFailed(format!("{e:?}")))?; + let n = chunk.len().min(buf.len()); + buf[..n].copy_from_slice(&chunk[..n]); + Ok(n) + } + + fn write_bytes(&self, buf: &[u8]) -> Result<()> { + self.output + .blocking_write_and_flush(buf) + .map_err(|e| TransportError::SendFailed(format!("{e:?}"))) } } -/// Dial `addr` (host:port) and return the connected read/write halves. -pub fn connect(addr: &str) -> Result<(Reader, Writer)> { +/// Send one length-prefixed frame over `conn`. +pub fn send_frame(conn: &Connection, frame: &[u8]) -> Result<()> { + conn.output + .blocking_write_and_flush(frame) + .map_err(|e| TransportError::SendFailed(format!("{e:?}")))?; + Ok(()) +} + +/// Dial `addr` (host:port) and return the connected socket. `host` must be a +/// numeric IPv4 literal (no DNS resolution on the preview2 path). +pub fn connect(addr: &str) -> Result> { let (host, port) = parse_addr(addr)?; - let network = instance_network(); - let addrs = network - .resolve_addresses(&host) - .map_err(to_err)?; - let ip = addrs - .into_iter() - .next() - .ok_or_else(|| TransportError::ConnectionRefused(format!("no address for {host}")))?; - let socket = TcpSocket::new(&network, IpAddressFamily::Ipv4, TcpSocketType::Stream) - .map_err(to_err)?; + let octets = parse_ipv4(&host) + .ok_or_else(|| TransportError::ConnectionRefused(format!("unresolved host {host}")))?; + let network: Network = instance_network(); + let socket = create_tcp_socket(IpAddressFamily::Ipv4).map_err(to_err)?; socket - .start_connect(&network, socket_addr(ip, port)) + .start_connect(&network, ipv4_socket_addr(octets, port)) .map_err(to_err)?; let (input, output) = socket.finish_connect().map_err(to_err)?; - Ok((Reader(input), Writer(output))) + Ok(Arc::new(Connection { input, output })) } /// Bind and listen on `port` on all interfaces. pub fn listen(port: u16) -> Result { - let network = instance_network(); - let socket = TcpSocket::new(&network, IpAddressFamily::Ipv4, TcpSocketType::Stream) + let network: Network = instance_network(); + let socket = create_tcp_socket(IpAddressFamily::Ipv4).map_err(to_err)?; + socket + .start_bind( + &network, + IpSocketAddress::Ipv4(Ipv4SocketAddress { + port, + address: (0, 0, 0, 0), + }), + ) .map_err(to_err)?; - let local = IpSocketAddress::Ipv4(Ipv4SocketAddress { - port, - address: Ipv4Address { - octets: [0, 0, 0, 0], - }, - }); - socket.start_bind(&network, local).map_err(to_err)?; socket.finish_bind().map_err(to_err)?; socket.start_listen().map_err(to_err)?; socket.finish_listen().map_err(to_err)?; @@ -107,9 +99,9 @@ pub fn listen(port: u16) -> Result { } impl Listener { - /// Accept one inbound connection and return its read/write halves. - pub fn accept(&self) -> Result<(Reader, Writer)> { + /// Accept one inbound connection and return its socket. + pub fn accept(&self) -> Result> { let (_new_socket, input, output) = self.socket.accept().map_err(to_err)?; - Ok((Reader(input), Writer(output))) + Ok(Arc::new(Connection { input, output })) } } diff --git a/Build/crates/saikuro-transport/wasi/tcp.rs b/Build/crates/saikuro-transport/wasi/tcp.rs index 69c94530..b2093cb5 100644 --- a/Build/crates/saikuro-transport/wasi/tcp.rs +++ b/Build/crates/saikuro-transport/wasi/tcp.rs @@ -1,15 +1,17 @@ +use alloc::boxed::Box; use alloc::string::{String, ToString}; -use alloc::vec::Vec; +use alloc::sync::Arc; +use async_trait::async_trait; use bytes::Bytes; -use embedded_io_async::{Read, Write}; use crate::shared::error::{Result, TransportError}; -use crate::shared::framed::{read_exact, read_first_byte, write_all}; +use crate::shared::framing::{read_frame, write_frame, AsyncByteRead, AsyncByteWrite}; use crate::shared::traits::{ LocalTransport, LocalTransportConnector, LocalTransportListener, LocalTransportReceiver, LocalTransportSender, }; +use crate::wasi::tcp::backend::{Connection, Listener}; #[cfg(all(feature = "wasi-preview2", feature = "wasi-preview1"))] compile_error!( @@ -21,37 +23,70 @@ compile_error!( "saikuro-transport: enable wasi-preview1 or wasi-preview2 to select the WASI socket backend" ); -#[cfg(feature = "wasi-preview2")] -pub use preview2 as backend; #[cfg(feature = "wasi-preview1")] -pub use preview1 as backend; +pub use crate::wasi::preview1 as backend; +#[cfg(feature = "wasi-preview2")] +pub use crate::wasi::preview2 as backend; + +/// Raw byte I/O over a WASI socket connection. +pub trait WasiConn { + /// Read up to `buf.len()` bytes into `buf`, returning the count (0 = EOF). + fn read_bytes(&self, buf: &mut [u8]) -> Result; + /// Write the entirety of `buf`. + fn write_bytes(&self, buf: &[u8]) -> Result<()>; +} + +/// Borrowing reader half that adapts a [`WasiConn`] to [`AsyncByteRead`]. +pub struct WasiReader<'a, C: WasiConn>(&'a C); -/// A length-prefixed WASI TCP transport. -pub struct WasiTcpTransport { - reader: R, - writer: W, +/// Borrowing writer half that adapts a [`WasiConn`] to [`AsyncByteWrite`]. +pub struct WasiWriter<'a, C: WasiConn>(&'a C); + +impl<'a, C: WasiConn> AsyncByteRead for WasiReader<'a, C> { + async fn read(&mut self, buf: &mut [u8]) -> Result { + self.0.read_bytes(buf) + } +} + +impl<'a, C: WasiConn> AsyncByteWrite for WasiWriter<'a, C> { + async fn write(&mut self, buf: &[u8]) -> Result { + self.0.write_bytes(buf)?; + Ok(buf.len()) + } + + async fn flush(&mut self) -> Result<()> { + Ok(()) + } +} + +/// A length-prefixed WASI TCP transport. Both halves share one socket. +pub struct WasiTcpTransport { + conn: Arc, peer: String, } -impl WasiTcpTransport { - /// Wrap an already-connected `(reader, writer)` pair. - pub fn new(reader: R, writer: W, peer: String) -> Self { - Self { reader, writer, peer } +impl WasiTcpTransport { + /// Wrap an already-connected socket. + pub fn new(conn: Arc, peer: String) -> Self { + Self { conn, peer } + } + + /// Return the address this transport is connected to. + pub fn peer_addr(&self) -> &str { + &self.peer } } -impl LocalTransport for WasiTcpTransport { - type Sender = WasiTcpSender; - type Receiver = WasiTcpReceiver; +impl LocalTransport for WasiTcpTransport { + type Sender = WasiTcpSender; + type Receiver = WasiTcpReceiver; fn split(self) -> (Self::Sender, Self::Receiver) { ( WasiTcpSender { - writer: self.writer, - }, - WasiTcpReceiver { - reader: self.reader, + conn: self.conn.clone(), }, + WasiTcpReceiver { conn: self.conn }, ) } @@ -61,13 +96,14 @@ impl LocalTransport for WasiTcpTransport { - writer: W, +pub struct WasiTcpSender { + conn: Arc, } -impl LocalTransportSender for WasiTcpSender { +#[async_trait(?Send)] +impl LocalTransportSender for WasiTcpSender { async fn send(&mut self, frame: Bytes) -> Result<()> { - write_a_frame(&mut self.writer, &frame).await + write_frame(&mut WasiWriter(self.conn.as_ref()), &frame).await } async fn close(&mut self) -> Result<()> { @@ -76,47 +112,15 @@ impl LocalTransportSender for WasiTcpSender { } /// Receiving half of a [`WasiTcpTransport`]. -pub struct WasiTcpReceiver { - reader: R, +pub struct WasiTcpReceiver { + conn: Arc, } -impl LocalTransportReceiver for WasiTcpReceiver { +#[async_trait(?Send)] +impl LocalTransportReceiver for WasiTcpReceiver { async fn recv(&mut self) -> Result> { - read_a_frame(&mut self.reader).await - } -} - -/// Write one length-prefixed frame to `writer`. -async fn write_a_frame(writer: &mut W, frame: &[u8]) -> Result<()> { - let len = frame.len(); - let header = [ - (len >> 24) as u8, - (len >> 16) as u8, - (len >> 8) as u8, - len as u8, - ]; - write_all(writer, &header).await?; - write_all(writer, frame).await?; - Ok(()) -} - -/// Read one length-prefixed frame, or `None` on a clean zero-length close. -async fn read_a_frame(reader: &mut R) -> Result> { - let mut first = 0u8; - read_first_byte(reader, &mut first).await?; - if first == 0 { - return Ok(None); - } - let mut rest = [0u8; 3]; - read_exact(reader, &mut rest, "wasi-tcp: closed during frame header").await?; - let len = ((first as usize) << 24) - | ((rest[0] as usize) << 16) - | ((rest[1] as usize) << 8) - | (rest[2] as usize); - let mut buf = Vec::with_capacity(len); - buf.resize(len, 0); - read_exact(reader, &mut buf, "wasi-tcp: closed during frame payload").await?; - Ok(Some(Bytes::from(buf))) + read_frame(&mut WasiReader(self.conn.as_ref())).await + } } /// Connects to a peer over WASI TCP. @@ -131,18 +135,19 @@ impl WasiTcpConnector { } } +#[async_trait(?Send)] impl LocalTransportConnector for WasiTcpConnector { - type Output = WasiTcpTransport; + type Output = WasiTcpTransport; async fn connect(&self) -> Result { - let (reader, writer) = backend::connect(&self.addr)?; - Ok(WasiTcpTransport::new(reader, writer, self.addr.clone())) + let conn = backend::connect(&self.addr)?; + Ok(WasiTcpTransport::new(conn, self.addr.clone())) } } /// Accepts inbound WASI TCP connections on a port. pub struct WasiTcpListener { - inner: backend::Listener, + inner: Listener, } impl WasiTcpListener { @@ -155,12 +160,13 @@ impl WasiTcpListener { } } +#[async_trait(?Send)] impl LocalTransportListener for WasiTcpListener { - type Output = WasiTcpTransport; + type Output = WasiTcpTransport; async fn accept(&mut self) -> Result> { - let (reader, writer) = self.inner.accept()?; - Ok(Some(WasiTcpTransport::new(reader, writer, String::new()))) + let conn = self.inner.accept()?; + Ok(Some(WasiTcpTransport::new(conn, String::new()))) } async fn close(&mut self) -> Result<()> { diff --git a/Build/crates/saikuro-transport/wasi/websocket.rs b/Build/crates/saikuro-transport/wasi/websocket.rs new file mode 100644 index 00000000..d923b415 --- /dev/null +++ b/Build/crates/saikuro-transport/wasi/websocket.rs @@ -0,0 +1,243 @@ +use alloc::boxed::Box; +use alloc::format; +use alloc::string::String; +use alloc::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use core::cell::RefCell; + +use crate::shared::error::{Result, TransportError}; +use crate::shared::traits::{Transport, TransportReceiver, TransportSender}; +use crate::wasi::tcp::backend::Connection; +use crate::wasi::tcp::WasiConn; + +use embedded_websocket::framer::{Framer, ReadResult, Stream as WsStream}; +use embedded_websocket::{ + WebSocketClient, WebSocketCloseStatusCode, WebSocketOptions, WebSocketSendMessageType, +}; + +/// Internal framing buffer size. +const WS_BUF: usize = 4096; + +struct WsRng; + +impl rand_core_06::RngCore for WsRng { + fn next_u32(&mut self) -> u32 { + let mut b = [0u8; 4]; + self.fill_bytes(&mut b); + u32::from_le_bytes(b) + } + + fn next_u64(&mut self) -> u64 { + let mut b = [0u8; 8]; + self.fill_bytes(&mut b); + u64::from_le_bytes(b) + } + + fn fill_bytes(&mut self, dest: &mut [u8]) { + getrandom::fill(dest).expect("ws rng: getrandom failed on WASI") + } + + fn try_fill_bytes( + &mut self, + dest: &mut [u8], + ) -> core::result::Result<(), rand_core_06::Error> { + match getrandom::fill(dest) { + Ok(()) => Ok(()), + Err(_) => Err(rand_core_06::Error::from( + core::num::NonZeroU32::new(0x10000u32).expect("nonzero"), + )), + } + } +} + +/// Adapts a connected WASI socket to the byte-stream interface the +/// `embedded-websocket` sync framer requires. +struct WasiWsConn { + conn: Arc, +} + +impl WsStream for WasiWsConn { + fn read(&mut self, buf: &mut [u8]) -> core::result::Result { + self.conn.read_bytes(buf) + } + + fn write_all(&mut self, buf: &[u8]) -> core::result::Result<(), TransportError> { + self.conn.write_bytes(buf) + } +} + +/// Shared per-connection state. The frame and parse buffers are owned here so +/// sender and receiver can share one socket through a single `RefCell`. +struct WasiWsState { + ws: WebSocketClient, + conn: WasiWsConn, + read_buf: [u8; WS_BUF], + write_buf: [u8; WS_BUF], + read_cursor: usize, +} + +fn ws_err(e: embedded_websocket::framer::FramerError) -> TransportError { + use embedded_websocket::framer::FramerError; + match e { + FramerError::Io(e) => TransportError::ConnectionLost(format!("ws io: {e:?}")), + FramerError::WebSocket(ws) => TransportError::ReceiveFailed(format!("ws: {ws:?}")), + FramerError::HttpHeader(h) => { + TransportError::ConnectionRefused(format!("ws handshake http: {h:?}")) + } + FramerError::FrameTooLarge(n) => TransportError::MessageTooLarge { + size: n, + limit: WS_BUF, + }, + FramerError::Utf8(u) => TransportError::ReceiveFailed(format!("ws utf8: {u}")), + } +} + +/// Parse `ws://host[:port][/path]`. WASI exposes only raw TCP, so TLS-based +/// `wss://` is not supported here. +fn parse_ws_url(url: &str) -> Result<(String, u16, String)> { + let rest = url.strip_prefix("ws://").ok_or_else(|| { + TransportError::ConnectionRefused(format!( + "ws: only non-TLS ws:// is supported on WASI: {url}" + )) + })?; + let (authority, path) = match rest.find('/') { + Some(idx) => (&rest[..idx], format!("/{}", &rest[idx + 1..])), + None => (rest, String::from("/")), + }; + let (host, port) = match authority.rsplit_once(':') { + Some((h, p)) => ( + String::from(h), + p.parse::() + .map_err(|_| TransportError::ConnectionRefused(format!("ws: bad port in {url}")))?, + ), + None => (String::from(authority), 80), + }; + Ok((host, port, path)) +} + +/// A `no_std` WebSocket client transport backed by a WASI socket. +pub struct WebSocketTransport { + state: Arc>, +} + +impl WebSocketTransport { + /// Open a WebSocket connection to `url` (`ws://host[:port][/path]`). + pub async fn connect(url: impl Into) -> Result { + let url = url.into(); + let (host, port, path) = parse_ws_url(&url)?; + let conn = crate::wasi::tcp::backend::connect(&format!("{host}:{port}"))?; + let mut state = WasiWsState { + ws: WebSocketClient::new_client(WsRng), + conn: WasiWsConn { conn }, + read_buf: [0u8; WS_BUF], + write_buf: [0u8; WS_BUF], + read_cursor: 0, + }; + let options = WebSocketOptions { + path: &path, + host: &host, + origin: &host, + sub_protocols: None, + additional_headers: None, + }; + { + let mut framer = Framer::new( + &mut state.read_buf, + &mut state.read_cursor, + &mut state.write_buf, + &mut state.ws, + ); + framer.connect(&mut state.conn, &options).map_err(ws_err)?; + } + Ok(Self { + state: Arc::new(RefCell::new(state)), + }) + } +} + +impl Transport for WebSocketTransport { + type Sender = WebSocketSender; + type Receiver = WebSocketReceiver; + + fn split(self) -> (Self::Sender, Self::Receiver) { + let s = self.state.clone(); + ( + WebSocketSender { + state: s.clone(), + }, + WebSocketReceiver { state: s }, + ) + } + + fn description(&self) -> &str { + "websocket" + } +} + +/// Sending half of a [`WebSocketTransport`]. +pub struct WebSocketSender { + state: Arc>, +} + +#[async_trait(?Send)] +impl TransportSender for WebSocketSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + let mut st = self.state.borrow_mut(); + let st: &mut WasiWsState = &mut *st; + let mut framer = Framer::new( + &mut st.read_buf, + &mut st.read_cursor, + &mut st.write_buf, + &mut st.ws, + ); + framer + .write(&mut st.conn, WebSocketSendMessageType::Binary, true, &frame) + .map_err(ws_err)?; + Ok(()) + } + + async fn close(&mut self) -> Result<()> { + let mut st = self.state.borrow_mut(); + let st: &mut WasiWsState = &mut *st; + let mut framer = Framer::new( + &mut st.read_buf, + &mut st.read_cursor, + &mut st.write_buf, + &mut st.ws, + ); + framer + .close(&mut st.conn, WebSocketCloseStatusCode::NormalClosure, None) + .map_err(ws_err)?; + Ok(()) + } +} + +/// Receiving half of a [`WebSocketTransport`]. +pub struct WebSocketReceiver { + state: Arc>, +} + +#[async_trait(?Send)] +impl TransportReceiver for WebSocketReceiver { + async fn recv(&mut self) -> Result> { + let mut st = self.state.borrow_mut(); + let st: &mut WasiWsState = &mut *st; + let mut frame_buf = [0u8; WS_BUF]; + loop { + let mut framer = Framer::new( + &mut st.read_buf, + &mut st.read_cursor, + &mut st.write_buf, + &mut st.ws, + ); + match framer.read(&mut st.conn, &mut frame_buf).map_err(ws_err)? { + ReadResult::Binary(b) => return Ok(Some(Bytes::copy_from_slice(b))), + ReadResult::Text(t) => return Ok(Some(Bytes::copy_from_slice(t.as_bytes()))), + ReadResult::Pong(_) => continue, + ReadResult::Closed => return Ok(None), + } + } + } +} diff --git a/Build/crates/saikuro-transport/wasm/host_browser.rs b/Build/crates/saikuro-transport/wasm/host_browser.rs index e9fddf1a..46551f9e 100644 --- a/Build/crates/saikuro-transport/wasm/host_browser.rs +++ b/Build/crates/saikuro-transport/wasm/host_browser.rs @@ -1,6 +1,8 @@ +use alloc::boxed::Box; use alloc::format; use alloc::string::String; use alloc::vec::Vec; +use async_trait::async_trait; use bytes::Bytes; use core::fmt::Write; use core::time::Duration; @@ -37,20 +39,23 @@ pub struct BroadcastChannelRecv { } /// The browser `wasm` engine's `WasmHostTransport` concrete type. -pub type WasmHost = crate::shared::host::WasmHostTransport; +pub type WasmHost = + crate::shared::host::WasmHostTransport; +#[async_trait(?Send)] impl HostPipeFactory for BroadcastChannelPipe { type Send = BroadcastChannelSend; type Recv = BroadcastChannelRecv; async fn open(channel: &str, role: Role) -> Result<(Self::Send, Self::Recv)> { match role { - Role::Connect => open_connect(channel), - Role::Accept => open_accept(channel), + Role::Connect => open_connect(channel).await, + Role::Accept => open_accept(channel).await, } } } +#[async_trait(?Send)] impl HostPipeSend for BroadcastChannelSend { async fn send(&mut self, frame: &[u8]) -> Result<()> { trace!(bytes = frame.len(), "wasm-host send"); @@ -58,6 +63,7 @@ impl HostPipeSend for BroadcastChannelSend { } } +#[async_trait(?Send)] impl HostPipeRecv for BroadcastChannelRecv { async fn recv(&mut self) -> Result>> { match self.rx.recv().await { @@ -91,7 +97,7 @@ async fn open_connect(channel: &str) -> Result<(BroadcastChannelSend, BroadcastC let (data_tx, data_rx) = mpsc::channel::(DEFAULT_CHANNEL_CAPACITY); let (accept_tx, accept_rx) = oneshot::channel::<()>(); - let handler: Closure = Closure::new({ + let handler: SendWrapper> = SendWrapper::new(Closure::new({ let data_tx = data_tx; let accept_tx = accept_tx; let expected = conn_id.clone(); @@ -108,8 +114,8 @@ async fn open_connect(channel: &str) -> Result<(BroadcastChannelSend, BroadcastC let _ = data_tx.try_send(Bytes::from(bytes)); } } - }); - private.set_onmessage(Some(handler.as_ref().unchecked_ref())); + })); + private.set_onmessage(Some((&*handler).as_ref().unchecked_ref())); let base = BroadcastChannel::new(channel) .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; @@ -119,18 +125,20 @@ async fn open_connect(channel: &str) -> Result<(BroadcastChannelSend, BroadcastC drop(base); match timeout(CONNECT_TIMEOUT, accept_rx.recv()).await { - Ok(Some(())) => { + Ok(Ok(())) => { let send = BroadcastChannelSend { channel: SendWrapper::new(private.clone()), }; let recv = BroadcastChannelRecv { channel: SendWrapper::new(private), rx: data_rx, - _handler: SendWrapper::new(handler), + _handler: handler, }; Ok((send, recv)) } - Ok(None) => Err(TransportError::ConnectionLost("accept channel closed".into())), + Ok(Err(_)) => Err(TransportError::ConnectionLost( + "accept channel closed".into(), + )), Err(_) => Err(TransportError::ConnectionLost("connect timeout".into())), } } @@ -140,22 +148,23 @@ async fn open_accept(channel: &str) -> Result<(BroadcastChannelSend, BroadcastCh let base = BroadcastChannel::new(channel) .map_err(|e| TransportError::ConnectionLost(format!("{e:?}")))?; - let (conn_tx, conn_rx) = mpsc::channel::( + let (conn_tx, mut conn_rx) = mpsc::channel::( saikuro_exec::ChannelCapacity::try_from(32).expect("32 is a valid channel capacity"), ); - let base_handler: Closure = Closure::new({ - let conn_tx = conn_tx; - move |event: MessageEvent| { - let data = event.data(); - if get_field(&data, "type").as_deref() != Some("connect") { - return; - } - if let Some(id) = get_field(&data, "id") { - let _ = conn_tx.try_send(id); + let base_handler: SendWrapper> = + SendWrapper::new(Closure::new({ + let conn_tx = conn_tx; + move |event: MessageEvent| { + let data = event.data(); + if get_field(&data, "type").as_deref() != Some("connect") { + return; + } + if let Some(id) = get_field(&data, "id") { + let _ = conn_tx.try_send(id); + } } - } - }); - base.set_onmessage(Some(base_handler.as_ref().unchecked_ref())); + })); + base.set_onmessage(Some((&*base_handler).as_ref().unchecked_ref())); let conn_id = match conn_rx.recv().await { Some(id) => id, @@ -203,7 +212,9 @@ fn short_id() -> Result { let mut buf = [0u8; 16]; crypto .get_random_values_with_u8_array(&mut buf) - .map_err(|e| TransportError::ConnectionLost(format!("crypto get_random_values failed: {e:?}")))?; + .map_err(|e| { + TransportError::ConnectionLost(format!("crypto get_random_values failed: {e:?}")) + })?; Ok(buf.iter().fold(String::with_capacity(32), |mut s, b| { let _ = write!(s, "{:02x}", b); s diff --git a/Build/crates/saikuro-transport/wasm/mod.rs b/Build/crates/saikuro-transport/wasm/mod.rs index 0c896afb..7d778d39 100644 --- a/Build/crates/saikuro-transport/wasm/mod.rs +++ b/Build/crates/saikuro-transport/wasm/mod.rs @@ -1,4 +1,4 @@ -#[cfg(feature = "ws")] +#[cfg(all(feature = "ws", feature = "std"))] pub mod websocket; #[cfg(feature = "wasm-host")] diff --git a/Build/crates/saikuro-transport/wasm/websocket.rs b/Build/crates/saikuro-transport/wasm/websocket.rs index 3b5048c5..53eeb1ed 100644 --- a/Build/crates/saikuro-transport/wasm/websocket.rs +++ b/Build/crates/saikuro-transport/wasm/websocket.rs @@ -34,7 +34,8 @@ impl WebSocketTransport { ws.set_binary_type(BinaryType::Arraybuffer); let (tx, rx) = oneshot::channel::>(); - let shared: Rc>>> = Rc::new(RefCell::new(Some(tx))); + let shared: Rc>>>> = + Rc::new(RefCell::new(Some(tx))); let open_shared = Rc::clone(&shared); let onopen = Closure::::new(move |_: Event| { @@ -153,7 +154,7 @@ pub struct WebSocketSender { url: String, } -#[async_trait] +#[async_trait(?Send)] impl TransportSender for WebSocketSender { async fn send(&mut self, frame: Bytes) -> Result<()> { use js_sys::{ArrayBuffer, Uint8Array}; @@ -201,7 +202,7 @@ impl Drop for WebSocketReceiver { } } -#[async_trait] +#[async_trait(?Send)] impl TransportReceiver for WebSocketReceiver { async fn recv(&mut self) -> Result> { match self.rx.recv().await { diff --git a/Build/scripts/check_matrix.py b/Build/scripts/check_matrix.py new file mode 100644 index 00000000..1a20f45c --- /dev/null +++ b/Build/scripts/check_matrix.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Cross-compile saikuro-runtime (or any crate) across the full engine x target +matrix and report a pass/fail table. + +Engines and their targets / feature sets: + + native host (current) default features std + wasm wasm32-unknown-unknown --no-default-features wasm no_std + embedded thumbv7m-none-eabi --no-default-features embedded,tcp no_std + wasi p1 wasm32-wasip1 --no-default-features no_std,wasi-tcp,wasi-host,wasi-preview1 no_std + wasi p2 wasm32-wasip1 --no-default-features no_std,wasi-tcp,wasi-host,wasi-preview2 no_std + +Usage: + python3 scripts/check_matrix.py + python3 scripts/check_matrix.py --crate saikuro-transport + python3 scripts/check_matrix.py --all-crates + python3 scripts/check_matrix.py --json out.json +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import time +from dataclasses import dataclass, field +from typing import Optional + +CARGO = shutil.which("cargo") or "/Users/neel/.cargo/bin/cargo" +ROOT = os.path.dirname(os.path.abspath(__file__)) +if os.path.basename(ROOT) == "scripts": + ROOT = os.path.dirname(ROOT) +MANIFEST = os.path.join(ROOT, "Cargo.toml") + +DEFAULT_CRATE = "saikuro-runtime" +TIMEOUT = 600 # seconds per check + + +@dataclass +class Combo: + name: str + target: Optional[str] + cargo_args: list[str] = field(default_factory=list) + notes: str = "" + + +MATRIX: list[Combo] = [ + Combo( + "native (host, std)", + None, + [], + "default features", + ), + Combo( + "native (ws)", + None, + ["--features", "ws"], + "default features + websocket transport", + ), + Combo( + "wasm (std)", + "wasm32-unknown-unknown", + ["--no-default-features", "--features", "std,wasm"], + "std wasm", + ), + Combo( + "wasm (no_std)", + "wasm32-unknown-unknown", + ["--no-default-features", "--features", "wasm"], + "no_std wasm", + ), + Combo( + "embedded (no_std)", + "thumbv7m-none-eabi", + ["--no-default-features", "--features", "embedded,tcp"], + "no_std embedded", + ), + Combo( + "wasi preview1 (no_std)", + "wasm32-wasip1", + [ + "--no-default-features", + "--features", + "no_std,wasi-tcp,wasi-host,wasi-preview1", + ], + "no_std wasi (preview1)", + ), + Combo( + "wasi preview1 (std)", + "wasm32-wasip1", + [ + "--no-default-features", + "--features", + "std,no_std,wasi-tcp,wasi-host,wasi-preview1", + ], + "std wasi (preview1)", + ), + Combo( + "wasi preview2 (no_std)", + "wasm32-wasip2", + [ + "--no-default-features", + "--features", + "no_std,wasi-tcp,wasi-host,wasi-preview2", + ], + "no_std wasi (preview2)", + ), + Combo( + "wasi preview2 (std)", + "wasm32-wasip2", + [ + "--no-default-features", + "--features", + "std,no_std,wasi-tcp,wasi-host,wasi-preview2", + ], + "std wasi (preview2)", + ), + Combo( + "wasi preview1 (ws)", + "wasm32-wasip1", + [ + "--no-default-features", + "--features", + "no_std,wasi-tcp,wasi-host,wasi-preview1,ws-wasi", + ], + "no_std wasi websocket client (preview1)", + ), + Combo( + "wasi preview2 (ws)", + "wasm32-wasip2", + [ + "--no-default-features", + "--features", + "no_std,wasi-tcp,wasi-host,wasi-preview2,ws-wasi", + ], + "no_std wasi websocket client (preview2)", + ), +] + + +def run_combo(crate: str, combo: Combo, verbose: bool) -> dict: + """Run `cargo check` for one combo; return a result dict.""" + cmd = [CARGO, "check", "-p", crate, "--lib", "--manifest-path", MANIFEST] + if combo.target: + cmd += ["--target", combo.target] + cmd += combo.cargo_args + + start = time.time() + env = dict(os.environ) + env["CARGO_TERM_COLOR"] = "never" + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + cwd=ROOT, + timeout=TIMEOUT, + ) + elapsed = time.time() - start + out = proc.stdout.decode("utf-8", errors="replace") + lines = out.splitlines() + err_count = sum(1 for line in lines if line.startswith("error")) + warn_count = sum( + 1 for line in lines if line.startswith("warning") and "generated" not in line + ) + # Capture the diagnostic lines so they can be shown after the run instead + # of being discarded; `error`/`warning` headlines plus their `note:`/`help:` + #/`-->` context lines. + diags = [ + line + for line in lines + if line.strip().startswith(("error", "warning", "note:", "help:", "-->")) + ] + passed = proc.returncode == 0 and err_count == 0 + + if verbose and not passed: + print(out) + + return { + "name": combo.name, + "target": combo.target or "", + "features": " ".join(combo.cargo_args) or "", + "notes": combo.notes, + "passed": passed, + "returncode": proc.returncode, + "errors": err_count, + "warnings": warn_count, + "diags": diags, + "seconds": round(elapsed, 1), + } + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--crate", default=DEFAULT_CRATE, help="crate to check") + ap.add_argument( + "--all-crates", + action="store_true", + help="check every workspace member across the matrix", + ) + ap.add_argument("--json", metavar="PATH", help="write results as JSON") + ap.add_argument("--verbose", action="store_true", help="print failing output") + args = ap.parse_args() + + crates = [] + if args.all_crates: + txt = subprocess.run( + [CARGO, "metadata", "--no-deps", "--format-version", "1", + "--manifest-path", MANIFEST], + stdout=subprocess.PIPE, + cwd=ROOT, + check=True, + ).stdout.decode() + import json + + crates = [p["name"] for p in json.loads(txt)["packages"]] + else: + crates = [args.crate] + + print(f"cargo : {CARGO}") + print(f"root : {ROOT}") + print(f"matrix: {len(MATRIX)} combos x {len(crates)} crate(s)\n") + + results: list[dict] = [] + for crate in crates: + print(f"=== crate: {crate} ===") + for combo in MATRIX: + r = run_combo(crate, combo, args.verbose) + status = "PASS" if r["passed"] else "FAIL" + print( + f" [{status}] {r['name']:<22} target={r['target']:<20} " + f"errs={r['errors']:<3} warns={r['warnings']:<3} {r['seconds']}s" + ) + results.append({**r, "crate": crate}) + + total = len(results) + passed = sum(1 for r in results if r["passed"]) + warn_total = sum(r["warnings"] for r in results) + err_total = sum(r["errors"] for r in results) + print( + f"\n=== SUMMARY: {passed}/{total} passed " + f"({warn_total} warnings, {err_total} errors) ===" + ) + for r in results: + if not r["passed"]: + print( + f" FAIL {r['crate']} :: {r['name']} " + f"(target={r['target']}, features={r['features']})" + ) + + diag_results = [r for r in results if r["warnings"] or r["errors"]] + if diag_results: + print("\n=== WARNINGS & ERRORS ===") + for r in diag_results: + tag = "FAIL" if not r["passed"] else "WARN" + print( + f"\n[{tag}] {r['crate']} :: {r['name']} " + f"(target={r['target']}, features={r['features']})" + ) + if r["diags"]: + for line in r["diags"]: + print(" " + line) + else: + print(" (no diagnostic lines captured)") + + if args.json: + with open(args.json, "w") as fh: + json.dump(results, fh, indent=2) + print(f"\nwrote {args.json}") + + return 0 if passed == total else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 00b146d6c6855ae52d3042178ecfb2e346f7eb83 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Mon, 17 Aug 2026 00:54:39 -0600 Subject: [PATCH 38/43] cleanup --- Build/crates/saikuro-exec/base/exec.rs | 4 +- .../shared/transport_adapter/mod.rs | 10 ++ .../shared/transport_adapter/native.rs | 77 +++++++++++++ .../nonnative.rs} | 104 +----------------- .../saikuro-transport/embedded/framed.rs | 25 +++++ .../crates/saikuro-transport/embedded/mod.rs | 6 + Build/crates/saikuro-transport/lib.rs | 54 +++------ Build/crates/saikuro-transport/native/mod.rs | 7 ++ .../saikuro-transport/shared/framing.rs | 24 ---- Build/crates/saikuro-transport/wasi/mod.rs | 5 + .../saikuro-transport/wasi/websocket.rs | 9 +- Build/crates/saikuro-transport/wasm/mod.rs | 5 + Build/scripts/check_matrix.py | 29 +++-- 13 files changed, 175 insertions(+), 184 deletions(-) create mode 100644 Build/crates/saikuro-runtime/shared/transport_adapter/mod.rs create mode 100644 Build/crates/saikuro-runtime/shared/transport_adapter/native.rs rename Build/crates/saikuro-runtime/shared/{transport_adapter.rs => transport_adapter/nonnative.rs} (63%) create mode 100644 Build/crates/saikuro-transport/embedded/framed.rs diff --git a/Build/crates/saikuro-exec/base/exec.rs b/Build/crates/saikuro-exec/base/exec.rs index a76d3b08..45446ce6 100644 --- a/Build/crates/saikuro-exec/base/exec.rs +++ b/Build/crates/saikuro-exec/base/exec.rs @@ -211,7 +211,9 @@ pub fn pump() {} fn static_executor() -> &'static mut ArchExecutor { static mut EXECUTOR: Option = None; #[cfg(feature = "no_std")] - let ex = unsafe { (*core::ptr::addr_of_mut!(EXECUTOR)).get_or_insert_with(|| ArchExecutor::new(null_mut())) }; + let ex = unsafe { + (*core::ptr::addr_of_mut!(EXECUTOR)).get_or_insert_with(|| ArchExecutor::new(null_mut())) + }; #[cfg(feature = "wasm")] let ex = unsafe { (*core::ptr::addr_of_mut!(EXECUTOR)).get_or_insert_with(ArchExecutor::new) }; // SAFETY: `EXECUTOR` is a `static mut` holding the sole executor instance; we diff --git a/Build/crates/saikuro-runtime/shared/transport_adapter/mod.rs b/Build/crates/saikuro-runtime/shared/transport_adapter/mod.rs new file mode 100644 index 00000000..1a4a7dd9 --- /dev/null +++ b/Build/crates/saikuro-runtime/shared/transport_adapter/mod.rs @@ -0,0 +1,10 @@ +//! Engine-specific runtime transport-trait bridges. +#[cfg(feature = "native")] +mod native; +#[cfg(not(feature = "native"))] +mod nonnative; + +#[cfg(feature = "native")] +pub use native::*; +#[cfg(not(feature = "native"))] +pub use nonnative::*; diff --git a/Build/crates/saikuro-runtime/shared/transport_adapter/native.rs b/Build/crates/saikuro-runtime/shared/transport_adapter/native.rs new file mode 100644 index 00000000..3e2b2151 --- /dev/null +++ b/Build/crates/saikuro-runtime/shared/transport_adapter/native.rs @@ -0,0 +1,77 @@ +use async_trait::async_trait; +use bytes::Bytes; + +use saikuro_transport::shared::error::Result; +use saikuro_transport::shared::traits::{ + Transport, TransportListener, TransportReceiver, TransportSender, +}; + +mod send_runtime_traits { + use super::*; + + #[async_trait] + pub trait RuntimeSender: Send { + async fn send(&mut self, frame: Bytes) -> Result<()>; + async fn close(&mut self) -> Result<()>; + } + #[async_trait] + pub trait RuntimeReceiver: Send { + async fn recv(&mut self) -> Result>; + } + #[async_trait] + pub trait RuntimeTransport: Send { + type Sender: RuntimeSender + Send + Sync + 'static; + type Receiver: RuntimeReceiver + Send + Sync + 'static; + fn split(self) -> (Self::Sender, Self::Receiver); + fn description(&self) -> &str; + } + #[async_trait] + pub trait RuntimeListener: Send { + type Output: RuntimeTransport + 'static; + async fn accept(&mut self) -> Result>; + async fn close(&mut self) -> Result<()>; + } +} + +pub use send_runtime_traits::*; + +// Blanket impls forwarding the `Transport*` family to the runtime traits. +#[async_trait] +impl RuntimeSender for T { + async fn send(&mut self, frame: Bytes) -> Result<()> { + T::send(self, frame).await + } + async fn close(&mut self) -> Result<()> { + T::close(self).await + } +} + +#[async_trait] +impl RuntimeReceiver for T { + async fn recv(&mut self) -> Result> { + T::recv(self).await + } +} + +#[async_trait] +impl RuntimeTransport for T { + type Sender = T::Sender; + type Receiver = T::Receiver; + fn split(self) -> (Self::Sender, Self::Receiver) { + T::split(self) + } + fn description(&self) -> &str { + T::description(self) + } +} + +#[async_trait] +impl RuntimeListener for T { + type Output = T::Output; + async fn accept(&mut self) -> Result> { + T::accept(self).await + } + async fn close(&mut self) -> Result<()> { + T::close(self).await + } +} diff --git a/Build/crates/saikuro-runtime/shared/transport_adapter.rs b/Build/crates/saikuro-runtime/shared/transport_adapter/nonnative.rs similarity index 63% rename from Build/crates/saikuro-runtime/shared/transport_adapter.rs rename to Build/crates/saikuro-runtime/shared/transport_adapter/nonnative.rs index fae548bf..47784db7 100644 --- a/Build/crates/saikuro-runtime/shared/transport_adapter.rs +++ b/Build/crates/saikuro-runtime/shared/transport_adapter/nonnative.rs @@ -1,49 +1,15 @@ use alloc::boxed::Box; -#[cfg(not(feature = "native"))] use alloc::string::String; use async_trait::async_trait; use bytes::Bytes; use saikuro_transport::shared::error::Result; -#[cfg(not(feature = "native"))] use saikuro_transport::shared::host::{HostPipeFactory, Role, WasmHostTransport}; -#[cfg(not(feature = "native"))] use saikuro_transport::shared::traits::{ LocalTransport, LocalTransportListener, LocalTransportReceiver, LocalTransportSender, -}; -use saikuro_transport::shared::traits::{ Transport, TransportListener, TransportReceiver, TransportSender, }; -#[cfg(feature = "native")] -mod send_runtime_traits { - use super::*; - - #[async_trait] - pub trait RuntimeSender: Send { - async fn send(&mut self, frame: Bytes) -> Result<()>; - async fn close(&mut self) -> Result<()>; - } - #[async_trait] - pub trait RuntimeReceiver: Send { - async fn recv(&mut self) -> Result>; - } - #[async_trait] - pub trait RuntimeTransport: Send { - type Sender: RuntimeSender + Send + Sync + 'static; - type Receiver: RuntimeReceiver + Send + Sync + 'static; - fn split(self) -> (Self::Sender, Self::Receiver); - fn description(&self) -> &str; - } - #[async_trait] - pub trait RuntimeListener: Send { - type Output: RuntimeTransport + 'static; - async fn accept(&mut self) -> Result>; - async fn close(&mut self) -> Result<()>; - } -} - -#[cfg(not(feature = "native"))] mod nosend_runtime_traits { use super::*; @@ -71,59 +37,9 @@ mod nosend_runtime_traits { } } -#[cfg(feature = "native")] -pub use send_runtime_traits::*; - -#[cfg(not(feature = "native"))] pub use nosend_runtime_traits::*; // Blanket impls forwarding the `Transport*` family to the runtime traits. -// Native needs `Send` bounds (tokio tasks); non-native engines are `?Send`. -#[cfg(feature = "native")] -#[async_trait] -impl RuntimeSender for T { - async fn send(&mut self, frame: Bytes) -> Result<()> { - T::send(self, frame).await - } - async fn close(&mut self) -> Result<()> { - T::close(self).await - } -} - -#[cfg(feature = "native")] -#[async_trait] -impl RuntimeReceiver for T { - async fn recv(&mut self) -> Result> { - T::recv(self).await - } -} - -#[cfg(feature = "native")] -#[async_trait] -impl RuntimeTransport for T { - type Sender = T::Sender; - type Receiver = T::Receiver; - fn split(self) -> (Self::Sender, Self::Receiver) { - T::split(self) - } - fn description(&self) -> &str { - T::description(self) - } -} - -#[cfg(feature = "native")] -#[async_trait] -impl RuntimeListener for T { - type Output = T::Output; - async fn accept(&mut self) -> Result> { - T::accept(self).await - } - async fn close(&mut self) -> Result<()> { - T::close(self).await - } -} - -#[cfg(not(feature = "native"))] #[async_trait(?Send)] impl RuntimeSender for T { async fn send(&mut self, frame: Bytes) -> Result<()> { @@ -134,7 +50,6 @@ impl RuntimeSender for T { } } -#[cfg(not(feature = "native"))] #[async_trait(?Send)] impl RuntimeReceiver for T { async fn recv(&mut self) -> Result> { @@ -142,7 +57,6 @@ impl RuntimeReceiver for T { } } -#[cfg(not(feature = "native"))] #[async_trait(?Send)] impl RuntimeTransport for T { type Sender = T::Sender; @@ -155,7 +69,6 @@ impl RuntimeTransport for T { } } -#[cfg(not(feature = "native"))] #[async_trait(?Send)] impl RuntimeListener for T { type Output = T::Output; @@ -168,11 +81,9 @@ impl RuntimeListener for T { } // Non-native engines adapt the `LocalTransport*` family into the runtime traits -// via these wrappers. Native does not use them: it forwards `Transport*` above. -#[cfg(not(feature = "native"))] +// via these wrappers. pub struct LocalRuntimeSender(S); -#[cfg(not(feature = "native"))] #[async_trait(?Send)] impl RuntimeSender for LocalRuntimeSender { async fn send(&mut self, frame: Bytes) -> Result<()> { @@ -183,10 +94,8 @@ impl RuntimeSender for LocalRuntimeSender { } } -#[cfg(not(feature = "native"))] pub struct LocalRuntimeReceiver(R); -#[cfg(not(feature = "native"))] #[async_trait(?Send)] impl RuntimeReceiver for LocalRuntimeReceiver { async fn recv(&mut self) -> Result> { @@ -194,10 +103,8 @@ impl RuntimeReceiver for LocalRuntimeReceiver { } } -#[cfg(not(feature = "native"))] pub struct LocalRuntimeTransport(T); -#[cfg(not(feature = "native"))] #[async_trait(?Send)] impl RuntimeTransport for LocalRuntimeTransport where @@ -215,10 +122,8 @@ where } } -#[cfg(not(feature = "native"))] pub struct LocalRuntimeListener(L); -#[cfg(not(feature = "native"))] impl LocalRuntimeListener { /// Wrap a `LocalTransportListener` so it satisfies [`RuntimeListener`]. pub fn new(listener: L) -> Self { @@ -226,7 +131,6 @@ impl LocalRuntimeListener { } } -#[cfg(not(feature = "native"))] #[async_trait(?Send)] impl RuntimeListener for LocalRuntimeListener where @@ -246,16 +150,11 @@ where } } -/// Adapts a `HostPipeFactory` (BroadcastChannel / WASI loopback) into a -/// [`RuntimeListener`]-compatible listener. Only meaningful for the wasm / wasi -/// engines; the embedded engine uses its own embassy-net listener. -#[cfg(not(feature = "native"))] pub struct HostPipeListener { channel: String, _marker: core::marker::PhantomData F>, } -#[cfg(not(feature = "native"))] impl HostPipeListener { /// Start listening for a rendezvous connection on `channel`. pub fn new(channel: impl Into) -> Self { @@ -266,7 +165,6 @@ impl HostPipeListener { } } -#[cfg(not(feature = "native"))] #[async_trait(?Send)] impl RuntimeListener for HostPipeListener where diff --git a/Build/crates/saikuro-transport/embedded/framed.rs b/Build/crates/saikuro-transport/embedded/framed.rs new file mode 100644 index 00000000..eec34ae1 --- /dev/null +++ b/Build/crates/saikuro-transport/embedded/framed.rs @@ -0,0 +1,25 @@ +//! Embedded (embedded-io-async) adapter for the shared framing core +use crate::shared::error::TransportError; +use crate::shared::framing::{AsyncByteRead, AsyncByteWrite}; + +impl AsyncByteRead for R { + async fn read(&mut self, buf: &mut [u8]) -> Result { + self.read(buf) + .await + .map_err(|e| TransportError::ConnectionLost(alloc::format!("{:?}", e))) + } +} + +impl AsyncByteWrite for W { + async fn write(&mut self, buf: &[u8]) -> Result { + self.write(buf) + .await + .map_err(|e| TransportError::ConnectionLost(alloc::format!("{:?}", e))) + } + + async fn flush(&mut self) -> Result<(), TransportError> { + embedded_io_async::Write::flush(self) + .await + .map_err(|e| TransportError::ConnectionLost(alloc::format!("{:?}", e))) + } +} diff --git a/Build/crates/saikuro-transport/embedded/mod.rs b/Build/crates/saikuro-transport/embedded/mod.rs index 00621f03..ace73044 100644 --- a/Build/crates/saikuro-transport/embedded/mod.rs +++ b/Build/crates/saikuro-transport/embedded/mod.rs @@ -1,4 +1,10 @@ +pub mod framed; pub mod io_transport; #[cfg(feature = "tcp")] pub mod tcp; + +pub use io_transport::{EmbeddedIoReceiver, EmbeddedIoSender, EmbeddedIoTransport}; + +#[cfg(feature = "tcp")] +pub use tcp::TcpTransport; diff --git a/Build/crates/saikuro-transport/lib.rs b/Build/crates/saikuro-transport/lib.rs index ba51ce91..484278da 100644 --- a/Build/crates/saikuro-transport/lib.rs +++ b/Build/crates/saikuro-transport/lib.rs @@ -44,30 +44,27 @@ pub mod shared; #[cfg(feature = "native")] pub mod native; -#[cfg(all(feature = "native", feature = "tcp"))] -pub use native::tcp; -#[cfg(all(feature = "native", feature = "unix"))] -pub use native::unix; -#[cfg(all(feature = "native", feature = "ws"))] -pub use native::websocket; - -/// Transport selection and configuration types. -#[cfg(any( - feature = "native", - feature = "no_std", - feature = "wasm", - feature = "embedded" -))] -pub use shared::selector; +#[cfg(feature = "native")] +#[allow(unused_imports)] +pub use native::*; #[cfg(feature = "embedded")] pub mod embedded; +#[cfg(feature = "embedded")] +#[allow(unused_imports)] +pub use embedded::*; #[cfg(feature = "wasm")] pub mod wasm; +#[cfg(feature = "wasm")] +#[allow(unused_imports)] +pub use wasm::*; #[cfg(feature = "no_std")] pub mod wasi; +#[cfg(feature = "no_std")] +#[allow(unused_imports)] +pub use wasi::*; pub use shared::error::TransportError; pub use shared::host::{ @@ -75,38 +72,13 @@ pub use shared::host::{ WasmHostTransport, }; pub use shared::memory::MemoryTransport; -pub use shared::selector::{TransportConfig, TransportKind, TransportSelector}; +pub use shared::selector::{self, TransportConfig, TransportKind, TransportSelector}; pub use shared::traits::{ LocalTransport, LocalTransportConnector, LocalTransportListener, LocalTransportReceiver, LocalTransportSender, Transport, TransportConnector, TransportListener, TransportReceiver, TransportSender, }; -#[cfg(all(feature = "native", feature = "tcp"))] -pub use native::tcp::TcpTransport; -#[cfg(all(feature = "native", feature = "unix", target_family = "unix"))] -pub use native::unix::UnixTransport; -#[cfg(all(feature = "native", feature = "ws"))] -pub use native::websocket::{WebSocketTransport, WsTransportListener}; - -#[cfg(feature = "embedded")] -pub use embedded::io_transport::{EmbeddedIoReceiver, EmbeddedIoSender, EmbeddedIoTransport}; -#[cfg(all(feature = "embedded", feature = "tcp"))] -pub use embedded::tcp::TcpTransport; - -#[cfg(all(feature = "wasm", feature = "wasm-host"))] -pub use wasm::host_browser::{BroadcastChannelPipe, WasmHost}; -#[cfg(all(feature = "wasm", feature = "ws", feature = "std"))] -pub use wasm::websocket::WebSocketTransport; - -#[cfg(all(feature = "no_std", feature = "ws-wasi"))] -pub use wasi::websocket; - -#[cfg(all(feature = "no_std", feature = "wasi-host"))] -pub use wasi::host::{WasiHostRecv, WasiHostSend, WasiPipe}; -#[cfg(all(feature = "no_std", feature = "wasi-tcp"))] -pub use wasi::tcp::{WasiTcpConnector, WasiTcpListener, WasiTcpTransport}; - /// Maximum allowed frame size (16 MiB). Frames larger than this are rejected /// to prevent memory exhaustion from malformed or malicious peers. pub const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024; diff --git a/Build/crates/saikuro-transport/native/mod.rs b/Build/crates/saikuro-transport/native/mod.rs index 865bfd41..78debb67 100644 --- a/Build/crates/saikuro-transport/native/mod.rs +++ b/Build/crates/saikuro-transport/native/mod.rs @@ -9,3 +9,10 @@ pub mod unix; #[cfg(feature = "ws")] pub mod websocket; + +#[cfg(feature = "tcp")] +pub use tcp::TcpTransport; +#[cfg(all(feature = "unix", target_family = "unix"))] +pub use unix::UnixTransport; +#[cfg(feature = "ws")] +pub use websocket::{WebSocketTransport, WsTransportListener}; diff --git a/Build/crates/saikuro-transport/shared/framing.rs b/Build/crates/saikuro-transport/shared/framing.rs index efbd224a..71b18302 100644 --- a/Build/crates/saikuro-transport/shared/framing.rs +++ b/Build/crates/saikuro-transport/shared/framing.rs @@ -34,30 +34,6 @@ pub trait AsyncByteWrite { async fn flush(&mut self) -> Result<()>; } -#[cfg(feature = "embedded")] -impl AsyncByteRead for R { - async fn read(&mut self, buf: &mut [u8]) -> Result { - self.read(buf) - .await - .map_err(|e| TransportError::ConnectionLost(alloc::format!("{:?}", e))) - } -} - -#[cfg(feature = "embedded")] -impl AsyncByteWrite for W { - async fn write(&mut self, buf: &[u8]) -> Result { - self.write(buf) - .await - .map_err(|e| TransportError::ConnectionLost(alloc::format!("{:?}", e))) - } - - async fn flush(&mut self) -> Result<()> { - embedded_io_async::Write::flush(self) - .await - .map_err(|e| TransportError::ConnectionLost(alloc::format!("{:?}", e))) - } -} - /// Read exactly `buf.len()` bytes, or fail if the peer closes first. async fn read_exact(reader: &mut R, buf: &mut [u8]) -> Result<()> { let mut filled = 0; diff --git a/Build/crates/saikuro-transport/wasi/mod.rs b/Build/crates/saikuro-transport/wasi/mod.rs index 3d5d2495..38ff0a5d 100644 --- a/Build/crates/saikuro-transport/wasi/mod.rs +++ b/Build/crates/saikuro-transport/wasi/mod.rs @@ -8,3 +8,8 @@ pub mod preview2; pub mod tcp; #[cfg(feature = "ws-wasi")] pub mod websocket; + +#[cfg(feature = "wasi-host")] +pub use host::{WasiHostRecv, WasiHostSend, WasiPipe}; +#[cfg(feature = "wasi-tcp")] +pub use tcp::{WasiTcpConnector, WasiTcpListener, WasiTcpTransport}; diff --git a/Build/crates/saikuro-transport/wasi/websocket.rs b/Build/crates/saikuro-transport/wasi/websocket.rs index d923b415..68790648 100644 --- a/Build/crates/saikuro-transport/wasi/websocket.rs +++ b/Build/crates/saikuro-transport/wasi/websocket.rs @@ -39,10 +39,7 @@ impl rand_core_06::RngCore for WsRng { getrandom::fill(dest).expect("ws rng: getrandom failed on WASI") } - fn try_fill_bytes( - &mut self, - dest: &mut [u8], - ) -> core::result::Result<(), rand_core_06::Error> { + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> core::result::Result<(), rand_core_06::Error> { match getrandom::fill(dest) { Ok(()) => Ok(()), Err(_) => Err(rand_core_06::Error::from( @@ -164,9 +161,7 @@ impl Transport for WebSocketTransport { fn split(self) -> (Self::Sender, Self::Receiver) { let s = self.state.clone(); ( - WebSocketSender { - state: s.clone(), - }, + WebSocketSender { state: s.clone() }, WebSocketReceiver { state: s }, ) } diff --git a/Build/crates/saikuro-transport/wasm/mod.rs b/Build/crates/saikuro-transport/wasm/mod.rs index 7d778d39..df4f0e59 100644 --- a/Build/crates/saikuro-transport/wasm/mod.rs +++ b/Build/crates/saikuro-transport/wasm/mod.rs @@ -3,3 +3,8 @@ pub mod websocket; #[cfg(feature = "wasm-host")] pub mod host_browser; + +#[cfg(feature = "wasm-host")] +pub use host_browser::{BroadcastChannelPipe, WasmHost}; +#[cfg(all(feature = "ws", feature = "std"))] +pub use websocket::WebSocketTransport; diff --git a/Build/scripts/check_matrix.py b/Build/scripts/check_matrix.py index 1a20f45c..c9fd50e7 100644 --- a/Build/scripts/check_matrix.py +++ b/Build/scripts/check_matrix.py @@ -165,14 +165,23 @@ def run_combo(crate: str, combo: Combo, verbose: bool) -> dict: warn_count = sum( 1 for line in lines if line.startswith("warning") and "generated" not in line ) - # Capture the diagnostic lines so they can be shown after the run instead - # of being discarded; `error`/`warning` headlines plus their `note:`/`help:` - #/`-->` context lines. - diags = [ - line - for line in lines - if line.strip().startswith(("error", "warning", "note:", "help:", "-->")) - ] + # Capture whole diagnostic blocks (headline + the `-->`, source-snippet and + # underline lines that follow) so the full error is preserved for display + # and for the JSON report instead of being discarded. + diags = [] + in_diag = False + for line in lines: + s = line.strip() + if s.startswith(("error", "warning", "note:", "help:", "-->")): + in_diag = True + diags.append(line) + elif in_diag: + if s == "": + in_diag = False + elif s.startswith(("|", "=", "^", "*")) or line[:1] in (" ", "\t"): + diags.append(line) + else: + in_diag = False passed = proc.returncode == 0 and err_count == 0 if verbose and not passed: @@ -233,6 +242,10 @@ def main() -> int: f" [{status}] {r['name']:<22} target={r['target']:<20} " f"errs={r['errors']:<3} warns={r['warnings']:<3} {r['seconds']}s" ) + if (not r["passed"] or r["warnings"]) and r["diags"]: + tag = "FAIL" if not r["passed"] else "WARN" + for line in r["diags"]: + print(f" [{tag}] {line}") results.append({**r, "crate": crate}) total = len(results) From c69d68fe0e39aacc969884f9b374c495d5cf8b60 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Mon, 17 Aug 2026 23:07:20 -0600 Subject: [PATCH 39/43] Adapter compiles --- Build/Cargo.lock | 35 +- Build/adapters/c/Cargo.toml | 31 +- Build/adapters/c/src/lib.rs | 1277 ++++++++++------- Build/adapters/rust/Cargo.toml | 79 +- Build/adapters/rust/src/client.rs | 14 +- Build/adapters/rust/src/error.rs | 4 +- Build/adapters/rust/src/lib.rs | 7 + Build/adapters/rust/src/provider.rs | 51 +- Build/adapters/rust/src/schema.rs | 5 + Build/adapters/rust/src/transport.rs | 151 +- .../crates/saikuro-event/log/wasm/console.rs | 2 + Build/crates/saikuro-exec/lib.rs | 1 - .../saikuro-storage/shared/traits/file.rs | 1 + Build/scripts/check_adapter_matrix.py | 282 ++++ 14 files changed, 1339 insertions(+), 601 deletions(-) create mode 100755 Build/scripts/check_adapter_matrix.py diff --git a/Build/Cargo.lock b/Build/Cargo.lock index 17d1f1d2..e8a82d31 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -1345,25 +1345,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - [[package]] name = "rustc_version" version = "0.4.1" @@ -1389,8 +1370,8 @@ dependencies = [ "clap", "dashmap 6.2.1", "futures", - "rmp-serde", "saikuro-core", + "saikuro-event", "saikuro-exec", "saikuro-random", "saikuro-storage", @@ -1415,7 +1396,8 @@ dependencies = [ "saikuro-runtime", "saikuro-transport", "serde_json", - "thiserror", + "spin 0.12.3", + "tokio", ] [[package]] @@ -1459,7 +1441,7 @@ dependencies = [ "serde", "serde_bytes", "serde_json", - "spin 0.12.2", + "spin 0.12.3", "strum", "thiserror", "tracing", @@ -1541,7 +1523,7 @@ dependencies = [ "saikuro-transport", "serde", "serde_json", - "spin 0.12.2", + "spin 0.12.3", "talc", "tracing", "tracing-subscriber", @@ -1578,7 +1560,7 @@ dependencies = [ "serde", "serde_json", "sled", - "spin 0.12.2", + "spin 0.12.3", "thiserror", "tokio", "tracing", @@ -1805,10 +1787,11 @@ dependencies = [ [[package]] name = "spin" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b" +checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10" dependencies = [ + "lock_api", "portable-atomic", ] diff --git a/Build/adapters/c/Cargo.toml b/Build/adapters/c/Cargo.toml index 44ef4076..61b3afbe 100644 --- a/Build/adapters/c/Cargo.toml +++ b/Build/adapters/c/Cargo.toml @@ -15,20 +15,37 @@ crate-type = ["cdylib", "staticlib", "rlib"] [[bin]] name = "saikuro-c-schema" path = "src/cli/saikuro_c_schema.rs" +required-features = ["native"] [features] -default = ["saikuro/default"] +default = ["std", "native", "saikuro/default"] +std = ["saikuro/std"] +no_std = ["saikuro/no_std", "saikuro-exec/no_std"] +native = ["std", "saikuro/native", "saikuro-exec/native", "dep:tokio", "dep:anyhow", "dep:clap", "dep:regex"] wasm = ["saikuro/wasm", "saikuro-exec/wasm"] +embedded = ["saikuro/embedded", "saikuro-exec/embedded"] +tcp = ["saikuro/tcp"] +unix = ["saikuro/unix"] +ws = ["saikuro/ws"] +ws-wasi = ["saikuro/ws-wasi"] +wasi-tcp = ["saikuro/wasi-tcp"] +wasi-host = ["saikuro/wasi-host"] +wasi-preview1 = ["saikuro/wasi-preview1"] +wasi-preview2 = ["saikuro/wasi-preview2"] [dependencies] saikuro = { workspace = true, default-features = false } -anyhow = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } - saikuro-exec = { workspace = true, default-features = false } -clap = { version = "4.5", features = ["derive"] } -regex = "1.11" +serde_json = { workspace = true, features = ["alloc"] } +spin = "0.12.3" +anyhow = { workspace = true, optional = true } +clap = { version = "4.5", features = ["derive"], optional = true } +regex = { version = "1.11", optional = true } + +[dependencies.tokio] +version = "1" +features = ["rt", "rt-multi-thread"] +optional = true [dev-dependencies] saikuro-core = { workspace = true, features = ["std"] } diff --git a/Build/adapters/c/src/lib.rs b/Build/adapters/c/src/lib.rs index a70be509..dc98a14e 100644 --- a/Build/adapters/c/src/lib.rs +++ b/Build/adapters/c/src/lib.rs @@ -1,36 +1,200 @@ -use std::cell::RefCell; -use std::ffi::{c_char, c_int, c_void, CStr, CString}; -use std::ptr; -use std::thread_local; -use std::time::Duration; +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +#[cfg(all( + not(feature = "std"), + not(feature = "native"), + any( + target_os = "none", + all(target_os = "wasi", target_env = "p1"), + ), +))] +mod embedded_rt { + use core::alloc::{GlobalAlloc, Layout}; + + struct StubAllocator; + + unsafe impl GlobalAlloc for StubAllocator { + unsafe fn alloc(&self, _layout: Layout) -> *mut u8 { + core::ptr::null_mut() + } + unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {} + } + + #[global_allocator] + static ALLOCATOR: StubAllocator = StubAllocator; + + #[panic_handler] + fn panic(_info: &core::panic::PanicInfo) -> ! { + loop {} + } +} + +#[cfg(not(feature = "std"))] +use alloc::borrow::ToOwned; +#[cfg(not(feature = "std"))] +use alloc::boxed::Box; +#[cfg(not(feature = "std"))] +use alloc::format; +use alloc::ffi::CString; +#[cfg(not(feature = "std"))] +use alloc::string::String; +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +use core::ffi::{c_char, c_int, c_void, CStr}; +use core::future::Future; +use core::ptr; use saikuro::{ - ArgDescriptor, Client, FunctionSchema, PrimitiveType, Provider, RegisterOptions, - SaikuroChannel, TypeDescriptor, Value, + ArgDescriptor, FunctionSchema, PrimitiveType, Provider, RegisterOptions, + TypeDescriptor, Value, }; -use saikuro_exec::Runtime; -use std::sync::Arc; +#[cfg(feature = "std")] +use saikuro::{Client, SaikuroChannel, SaikuroStream}; -// C API helpers for client handle validation and result serialization +// C API helpers for client handle validation and result serialization. const ERR_HANDLE_NULL: &str = "handle must not be null"; -thread_local! { - static LAST_ERROR: RefCell> = const { RefCell::new(None) }; -} +// Last-error slot. +#[cfg(feature = "std")] +static LAST_ERROR: std::sync::Mutex> = std::sync::Mutex::new(None); + +#[cfg(not(feature = "std"))] +static LAST_ERROR: spin::Mutex> = spin::Mutex::new(None); fn set_last_error(msg: impl Into) { - LAST_ERROR.with(|cell| { - *cell.borrow_mut() = Some(msg.into()); - }); + #[cfg(feature = "std")] + { + *LAST_ERROR.lock().expect("last-error lock poisoned") = Some(msg.into()); + } + #[cfg(not(feature = "std"))] + { + *LAST_ERROR.lock() = Some(msg.into()); + } } fn clear_last_error() { - LAST_ERROR.with(|cell| { - *cell.borrow_mut() = None; - }); + #[cfg(feature = "std")] + { + *LAST_ERROR.lock().expect("last-error lock poisoned") = None; + } + #[cfg(not(feature = "std"))] + { + *LAST_ERROR.lock() = None; + } +} + +fn last_error_string() -> String { + #[cfg(feature = "std")] + { + LAST_ERROR + .lock() + .expect("last-error lock poisoned") + .clone() + .unwrap_or_default() + } + #[cfg(not(feature = "std"))] + { + LAST_ERROR.lock().clone().unwrap_or_default() + } +} + +#[cfg(feature = "native")] +mod exec { + use core::future::Future; + use std::sync::OnceLock; + use tokio::runtime::Runtime as TokioRuntime; + + static RT: OnceLock = OnceLock::new(); + + pub(super) fn spawn(fut: F) + where + F: Future + Send + 'static, + F::Output: Send + 'static, + { + let rt = RT.get_or_init(|| { + TokioRuntime::new().expect("saikuro-c: failed to start tokio runtime") + }); + rt.handle().spawn(fut); + } +} + +#[cfg(feature = "native")] +fn spawn_future(fut: F) +where + F: Future + Send + 'static, +{ + exec::spawn(fut); +} + +#[cfg(not(feature = "native"))] +fn spawn_future(fut: F) +where + F: Future + 'static, +{ + saikuro_exec::spawn(fut); } +// C callback signatures. + +/// Called with the created handle (or null on error) once a connect / stream / +/// channel open completes. +pub type SaikuroConnectCb = extern "C" fn(*mut c_void, *mut c_void); + +/// Called with the serialised result (or null on error) once an RPC completes. +pub type SaikuroResultCb = extern "C" fn(*mut c_char, *mut c_void); + +/// Called with a status code (0 = ok, 1 = error) once a fire-and-forget op +/// (cast / log / close / abort / serve) completes. +pub type SaikuroStatusCb = extern "C" fn(c_int, *mut c_void); + +/// Called with the next stream/channel item. `item` is null when the stream is +/// exhausted or an error occurred (see `saikuro_last_error_message`). `done` is +/// 0 when `item` holds a value, 1 otherwise. +pub type SaikuroItemCb = extern "C" fn(*mut c_char, c_int, *mut c_void); + +// Handles. + +#[cfg(feature = "std")] +struct ClientHandle { + client: Option, +} + +#[cfg(feature = "std")] +impl ClientHandle { + fn client(&self) -> &Client { + self.client.as_ref().expect("client already closed") + } +} + +#[cfg(feature = "std")] +struct StreamHandle { + stream: SaikuroStream, +} + +#[cfg(feature = "std")] +struct ChannelHandle { + channel: SaikuroChannel, +} + +/// C callback for provider functions. +/// +/// # Safety +/// The returned pointer must be an owned C string allocated via +/// `saikuro_string_dup` (or `CString::into_raw`-compatible allocation). Ownership +/// is transferred to Rust, which reclaims it with `CString::from_raw`. Returning +/// strings from `malloc`/`strdup` is undefined behavior because allocator +/// ownership does not match `CString::from_raw` expectations. +type ProviderHandler = unsafe extern "C" fn(*mut c_void, *const c_char) -> *mut c_char; + +struct ProviderHandle { + provider: Option, +} + +// Parsing / serialisation helpers. + fn cstr_to_string(ptr: *const c_char, arg_name: &str) -> Result { if ptr.is_null() { return Err(format!("{arg_name} must not be null")); @@ -49,6 +213,7 @@ fn into_c_string_ptr(s: &str) -> *mut c_char { } } +#[cfg(feature = "std")] fn parse_json_array_arg(raw: &str, arg_name: &str) -> Result, String> { let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| format!("{arg_name} must be valid JSON: {e}"))?; @@ -58,6 +223,7 @@ fn parse_json_array_arg(raw: &str, arg_name: &str) -> Result, String> } } +#[cfg(feature = "std")] fn parse_batch_calls(raw: &str) -> Result)>, String> { let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|e| format!("calls_json must be valid JSON: {e}"))?; @@ -103,6 +269,7 @@ fn parse_batch_calls(raw: &str) -> Result)>, String> { Ok(calls) } +#[cfg(feature = "std")] fn parse_json_object_arg( raw: &str, arg_name: &str, @@ -115,52 +282,13 @@ fn parse_json_object_arg( } } -// C API helpers factor out the null-check / cast / error pattern - -macro_rules! ok_or_ptr { - ($expr:expr) => { - match $expr { - Ok(v) => v, - Err(e) => { - set_last_error(e); - return ptr::null_mut(); - } - } - }; -} - -macro_rules! ok_or_int { - ($expr:expr) => { - match $expr { - Ok(v) => v, - Err(e) => { - set_last_error(e); - return 1; - } - } - }; -} - -/// Parse a JSON array from a C string pointer. +#[cfg(feature = "std")] fn c_json_array(ptr: *const c_char) -> Result, String> { let s = cstr_to_string(ptr, "args_json")?; parse_json_array_arg(&s, "args_json") } -/// Validate and dereference a client handle. -fn client_handle(h: *mut c_void) -> Result<&'static mut ClientHandle, String> { - if h.is_null() { - return Err(ERR_HANDLE_NULL.to_owned()); - } - let h = unsafe { &mut *(h as *mut ClientHandle) }; - if h.client.is_none() { - return Err("client is already closed".to_owned()); - } - Ok(h) -} - -/// Serialise a `saikuro::Result` into a heap-allocated C string pointer, -/// or set `last_error` and return null on failure. +#[cfg(feature = "std")] fn ptr_saikuro(result: Result, op: &str) -> *mut c_char { match result { Ok(v) => match serde_json::to_string(&v) { @@ -177,7 +305,7 @@ fn ptr_saikuro(result: Result, op: &str) -> *mut c_char { } } -/// Map a `saikuro::Result<()>` to a C `c_int` return, setting `last_error` on failure. +#[cfg(feature = "std")] fn int_saikuro(result: Result<(), saikuro::Error>, op: &str) -> c_int { match result { Ok(()) => 0, @@ -188,82 +316,13 @@ fn int_saikuro(result: Result<(), saikuro::Error>, op: &str) -> c_int { } } -struct ClientHandle { - rt: Arc, - client: Option, +// Lifetime-safe handle accessors for spawned futures. +#[cfg(feature = "std")] +fn client_ref(h: *mut c_void) -> &'static ClientHandle { + unsafe { &*(h as *const ClientHandle) } } -impl ClientHandle { - fn client(&self) -> &Client { - self.client.as_ref().expect("client already closed") - } - - fn new(address: &str) -> Result { - let rt = Arc::new( - saikuro_exec::new_runtime() - .enable_all() - .build() - .map_err(|e| format!("failed to create runtime: {e}"))?, - ); - - let client = rt - .block_on(Client::connect(address)) - .map_err(|e| format!("failed to connect client: {e}"))?; - - Ok(Self { - rt, - client: Some(client), - }) - } - - fn close(&mut self) -> Result<(), String> { - if let Some(client) = self.client.take() { - self.rt - .block_on(client.close()) - .map_err(|e| format!("failed to close client: {e}"))?; - } - Ok(()) - } -} - -/// C callback for provider functions. -/// -/// # Safety -/// The returned pointer must be an owned C string allocated via `saikuro_string_dup` -/// (or `CString::into_raw`-compatible allocation semantics). -/// Ownership is transferred to Rust, which reclaims it with `CString::from_raw`. -/// Returning strings from `malloc`/`strdup` is undefined behavior because allocator -/// ownership does not match `CString::from_raw` expectations. -type ProviderHandler = unsafe extern "C" fn(*mut c_void, *const c_char) -> *mut c_char; - -struct ProviderHandle { - rt: saikuro_exec::Runtime, - provider: Option, -} - -struct StreamHandle { - rt: Arc, - stream: saikuro::SaikuroStream, -} - -struct ChannelHandle { - rt: Arc, - channel: SaikuroChannel, -} - -impl ProviderHandle { - fn new(namespace: &str) -> Result { - let rt = saikuro_exec::new_runtime() - .enable_all() - .build() - .map_err(|e| format!("failed to create runtime: {e}"))?; - - Ok(Self { - rt, - provider: Some(Provider::new(namespace)), - }) - } -} +// String lifecycle #[no_mangle] pub extern "C" fn saikuro_string_dup(input: *const c_char) -> *mut c_char { @@ -276,14 +335,10 @@ pub extern "C" fn saikuro_string_dup(input: *const c_char) -> *mut c_char { } } -/// Frees a heap-allocated string returned by the Saikuro C API. -/// /// # Safety -/// /// `ptr` must be either null or a pointer previously returned by -/// [`saikuro_string_dup`], [`saikuro_last_error_message`], or another Saikuro C API function -/// that transfers ownership of a heap string to the caller. Passing any other pointer, -/// or a pointer not obtained from Saikuro, results in undefined behavior. +/// [`saikuro_string_dup`], [`saikuro_last_error_message`], or another Saikuro C +/// API function that transfers ownership of a heap string to the caller. #[no_mangle] pub unsafe extern "C" fn saikuro_string_free(ptr: *mut c_char) { if ptr.is_null() { @@ -296,448 +351,697 @@ pub unsafe extern "C" fn saikuro_string_free(ptr: *mut c_char) { #[no_mangle] pub extern "C" fn saikuro_last_error_message() -> *mut c_char { - let msg = LAST_ERROR - .with(|cell| cell.borrow().clone()) - .unwrap_or_else(|| "".to_owned()); + let msg = last_error_string(); into_c_string_ptr(&msg) } +// Client lifecycle (async). + +/// # Safety +/// `cb` must not be null. +#[cfg(feature = "std")] #[no_mangle] -pub extern "C" fn saikuro_client_connect(address: *const c_char) -> *mut c_void { +pub extern "C" fn saikuro_client_connect_async( + address: *const c_char, + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; let address = match cstr_to_string(address, "address") { Ok(s) => s, Err(e) => { set_last_error(e); - return ptr::null_mut(); + return; } }; - match ClientHandle::new(&address) { - Ok(handle) => Box::into_raw(Box::new(handle)) as *mut c_void, - Err(e) => { - set_last_error(e); - ptr::null_mut() + let handle = Box::into_raw(Box::new(ClientHandle { client: None })); + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + match Client::connect(address).await { + Ok(client) => { + let handle = handle_addr as *mut ClientHandle; + unsafe { + (*handle).client = Some(client); + } + cb(handle as *mut c_void, user_data_addr as *mut c_void); + } + Err(e) => { + set_last_error(format!("failed to connect client: {e}")); + unsafe { + let _ = Box::from_raw(handle_addr as *mut ClientHandle); + } + cb(ptr::null_mut(), user_data_addr as *mut c_void); + } } - } + }); } +#[cfg(feature = "std")] #[no_mangle] -pub extern "C" fn saikuro_client_close(handle: *mut c_void) -> c_int { - clear_last_error(); +pub extern "C" fn saikuro_client_free(handle: *mut c_void) { if handle.is_null() { - set_last_error(ERR_HANDLE_NULL); - return 1; - } - match unsafe { &mut *(handle as *mut ClientHandle) }.close() { - Ok(()) => 0, - Err(e) => { - set_last_error(e); - 1 - } + return; } + let _ = unsafe { Box::from_raw(handle as *mut ClientHandle) }; } +/// # Safety +/// `cb` must not be null. The handle must not be freed while a close is in flight. +#[cfg(feature = "std")] #[no_mangle] -pub extern "C" fn saikuro_client_free(handle: *mut c_void) { +pub extern "C" fn saikuro_client_close_async( + handle: *mut c_void, + cb: Option, + user_data: *mut c_void, +) { + clear_last_error(); + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(1, user_data); return; } - let mut boxed = unsafe { Box::from_raw(handle as *mut ClientHandle) }; - let _ = boxed.close(); + let handle = unsafe { Box::from_raw(handle as *mut ClientHandle) }; + let user_data_addr = user_data as usize; + spawn_future(async move { + let status = match handle.client { + Some(client) => int_saikuro(client.close().await, "close"), + None => 0, + }; + cb(status, user_data_addr as *mut c_void); + }); } +// Client RPC (async). + +/// # Safety +/// `cb` must not be null. The client handle must remain valid until `cb` fires. +#[cfg(feature = "std")] #[no_mangle] -pub extern "C" fn saikuro_client_call_json( +pub extern "C" fn saikuro_client_call_json_async( handle: *mut c_void, target: *const c_char, args_json: *const c_char, -) -> *mut c_char { + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - let h = ok_or_ptr!(client_handle(handle)); - let target = ok_or_ptr!(cstr_to_string(target, "target")); - let args = ok_or_ptr!(c_json_array(args_json)); - ptr_saikuro(h.rt.block_on(h.client().call(target, args)), "call") + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; + let h = match cstr_to_string(target, "target").and_then(|t| { + c_json_array(args_json).map(|a| (t, a)) + }) { + Ok(v) => v, + Err(e) => { + set_last_error(e); + return; + } + }; + let (target, args) = h; + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + let handle = handle_addr as *mut c_void; + let h = client_ref(handle); + let res = h.client().call(target, args).await; + let out = ptr_saikuro(res, "call"); + cb(out, user_data_addr as *mut c_void); + }); } +/// # Safety +/// `cb` must not be null. The client handle must remain valid until `cb` fires. +#[cfg(feature = "std")] #[no_mangle] -pub extern "C" fn saikuro_client_call_json_timeout( +pub extern "C" fn saikuro_client_call_json_timeout_async( handle: *mut c_void, target: *const c_char, args_json: *const c_char, timeout_ms: c_int, -) -> *mut c_char { + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - let h = ok_or_ptr!(client_handle(handle)); + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; if timeout_ms < 0 { set_last_error("timeout_ms must be non-negative"); - return ptr::null_mut(); + return; } - let target = ok_or_ptr!(cstr_to_string(target, "target")); - let args = ok_or_ptr!(c_json_array(args_json)); - let timeout = Duration::from_millis(timeout_ms as u64); - ptr_saikuro( - h.rt.block_on(h.client().call_with_timeout(target, args, Some(timeout))), - "call", - ) + let parsed = match cstr_to_string(target, "target").and_then(|t| { + c_json_array(args_json).map(|a| (t, a)) + }) { + Ok(v) => v, + Err(e) => { + set_last_error(e); + return; + } + }; + let (target, args) = parsed; + let timeout = core::time::Duration::from_millis(timeout_ms as u64); + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + let handle = handle_addr as *mut c_void; + let h = client_ref(handle); + let res = h.client().call_with_timeout(target, args, Some(timeout)).await; + let out = ptr_saikuro(res, "call"); + cb(out, user_data_addr as *mut c_void); + }); } +/// # Safety +/// `cb` must not be null. The client handle must remain valid until `cb` fires. +#[cfg(feature = "std")] #[no_mangle] -pub extern "C" fn saikuro_client_cast_json( +pub extern "C" fn saikuro_client_cast_json_async( handle: *mut c_void, target: *const c_char, args_json: *const c_char, -) -> c_int { + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - let h = ok_or_int!(client_handle(handle)); - let target = ok_or_int!(cstr_to_string(target, "target")); - let args = ok_or_int!(c_json_array(args_json)); - int_saikuro(h.rt.block_on(h.client().cast(target, args)), "cast") + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; + let parsed = match cstr_to_string(target, "target").and_then(|t| { + c_json_array(args_json).map(|a| (t, a)) + }) { + Ok(v) => v, + Err(e) => { + set_last_error(e); + cb(1, user_data); + return; + } + }; + let (target, args) = parsed; + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + let handle = handle_addr as *mut c_void; + let h = client_ref(handle); + let res = h.client().cast(target, args).await; + cb(int_saikuro(res, "cast"), user_data_addr as *mut c_void); + }); } +/// # Safety +/// `cb` must not be null. The client handle must remain valid until `cb` fires. +#[cfg(feature = "std")] #[no_mangle] -pub extern "C" fn saikuro_client_batch_json( +pub extern "C" fn saikuro_client_batch_json_async( handle: *mut c_void, calls_json: *const c_char, -) -> *mut c_char { + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - let h = ok_or_ptr!(client_handle(handle)); - let raw = ok_or_ptr!(cstr_to_string(calls_json, "calls_json")); - let calls = ok_or_ptr!(parse_batch_calls(&raw)); - match h.rt.block_on(h.client().batch(calls)) { - Ok(v) => match serde_json::to_string(&v) { - Ok(json) => into_c_string_ptr(&json), + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; + let raw = match cstr_to_string(calls_json, "calls_json") { + Ok(s) => s, + Err(e) => { + set_last_error(e); + return; + } + }; + let calls = match parse_batch_calls(&raw) { + Ok(c) => c, + Err(e) => { + set_last_error(e); + return; + } + }; + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + let handle = handle_addr as *mut c_void; + let h = client_ref(handle); + match h.client().batch(calls).await { + Ok(v) => match serde_json::to_string(&v) { + Ok(json) => cb(into_c_string_ptr(&json), user_data_addr as *mut c_void), + Err(e) => { + set_last_error(format!("failed to serialize result: {e}")); + cb(ptr::null_mut(), user_data_addr as *mut c_void); + } + }, + Err(e) => { + set_last_error(format!("batch failed: {e}")); + cb(ptr::null_mut(), user_data_addr as *mut c_void); + } + } + }); +} + +/// # Safety +/// `cb` must not be null. The client handle must remain valid until `cb` fires. +#[cfg(feature = "std")] +#[no_mangle] +pub extern "C" fn saikuro_client_resource_json_async( + handle: *mut c_void, + target: *const c_char, + args_json: *const c_char, + cb: Option, + user_data: *mut c_void, +) { + clear_last_error(); + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; + let parsed = match cstr_to_string(target, "target").and_then(|t| { + c_json_array(args_json).map(|a| (t, a)) + }) { + Ok(v) => v, + Err(e) => { + set_last_error(e); + return; + } + }; + let (target, args) = parsed; + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + let handle = handle_addr as *mut c_void; + let h = client_ref(handle); + let res = h.client().resource(target, args).await; + let out = ptr_saikuro(res, "resource"); + cb(out, user_data_addr as *mut c_void); + }); +} + +/// # Safety +/// `cb` must not be null. The client handle must remain valid until `cb` fires. +#[cfg(feature = "std")] +#[no_mangle] +pub extern "C" fn saikuro_client_log_async( + handle: *mut c_void, + level: *const c_char, + name: *const c_char, + msg: *const c_char, + fields_json: *const c_char, + cb: Option, + user_data: *mut c_void, +) { + clear_last_error(); + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; + let parsed = match cstr_to_string(level, "level") + .and_then(|l| cstr_to_string(name, "name").map(|n| (l, n))) + .and_then(|(l, n)| cstr_to_string(msg, "msg").map(|m| (l, n, m))) + { + Ok(v) => v, + Err(e) => { + set_last_error(e); + cb(1, user_data); + return; + } + }; + let (level, name, msg) = parsed; + let fields = if fields_json.is_null() { + None + } else { + match cstr_to_string(fields_json, "fields_json").and_then(|raw| { + parse_json_object_arg(&raw, "fields_json").map(Value::Object) + }) { + Ok(v) => Some(v), Err(e) => { - set_last_error(format!("failed to serialize result: {e}")); - ptr::null_mut() + set_last_error(e); + cb(1, user_data); + return; } - }, - Err(e) => { - set_last_error(format!("batch failed: {e}")); - ptr::null_mut() } - } + }; + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + let handle = handle_addr as *mut c_void; + let h = client_ref(handle); + let res = h.client().log(level, name, msg, fields).await; + cb(int_saikuro(res, "log"), user_data_addr as *mut c_void); + }); } +// Streams (async open + async next). + +/// # Safety +/// `cb` must not be null. The client handle must remain valid until `cb` fires. +#[cfg(feature = "std")] #[no_mangle] -pub extern "C" fn saikuro_client_stream_json( +pub extern "C" fn saikuro_client_stream_json_async( handle: *mut c_void, target: *const c_char, args_json: *const c_char, -) -> *mut c_void { + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - let h = ok_or_ptr!(client_handle(handle)); - let target = ok_or_ptr!(cstr_to_string(target, "target")); - let args = ok_or_ptr!(c_json_array(args_json)); - let rt = h.rt.clone(); - let stream = match h.rt.block_on(h.client().stream(target, args)) { - Ok(s) => s, + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; + let parsed = match cstr_to_string(target, "target").and_then(|t| { + c_json_array(args_json).map(|a| (t, a)) + }) { + Ok(v) => v, Err(e) => { - set_last_error(format!("stream open failed: {e}")); - return ptr::null_mut(); + set_last_error(e); + return; } }; - Box::into_raw(Box::new(StreamHandle { rt, stream })) as *mut c_void + let (target, args) = parsed; + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + let handle = handle_addr as *mut c_void; + let h = client_ref(handle); + match h.client().stream(target, args).await { + Ok(stream) => { + let sh = Box::into_raw(Box::new(StreamHandle { stream })); + cb(sh as *mut c_void, user_data_addr as *mut c_void); + } + Err(e) => { + set_last_error(format!("stream open failed: {e}")); + cb(ptr::null_mut(), user_data_addr as *mut c_void); + } + } + }); } +#[cfg(feature = "std")] #[no_mangle] +pub extern "C" fn saikuro_stream_free(stream: *mut c_void) { + if stream.is_null() { + return; + } + let _ = unsafe { Box::from_raw(stream as *mut StreamHandle) }; +} + /// # Safety -/// -/// `stream` must be a valid handle returned by `saikuro_client_stream_json`. -/// `out_item_json` and `out_done` must be non-null writable pointers valid for -/// writes for the duration of this call. -pub unsafe extern "C" fn saikuro_stream_next_json( +/// `cb` must not be null. The stream handle must remain valid until `cb` fires, +/// and `saikuro_stream_next_json_async` must not be called concurrently on the +/// same stream. +#[cfg(feature = "std")] +#[no_mangle] +pub unsafe extern "C" fn saikuro_stream_next_json_async( stream: *mut c_void, - out_item_json: *mut *mut c_char, - out_done: *mut c_int, -) -> c_int { + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - - unsafe { - if !out_done.is_null() { - *out_done = 1; - } - if !out_item_json.is_null() { - *out_item_json = ptr::null_mut(); + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; } - } - + }; if stream.is_null() { set_last_error("stream must not be null"); - return 1; - } - if out_item_json.is_null() || out_done.is_null() { - set_last_error("out_item_json and out_done must not be null"); - return 1; + return; } - let stream = unsafe { &mut *(stream as *mut StreamHandle) }; - let next = stream.rt.block_on(stream.stream.next()); - - match next { - Some(Ok(value)) => match serde_json::to_string(&value) { - Ok(json) => { - unsafe { - *out_done = 0; - *out_item_json = into_c_string_ptr(&json); - } - 0 - } - Err(e) => { - unsafe { - *out_done = 1; - *out_item_json = ptr::null_mut(); + let stream_addr = stream as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + let stream = stream_addr as *mut StreamHandle; + let s = unsafe { &mut *stream }; + match s.stream.next().await { + Some(Ok(value)) => match serde_json::to_string(&value) { + Ok(json) => cb(into_c_string_ptr(&json), 0, user_data_addr as *mut c_void), + Err(e) => { + set_last_error(format!("failed to serialize stream item: {e}")); + cb(ptr::null_mut(), 1, user_data_addr as *mut c_void); } - set_last_error(format!("failed to serialize stream item: {e}")); - 1 - } - }, - Some(Err(e)) => { - unsafe { - *out_done = 1; - *out_item_json = ptr::null_mut(); - } - set_last_error(format!("stream receive failed: {e}")); - 1 - } - None => { - unsafe { - *out_done = 1; - *out_item_json = ptr::null_mut(); + }, + Some(Err(e)) => { + set_last_error(format!("stream receive failed: {e}")); + cb(ptr::null_mut(), 1, user_data_addr as *mut c_void); } - 0 + None => cb(ptr::null_mut(), 1, user_data_addr as *mut c_void), } - } + }); } -#[no_mangle] -pub extern "C" fn saikuro_stream_free(stream: *mut c_void) { - if stream.is_null() { - return; - } - let _ = unsafe { Box::from_raw(stream as *mut StreamHandle) }; -} +// Channels (async open + async send/next). +/// # Safety +/// `cb` must not be null. The client handle must remain valid until `cb` fires. +#[cfg(feature = "std")] #[no_mangle] -pub extern "C" fn saikuro_client_channel_json( +pub extern "C" fn saikuro_client_channel_json_async( handle: *mut c_void, target: *const c_char, args_json: *const c_char, -) -> *mut c_void { + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - let h = ok_or_ptr!(client_handle(handle)); - let target = ok_or_ptr!(cstr_to_string(target, "target")); - let args = ok_or_ptr!(c_json_array(args_json)); - let rt = h.rt.clone(); - let channel = match h.rt.block_on(h.client().channel(target, args)) { - Ok(c) => c, + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; + let parsed = match cstr_to_string(target, "target").and_then(|t| { + c_json_array(args_json).map(|a| (t, a)) + }) { + Ok(v) => v, Err(e) => { - set_last_error(format!("channel open failed: {e}")); - return ptr::null_mut(); + set_last_error(e); + return; } }; - Box::into_raw(Box::new(ChannelHandle { rt, channel })) as *mut c_void + let (target, args) = parsed; + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + let handle = handle_addr as *mut c_void; + let h = client_ref(handle); + match h.client().channel(target, args).await { + Ok(channel) => { + let ch = Box::into_raw(Box::new(ChannelHandle { channel })); + cb(ch as *mut c_void, user_data_addr as *mut c_void); + } + Err(e) => { + set_last_error(format!("channel open failed: {e}")); + cb(ptr::null_mut(), user_data_addr as *mut c_void); + } + } + }); } +/// # Safety +/// `cb` must not be null. The channel handle must remain valid until `cb` fires. +#[cfg(feature = "std")] #[no_mangle] -pub extern "C" fn saikuro_channel_send_json( +pub extern "C" fn saikuro_channel_send_json_async( channel: *mut c_void, item_json: *const c_char, -) -> c_int { + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; if channel.is_null() { set_last_error("channel must not be null"); - return 1; + cb(1, user_data); + return; } - let item_json = match cstr_to_string(item_json, "item_json") { Ok(s) => s, Err(e) => { set_last_error(e); - return 1; + cb(1, user_data); + return; } }; - let item: Value = match serde_json::from_str(&item_json) { Ok(v) => v, Err(e) => { set_last_error(format!("item_json must be valid JSON: {e}")); - return 1; + cb(1, user_data); + return; } }; - let channel = unsafe { &mut *(channel as *mut ChannelHandle) }; - match channel.rt.block_on(channel.channel.send(item)) { - Ok(()) => 0, - Err(e) => { - set_last_error(format!("channel send failed: {e}")); - 1 - } - } + let channel_addr = channel as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + let channel = channel_addr as *mut ChannelHandle; + let c = unsafe { &mut *channel }; + let res = c.channel.send(item).await; + cb(int_saikuro(res, "channel send"), user_data_addr as *mut c_void); + }); } +#[cfg(feature = "std")] #[no_mangle] -pub extern "C" fn saikuro_channel_close(channel: *mut c_void) -> c_int { +pub extern "C" fn saikuro_channel_close_async( + channel: *mut c_void, + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; if channel.is_null() { set_last_error("channel must not be null"); - return 1; + cb(1, user_data); + return; } - let channel = unsafe { &mut *(channel as *mut ChannelHandle) }; - match channel.rt.block_on(channel.channel.close()) { - Ok(()) => 0, - Err(e) => { - set_last_error(format!("channel close failed: {e}")); - 1 - } - } + let channel = unsafe { Box::from_raw(channel as *mut ChannelHandle) }; + let user_data_addr = user_data as usize; + spawn_future(async move { + let res = channel.channel.close().await; + cb(int_saikuro(res, "channel close"), user_data_addr as *mut c_void); + }); } +#[cfg(feature = "std")] #[no_mangle] -pub extern "C" fn saikuro_channel_abort(channel: *mut c_void) -> c_int { +pub extern "C" fn saikuro_channel_abort_async( + channel: *mut c_void, + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; if channel.is_null() { set_last_error("channel must not be null"); - return 1; + cb(1, user_data); + return; } - let channel = unsafe { &mut *(channel as *mut ChannelHandle) }; - match channel.rt.block_on(channel.channel.abort()) { - Ok(()) => 0, - Err(e) => { - set_last_error(format!("channel abort failed: {e}")); - 1 - } - } + let channel = unsafe { Box::from_raw(channel as *mut ChannelHandle) }; + let user_data_addr = user_data as usize; + spawn_future(async move { + let res = channel.channel.abort().await; + cb(int_saikuro(res, "channel abort"), user_data_addr as *mut c_void); + }); } -#[no_mangle] /// # Safety -/// -/// `channel` must be a valid handle returned by `saikuro_client_channel_json`. -/// `out_item_json` and `out_done` must be non-null writable pointers valid for -/// writes for the duration of this call. -pub unsafe extern "C" fn saikuro_channel_next_json( +/// `cb` must not be null. The channel handle must remain valid until `cb` fires, +/// and `saikuro_channel_next_json_async` must not be called concurrently on the +/// same channel. +#[cfg(feature = "std")] +#[no_mangle] +pub unsafe extern "C" fn saikuro_channel_next_json_async( channel: *mut c_void, - out_item_json: *mut *mut c_char, - out_done: *mut c_int, -) -> c_int { + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - - unsafe { - if !out_done.is_null() { - *out_done = 1; - } - if !out_item_json.is_null() { - *out_item_json = ptr::null_mut(); + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; } - } - + }; if channel.is_null() { set_last_error("channel must not be null"); - return 1; - } - if out_item_json.is_null() || out_done.is_null() { - set_last_error("out_item_json and out_done must not be null"); - return 1; + return; } - let channel = unsafe { &mut *(channel as *mut ChannelHandle) }; - let next = channel.rt.block_on(channel.channel.next()); - - match next { - Some(Ok(value)) => match serde_json::to_string(&value) { - Ok(json) => { - unsafe { - *out_done = 0; - *out_item_json = into_c_string_ptr(&json); - } - 0 - } - Err(e) => { - unsafe { - *out_done = 1; - *out_item_json = ptr::null_mut(); + let channel_addr = channel as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + let channel = channel_addr as *mut ChannelHandle; + let c = unsafe { &mut *channel }; + match c.channel.next().await { + Some(Ok(value)) => match serde_json::to_string(&value) { + Ok(json) => cb(into_c_string_ptr(&json), 0, user_data_addr as *mut c_void), + Err(e) => { + set_last_error(format!("failed to serialize channel item: {e}")); + cb(ptr::null_mut(), 1, user_data_addr as *mut c_void); } - set_last_error(format!("failed to serialize channel item: {e}")); - 1 - } - }, - Some(Err(e)) => { - unsafe { - *out_done = 1; - *out_item_json = ptr::null_mut(); - } - set_last_error(format!("channel receive failed: {e}")); - 1 - } - None => { - unsafe { - *out_done = 1; - *out_item_json = ptr::null_mut(); + }, + Some(Err(e)) => { + set_last_error(format!("channel receive failed: {e}")); + cb(ptr::null_mut(), 1, user_data_addr as *mut c_void); } - 0 + None => cb(ptr::null_mut(), 1, user_data_addr as *mut c_void), } - } -} - -#[no_mangle] -pub extern "C" fn saikuro_channel_free(channel: *mut c_void) { - if channel.is_null() { - return; - } - let _ = unsafe { Box::from_raw(channel as *mut ChannelHandle) }; -} - -#[no_mangle] -pub extern "C" fn saikuro_client_resource_json( - handle: *mut c_void, - target: *const c_char, - args_json: *const c_char, -) -> *mut c_char { - clear_last_error(); - let h = ok_or_ptr!(client_handle(handle)); - let target = ok_or_ptr!(cstr_to_string(target, "target")); - let args = ok_or_ptr!(c_json_array(args_json)); - ptr_saikuro(h.rt.block_on(h.client().resource(target, args)), "resource") + }); } -#[no_mangle] -pub extern "C" fn saikuro_client_log( - handle: *mut c_void, - level: *const c_char, - name: *const c_char, - msg: *const c_char, - fields_json: *const c_char, -) -> c_int { - clear_last_error(); - let h = ok_or_int!(client_handle(handle)); - let level = ok_or_int!(cstr_to_string(level, "level")); - let name = ok_or_int!(cstr_to_string(name, "name")); - let msg = ok_or_int!(cstr_to_string(msg, "msg")); - let fields = if fields_json.is_null() { - None - } else { - let raw = ok_or_int!(cstr_to_string(fields_json, "fields_json")); - match parse_json_object_arg(&raw, "fields_json") { - Ok(map) => Some(Value::Object(map)), - Err(e) => { - set_last_error(e); - return 1; - } - } - }; - int_saikuro( - h.rt.block_on(h.client().log(level, name, msg, fields)), - "log", - ) -} +// Provider lifecycle (sync register; async serve). #[no_mangle] pub extern "C" fn saikuro_provider_new(namespace: *const c_char) -> *mut c_void { @@ -751,22 +1055,15 @@ pub extern "C" fn saikuro_provider_new(namespace: *const c_char) -> *mut c_void } }; - match ProviderHandle::new(&namespace) { - Ok(handle) => Box::into_raw(Box::new(handle)) as *mut c_void, - Err(e) => { - set_last_error(e); - ptr::null_mut() - } - } + Box::into_raw(Box::new(ProviderHandle { + provider: Some(Provider::new(&namespace)), + })) as *mut c_void } -/// Safety: The `user_data` pointer is captured and later used inside -/// asynchronous callbacks registered with the provider. Callers must ensure -/// that the `user_data` pointer remains valid for the entire lifetime of the -/// registered provider (i.e., until `saikuro_provider_free` is called). If -/// `user_data` is freed or becomes dangling while the provider remains -/// registered, subsequent callback invocations will dereference invalid -/// memory and cause undefined behavior. +/// Safety: The `user_data` pointer is captured and later used inside asynchronous +/// callbacks registered with the provider. Callers must ensure that `user_data` +/// remains valid for the entire lifetime of the registered provider (until +/// `saikuro_provider_free` is called). async fn invoke_c_handler( callback: ProviderHandler, user_data_addr: usize, @@ -811,7 +1108,6 @@ pub extern "C" fn saikuro_provider_register( return 1; } - let handle = unsafe { &mut *(handle as *mut ProviderHandle) }; let callback = match callback { Some(cb) => cb, None => { @@ -943,20 +1239,35 @@ pub extern "C" fn saikuro_provider_register_with_schema( 0 } +/// # Safety +/// `cb` must not be null. The provider handle must remain valid until `cb` fires. #[no_mangle] -pub extern "C" fn saikuro_provider_serve(handle: *mut c_void, address: *const c_char) -> c_int { +pub extern "C" fn saikuro_provider_serve_async( + handle: *mut c_void, + address: *const c_char, + cb: Option, + user_data: *mut c_void, +) { clear_last_error(); - + let cb = match cb { + Some(c) => c, + None => { + set_last_error("callback must not be null"); + return; + } + }; if handle.is_null() { set_last_error(ERR_HANDLE_NULL); - return 1; + cb(1, user_data); + return; } let address = match cstr_to_string(address, "address") { Ok(s) => s, Err(e) => { set_last_error(e); - return 1; + cb(1, user_data); + return; } }; @@ -965,45 +1276,21 @@ pub extern "C" fn saikuro_provider_serve(handle: *mut c_void, address: *const c_ Some(p) => p, None => { set_last_error("provider has already started serving"); - return 1; + cb(1, user_data); + return; } }; - #[cfg(target_arch = "wasm32")] - { - // On single-threaded wasm (no atomics), `block_on` cannot yield to the - // JS event loop, so futures that depend on JS I/O will never complete. - // Spawn the serve loop on the event loop and return immediately. - saikuro_exec::spawn(async move { - let _ = provider.serve(address).await; - }); - 0 - } - - #[cfg(not(target_arch = "wasm32"))] - match handle.rt.block_on(provider.serve(address)) { - Ok(()) => 0, - Err(e) => { - set_last_error(format!("provider serve failed: {e}")); - 1 + let user_data_addr = user_data as usize; + spawn_future(async move { + match provider.serve(address).await { + Ok(()) => cb(0, user_data_addr as *mut c_void), + Err(e) => { + set_last_error(format!("provider serve failed: {e}")); + cb(1, user_data_addr as *mut c_void); + } } - } -} - -#[no_mangle] -pub extern "C" fn saikuro_provider_close(handle: *mut c_void) -> c_int { - clear_last_error(); - - if handle.is_null() { - set_last_error(ERR_HANDLE_NULL); - return 1; - } - - let handle = unsafe { &mut *(handle as *mut ProviderHandle) }; - // If the provider was registered but never served, drop it now. - // If serve() already consumed it, there's nothing left to close. - let _ = handle.provider.take(); - 0 + }); } #[no_mangle] @@ -1012,11 +1299,7 @@ pub extern "C" fn saikuro_provider_free(handle: *mut c_void) { return; } - // Close first so registered handlers are cleaned up before the runtime drops. - unsafe { - let h = &mut *(handle as *mut ProviderHandle); - let _ = h.provider.take(); - } - - let _ = unsafe { Box::from_raw(handle as *mut ProviderHandle) }; + // Drop the provider (and any pending handlers) before freeing the box. + let mut boxed = unsafe { Box::from_raw(handle as *mut ProviderHandle) }; + let _ = boxed.provider.take(); } diff --git a/Build/adapters/rust/Cargo.toml b/Build/adapters/rust/Cargo.toml index 82787239..5bef4a8b 100644 --- a/Build/adapters/rust/Cargo.toml +++ b/Build/adapters/rust/Cargo.toml @@ -13,38 +13,73 @@ categories = ["network-programming", "asynchronous"] [[bin]] name = "saikuro-rust-schema" path = "src/cli/saikuro_rust_schema.rs" +required-features = ["native"] [features] -default = ["tcp", "unix", "ws", "storage", "saikuro-exec/native"] +default = ["std", "native", "tcp", "unix", "ws", "storage", "inmemory"] +std = ["dep:dashmap"] +native = [ + "std", + "saikuro-core/native", + "saikuro-transport/native", + "saikuro-random/native", + "saikuro-exec/native", + "storage", + "saikuro-storage/native", + "dep:anyhow", + "dep:clap", + "dep:syn", +] +wasm = [ + "saikuro-core/wasm", + "saikuro-transport/wasm", + "saikuro-transport/wasm-host", + "saikuro-random/wasm", + "saikuro-exec/wasm", +] +embedded = [ + "saikuro-core/embedded", + "saikuro-transport/embedded", + "saikuro-random/embedded", + "saikuro-exec/embedded", +] +no_std = [ + "saikuro-core/no_std", + "saikuro-transport/no_std", + "saikuro-random/no_std", + "saikuro-exec/no_std", +] tcp = ["saikuro-transport/tcp"] unix = ["saikuro-transport/unix"] ws = ["saikuro-transport/ws"] ws-wasi = ["saikuro-transport/ws-wasi"] -wasm = ["saikuro-transport/wasm", "saikuro-transport/wasm-host", "saikuro-random/wasm", "saikuro-transport/ws"] - -# Storage backends: platform-agnostic factory in storage module -storage = ["saikuro-storage/native"] -storage-fs = ["saikuro-storage/fs"] -storage-sled = ["saikuro-storage/sled"] -storage-sqlite = ["saikuro-storage/sqlite"] -wasm-storage = ["saikuro-storage/wasm"] +wasi-tcp = ["saikuro-transport/wasi-tcp"] +wasi-host = ["saikuro-transport/wasi-host"] +wasi-preview1 = ["saikuro-transport/wasi-preview1"] +wasi-preview2 = ["saikuro-transport/wasi-preview2"] +storage = ["saikuro-storage"] +inmemory = ["saikuro-storage/inmemory"] +storage-fs = ["saikuro-storage/fs"] +storage-sled = ["saikuro-storage/sled"] +storage-sqlite = ["saikuro-storage/sqlite"] +wasm-storage = ["saikuro-storage/wasm"] [dependencies] saikuro-core = { path = "../../crates/saikuro-core", default-features = false } -saikuro-storage = { path = "../../crates/saikuro-storage", default-features = false } +saikuro-storage = { path = "../../crates/saikuro-storage", default-features = false, optional = true } saikuro-transport = { path = "../../crates/saikuro-transport", default-features = false } saikuro-random = { path = "../../crates/saikuro-random", default-features = false } +saikuro-event = { path = "../../crates/saikuro-event", default-features = false } -anyhow = { workspace = true } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0.150" -rmp-serde = "1.3" -bytes = "1.7" +anyhow = { workspace = true, optional = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["alloc"] } +bytes = { workspace = true } saikuro-exec = { workspace = true, default-features = false } -futures = "0.3" -async-trait = "0.1" -tracing = "0.1" -thiserror = "2.0" -dashmap = "6.2.1" -clap = { version = "4.5", features = ["derive"] } -syn = { version = "2.0", features = ["full"] } +futures = { workspace = true, features = ["async-await"] } +async-trait = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +dashmap = { version = "6.2.1", optional = true } +clap = { version = "4.5", features = ["derive"], optional = true } +syn = { version = "2.0", features = ["full"], optional = true } diff --git a/Build/adapters/rust/src/client.rs b/Build/adapters/rust/src/client.rs index 7fec6a8d..23bc7497 100644 --- a/Build/adapters/rust/src/client.rs +++ b/Build/adapters/rust/src/client.rs @@ -4,24 +4,20 @@ //! invocation IDs as correlation keys. //! -use std::{ - sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, - }, - time::Duration, -}; +use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use alloc::sync::Arc; +use alloc::{boxed::Box, string::{String, ToString}, vec::Vec, borrow::ToOwned}; +use core::time::Duration; use bytes::Bytes; use dashmap::DashMap; use futures::future::FutureExt; use saikuro_core::{ envelope::{Envelope, InvocationType, ResponseEnvelope, StreamControl}, - error::{ErrorCode, ErrorDetail}, invocation::InvocationId, - value::Value as CoreValue, PROTOCOL_VERSION, }; +use saikuro_event::{ErrorCode, ErrorDetail, Value as CoreValue}; use saikuro_exec::{mpsc, oneshot, sync::Mutex}; use tracing::{debug, error, warn}; diff --git a/Build/adapters/rust/src/error.rs b/Build/adapters/rust/src/error.rs index b3d388e6..c814d176 100644 --- a/Build/adapters/rust/src/error.rs +++ b/Build/adapters/rust/src/error.rs @@ -1,9 +1,11 @@ //! Error types for the Saikuro Rust adapter. use thiserror::Error; +#[cfg(not(feature = "std"))] +use alloc::string::{String, ToString}; /// The result type used throughout this crate. -pub type Result = std::result::Result; +pub type Result = core::result::Result; /// All errors that can be produced by the Saikuro adapter. #[derive(Debug, Error)] diff --git a/Build/adapters/rust/src/lib.rs b/Build/adapters/rust/src/lib.rs index fc09df04..a7dd4307 100644 --- a/Build/adapters/rust/src/lib.rs +++ b/Build/adapters/rust/src/lib.rs @@ -5,6 +5,12 @@ //! //! For testing without a live runtime use [`transport::InMemoryTransport`]. +#![cfg_attr(not(feature = "std"), no_std)] + +#[macro_use] +extern crate alloc; + +#[cfg(feature = "std")] pub mod client; pub mod error; pub mod provider; @@ -15,6 +21,7 @@ pub mod value; #[cfg(all(not(target_arch = "wasm32"), feature = "storage"))] pub mod storage; +#[cfg(feature = "std")] pub use client::{Client, ClientOptions, SaikuroChannel, SaikuroStream}; pub use error::{Error, Result}; pub use provider::{HandlerArgs, Provider, RegisterOptions}; diff --git a/Build/adapters/rust/src/provider.rs b/Build/adapters/rust/src/provider.rs index f2e39063..fc614be8 100644 --- a/Build/adapters/rust/src/provider.rs +++ b/Build/adapters/rust/src/provider.rs @@ -1,15 +1,21 @@ //! Saikuro provider: register Rust functions and serve them to the runtime. //! -use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; +#[cfg(feature = "std")] +use std::collections::HashMap; +#[cfg(not(feature = "std"))] +use alloc::collections::BTreeMap as HashMap; +use alloc::{boxed::Box, string::{String, ToString}, vec::Vec, borrow::ToOwned}; +use alloc::sync::Arc; +use core::{future::Future, pin::Pin}; use bytes::Bytes; use saikuro_core::{ envelope::{Envelope, InvocationType, ResponseEnvelope}, - error::{ErrorCode, ErrorDetail}, invocation::InvocationId, schema::Schema, }; +use saikuro_event::{ErrorCode, ErrorDetail}; use tracing::{debug, error, info, warn}; use crate::{ @@ -24,10 +30,16 @@ use crate::{ pub type HandlerArgs = Vec; /// A boxed future returned by handler closures. +#[cfg(not(feature = "wasm"))] type HandlerFuture = Pin> + Send>>; +#[cfg(feature = "wasm")] +type HandlerFuture = Pin>>>; /// A boxed handler that accepts args and returns a result. +#[cfg(not(feature = "wasm"))] type BoxedHandler = Arc HandlerFuture + Send + Sync>; +#[cfg(feature = "wasm")] +type BoxedHandler = Arc HandlerFuture>; /// Options that can be supplied when registering a function. #[derive(Debug, Clone, Default)] @@ -80,6 +92,7 @@ impl Provider { /// Ok(serde_json::json!(args[0].as_i64().unwrap_or(0) + args[1].as_i64().unwrap_or(0))) /// }); /// ``` + #[cfg(not(feature = "wasm"))] pub fn register(&mut self, name: impl Into, handler: F) where F: Fn(HandlerArgs) -> Fut + Send + Sync + 'static, @@ -88,7 +101,17 @@ impl Provider { self.register_with_options(name, handler, RegisterOptions::default()); } + #[cfg(feature = "wasm")] + pub fn register(&mut self, name: impl Into, handler: F) + where + F: Fn(HandlerArgs) -> Fut + 'static, + Fut: Future> + 'static, + { + self.register_with_options(name, handler, RegisterOptions::default()); + } + /// Register a function handler with schema metadata. + #[cfg(not(feature = "wasm"))] pub fn register_with_options( &mut self, name: impl Into, @@ -110,6 +133,28 @@ impl Provider { ); } + #[cfg(feature = "wasm")] + pub fn register_with_options( + &mut self, + name: impl Into, + handler: F, + options: RegisterOptions, + ) where + F: Fn(HandlerArgs) -> Fut + 'static, + Fut: Future> + 'static, + { + let name = name.into(); + debug!(namespace = %self.namespace, function = %name, "registering handler"); + let boxed: BoxedHandler = Arc::new(move |args| Box::pin(handler(args))); + self.handlers.insert( + name, + HandlerEntry { + handler: boxed, + schema: options.schema, + }, + ); + } + // Schema /// Build the schema announcement for this provider. @@ -239,7 +284,7 @@ impl Provider { // is non-fatal: the provider enters the serve loop regardless so that // direct-transport test setups (no runtime) work without a 5-second // delay. A real deployment failure is surfaced via the tracing warning. - match saikuro_exec::timeout(std::time::Duration::from_millis(500), transport.recv()).await { + match saikuro_exec::timeout(core::time::Duration::from_millis(500), transport.recv()).await { Ok(Ok(Some(ack_frame))) => match ResponseEnvelope::from_msgpack(&ack_frame) { Ok(ack) if ack.ok => { debug!(namespace = %self.namespace, "schema announce acknowledged"); diff --git a/Build/adapters/rust/src/schema.rs b/Build/adapters/rust/src/schema.rs index 449f6d2f..3e6941f0 100644 --- a/Build/adapters/rust/src/schema.rs +++ b/Build/adapters/rust/src/schema.rs @@ -3,7 +3,12 @@ //! Used by [`Provider`](crate::Provider) to construct the schema announcement //! envelope that it sends to the runtime when it first connects. +#[cfg(feature = "std")] use std::collections::HashMap; +#[cfg(not(feature = "std"))] +use alloc::collections::BTreeMap as HashMap; +#[cfg(not(feature = "std"))] +use alloc::{boxed::Box, string::String, vec::Vec}; use crate::error::{Error, Result}; use saikuro_core::schema::{ diff --git a/Build/adapters/rust/src/transport.rs b/Build/adapters/rust/src/transport.rs index 83a28cbd..3460cc61 100644 --- a/Build/adapters/rust/src/transport.rs +++ b/Build/adapters/rust/src/transport.rs @@ -7,6 +7,8 @@ use bytes::Bytes; use saikuro_transport::DEFAULT_CHANNEL_CAPACITY; use crate::error::{Error, Result}; +#[cfg(not(feature = "std"))] +use alloc::{boxed::Box, string::{String, ToString}}; /// A URL-style address string understood by the Saikuro adapter. /// @@ -18,9 +20,15 @@ use crate::error::{Error, Result}; /// - `wasm-host` (uses default channel "saikuro") pub struct Address(pub String); -impl> From for Address { - fn from(s: S) -> Self { - Self(s.into()) +impl From for Address { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for Address { + fn from(s: &str) -> Self { + Self(s.to_string()) } } @@ -28,6 +36,11 @@ impl> From for Address { /// /// This is a thin adapter over the underlying saikuro-transport types so that /// the Provider and Client don't need to be generic over the concrete transport. +// On the browser-wasm engine transports are `!Send` (JS-object backed). +// On the embedded engine transports are `!Send` (single-threaded, embassy-net +// types use `RefCell`). On every other engine (native, wasi) they are `Send`, +// so the trait's `Send` bound is gated on multi-threaded engines only. +#[cfg(not(any(feature = "wasm", feature = "embedded")))] #[async_trait::async_trait] pub trait AdapterTransport: Send + 'static { async fn send(&mut self, frame: Bytes) -> Result<()>; @@ -35,10 +48,19 @@ pub trait AdapterTransport: Send + 'static { async fn close(&mut self) -> Result<()>; } +#[cfg(any(feature = "wasm", feature = "embedded"))] +#[async_trait::async_trait(?Send)] +pub trait AdapterTransport: 'static { + async fn send(&mut self, frame: Bytes) -> Result<()>; + async fn recv(&mut self) -> Result>; + async fn close(&mut self) -> Result<()>; +} + // Concrete implementations for each transport backend. #[cfg(any(feature = "tcp", feature = "unix", feature = "ws", feature = "wasm"))] macro_rules! impl_adapter_transport { ($Adapter:ident) => { + #[cfg(not(any(feature = "wasm", feature = "embedded")))] #[async_trait::async_trait] impl AdapterTransport for $Adapter { async fn send(&mut self, frame: Bytes) -> Result<()> { @@ -53,19 +75,32 @@ macro_rules! impl_adapter_transport { self.sender.close().await.map_err(Into::into) } } + + #[cfg(any(feature = "wasm", feature = "embedded"))] + #[async_trait::async_trait(?Send)] + impl AdapterTransport for $Adapter { + async fn send(&mut self, frame: Bytes) -> Result<()> { + self.sender.send(frame).await.map_err(Into::into) + } + + async fn recv(&mut self) -> Result> { + self.receiver.recv().await.map_err(Into::into) + } + + async fn close(&mut self) -> Result<()> { + self.sender.close().await.map_err(Into::into) + } + } }; } // TCP -#[cfg(all(feature = "tcp", not(target_arch = "wasm32")))] +#[cfg(all(feature = "tcp", feature = "std"))] mod tcp_impl { use super::*; - use saikuro_transport::tcp::{TcpReceiver, TcpSender}; - use saikuro_transport::{ - traits::{TransportReceiver, TransportSender}, - TcpTransport, - }; + use saikuro_transport::tcp::{TcpConnector, TcpReceiver, TcpSender}; + use saikuro_transport::shared::traits::{TransportConnector, TransportReceiver, TransportSender}; pub struct TcpAdapter { sender: TcpSender, @@ -74,13 +109,11 @@ mod tcp_impl { impl TcpAdapter { pub async fn connect(addr: std::net::SocketAddr) -> Result { - use saikuro_transport::traits::Transport; - let transport = TcpTransport::new( - saikuro_exec::net::TcpStream::connect(addr) - .await - .map_err(|e| Error::Transport(e.to_string()))?, - ) - .map_err(|e| Error::Transport(e.to_string()))?; + use saikuro_transport::shared::traits::Transport; + let transport = TcpConnector::new(addr) + .connect() + .await + .map_err(|e| Error::Transport(e.to_string()))?; let (sender, receiver) = transport.split(); Ok(Self { sender, receiver }) } @@ -96,15 +129,38 @@ mod tcp_impl { } } +// Embedded TCP (embassy-net, single-threaded, !Send) + +#[cfg(all(feature = "tcp", feature = "embedded"))] +pub mod tcp_embedded { + use super::*; + use saikuro_transport::embedded::tcp::{TcpReceiver, TcpSender}; + use saikuro_transport::shared::traits::{Transport, TransportReceiver, TransportSender}; + + pub struct TcpAdapter { + sender: TcpSender, + receiver: TcpReceiver, + } + + impl TcpAdapter { + pub fn from_transport(transport: saikuro_transport::embedded::tcp::TcpTransport) -> Self { + let (sender, receiver) = transport.split(); + Self { sender, receiver } + } + } + + impl_adapter_transport!(TcpAdapter); +} + // Unix socket #[cfg(all(feature = "unix", not(target_arch = "wasm32"), target_family = "unix"))] mod unix_impl { use super::*; - use saikuro_transport::traits::TransportConnector; + use saikuro_transport::shared::traits::TransportConnector; use saikuro_transport::unix::UnixConnector; use saikuro_transport::{ - traits::{Transport, TransportReceiver, TransportSender}, + shared::traits::{Transport, TransportReceiver, TransportSender}, unix::{UnixReceiver, UnixSender}, }; @@ -134,11 +190,11 @@ mod unix_impl { // WebSocket -#[cfg(any(feature = "ws", feature = "wasm"))] +#[cfg(feature = "ws")] mod ws_impl { use super::*; use saikuro_transport::{ - traits::{Transport, TransportReceiver, TransportSender}, + shared::traits::{Transport, TransportReceiver, TransportSender}, websocket::{WebSocketReceiver, WebSocketSender}, WebSocketTransport, }; @@ -170,23 +226,27 @@ mod ws_impl { #[cfg(all(feature = "wasm", target_arch = "wasm32"))] mod wasm_host_impl { use super::*; - use saikuro_transport::{ - traits::{Transport, TransportReceiver, TransportSender}, - wasm_host::{WasmHostConnector, WasmHostReceiver, WasmHostSender}, + use saikuro_transport::LocalTransport; + use saikuro_transport::WasmHostConnector; + use saikuro_transport::shared::host::{WasmHostReceiver, WasmHostSender}; + use saikuro_transport::wasm::host_browser::{ + BroadcastChannelPipe, BroadcastChannelRecv, BroadcastChannelSend, + }; + use saikuro_transport::shared::traits::{ + LocalTransportConnector, LocalTransportReceiver, LocalTransportSender, }; const DEFAULT_WASM_HOST_CHANNEL: &str = "saikuro"; pub struct WasmHostAdapter { - sender: WasmHostSender, - receiver: WasmHostReceiver, + sender: WasmHostSender, + receiver: WasmHostReceiver, } impl WasmHostAdapter { pub async fn connect(channel_name: &str) -> Result { - use saikuro_transport::traits::TransportConnector; - let connector = WasmHostConnector::new(channel_name); - let transport = connector + let connector = WasmHostConnector::::new(channel_name); + let transport: saikuro_transport::wasm::WasmHost = connector .connect() .await .map_err(|e| Error::Transport(e.to_string()))?; @@ -215,7 +275,7 @@ mod wasm_host_impl { /// - `wasm-host` (uses default channel "saikuro") pub async fn connect(address: &str) -> Result> { if let Some(_rest) = address.strip_prefix("tcp://") { - #[cfg(all(feature = "tcp", not(target_arch = "wasm32")))] + #[cfg(all(feature = "tcp", feature = "std"))] { let (host, port_str) = parse_host_port(_rest)?; let port: u16 = port_str @@ -223,18 +283,18 @@ pub async fn connect(address: &str) -> Result> { .map_err(|_| Error::Transport(format!("invalid port in address: {address}")))?; return tcp_impl::connect_tcp(&host, port).await; } - #[cfg(not(all(feature = "tcp", not(target_arch = "wasm32"))))] + #[cfg(not(all(feature = "tcp", feature = "std")))] return Err(Error::Transport( - "TCP transport is not available (feature 'tcp' disabled or wasm32 target)".into(), + "TCP transport via address string requires feature 'std' (use tcp_embedded for embedded targets)".into(), )); } if address.starts_with("ws://") || address.starts_with("wss://") { - #[cfg(any(feature = "ws", feature = "wasm"))] + #[cfg(feature = "ws")] return ws_impl::connect_ws(address).await; - #[cfg(not(any(feature = "ws", feature = "wasm")))] + #[cfg(not(feature = "ws"))] return Err(Error::Transport( - "WebSocket transport is not available (feature 'ws' or 'wasm' disabled)".into(), + "WebSocket transport is not available (feature 'ws' disabled)".into(), )); } @@ -266,8 +326,9 @@ pub async fn connect(address: &str) -> Result> { ))) } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "tcp", feature = "std"))] fn parse_host_port(s: &str) -> Result<(String, &str)> { + use alloc::borrow::ToOwned; // Handle IPv6 like [::1]:7700 if let Some(bracket_end) = s.find(']') { let host = &s[1..bracket_end]; @@ -336,6 +397,7 @@ impl InMemoryTransport { } } +#[cfg(not(any(feature = "wasm", feature = "embedded")))] #[async_trait::async_trait] impl AdapterTransport for InMemoryTransport { async fn send(&mut self, frame: Bytes) -> Result<()> { @@ -353,3 +415,22 @@ impl AdapterTransport for InMemoryTransport { Ok(()) } } + +#[cfg(any(feature = "wasm", feature = "embedded"))] +#[async_trait::async_trait(?Send)] +impl AdapterTransport for InMemoryTransport { + async fn send(&mut self, frame: Bytes) -> Result<()> { + self.sender + .send(frame) + .await + .map_err(|_| Error::Transport("in-memory channel closed".into())) + } + + async fn recv(&mut self) -> Result> { + Ok(self.receiver.recv().await) + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/Build/crates/saikuro-event/log/wasm/console.rs b/Build/crates/saikuro-event/log/wasm/console.rs index 48ec94ad..e014a245 100644 --- a/Build/crates/saikuro-event/log/wasm/console.rs +++ b/Build/crates/saikuro-event/log/wasm/console.rs @@ -1,4 +1,6 @@ +#[cfg(feature = "console")] use serde_json; +#[cfg(feature = "console")] use wasm_bindgen::JsValue; use crate::record::LogRecord; diff --git a/Build/crates/saikuro-exec/lib.rs b/Build/crates/saikuro-exec/lib.rs index 295f84ac..a504a98e 100644 --- a/Build/crates/saikuro-exec/lib.rs +++ b/Build/crates/saikuro-exec/lib.rs @@ -1,7 +1,6 @@ //! Saikuro execution and concurrency facade. #![cfg_attr(not(feature = "std"), no_std)] -#[cfg(not(feature = "std"))] extern crate alloc; // Exactly one engine must be selected diff --git a/Build/crates/saikuro-storage/shared/traits/file.rs b/Build/crates/saikuro-storage/shared/traits/file.rs index 8cf31cf9..bcef80d3 100644 --- a/Build/crates/saikuro-storage/shared/traits/file.rs +++ b/Build/crates/saikuro-storage/shared/traits/file.rs @@ -1,3 +1,4 @@ +use alloc::boxed::Box; use alloc::string::String; use alloc::vec::Vec; use async_trait::async_trait; diff --git a/Build/scripts/check_adapter_matrix.py b/Build/scripts/check_adapter_matrix.py new file mode 100755 index 00000000..cbe73d9e --- /dev/null +++ b/Build/scripts/check_adapter_matrix.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Cross-compile adapter crates across the full engine x target matrix. + +Engines and their targets / feature sets: + + native host (current) default features std + native (ws) host (current) --features ws std + wasm wasm32-unknown-unknown --no-default-features no_std / std + wasi p1 wasm32-wasip1 --no-default-features no_std / std / ws + wasi p2 wasm32-wasip2 --no-default-features no_std / std / ws + +Usage: + python3 scripts/check_adapter_matrix.py + python3 scripts/check_adapter_matrix.py --crate saikuro + python3 scripts/check_adapter_matrix.py --crate saikuro-c + python3 scripts/check_adapter_matrix.py --all-adapters + python3 scripts/check_adapter_matrix.py --json out.json +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import time +from dataclasses import dataclass, field +from typing import Optional + +CARGO = shutil.which("cargo") or "/Users/neel/.cargo/bin/cargo" +ROOT = os.path.dirname(os.path.abspath(__file__)) +if os.path.basename(ROOT) == "scripts": + ROOT = os.path.dirname(ROOT) +MANIFEST = os.path.join(ROOT, "Cargo.toml") + +DEFAULT_CRATE = "saikuro-c" +TIMEOUT = 600 # seconds per check + +ADAPTER_CRATES = ["saikuro", "saikuro-c"] + + +@dataclass +class Combo: + name: str + target: Optional[str] + cargo_args: list[str] = field(default_factory=list) + notes: str = "" + + +MATRIX: list[Combo] = [ + Combo( + "native (host, std)", + None, + [], + "default features", + ), + Combo( + "native (ws)", + None, + ["--features", "ws"], + "default features + websocket transport", + ), + Combo( + "wasm (std)", + "wasm32-unknown-unknown", + ["--no-default-features", "--features", "std,wasm"], + "std wasm", + ), + Combo( + "wasm (no_std)", + "wasm32-unknown-unknown", + ["--no-default-features", "--features", "wasm"], + "no_std wasm", + ), + Combo( + "embedded", + "thumbv7m-none-eabi", + ["--no-default-features", "--features", "embedded,tcp"], + "no_std embedded (thumbv7m)", + ), + Combo( + "wasi preview1 (no_std)", + "wasm32-wasip1", + [ + "--no-default-features", + "--features", + "no_std,wasi-tcp,wasi-host,wasi-preview1", + ], + "no_std wasi (preview1)", + ), + Combo( + "wasi preview1 (std)", + "wasm32-wasip1", + [ + "--no-default-features", + "--features", + "std,no_std,wasi-tcp,wasi-host,wasi-preview1", + ], + "std wasi (preview1)", + ), + Combo( + "wasi preview2 (no_std)", + "wasm32-wasip2", + [ + "--no-default-features", + "--features", + "no_std,wasi-tcp,wasi-host,wasi-preview2", + ], + "no_std wasi (preview2)", + ), + Combo( + "wasi preview2 (std)", + "wasm32-wasip2", + [ + "--no-default-features", + "--features", + "std,no_std,wasi-tcp,wasi-host,wasi-preview2", + ], + "std wasi (preview2)", + ), + Combo( + "wasi preview1 (ws)", + "wasm32-wasip1", + [ + "--no-default-features", + "--features", + "no_std,wasi-tcp,wasi-host,wasi-preview1,ws-wasi", + ], + "no_std wasi websocket client (preview1)", + ), + Combo( + "wasi preview2 (ws)", + "wasm32-wasip2", + [ + "--no-default-features", + "--features", + "no_std,wasi-tcp,wasi-host,wasi-preview2,ws-wasi", + ], + "no_std wasi websocket client (preview2)", + ), +] + + +def run_combo(crate: str, combo: Combo, verbose: bool) -> dict: + """Run `cargo check` for one combo; return a result dict.""" + cmd = [CARGO, "check", "-p", crate, "--lib", "--manifest-path", MANIFEST] + if combo.target: + cmd += ["--target", combo.target] + cmd += combo.cargo_args + + start = time.time() + env = dict(os.environ) + env["CARGO_TERM_COLOR"] = "never" + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + cwd=ROOT, + timeout=TIMEOUT, + ) + elapsed = time.time() - start + out = proc.stdout.decode("utf-8", errors="replace") + lines = out.splitlines() + err_count = sum(1 for line in lines if line.startswith("error")) + warn_count = sum( + 1 for line in lines if line.startswith("warning") and "generated" not in line + ) + diags = [] + in_diag = False + for line in lines: + s = line.strip() + if s.startswith(("error", "warning", "note:", "help:", "-->")): + in_diag = True + diags.append(line) + elif in_diag: + if s == "": + in_diag = False + elif s.startswith(("|", "=", "^", "*")) or line[:1] in (" ", "\t"): + diags.append(line) + else: + in_diag = False + passed = proc.returncode == 0 and err_count == 0 + + if verbose and not passed: + print(out) + + return { + "name": combo.name, + "target": combo.target or "", + "features": " ".join(combo.cargo_args) or "", + "notes": combo.notes, + "passed": passed, + "returncode": proc.returncode, + "errors": err_count, + "warnings": warn_count, + "diags": diags, + "seconds": round(elapsed, 1), + } + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--crate", default=DEFAULT_CRATE, help="crate to check") + ap.add_argument( + "--all-adapters", + action="store_true", + help="check all adapter crates (saikuro, saikuro-c)", + ) + ap.add_argument("--json", metavar="PATH", help="write results as JSON") + ap.add_argument("--verbose", action="store_true", help="print failing output") + args = ap.parse_args() + + crates = [] + if args.all_adapters: + crates = ADAPTER_CRATES + else: + crates = [args.crate] + + print(f"cargo : {CARGO}") + print(f"root : {ROOT}") + print(f"matrix: {len(MATRIX)} combos x {len(crates)} crate(s)\n") + + results: list[dict] = [] + for crate in crates: + print(f"=== crate: {crate} ===") + for combo in MATRIX: + r = run_combo(crate, combo, args.verbose) + status = "PASS" if r["passed"] else "FAIL" + print( + f" [{status}] {r['name']:<22} target={r['target']:<20} " + f"errs={r['errors']:<3} warns={r['warnings']:<3} {r['seconds']}s" + ) + if (not r["passed"] or r["warnings"]) and r["diags"]: + tag = "FAIL" if not r["passed"] else "WARN" + for line in r["diags"]: + print(f" [{tag}] {line}") + results.append({**r, "crate": crate}) + + total = len(results) + passed = sum(1 for r in results if r["passed"]) + warn_total = sum(r["warnings"] for r in results) + err_total = sum(r["errors"] for r in results) + print( + f"\n=== SUMMARY: {passed}/{total} passed " + f"({warn_total} warnings, {err_total} errors) ===" + ) + for r in results: + if not r["passed"]: + print( + f" FAIL {r['crate']} :: {r['name']} " + f"(target={r['target']}, features={r['features']})" + ) + + diag_results = [r for r in results if r["warnings"] or r["errors"]] + if diag_results: + print("\n=== WARNINGS & ERRORS ===") + for r in diag_results: + tag = "FAIL" if not r["passed"] else "WARN" + print( + f"\n[{tag}] {r['crate']} :: {r['name']} " + f"(target={r['target']}, features={r['features']})" + ) + if r["diags"]: + for line in r["diags"]: + print(" " + line) + else: + print(" (no diagnostic lines captured)") + + if args.json: + import json + + with open(args.json, "w") as fh: + json.dump(results, fh, indent=2) + print(f"\nwrote {args.json}") + + return 0 if passed == total else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 156efaae07bf133807ae0c1fb66931acb501cca2 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Tue, 18 Aug 2026 22:00:59 -0600 Subject: [PATCH 40/43] Better logging --- .cargo/config.toml | 45 --- Build/Cargo.lock | 196 ++++++++++- Build/Cargo.toml | 5 +- Build/adapters/c/Cargo.toml | 1 + Build/adapters/c/src/lib.rs | 147 +++++--- Build/adapters/c/tests/c_api_protocol.rs | 169 ++++++--- Build/adapters/c/tests/c_api_runtime.rs | 135 +++++--- Build/adapters/c/tests/c_api_smoke.rs | 128 ++++--- Build/adapters/c/tests/c_api_validation.rs | 144 ++++++-- Build/adapters/c/tests/common/mod.rs | 44 ++- Build/adapters/c/tests/cpp_wrapper_runtime.rs | 101 +++--- Build/adapters/rust/Cargo.toml | 8 +- Build/adapters/rust/src/client.rs | 114 +++---- Build/adapters/rust/src/error.rs | 2 +- Build/adapters/rust/src/provider.rs | 321 ++++++++++++------ Build/adapters/rust/src/schema.rs | 4 +- Build/adapters/rust/src/storage.rs | 10 +- Build/adapters/rust/src/transport.rs | 33 +- Build/adapters/rust/src/value.rs | 10 +- Build/crates/saikuro-event/Cargo.toml | 1 + .../saikuro-event/log/embedded/serial.rs | 8 + .../crates/saikuro-event/log/native/stderr.rs | 2 + .../saikuro-event/log/native/tracing.rs | 3 + Build/crates/saikuro-event/log/record.rs | 33 ++ Build/crates/saikuro-event/log/ring.rs | 18 + Build/crates/saikuro-event/log/sink.rs | 41 ++- .../crates/saikuro-event/log/wasm/console.rs | 9 + Build/crates/saikuro-exec/Cargo.toml | 2 + Build/crates/saikuro-exec/base/exec.rs | 56 ++- Build/crates/saikuro-exec/base/mod.rs | 3 + Build/crates/saikuro-router/Cargo.toml | 2 + .../saikuro-router/provider/provider.rs | 8 +- Build/crates/saikuro-router/router/router.rs | 6 +- .../stream_state/stream_state.rs | 6 +- Build/crates/saikuro-runtime/Cargo.toml | 7 +- Build/crates/saikuro-runtime/native/mod.rs | 31 +- .../saikuro-runtime/shared/connection.rs | 203 +++++++++-- Build/crates/saikuro-runtime/shared/handle.rs | 31 +- .../crates/saikuro-runtime/shared/runtime.rs | 86 ++++- Build/crates/saikuro-schema/Cargo.toml | 2 + .../saikuro-schema/registry/registry.rs | 6 +- Build/crates/saikuro-storage/Cargo.toml | 3 +- .../crates/saikuro-storage/common/inmemory.rs | 47 ++- Build/crates/saikuro-transport/Cargo.toml | 12 +- .../crates/saikuro-transport/embedded/tcp.rs | 15 +- Build/crates/saikuro-transport/lib.rs | 55 ++- Build/crates/saikuro-transport/native/tcp.rs | 68 +++- Build/crates/saikuro-transport/native/unix.rs | 74 +++- .../saikuro-transport/native/websocket.rs | 130 ++++++- .../crates/saikuro-transport/shared/memory.rs | 87 ++++- .../crates/saikuro-transport/wasi/preview1.rs | 3 + .../crates/saikuro-transport/wasi/preview2.rs | 3 + Build/crates/saikuro-transport/wasi/tcp.rs | 3 + .../saikuro-transport/wasi/websocket.rs | 3 + .../saikuro-transport/wasm/host_browser.rs | 12 +- .../saikuro-transport/wasm/websocket.rs | 24 +- Build/scripts/check_adapter_matrix.py | 34 +- Build/scripts/check_matrix.py | 32 +- Build/tests/Cargo.toml | 3 + Build/tests/lib.rs | 82 ++++- .../tests/saikuro-router/announce_dispatch.rs | 2 +- .../tests/saikuro-router/channel_dispatch.rs | 2 +- .../tests/saikuro-router/resource_dispatch.rs | 2 +- .../tests/saikuro-router/sandbox_dispatch.rs | 158 +++++---- Build/tests/saikuro-router/stream_dispatch.rs | 56 ++- Build/tests/saikuro-storage/flash.rs | 17 +- .../transport_memory_stress.rs | 3 - 67 files changed, 2289 insertions(+), 822 deletions(-) delete mode 100644 .cargo/config.toml mode change 100644 => 100755 Build/scripts/check_matrix.py diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 9b8db2d8..00000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,45 +0,0 @@ -# Saikuro Cargo config -# -# WASM tests need wasm-bindgen-cli (cargo install wasm-bindgen-cli): -# cargo test -p saikuro-tests --target wasm32-unknown-unknown -# wasm-pack test --headless --chrome Build/tests - -# getrandom 0.3 picks its backend at compile time via the `getrandom_backend` -# cfg, so it's set per-target below. -# -# wasm32-unknown-unknown: no OS entropy, so pin the `wasm_js` backend. -# saikuro-random's `wasm` engine enables the matching cargo feature (wired -# through adapters/rust, saikuro-runtime, and saikuro-tests' wasm32 deps). -# -# WASI (preview1/preview2): the `no_std` engine uses getrandom's built-in WASI -# backend, picked automatically from the target triple. No cfg needed. -# -# Bare-metal MCU targets don't use getrandom at all. saikuro-random's -# `embedded` engine pulls entropy from an application-provided `EntropySource` -# via `init_from`, so there's no `__getrandom_v03_custom` symbol to link. The -# `custom` cfgs below only matter for direct getrandom usage and are inert for -# the embedded engine. -# -# Host targets: leave the cfg alone; getrandom uses the OS backend. -[target.wasm32-unknown-unknown] -runner = "wasm-bindgen-test-runner" -rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""] - -# Bare-metal targets (kept for direct getrandom usage; the embedded engine uses -# EntropySource instead): -# riscv32imc-unknown-none-elf ESP32-C3 -# thumbv6m-none-eabi RP2040 -# thumbv8m.main-none-eabihf RP2350 -[target.aarch64-unknown-none] -rustflags = ["--cfg", "getrandom_backend=\"custom\""] -[target.riscv32imac-unknown-none-elf] -rustflags = ["--cfg", "getrandom_backend=\"custom\""] -[target.riscv32imc-unknown-none-elf] -rustflags = ["--cfg", "getrandom_backend=\"custom\""] -[target.thumbv6m-none-eabi] -rustflags = ["--cfg", "getrandom_backend=\"custom\""] -[target.thumbv8m.main-none-eabihf] -rustflags = ["--cfg", "getrandom_backend=\"custom\""] - -# [build] -# rustflags = ["--cfg=web_sys_unstable_apis"] diff --git a/Build/Cargo.lock b/Build/Cargo.lock index e8a82d31..88d88954 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -146,6 +146,25 @@ name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] [[package]] name = "cfg-if" @@ -667,6 +686,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + [[package]] name = "fluvio-wasm-timer" version = "0.2.5" @@ -1039,6 +1064,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "litrs" version = "1.0.0" @@ -1101,6 +1132,16 @@ dependencies = [ "serde", ] +[[package]] +name = "minicov" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "mio" version = "1.2.0" @@ -1143,6 +1184,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1157,6 +1199,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "parking_lot" version = "0.11.2" @@ -1226,6 +1274,15 @@ dependencies = [ "critical-section", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1345,6 +1402,25 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1370,6 +1446,8 @@ dependencies = [ "clap", "dashmap 6.2.1", "futures", + "portable-atomic", + "portable-atomic-util", "saikuro-core", "saikuro-event", "saikuro-exec", @@ -1380,7 +1458,6 @@ dependencies = [ "serde_json", "syn", "thiserror", - "tracing", ] [[package]] @@ -1392,6 +1469,7 @@ dependencies = [ "regex", "saikuro", "saikuro-core", + "saikuro-event", "saikuro-exec", "saikuro-runtime", "saikuro-transport", @@ -1434,6 +1512,7 @@ dependencies = [ name = "saikuro-event" version = "0.1.0" dependencies = [ + "async-trait", "embedded-io-async 0.7.0", "getrandom 0.3.4", "heapless 0.8.0", @@ -1461,6 +1540,8 @@ dependencies = [ "embassy-time", "fluvio-wasm-timer", "futures", + "portable-atomic", + "portable-atomic-util", "tokio", "tokio-util", "wasm-bindgen-futures", @@ -1494,6 +1575,8 @@ name = "saikuro-router" version = "0.1.0" dependencies = [ "async-trait", + "portable-atomic", + "portable-atomic-util", "saikuro-core", "saikuro-event", "saikuro-exec", @@ -1513,6 +1596,7 @@ dependencies = [ "embassy-executor", "futures", "portable-atomic", + "portable-atomic-util", "saikuro-core", "saikuro-event", "saikuro-exec", @@ -1535,6 +1619,8 @@ dependencies = [ name = "saikuro-schema" version = "0.1.0" dependencies = [ + "portable-atomic", + "portable-atomic-util", "saikuro-core", "saikuro-event", "saikuro-exec", @@ -1563,8 +1649,6 @@ dependencies = [ "spin 0.12.3", "thiserror", "tokio", - "tracing", - "tracing-subscriber", "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", "wasm-bindgen-futures", @@ -1572,6 +1656,34 @@ dependencies = [ "wit-bindgen 0.46.0", ] +[[package]] +name = "saikuro-tests" +version = "0.1.0" +dependencies = [ + "bytes", + "futures", + "js-sys", + "rmp-serde", + "saikuro", + "saikuro-codegen", + "saikuro-core", + "saikuro-event", + "saikuro-exec", + "saikuro-random", + "saikuro-router", + "saikuro-runtime", + "saikuro-schema", + "saikuro-transport", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", +] + [[package]] name = "saikuro-transport" version = "0.1.0" @@ -1585,8 +1697,11 @@ dependencies = [ "getrandom 0.3.4", "js-sys", "pin-project-lite", + "portable-atomic", + "portable-atomic-util", "rand_core 0.6.4", "saikuro-core", + "saikuro-event", "saikuro-exec", "saikuro-net", "saikuro-random", @@ -1594,8 +1709,6 @@ dependencies = [ "serde", "thiserror", "tokio-tungstenite", - "tracing", - "tracing-subscriber", "wasi 0.14.7+wasi-0.2.4", "wasip1", "wasm-bindgen", @@ -1603,6 +1716,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1715,6 +1837,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2069,6 +2197,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2154,6 +2292,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fde991ccdc895cb7fbaa14b137d62af74d9011be67b71c694bfc40edd3119c" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e925354648d2a4d1bf205412e36d520a800280622eef4719678d268e5d40e978" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "684365b586a9a6256c1cc3544eee8680de48d6041142f581776ec7b139622ae9" + [[package]] name = "wasm-encoder" version = "0.239.0" @@ -2229,6 +2406,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Build/Cargo.toml b/Build/Cargo.toml index 2a334602..8a4ebe89 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/saikuro-codegen", "adapters/c", "adapters/rust", + "tests" ] [workspace.package] @@ -51,6 +52,7 @@ portable-atomic = { version = "1", default-features = false, features = [ "fallback", "critical-section", ] } +portable-atomic-util = { version = "0.2", default-features = false, features = ["alloc"] } rand_core = { version = "0.9", default-features = false } @@ -143,9 +145,6 @@ saikuro-random = { path = "crates/saikuro-random", default-features = false } saikuro-event = { path = "crates/saikuro-event", default-features = false } saikuro = { path = "adapters/rust", default-features = false } -# no_std (wasm / wasi) targets cannot unwind; abort on panic everywhere. [profile.dev] -panic = "abort" [profile.release] -panic = "abort" diff --git a/Build/adapters/c/Cargo.toml b/Build/adapters/c/Cargo.toml index 61b3afbe..62225b9f 100644 --- a/Build/adapters/c/Cargo.toml +++ b/Build/adapters/c/Cargo.toml @@ -49,5 +49,6 @@ optional = true [dev-dependencies] saikuro-core = { workspace = true, features = ["std"] } +saikuro-event = { workspace = true } saikuro-runtime = { workspace = true } saikuro-transport = { workspace = true } diff --git a/Build/adapters/c/src/lib.rs b/Build/adapters/c/src/lib.rs index dc98a14e..74384b84 100644 --- a/Build/adapters/c/src/lib.rs +++ b/Build/adapters/c/src/lib.rs @@ -5,10 +5,7 @@ extern crate alloc; #[cfg(all( not(feature = "std"), not(feature = "native"), - any( - target_os = "none", - all(target_os = "wasi", target_env = "p1"), - ), + any(target_os = "none", all(target_os = "wasi", target_env = "p1"),), ))] mod embedded_rt { use core::alloc::{GlobalAlloc, Layout}; @@ -35,9 +32,9 @@ mod embedded_rt { use alloc::borrow::ToOwned; #[cfg(not(feature = "std"))] use alloc::boxed::Box; +use alloc::ffi::CString; #[cfg(not(feature = "std"))] use alloc::format; -use alloc::ffi::CString; #[cfg(not(feature = "std"))] use alloc::string::String; #[cfg(not(feature = "std"))] @@ -47,8 +44,7 @@ use core::future::Future; use core::ptr; use saikuro::{ - ArgDescriptor, FunctionSchema, PrimitiveType, Provider, RegisterOptions, - TypeDescriptor, Value, + ArgDescriptor, FunctionSchema, PrimitiveType, Provider, RegisterOptions, TypeDescriptor, Value, }; #[cfg(feature = "std")] use saikuro::{Client, SaikuroChannel, SaikuroStream}; @@ -114,9 +110,8 @@ mod exec { F: Future + Send + 'static, F::Output: Send + 'static, { - let rt = RT.get_or_init(|| { - TokioRuntime::new().expect("saikuro-c: failed to start tokio runtime") - }); + let rt = RT + .get_or_init(|| TokioRuntime::new().expect("saikuro-c: failed to start tokio runtime")); rt.handle().spawn(fut); } } @@ -440,7 +435,7 @@ pub extern "C" fn saikuro_client_close_async( let handle = unsafe { Box::from_raw(handle as *mut ClientHandle) }; let user_data_addr = user_data as usize; spawn_future(async move { - let status = match handle.client { + let status = match handle.client { Some(client) => int_saikuro(client.close().await, "close"), None => 0, }; @@ -469,9 +464,14 @@ pub extern "C" fn saikuro_client_call_json_async( return; } }; - let h = match cstr_to_string(target, "target").and_then(|t| { - c_json_array(args_json).map(|a| (t, a)) - }) { + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(ptr::null_mut(), user_data); + return; + } + let h = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { Ok(v) => v, Err(e) => { set_last_error(e); @@ -484,7 +484,7 @@ pub extern "C" fn saikuro_client_call_json_async( let user_data_addr = user_data as usize; spawn_future(async move { let handle = handle_addr as *mut c_void; - let h = client_ref(handle); + let h = client_ref(handle); let res = h.client().call(target, args).await; let out = ptr_saikuro(res, "call"); cb(out, user_data_addr as *mut c_void); @@ -511,13 +511,18 @@ pub extern "C" fn saikuro_client_call_json_timeout_async( return; } }; + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(ptr::null_mut(), user_data); + return; + } if timeout_ms < 0 { set_last_error("timeout_ms must be non-negative"); return; } - let parsed = match cstr_to_string(target, "target").and_then(|t| { - c_json_array(args_json).map(|a| (t, a)) - }) { + let parsed = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { Ok(v) => v, Err(e) => { set_last_error(e); @@ -531,8 +536,11 @@ pub extern "C" fn saikuro_client_call_json_timeout_async( let user_data_addr = user_data as usize; spawn_future(async move { let handle = handle_addr as *mut c_void; - let h = client_ref(handle); - let res = h.client().call_with_timeout(target, args, Some(timeout)).await; + let h = client_ref(handle); + let res = h + .client() + .call_with_timeout(target, args, Some(timeout)) + .await; let out = ptr_saikuro(res, "call"); cb(out, user_data_addr as *mut c_void); }); @@ -557,9 +565,14 @@ pub extern "C" fn saikuro_client_cast_json_async( return; } }; - let parsed = match cstr_to_string(target, "target").and_then(|t| { - c_json_array(args_json).map(|a| (t, a)) - }) { + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(1, user_data); + return; + } + let parsed = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { Ok(v) => v, Err(e) => { set_last_error(e); @@ -573,7 +586,7 @@ pub extern "C" fn saikuro_client_cast_json_async( let user_data_addr = user_data as usize; spawn_future(async move { let handle = handle_addr as *mut c_void; - let h = client_ref(handle); + let h = client_ref(handle); let res = h.client().cast(target, args).await; cb(int_saikuro(res, "cast"), user_data_addr as *mut c_void); }); @@ -597,6 +610,11 @@ pub extern "C" fn saikuro_client_batch_json_async( return; } }; + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(ptr::null_mut(), user_data); + return; + } let raw = match cstr_to_string(calls_json, "calls_json") { Ok(s) => s, Err(e) => { @@ -616,7 +634,7 @@ pub extern "C" fn saikuro_client_batch_json_async( let user_data_addr = user_data as usize; spawn_future(async move { let handle = handle_addr as *mut c_void; - let h = client_ref(handle); + let h = client_ref(handle); match h.client().batch(calls).await { Ok(v) => match serde_json::to_string(&v) { Ok(json) => cb(into_c_string_ptr(&json), user_data_addr as *mut c_void), @@ -652,9 +670,14 @@ pub extern "C" fn saikuro_client_resource_json_async( return; } }; - let parsed = match cstr_to_string(target, "target").and_then(|t| { - c_json_array(args_json).map(|a| (t, a)) - }) { + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(ptr::null_mut(), user_data); + return; + } + let parsed = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { Ok(v) => v, Err(e) => { set_last_error(e); @@ -667,7 +690,7 @@ pub extern "C" fn saikuro_client_resource_json_async( let user_data_addr = user_data as usize; spawn_future(async move { let handle = handle_addr as *mut c_void; - let h = client_ref(handle); + let h = client_ref(handle); let res = h.client().resource(target, args).await; let out = ptr_saikuro(res, "resource"); cb(out, user_data_addr as *mut c_void); @@ -695,6 +718,11 @@ pub extern "C" fn saikuro_client_log_async( return; } }; + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(1, user_data); + return; + } let parsed = match cstr_to_string(level, "level") .and_then(|l| cstr_to_string(name, "name").map(|n| (l, n))) .and_then(|(l, n)| cstr_to_string(msg, "msg").map(|m| (l, n, m))) @@ -710,9 +738,9 @@ pub extern "C" fn saikuro_client_log_async( let fields = if fields_json.is_null() { None } else { - match cstr_to_string(fields_json, "fields_json").and_then(|raw| { - parse_json_object_arg(&raw, "fields_json").map(Value::Object) - }) { + match cstr_to_string(fields_json, "fields_json") + .and_then(|raw| parse_json_object_arg(&raw, "fields_json").map(Value::Object)) + { Ok(v) => Some(v), Err(e) => { set_last_error(e); @@ -726,7 +754,7 @@ pub extern "C" fn saikuro_client_log_async( let user_data_addr = user_data as usize; spawn_future(async move { let handle = handle_addr as *mut c_void; - let h = client_ref(handle); + let h = client_ref(handle); let res = h.client().log(level, name, msg, fields).await; cb(int_saikuro(res, "log"), user_data_addr as *mut c_void); }); @@ -753,9 +781,14 @@ pub extern "C" fn saikuro_client_stream_json_async( return; } }; - let parsed = match cstr_to_string(target, "target").and_then(|t| { - c_json_array(args_json).map(|a| (t, a)) - }) { + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(ptr::null_mut(), user_data); + return; + } + let parsed = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { Ok(v) => v, Err(e) => { set_last_error(e); @@ -768,7 +801,7 @@ pub extern "C" fn saikuro_client_stream_json_async( let user_data_addr = user_data as usize; spawn_future(async move { let handle = handle_addr as *mut c_void; - let h = client_ref(handle); + let h = client_ref(handle); match h.client().stream(target, args).await { Ok(stream) => { let sh = Box::into_raw(Box::new(StreamHandle { stream })); @@ -819,7 +852,7 @@ pub unsafe extern "C" fn saikuro_stream_next_json_async( let user_data_addr = user_data as usize; spawn_future(async move { let stream = stream_addr as *mut StreamHandle; - let s = unsafe { &mut *stream }; + let s = unsafe { &mut *stream }; match s.stream.next().await { Some(Ok(value)) => match serde_json::to_string(&value) { Ok(json) => cb(into_c_string_ptr(&json), 0, user_data_addr as *mut c_void), @@ -858,9 +891,14 @@ pub extern "C" fn saikuro_client_channel_json_async( return; } }; - let parsed = match cstr_to_string(target, "target").and_then(|t| { - c_json_array(args_json).map(|a| (t, a)) - }) { + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(ptr::null_mut(), user_data); + return; + } + let parsed = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { Ok(v) => v, Err(e) => { set_last_error(e); @@ -873,7 +911,7 @@ pub extern "C" fn saikuro_client_channel_json_async( let user_data_addr = user_data as usize; spawn_future(async move { let handle = handle_addr as *mut c_void; - let h = client_ref(handle); + let h = client_ref(handle); match h.client().channel(target, args).await { Ok(channel) => { let ch = Box::into_raw(Box::new(ChannelHandle { channel })); @@ -931,9 +969,12 @@ pub extern "C" fn saikuro_channel_send_json_async( let user_data_addr = user_data as usize; spawn_future(async move { let channel = channel_addr as *mut ChannelHandle; - let c = unsafe { &mut *channel }; + let c = unsafe { &mut *channel }; let res = c.channel.send(item).await; - cb(int_saikuro(res, "channel send"), user_data_addr as *mut c_void); + cb( + int_saikuro(res, "channel send"), + user_data_addr as *mut c_void, + ); }); } @@ -961,8 +1002,11 @@ pub extern "C" fn saikuro_channel_close_async( let channel = unsafe { Box::from_raw(channel as *mut ChannelHandle) }; let user_data_addr = user_data as usize; spawn_future(async move { - let res = channel.channel.close().await; - cb(int_saikuro(res, "channel close"), user_data_addr as *mut c_void); + let res = channel.channel.close().await; + cb( + int_saikuro(res, "channel close"), + user_data_addr as *mut c_void, + ); }); } @@ -990,8 +1034,11 @@ pub extern "C" fn saikuro_channel_abort_async( let channel = unsafe { Box::from_raw(channel as *mut ChannelHandle) }; let user_data_addr = user_data as usize; spawn_future(async move { - let res = channel.channel.abort().await; - cb(int_saikuro(res, "channel abort"), user_data_addr as *mut c_void); + let res = channel.channel.abort().await; + cb( + int_saikuro(res, "channel abort"), + user_data_addr as *mut c_void, + ); }); } @@ -1023,7 +1070,7 @@ pub unsafe extern "C" fn saikuro_channel_next_json_async( let user_data_addr = user_data as usize; spawn_future(async move { let channel = channel_addr as *mut ChannelHandle; - let c = unsafe { &mut *channel }; + let c = unsafe { &mut *channel }; match c.channel.next().await { Some(Ok(value)) => match serde_json::to_string(&value) { Ok(json) => cb(into_c_string_ptr(&json), 0, user_data_addr as *mut c_void), @@ -1283,7 +1330,7 @@ pub extern "C" fn saikuro_provider_serve_async( let user_data_addr = user_data as usize; spawn_future(async move { - match provider.serve(address).await { + match provider.serve(address).await { Ok(()) => cb(0, user_data_addr as *mut c_void), Err(e) => { set_last_error(format!("provider serve failed: {e}")); diff --git a/Build/adapters/c/tests/c_api_protocol.rs b/Build/adapters/c/tests/c_api_protocol.rs index 9ef54a09..a3cd1b54 100644 --- a/Build/adapters/c/tests/c_api_protocol.rs +++ b/Build/adapters/c/tests/c_api_protocol.rs @@ -4,21 +4,21 @@ use std::thread; use std::time::Duration; use saikuro_c::{ - saikuro_channel_close, saikuro_channel_next_json, saikuro_channel_send_json, - saikuro_client_call_json, saikuro_client_call_json_timeout, saikuro_client_channel_json, - saikuro_client_close, saikuro_client_connect, saikuro_client_free, saikuro_client_log, - saikuro_client_resource_json, saikuro_client_stream_json, saikuro_provider_free, - saikuro_provider_new, saikuro_provider_register, saikuro_provider_serve, - saikuro_stream_next_json, saikuro_string_dup, + saikuro_channel_close_async, saikuro_channel_next_json_async, saikuro_channel_send_json_async, + saikuro_client_call_json_async, saikuro_client_call_json_timeout_async, + saikuro_client_channel_json_async, saikuro_client_close_async, saikuro_client_connect_async, + saikuro_client_free, saikuro_client_log_async, saikuro_client_resource_json_async, + saikuro_client_stream_json_async, saikuro_provider_free, saikuro_provider_new, + saikuro_provider_register, saikuro_provider_serve_async, saikuro_stream_next_json_async, + saikuro_string_dup, }; use saikuro_core::{ envelope::{Envelope, InvocationType}, - error::{ErrorCode, ErrorDetail}, - value::Value, ResponseEnvelope, }; +use saikuro_event::{ErrorCode, ErrorDetail, Value}; use saikuro_transport::tcp::TcpTransportListener; -use saikuro_transport::traits::{Transport, TransportListener, TransportReceiver, TransportSender}; +use saikuro_transport::{Transport, TransportListener, TransportReceiver, TransportSender}; mod common; @@ -33,16 +33,16 @@ struct ScriptReport { fn spawn_scripted_server_for_client() -> (String, thread::JoinHandle) { let (ready_tx, ready_rx) = std::sync::mpsc::channel(); let handle = thread::spawn(move || { - let rt = saikuro_exec::runtime::Builder::new_current_thread() + let rt = saikuro_exec::RuntimeBuilder::new_current_thread() .enable_all() - .build() - .expect("create runtime"); + .build(); rt.block_on(async move { let socket = SocketAddr::from(([127, 0, 0, 1], 0)); - let mut listener = TcpTransportListener::bind(socket) - .await - .expect("bind listener"); + let mut listener = + TcpTransportListener::bind(socket, std::sync::Arc::new(saikuro_event::NullSink)) + .await + .expect("bind listener"); let _ = ready_tx.send(format!("tcp://{}", listener.local_addr())); let transport = listener .accept() @@ -89,10 +89,7 @@ fn spawn_scripted_server_for_client() -> (String, thread::JoinHandle { - if matches!( - env.stream_control, - Some(saikuro_core::envelope::StreamControl::End) - ) { + if matches!(env.stream_control, Some(saikuro_core::StreamControl::End)) { report.saw_channel_close = true; continue; } @@ -147,18 +144,30 @@ fn spawn_scripted_server_for_client() -> (String, thread::JoinHandle(); + saikuro_client_connect_async( + common::c(&address).as_ptr(), + Some(common::connect_cb), + user_data, + ); + let handle = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!( !handle.is_null(), "connect failed: {}", common::take_error() ); - let resource = saikuro_client_resource_json( + // Resource. + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); + saikuro_client_resource_json_async( handle, common::c("files.read").as_ptr(), common::c("[]").as_ptr(), + Some(common::result_cb), + user_data, ); + let resource = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!( !resource.is_null(), "resource failed: {}", @@ -166,67 +175,111 @@ fn c_client_protocol_paths_cover_stream_channel_resource_log_error_and_timeout() ); assert_eq!(common::take_c_string(resource), "\"contents\""); - let log_rc = saikuro_client_log( + // Log. + let (rx, user_data) = common::channel_pair::(); + saikuro_client_log_async( handle, common::c("info").as_ptr(), common::c("tests").as_ptr(), common::c("hello").as_ptr(), common::c("{}").as_ptr(), + Some(common::status_cb), + user_data, ); + let log_rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(log_rc, 0, "log failed: {}", common::take_error()); - let stream = saikuro_client_stream_json( + // Stream open. + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_void>(); + saikuro_client_stream_json_async( handle, common::c("events.watch").as_ptr(), common::c("[]").as_ptr(), + Some(common::connect_cb), + user_data, ); + let stream = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!( !stream.is_null(), "stream open failed: {}", common::take_error() ); - let mut out_json = ptr::null_mut(); - let mut out_done = 0; - let rc = unsafe { saikuro_stream_next_json(stream, &mut out_json, &mut out_done) }; - assert_eq!(rc, 0); + // Stream next (item 1). + let (rx, user_data) = common::channel_pair::<(*mut std::ffi::c_char, std::ffi::c_int)>(); + unsafe { + saikuro_stream_next_json_async(stream, Some(common::item_cb), user_data); + } + let (out_json, out_done) = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(out_done, 0); assert_eq!(common::take_c_string(out_json), "1"); - let rc = unsafe { saikuro_stream_next_json(stream, &mut out_json, &mut out_done) }; - assert_eq!(rc, 0); + // Stream next (item 2). + let (rx, user_data) = common::channel_pair::<(*mut std::ffi::c_char, std::ffi::c_int)>(); + unsafe { + saikuro_stream_next_json_async(stream, Some(common::item_cb), user_data); + } + let (out_json, out_done) = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(out_done, 0); assert_eq!(common::take_c_string(out_json), "2"); - let rc = unsafe { saikuro_stream_next_json(stream, &mut out_json, &mut out_done) }; - assert_eq!(rc, 0); + // Stream next (done). + let (rx, user_data) = common::channel_pair::<(*mut std::ffi::c_char, std::ffi::c_int)>(); + unsafe { + saikuro_stream_next_json_async(stream, Some(common::item_cb), user_data); + } + let (_out_json, out_done) = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(out_done, 1); - let channel = saikuro_client_channel_json( + // Channel open. + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_void>(); + saikuro_client_channel_json_async( handle, common::c("chat.open").as_ptr(), common::c("[]").as_ptr(), + Some(common::connect_cb), + user_data, ); + let channel = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!( !channel.is_null(), "channel open failed: {}", common::take_error() ); - let rc = unsafe { saikuro_channel_next_json(channel, &mut out_json, &mut out_done) }; - assert_eq!(rc, 0); + // Channel next (welcome). + let (rx, user_data) = common::channel_pair::<(*mut std::ffi::c_char, std::ffi::c_int)>(); + unsafe { + saikuro_channel_next_json_async(channel, Some(common::item_cb), user_data); + } + let (out_json, out_done) = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(out_done, 0); assert_eq!(common::take_c_string(out_json), "\"welcome\""); - let send_rc = saikuro_channel_send_json(channel, common::c("\"ping\"").as_ptr()); + // Channel send. + let (rx, user_data) = common::channel_pair::(); + saikuro_channel_send_json_async( + channel, + common::c("\"ping\"").as_ptr(), + Some(common::status_cb), + user_data, + ); + let send_rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(send_rc, 0, "channel send failed: {}", common::take_error()); - let rc = unsafe { saikuro_channel_next_json(channel, &mut out_json, &mut out_done) }; - assert_eq!(rc, 0); + // Channel next (pong). + let (rx, user_data) = common::channel_pair::<(*mut std::ffi::c_char, std::ffi::c_int)>(); + unsafe { + saikuro_channel_next_json_async(channel, Some(common::item_cb), user_data); + } + let (out_json, out_done) = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(out_done, 0); assert_eq!(common::take_c_string(out_json), "\"pong\""); - let close_rc = saikuro_channel_close(channel); + // Channel close. + let (rx, user_data) = common::channel_pair::(); + saikuro_channel_close_async(channel, Some(common::status_cb), user_data); + let close_rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!( close_rc, 0, @@ -234,11 +287,16 @@ fn c_client_protocol_paths_cover_stream_channel_resource_log_error_and_timeout() common::take_error() ); - let call_fail = saikuro_client_call_json( + // Call (error path). + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); + saikuro_client_call_json_async( handle, common::c("math.fail").as_ptr(), common::c("[]").as_ptr(), + Some(common::result_cb), + user_data, ); + let call_fail = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(call_fail.is_null(), "call should fail"); let call_error = common::take_error(); assert!( @@ -246,12 +304,17 @@ fn c_client_protocol_paths_cover_stream_channel_resource_log_error_and_timeout() "unexpected error mapping: {call_error}" ); - let timeout = saikuro_client_call_json_timeout( + // Call (timeout path). + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); + saikuro_client_call_json_timeout_async( handle, common::c("slow.never").as_ptr(), common::c("[]").as_ptr(), 30, + Some(common::result_cb), + user_data, ); + let timeout = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(timeout.is_null(), "timeout call should fail"); let timeout_error = common::take_error(); assert!( @@ -259,7 +322,10 @@ fn c_client_protocol_paths_cover_stream_channel_resource_log_error_and_timeout() "unexpected timeout error: {timeout_error}" ); - let client_close_rc = saikuro_client_close(handle); + // Close client. + let (rx, user_data) = common::channel_pair::(); + saikuro_client_close_async(handle, Some(common::status_cb), user_data); + let client_close_rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!( client_close_rc, 0, @@ -287,16 +353,16 @@ unsafe extern "C" fn add_cb( fn spawn_scripted_server_for_provider() -> (String, thread::JoinHandle) { let (ready_tx, ready_rx) = std::sync::mpsc::channel(); let handle = thread::spawn(move || { - let rt = saikuro_exec::runtime::Builder::new_current_thread() + let rt = saikuro_exec::RuntimeBuilder::new_current_thread() .enable_all() - .build() - .expect("create runtime"); + .build(); rt.block_on(async move { let socket = SocketAddr::from(([127, 0, 0, 1], 0)); - let mut listener = TcpTransportListener::bind(socket) - .await - .expect("bind listener"); + let mut listener = + TcpTransportListener::bind(socket, std::sync::Arc::new(saikuro_event::NullSink)) + .await + .expect("bind listener"); let _ = ready_tx.send(format!("tcp://{}", listener.local_addr())); let transport = listener .accept() @@ -371,7 +437,14 @@ fn c_provider_announce_and_runtime_dispatch_roundtrip() { common::take_error() ); - let serve_rc = saikuro_provider_serve(provider, common::c(&address).as_ptr()); + let (rx, user_data) = common::channel_pair::(); + saikuro_provider_serve_async( + provider, + common::c(&address).as_ptr(), + Some(common::status_cb), + user_data, + ); + let serve_rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!( serve_rc, 0, diff --git a/Build/adapters/c/tests/c_api_runtime.rs b/Build/adapters/c/tests/c_api_runtime.rs index ce806f64..77f1684c 100644 --- a/Build/adapters/c/tests/c_api_runtime.rs +++ b/Build/adapters/c/tests/c_api_runtime.rs @@ -5,27 +5,27 @@ use std::thread; use std::time::Duration; use saikuro_c::{ - saikuro_client_batch_json, saikuro_client_call_json, saikuro_client_cast_json, - saikuro_client_close, saikuro_client_connect, saikuro_client_free, + saikuro_client_batch_json_async, saikuro_client_call_json_async, + saikuro_client_cast_json_async, saikuro_client_close_async, saikuro_client_connect_async, + saikuro_client_free, }; use saikuro_core::{ - capability::CapabilitySet, envelope::{Envelope, InvocationType}, schema::{ FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, TypeMap, Visibility, }, - value::Value, ResponseEnvelope, }; -use saikuro_runtime::runtime::SaikuroRuntime; +use saikuro_event::Value; +use saikuro_runtime::SaikuroRuntime; use saikuro_transport::tcp::TcpTransportListener; -use saikuro_transport::traits::TransportListener; +use saikuro_transport::TransportListener; mod common; fn make_schema(namespace: &str, function: &str, n_args: usize) -> Schema { - use saikuro_core::schema::ArgumentDescriptor; + use saikuro_core::ArgumentDescriptor; let args = (0..n_args) .map(|i| ArgumentDescriptor { @@ -83,46 +83,52 @@ impl RuntimeHarness { let (ready_tx, ready_rx) = std::sync::mpsc::channel(); let worker = thread::spawn(move || { - let rt = saikuro_exec::runtime::Builder::new_current_thread() + let rt = saikuro_exec::RuntimeBuilder::new_current_thread() .enable_all() - .build() - .expect("create test runtime"); + .build(); rt.block_on(async move { let socket = SocketAddr::from(([127, 0, 0, 1], 0)); - let runtime = Arc::new(SaikuroRuntime::builder().build()); + let runtime = Arc::new(SaikuroRuntime::builder().build().await); let handle = runtime.handle(); - let mut listener = TcpTransportListener::bind(socket) - .await - .expect("bind TCP listener"); + let mut listener = TcpTransportListener::bind( + socket, + std::sync::Arc::new(saikuro_event::NullSink), + ) + .await + .expect("bind TCP listener"); let schema = make_schema("math", "add", 2); runtime .handle() .register_schema(schema, "c-test-provider") + .await .expect("register schema"); - runtime.handle().register_fn_provider( - "c-test-provider", - vec!["math".to_owned()], - |env: Envelope| async move { - match env.invocation_type { - InvocationType::Call | InvocationType::Cast => { - let a = match env.args.first() { - Some(Value::Int(v)) => *v, - _ => 0, - }; - let b = match env.args.get(1) { - Some(Value::Int(v)) => *v, - _ => 0, - }; - ResponseEnvelope::ok(env.id, Value::Int(a + b)) + runtime + .handle() + .register_fn_provider( + "c-test-provider", + vec!["math".to_owned()], + |env: Envelope| async move { + match env.invocation_type { + InvocationType::Call | InvocationType::Cast => { + let a = match env.args.first() { + Some(Value::Int(v)) => *v, + _ => 0, + }; + let b = match env.args.get(1) { + Some(Value::Int(v)) => *v, + _ => 0, + }; + ResponseEnvelope::ok(env.id, Value::Int(a + b)) + } + _ => ResponseEnvelope::ok_empty(env.id), } - _ => ResponseEnvelope::ok_empty(env.id), - } - }, - ); + }, + ) + .await; let _ = ready_tx.send(format!("tcp://{}", listener.local_addr())); let mut peer_counter: u64 = 0; @@ -135,7 +141,7 @@ impl RuntimeHarness { handle.accept_transport( transport, format!("c-test-peer-{peer_counter}"), - CapabilitySet::default(), + saikuro_core::CapabilitySet::default(), ); } Ok(None) => break, @@ -177,19 +183,30 @@ impl Drop for RuntimeHarness { fn c_client_call_cast_batch_roundtrip_with_runtime() { let runtime = RuntimeHarness::start(); - let address = common::c(&runtime.address); - let handle = saikuro_client_connect(address.as_ptr()); + // Connect. + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_void>(); + saikuro_client_connect_async( + common::c(&runtime.address).as_ptr(), + Some(common::connect_cb), + user_data, + ); + let handle = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!( !handle.is_null(), "connect should succeed: {}", common::take_error() ); - let call_result = saikuro_client_call_json( + // Call. + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); + saikuro_client_call_json_async( handle, common::c("math.add").as_ptr(), common::c("[2, 40]").as_ptr(), + Some(common::result_cb), + user_data, ); + let call_result = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!( !call_result.is_null(), "call failed: {}", @@ -198,20 +215,33 @@ fn c_client_call_cast_batch_roundtrip_with_runtime() { let call_json = common::take_c_string(call_result); assert_eq!(call_json, "42"); - let cast_rc = saikuro_client_cast_json( + // Cast. + let (rx, user_data) = common::channel_pair::(); + saikuro_client_cast_json_async( handle, common::c("math.add").as_ptr(), common::c("[5, 6]").as_ptr(), + Some(common::status_cb), + user_data, ); + let cast_rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(cast_rc, 0, "cast should succeed: {}", common::take_error()); + // Batch. let batch_calls = common::c( r#"[ {"target": "math.add", "args": [1, 2]}, {"target": "math.add", "args": [3, 4]} ]"#, ); - let batch_result = saikuro_client_batch_json(handle, batch_calls.as_ptr()); + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); + saikuro_client_batch_json_async( + handle, + batch_calls.as_ptr(), + Some(common::result_cb), + user_data, + ); + let batch_result = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!( !batch_result.is_null(), "batch failed: {}", @@ -220,7 +250,10 @@ fn c_client_call_cast_batch_roundtrip_with_runtime() { let batch_json = common::take_c_string(batch_result); assert_eq!(batch_json, "[3,7]"); - let close_rc = saikuro_client_close(handle); + // Close. + let (rx, user_data) = common::channel_pair::(); + saikuro_client_close_async(handle, Some(common::status_cb), user_data); + let close_rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!( close_rc, 0, @@ -234,15 +267,26 @@ fn c_client_call_cast_batch_roundtrip_with_runtime() { fn c_client_reports_transport_error_when_namespace_missing() { let runtime = RuntimeHarness::start(); - let address = common::c(&runtime.address); - let handle = saikuro_client_connect(address.as_ptr()); + // Connect. + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_void>(); + saikuro_client_connect_async( + common::c(&runtime.address).as_ptr(), + Some(common::connect_cb), + user_data, + ); + let handle = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(!handle.is_null()); - let missing = saikuro_client_call_json( + // Call missing namespace. + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); + saikuro_client_call_json_async( handle, common::c("missing.add").as_ptr(), common::c("[1, 1]").as_ptr(), + Some(common::result_cb), + user_data, ); + let missing = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(missing.is_null(), "unknown namespace call should fail"); let message = common::take_error(); @@ -251,7 +295,10 @@ fn c_client_reports_transport_error_when_namespace_missing() { "unexpected error message: {message}" ); - let close_rc = saikuro_client_close(handle); + // Close. + let (rx, user_data) = common::channel_pair::(); + saikuro_client_close_async(handle, Some(common::status_cb), user_data); + let close_rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!( close_rc, 0, diff --git a/Build/adapters/c/tests/c_api_smoke.rs b/Build/adapters/c/tests/c_api_smoke.rs index 3e2f552f..191ba364 100644 --- a/Build/adapters/c/tests/c_api_smoke.rs +++ b/Build/adapters/c/tests/c_api_smoke.rs @@ -1,11 +1,12 @@ -use std::ffi::{CStr, CString}; +use std::ffi::{c_void, CStr, CString}; use std::ptr; use saikuro_c::{ - saikuro_channel_next_json, saikuro_channel_send_json, saikuro_client_batch_json, - saikuro_client_channel_json, saikuro_client_connect, saikuro_client_log, - saikuro_client_resource_json, saikuro_client_stream_json, saikuro_provider_free, - saikuro_provider_new, saikuro_provider_register, saikuro_stream_free, saikuro_stream_next_json, + saikuro_channel_next_json_async, saikuro_channel_send_json_async, + saikuro_client_batch_json_async, saikuro_client_channel_json_async, + saikuro_client_connect_async, saikuro_client_log_async, saikuro_client_resource_json_async, + saikuro_client_stream_json_async, saikuro_provider_free, saikuro_provider_new, + saikuro_provider_register, saikuro_stream_free, saikuro_stream_next_json_async, saikuro_string_dup, saikuro_string_free, }; @@ -27,11 +28,9 @@ fn string_dup_roundtrip() { #[test] fn client_connect_rejects_null_address() { - let handle = saikuro_client_connect(ptr::null()); - assert!(handle.is_null()); - - let message = common::take_error(); - assert!(message.contains("address must not be null")); + // Null address is validated synchronously; callback is never called. + saikuro_client_connect_async(ptr::null(), Some(common::noop_connect_cb), ptr::null_mut()); + assert!(common::take_error().contains("address must not be null")); } #[test] @@ -52,74 +51,117 @@ fn provider_register_rejects_null_callback() { #[test] fn batch_rejects_null_handle() { - // null handle error should trigger before JSON parsing. let calls = CString::new("{}").expect("CString should be created"); - let result = saikuro_client_batch_json(ptr::null_mut(), calls.as_ptr()); + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); + saikuro_client_batch_json_async( + ptr::null_mut(), + calls.as_ptr(), + Some(common::result_cb), + user_data, + ); + let result = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(result.is_null()); - let message = common::take_error(); - assert!(message.contains("handle must not be null")); + assert!(common::take_error().contains("handle must not be null")); } #[test] fn stream_rejects_null_stream_handle() { - let stream = saikuro_client_stream_json(ptr::null_mut(), ptr::null(), ptr::null()); + // Null client handle on open. + let (rx, user_data) = common::channel_pair::<*mut c_void>(); + saikuro_client_stream_json_async( + ptr::null_mut(), + ptr::null(), + ptr::null(), + Some(common::connect_cb), + user_data, + ); + let stream = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(stream.is_null()); + assert!(common::take_error().contains("handle must not be null")); - let mut out_json = ptr::null_mut(); - let mut out_done = 0; - // This test verifies that passing a null stream handle to `saikuro_stream_next_json` - // is rejected; the output pointers provided here are valid (non-null). - let rc = unsafe { saikuro_stream_next_json(ptr::null_mut(), &mut out_json, &mut out_done) }; - assert_eq!(rc, 1); - let message = common::take_error(); - assert!(message.contains("stream must not be null")); + // Null stream handle on next — validated synchronously, callback not called. + unsafe { + saikuro_stream_next_json_async(ptr::null_mut(), Some(common::noop_item_cb), ptr::null_mut()) + }; + assert!(common::take_error().contains("stream must not be null")); - // Ensure stream_free is null-safe for callers. + // stream_free is null-safe. saikuro_stream_free(ptr::null_mut()); } #[test] fn channel_calls_reject_null_handles() { - let ch = saikuro_client_channel_json(ptr::null_mut(), ptr::null(), ptr::null()); + // Null client handle on channel open. + let (rx, user_data) = common::channel_pair::<*mut c_void>(); + saikuro_client_channel_json_async( + ptr::null_mut(), + ptr::null(), + ptr::null(), + Some(common::connect_cb), + user_data, + ); + let ch = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(ch.is_null()); - let message = common::take_error(); - assert!(message.contains("handle must not be null")); + assert!(common::take_error().contains("handle must not be null")); + // Null channel on send. let payload = CString::new("{}").expect("CString should be created"); - let rc = saikuro_channel_send_json(ptr::null_mut(), payload.as_ptr()); - assert_eq!(rc, 1); - let message = common::take_error(); - assert!(message.contains("channel must not be null")); - - let mut out_json = ptr::null_mut(); - let mut out_done = 0; - let rc = unsafe { saikuro_channel_next_json(ptr::null_mut(), &mut out_json, &mut out_done) }; + let (rx, user_data) = common::channel_pair::(); + saikuro_channel_send_json_async( + ptr::null_mut(), + payload.as_ptr(), + Some(common::status_cb), + user_data, + ); + let rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(rc, 1); - let message = common::take_error(); - assert!(message.contains("channel must not be null")); + assert!(common::take_error().contains("channel must not be null")); + + // Null channel on next — validated synchronously. + unsafe { + saikuro_channel_next_json_async( + ptr::null_mut(), + Some(common::noop_item_cb), + ptr::null_mut(), + ) + }; + assert!(common::take_error().contains("channel must not be null")); } #[test] fn resource_and_log_reject_null_handles() { let target = CString::new("files.open").expect("CString should be created"); let args = CString::new("[]").expect("CString should be created"); - let res = saikuro_client_resource_json(ptr::null_mut(), target.as_ptr(), args.as_ptr()); + + // Null client handle on resource. + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); + saikuro_client_resource_json_async( + ptr::null_mut(), + target.as_ptr(), + args.as_ptr(), + Some(common::result_cb), + user_data, + ); + let res = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(res.is_null()); - let message = common::take_error(); - assert!(message.contains("handle must not be null")); + assert!(common::take_error().contains("handle must not be null")); + // Null client handle on log. let level = CString::new("info").expect("CString should be created"); let name = CString::new("tests").expect("CString should be created"); let msg = CString::new("hello").expect("CString should be created"); let fields = CString::new("{}").expect("CString should be created"); - let rc = saikuro_client_log( + let (rx, user_data) = common::channel_pair::(); + saikuro_client_log_async( ptr::null_mut(), level.as_ptr(), name.as_ptr(), msg.as_ptr(), fields.as_ptr(), + Some(common::status_cb), + user_data, ); + let rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(rc, 1); - let message = common::take_error(); - assert!(message.contains("handle must not be null")); + assert!(common::take_error().contains("handle must not be null")); } diff --git a/Build/adapters/c/tests/c_api_validation.rs b/Build/adapters/c/tests/c_api_validation.rs index 0b94656f..6bfd65ba 100644 --- a/Build/adapters/c/tests/c_api_validation.rs +++ b/Build/adapters/c/tests/c_api_validation.rs @@ -1,13 +1,14 @@ use std::ptr; use saikuro_c::{ - saikuro_channel_abort, saikuro_channel_close, saikuro_channel_free, saikuro_channel_next_json, - saikuro_channel_send_json, saikuro_client_batch_json, saikuro_client_call_json, - saikuro_client_call_json_timeout, saikuro_client_cast_json, saikuro_client_channel_json, - saikuro_client_connect, saikuro_client_log, saikuro_client_resource_json, - saikuro_client_stream_json, saikuro_provider_free, saikuro_provider_new, - saikuro_provider_register, saikuro_stream_free, saikuro_stream_next_json, saikuro_string_dup, - saikuro_string_free, + saikuro_channel_abort_async, saikuro_channel_close_async, saikuro_channel_next_json_async, + saikuro_channel_send_json_async, saikuro_client_batch_json_async, + saikuro_client_call_json_async, saikuro_client_call_json_timeout_async, + saikuro_client_cast_json_async, saikuro_client_channel_json_async, + saikuro_client_connect_async, saikuro_client_log_async, saikuro_client_resource_json_async, + saikuro_client_stream_json_async, saikuro_provider_free, saikuro_provider_new, + saikuro_provider_register, saikuro_stream_free, saikuro_stream_next_json_async, + saikuro_string_dup, saikuro_string_free, }; mod common; @@ -28,11 +29,8 @@ fn string_helpers_work_and_null_is_safe() { #[test] fn client_connect_requires_non_null_address() { - let handle = saikuro_client_connect(ptr::null()); - assert!(handle.is_null()); - - let message = common::take_error(); - assert!(message.contains("address must not be null")); + saikuro_client_connect_async(ptr::null(), Some(common::noop_connect_cb), ptr::null_mut()); + assert!(common::take_error().contains("address must not be null")); } #[test] @@ -40,78 +38,156 @@ fn call_cast_batch_require_non_null_handle() { let target = common::c("math.add"); let args = common::c("[1,2]"); - let call = saikuro_client_call_json(ptr::null_mut(), target.as_ptr(), args.as_ptr()); + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); + saikuro_client_call_json_async( + ptr::null_mut(), + target.as_ptr(), + args.as_ptr(), + Some(common::result_cb), + user_data, + ); + let call = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(call.is_null()); assert!(common::take_error().contains("handle must not be null")); - let cast = saikuro_client_cast_json(ptr::null_mut(), target.as_ptr(), args.as_ptr()); + let (rx, user_data) = common::channel_pair::(); + saikuro_client_cast_json_async( + ptr::null_mut(), + target.as_ptr(), + args.as_ptr(), + Some(common::status_cb), + user_data, + ); + let cast = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(cast, 1); assert!(common::take_error().contains("handle must not be null")); - let batch = saikuro_client_batch_json(ptr::null_mut(), common::c("[]").as_ptr()); + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); + saikuro_client_batch_json_async( + ptr::null_mut(), + common::c("[]").as_ptr(), + Some(common::result_cb), + user_data, + ); + let batch = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(batch.is_null()); assert!(common::take_error().contains("handle must not be null")); - let timeout_call = - saikuro_client_call_json_timeout(ptr::null_mut(), target.as_ptr(), args.as_ptr(), 100); + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); + saikuro_client_call_json_timeout_async( + ptr::null_mut(), + target.as_ptr(), + args.as_ptr(), + 100, + Some(common::result_cb), + user_data, + ); + let timeout_call = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(timeout_call.is_null()); assert!(common::take_error().contains("handle must not be null")); } #[test] fn stream_and_channel_null_handle_paths_are_safe() { - let stream = saikuro_client_stream_json(ptr::null_mut(), ptr::null(), ptr::null()); + // Null client handle on stream open. + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_void>(); + saikuro_client_stream_json_async( + ptr::null_mut(), + ptr::null(), + ptr::null(), + Some(common::connect_cb), + user_data, + ); + let stream = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(stream.is_null()); assert!(common::take_error().contains("handle must not be null")); - let channel = saikuro_client_channel_json(ptr::null_mut(), ptr::null(), ptr::null()); + // Null client handle on channel open. + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_void>(); + saikuro_client_channel_json_async( + ptr::null_mut(), + ptr::null(), + ptr::null(), + Some(common::connect_cb), + user_data, + ); + let channel = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(channel.is_null()); assert!(common::take_error().contains("handle must not be null")); - let mut out_json = ptr::null_mut(); - let mut out_done = 0; - - let stream_next = - unsafe { saikuro_stream_next_json(ptr::null_mut(), &mut out_json, &mut out_done) }; - assert_eq!(stream_next, 1); + // Null stream on next. + unsafe { + saikuro_stream_next_json_async(ptr::null_mut(), Some(common::noop_item_cb), ptr::null_mut()) + }; assert!(common::take_error().contains("stream must not be null")); - let channel_next = - unsafe { saikuro_channel_next_json(ptr::null_mut(), &mut out_json, &mut out_done) }; - assert_eq!(channel_next, 1); + // Null channel on next. + unsafe { + saikuro_channel_next_json_async( + ptr::null_mut(), + Some(common::noop_item_cb), + ptr::null_mut(), + ) + }; assert!(common::take_error().contains("channel must not be null")); - let send_rc = saikuro_channel_send_json(ptr::null_mut(), common::c("{}").as_ptr()); + // Null channel on send. + let (rx, user_data) = common::channel_pair::(); + saikuro_channel_send_json_async( + ptr::null_mut(), + common::c("{}").as_ptr(), + Some(common::status_cb), + user_data, + ); + let send_rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(send_rc, 1); assert!(common::take_error().contains("channel must not be null")); - let close_rc = saikuro_channel_close(ptr::null_mut()); + // Null channel on close. + let (rx, user_data) = common::channel_pair::(); + saikuro_channel_close_async(ptr::null_mut(), Some(common::status_cb), user_data); + let close_rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(close_rc, 1); assert!(common::take_error().contains("channel must not be null")); - let abort_rc = saikuro_channel_abort(ptr::null_mut()); + // Null channel on abort. + let (rx, user_data) = common::channel_pair::(); + saikuro_channel_abort_async(ptr::null_mut(), Some(common::status_cb), user_data); + let abort_rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(abort_rc, 1); assert!(common::take_error().contains("channel must not be null")); saikuro_stream_free(ptr::null_mut()); - saikuro_channel_free(ptr::null_mut()); } #[test] fn resource_and_log_require_non_null_handle() { let target = common::c("files.open"); let args = common::c("[]"); - let resource = saikuro_client_resource_json(ptr::null_mut(), target.as_ptr(), args.as_ptr()); + + let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); + saikuro_client_resource_json_async( + ptr::null_mut(), + target.as_ptr(), + args.as_ptr(), + Some(common::result_cb), + user_data, + ); + let resource = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert!(resource.is_null()); assert!(common::take_error().contains("handle must not be null")); - let log_rc = saikuro_client_log( + let (rx, user_data) = common::channel_pair::(); + saikuro_client_log_async( ptr::null_mut(), common::c("info").as_ptr(), common::c("tests").as_ptr(), common::c("hello").as_ptr(), common::c("{}").as_ptr(), + Some(common::status_cb), + user_data, ); + let log_rc = rx.recv_timeout(common::CALLBACK_TIMEOUT).unwrap(); assert_eq!(log_rc, 1); assert!(common::take_error().contains("handle must not be null")); } diff --git a/Build/adapters/c/tests/common/mod.rs b/Build/adapters/c/tests/common/mod.rs index 70249b20..f59b2b79 100644 --- a/Build/adapters/c/tests/common/mod.rs +++ b/Build/adapters/c/tests/common/mod.rs @@ -1,7 +1,11 @@ -use std::ffi::{CStr, CString}; +use std::ffi::{c_int, c_void, CStr, CString}; +use std::sync::mpsc; +use std::time::Duration; use saikuro_c::{saikuro_last_error_message, saikuro_string_free}; +pub const CALLBACK_TIMEOUT: Duration = Duration::from_secs(5); + pub fn c(text: &str) -> CString { CString::new(text).expect("CString should be created") } @@ -18,3 +22,41 @@ pub fn take_c_string(ptr: *mut std::ffi::c_char) -> String { pub fn take_error() -> String { take_c_string(saikuro_last_error_message()) } + +pub fn channel_pair() -> (mpsc::Receiver, *mut c_void) { + let (tx, rx) = mpsc::channel(); + let user_data = Box::into_raw(Box::new(tx)) as *mut c_void; + (rx, user_data) +} + +/// `SaikuroConnectCb = extern "C" fn(*mut c_void, *mut c_void)` +pub extern "C" fn connect_cb(handle: *mut c_void, user_data: *mut c_void) { + let tx = unsafe { Box::from_raw(user_data as *mut mpsc::Sender<*mut c_void>) }; + tx.send(handle).ok(); +} + +/// `SaikuroResultCb = extern "C" fn(*mut c_char, *mut c_void)` +pub extern "C" fn result_cb(result: *mut std::ffi::c_char, user_data: *mut c_void) { + let tx = unsafe { Box::from_raw(user_data as *mut mpsc::Sender<*mut std::ffi::c_char>) }; + tx.send(result).ok(); +} + +/// `SaikuroStatusCb = extern "C" fn(c_int, *mut c_void)` +pub extern "C" fn status_cb(status: c_int, user_data: *mut c_void) { + let tx = unsafe { Box::from_raw(user_data as *mut mpsc::Sender) }; + tx.send(status).ok(); +} + +/// `SaikuroItemCb = extern "C" fn(*mut c_char, c_int, *mut c_void)` +pub extern "C" fn item_cb(item: *mut std::ffi::c_char, done: c_int, user_data: *mut c_void) { + let tx = + unsafe { Box::from_raw(user_data as *mut mpsc::Sender<(*mut std::ffi::c_char, c_int)>) }; + tx.send((item, done)).ok(); +} + +/// Dummy no-op callbacks for tests that expect the callback to NOT fire +/// (e.g. synchronous validation errors that return before spawning). +pub extern "C" fn noop_connect_cb(_h: *mut c_void, _ud: *mut c_void) {} +pub extern "C" fn noop_result_cb(_r: *mut std::ffi::c_char, _ud: *mut c_void) {} +pub extern "C" fn noop_status_cb(_s: c_int, _ud: *mut c_void) {} +pub extern "C" fn noop_item_cb(_item: *mut std::ffi::c_char, _done: c_int, _ud: *mut c_void) {} diff --git a/Build/adapters/c/tests/cpp_wrapper_runtime.rs b/Build/adapters/c/tests/cpp_wrapper_runtime.rs index 5113c705..2e6511ff 100644 --- a/Build/adapters/c/tests/cpp_wrapper_runtime.rs +++ b/Build/adapters/c/tests/cpp_wrapper_runtime.rs @@ -9,18 +9,17 @@ use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; use saikuro_core::{ - capability::CapabilitySet, envelope::{Envelope, InvocationType}, schema::{ ArgumentDescriptor, FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, TypeMap, Visibility, }, - value::Value, - ResponseEnvelope, + CapabilitySet, ResponseEnvelope, }; -use saikuro_runtime::runtime::SaikuroRuntime; +use saikuro_event::Value; +use saikuro_runtime::SaikuroRuntime; use saikuro_transport::tcp::TcpTransportListener; -use saikuro_transport::traits::{Transport, TransportListener, TransportReceiver, TransportSender}; +use saikuro_transport::{Transport, TransportListener, TransportReceiver, TransportSender}; fn repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -179,16 +178,15 @@ fn spawn_runtime_for_cpp_client() -> (String, thread::JoinHandle<()>) { let (ready_tx, ready_rx) = mpsc::channel(); let handle = thread::spawn(move || { - let rt = saikuro_exec::runtime::Builder::new_current_thread() + let rt = saikuro_exec::RuntimeBuilder::new_current_thread() .enable_all() - .build() - .expect("runtime"); + .build(); rt.block_on(async move { let socket = SocketAddr::from(([127, 0, 0, 1], 0)); - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); - let (done_tx, done_rx) = saikuro_exec::oneshot::channel::<()>(); + let (done_tx, done_rx) = saikuro_exec::_tokio::sync::oneshot::channel::<()>(); let done_tx = Arc::new(Mutex::new(Some(done_tx))); let call_count = Arc::new(AtomicUsize::new(0)); @@ -253,48 +251,53 @@ fn spawn_runtime_for_cpp_client() -> (String, thread::JoinHandle<()>) { }; handle .register_schema(schema, "cpp-runtime-provider") + .await .expect("register schema"); let done_tx_closure = done_tx.clone(); let call_count_closure = call_count.clone(); - handle.register_fn_provider( - "cpp-runtime-provider", - vec!["math".to_owned()], - move |env: Envelope| { - let done_tx_closure = done_tx_closure.clone(); - let call_count_closure = call_count_closure.clone(); - async move { - match env.target.as_str() { - "math.add" => { - let seen = call_count_closure.fetch_add(1, Ordering::Relaxed) + 1; - if seen >= 3 { - if let Some(tx) = done_tx_closure - .lock() - .expect("done sender mutex poisoned") - .take() - { - let _ = tx.send(()); + handle + .register_fn_provider( + "cpp-runtime-provider", + vec!["math".to_owned()], + move |env: Envelope| { + let done_tx_closure = done_tx_closure.clone(); + let call_count_closure = call_count_closure.clone(); + async move { + match env.target.as_str() { + "math.add" => { + let seen = + call_count_closure.fetch_add(1, Ordering::Relaxed) + 1; + if seen >= 3 { + if let Some(tx) = done_tx_closure + .lock() + .expect("done sender mutex poisoned") + .take() + { + let _ = tx.send(()); + } } + let a = match env.args.first() { + Some(Value::Int(v)) => *v, + _ => 0, + }; + let b = match env.args.get(1) { + Some(Value::Int(v)) => *v, + _ => 0, + }; + ResponseEnvelope::ok(env.id, Value::Int(a + b)) } - let a = match env.args.first() { - Some(Value::Int(v)) => *v, - _ => 0, - }; - let b = match env.args.get(1) { - Some(Value::Int(v)) => *v, - _ => 0, - }; - ResponseEnvelope::ok(env.id, Value::Int(a + b)) + _ => ResponseEnvelope::ok_empty(env.id), } - _ => ResponseEnvelope::ok_empty(env.id), } - } - }, - ); + }, + ) + .await; - let mut listener = TcpTransportListener::bind(socket) - .await - .expect("bind listener"); + let mut listener = + TcpTransportListener::bind(socket, std::sync::Arc::new(saikuro_event::NullSink)) + .await + .expect("bind listener"); let _ = ready_tx.send(format!("tcp://{}", listener.local_addr())); let transport = saikuro_exec::timeout(Duration::from_secs(60), listener.accept()) @@ -322,16 +325,16 @@ fn spawn_scripted_runtime_for_cpp_provider() -> (String, thread::JoinHandle, options: ClientOptions, + /// The log sink used by this client. + pub log: Arc, } impl Client { /// Connect to a Saikuro runtime at `address` and return a ready client. pub async fn connect(address: impl AsRef) -> Result { let address = address.as_ref(); - debug!(address = %address, "client connecting"); let transport = connect(address).await?; Self::from_transport(transport, None) } @@ -202,59 +207,68 @@ impl Client { Self::from_transport(transport, Some(options)) } + /// Connect with a custom log sink. + pub async fn connect_with_log( + address: impl AsRef, + options: Option, + log: Arc, + ) -> Result { + let address = address.as_ref(); + { + let mut record = + LogRecord::now(LogLevel::Debug, "saikuro.rust.client", "client connecting"); + record.set_context("address", address.to_owned()); + log.emit(&record).await; + } + let transport = connect(address).await?; + Self::from_transport_with_log(transport, options, log) + } + /// Construct a client from an already-connected transport. - /// - /// Starts the background I/O task immediately. The task first drains any - /// announce frames already waiting in the transport (which happens when a - /// provider and client share an in-process transport pair directly), then - /// enters the normal send/receive loop. pub fn from_transport( + transport: Box, + options: Option, + ) -> Result { + Self::from_transport_with_log(transport, options, Arc::new(saikuro_event::NullSink)) + } + + /// Construct a client from an already-connected transport with a log sink. + pub fn from_transport_with_log( mut transport: Box, options: Option, + log: Arc, ) -> Result { let options = options.unwrap_or_default(); let pending: Arc> = Arc::new(DashMap::new()); let channel_senders: Arc> = Arc::new(DashMap::new()); let connected = Arc::new(AtomicBool::new(true)); - // Outbound frame channel: callers push frames here; the I/O task - // drains them and writes to the transport. The channel capacity is - // large enough that a burst of concurrent calls never blocks a caller. let (send_tx, mut send_rx) = mpsc::channel::(CHANNEL_CAPACITY); let pending_recv = pending.clone(); let channel_senders_recv = channel_senders.clone(); let connected_recv = connected.clone(); + let log_recv = log.clone(); let recv_task = saikuro_exec::spawn(async move { - // Handshake phase: drain any announce frames that may have arrived - // before this task started. This is the normal path when a - // provider and client are connected directly via InMemoryTransport - // (e.g. integration tests), where the provider sends its announce - // before the client task is even spawned. - // - // We use try_recv rather than a timeout-based poll so that the - // phase is instant for normal runtime connections (where no announce - // arrives on the client side at all). drain_announces(&mut *transport).await; - // I/O loop: multiplex outbound sends and inbound responses. loop { saikuro_exec::select! { - // Forward outbound frames from callers to the transport. frame = send_rx.recv() => { match frame { Some(f) => { if let Err(e) = transport.send(f).await { - error!(error = %e, "client send error"); + let mut record = LogRecord::now(LogLevel::Error, "saikuro.rust.client", "client send error"); + record.set_context("error", alloc::format!("{e}")); + log_recv.emit(&record).await; break; } } - None => break, // all Client handles dropped + None => break, } } - // Route inbound response frames to their waiting callers. incoming = transport.recv().fuse() => { match incoming { Ok(Some(frame)) => { @@ -267,11 +281,14 @@ impl Client { .await; } Ok(None) => { - debug!("client: transport closed"); + let record = LogRecord::now(LogLevel::Debug, "saikuro.rust.client", "client: transport closed"); + log_recv.emit(&record).await; break; } Err(e) => { - error!(error = %e, "client recv error"); + let mut record = LogRecord::now(LogLevel::Error, "saikuro.rust.client", "client recv error"); + record.set_context("error", alloc::format!("{e}")); + log_recv.emit(&record).await; break; } } @@ -292,6 +309,7 @@ impl Client { recv_task: Some(recv_task), connected, options, + log, }) } @@ -560,7 +578,6 @@ async fn drain_announces(transport: &mut dyn AdapterTransport) { } // Non-announce frame arrived before any pending slot exists; // this is unexpected. - warn!("client: unexpected frame during handshake phase, discarding"); } } @@ -588,17 +605,9 @@ async fn handle_inbound( if let Ok(ack_bytes) = ack.to_msgpack() { let _ = transport.send(Bytes::from(ack_bytes)).await; } - } else { - warn!( - target = %env.target, - invocation_type = %env.invocation_type, - "client received unexpected inbound envelope" - ); } return; } - - warn!("client: received undecodable inbound frame"); } async fn route_response( @@ -636,22 +645,17 @@ async fn route_response( let detail = resp.error.unwrap_or_else(|| { ErrorDetail::new(ErrorCode::Internal, "stream error") }); - if tx + let _ = tx .send(Err(Error::remote( detail.code.to_string(), detail.message, None, ))) - .await - .is_err() - { - warn!(id = %id, "stream receiver closed while sending error"); - } + .await; pending.remove(&id); } else { let value = resp.result.map(core_to_json).unwrap_or(Value::Null); if tx.send(Ok(value)).await.is_err() { - warn!(id = %id, "stream receiver closed while sending value"); pending.remove(&id); } } @@ -672,16 +676,11 @@ async fn route_response( let detail = resp.error.unwrap_or_else(|| { ErrorDetail::new(ErrorCode::Internal, "channel error") }); - if tx - .try_send(Err(Error::remote( - detail.code.to_string(), - detail.message, - None, - ))) - .is_err() - { - warn!(id = %id, "channel receiver closed while sending error"); - } + let _ = tx.try_send(Err(Error::remote( + detail.code.to_string(), + detail.message, + None, + ))); pending.remove(&id); if let Some((_, sender)) = channel_senders.remove(&id) { let _ = sender.lock().await.take(); @@ -689,7 +688,6 @@ async fn route_response( } else { let value = resp.result.map(core_to_json).unwrap_or(Value::Null); if tx.try_send(Ok(value)).is_err() { - warn!(id = %id, "channel receiver closed while sending value"); pending.remove(&id); if let Some((_, sender)) = channel_senders.remove(&id) { let _ = sender.lock().await.take(); @@ -699,9 +697,7 @@ async fn route_response( } } } - _ => { - debug!(id = %id, "received response for unknown invocation id"); - } + _ => {} } } diff --git a/Build/adapters/rust/src/error.rs b/Build/adapters/rust/src/error.rs index c814d176..1c1e22bc 100644 --- a/Build/adapters/rust/src/error.rs +++ b/Build/adapters/rust/src/error.rs @@ -1,8 +1,8 @@ //! Error types for the Saikuro Rust adapter. -use thiserror::Error; #[cfg(not(feature = "std"))] use alloc::string::{String, ToString}; +use thiserror::Error; /// The result type used throughout this crate. pub type Result = core::result::Result; diff --git a/Build/adapters/rust/src/provider.rs b/Build/adapters/rust/src/provider.rs index fc614be8..8eb60db4 100644 --- a/Build/adapters/rust/src/provider.rs +++ b/Build/adapters/rust/src/provider.rs @@ -1,13 +1,21 @@ //! Saikuro provider: register Rust functions and serve them to the runtime. //! -#[cfg(feature = "std")] -use std::collections::HashMap; #[cfg(not(feature = "std"))] use alloc::collections::BTreeMap as HashMap; -use alloc::{boxed::Box, string::{String, ToString}, vec::Vec, borrow::ToOwned}; +#[cfg(target_has_atomic = "ptr")] use alloc::sync::Arc; +use alloc::{ + borrow::ToOwned, + boxed::Box, + string::{String, ToString}, + vec::Vec, +}; use core::{future::Future, pin::Pin}; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; +#[cfg(feature = "std")] +use std::collections::HashMap; use bytes::Bytes; use saikuro_core::{ @@ -15,8 +23,7 @@ use saikuro_core::{ invocation::InvocationId, schema::Schema, }; -use saikuro_event::{ErrorCode, ErrorDetail}; -use tracing::{debug, error, info, warn}; +use saikuro_event::{ErrorCode, ErrorDetail, LogLevel, LogRecord, LogSink}; use crate::{ error::{Error, Result}, @@ -61,6 +68,7 @@ pub struct Provider { namespace: String, handlers: HashMap, extra_namespaces: HashMap, + log: Arc, } impl Provider { @@ -70,9 +78,16 @@ impl Provider { namespace: namespace.into(), handlers: HashMap::new(), extra_namespaces: HashMap::new(), + log: Arc::from(Box::new(saikuro_event::NullSink) as Box), } } + /// Set the log sink for this provider. + pub fn with_log_sink(mut self, log: Arc) -> Self { + self.log = log; + self + } + /// The namespace this provider publishes under. pub fn namespace(&self) -> &str { &self.namespace @@ -81,17 +96,6 @@ impl Provider { // Registration /// Register a function handler. - /// - /// The closure receives a `Vec` (JSON values) and must return a - /// `Future>`. - /// - /// ```no_run - /// # use saikuro::{Provider, Result}; - /// # let mut provider = Provider::new("math"); - /// provider.register("add", |args: Vec| async move { - /// Ok(serde_json::json!(args[0].as_i64().unwrap_or(0) + args[1].as_i64().unwrap_or(0))) - /// }); - /// ``` #[cfg(not(feature = "wasm"))] pub fn register(&mut self, name: impl Into, handler: F) where @@ -122,8 +126,9 @@ impl Provider { Fut: Future> + Send + 'static, { let name = name.into(); - debug!(namespace = %self.namespace, function = %name, "registering handler"); - let boxed: BoxedHandler = Arc::new(move |args| Box::pin(handler(args))); + let handler = move |args| Box::pin(handler(args)) as HandlerFuture; + let boxed: BoxedHandler = + Arc::from(Box::new(handler) as Box HandlerFuture + Send + Sync>); self.handlers.insert( name, HandlerEntry { @@ -144,8 +149,9 @@ impl Provider { Fut: Future> + 'static, { let name = name.into(); - debug!(namespace = %self.namespace, function = %name, "registering handler"); - let boxed: BoxedHandler = Arc::new(move |args| Box::pin(handler(args))); + let handler = move |args| Box::pin(handler(args)) as HandlerFuture; + let boxed: BoxedHandler = + Arc::from(Box::new(handler) as Box HandlerFuture>); self.handlers.insert( name, HandlerEntry { @@ -181,30 +187,56 @@ impl Provider { /// connection is closed or an unrecoverable error occurs. pub async fn serve(self, address: impl AsRef) -> Result<()> { let addr = address.as_ref(); - info!(namespace = %self.namespace, address = %addr, "connecting to runtime"); + { + let mut record = LogRecord::now( + LogLevel::Info, + "saikuro.rust.provider", + "connecting to runtime", + ); + record.set_context("namespace", self.namespace.clone()); + record.set_context("address", addr.to_owned()); + self.log.emit(&record).await; + } let transport = connect(addr).await?; self.serve_on(transport).await } /// Serve on an already-connected transport. pub async fn serve_on(self, mut transport: Box) -> Result<()> { - // Announce schema. self.announce(&mut *transport).await?; - // Serve loop. - info!(namespace = %self.namespace, "provider ready, entering serve loop"); + { + let mut record = LogRecord::now( + LogLevel::Info, + "saikuro.rust.provider", + "provider ready, entering serve loop", + ); + record.set_context("namespace", self.namespace.clone()); + self.log.emit(&record).await; + } let handlers = Arc::new(self.handlers); let namespace = Arc::new(self.namespace); + let log = self.log.clone(); loop { let frame = match transport.recv().await { Ok(Some(f)) => f, Ok(None) => { - info!(namespace = %namespace, "runtime closed connection"); + let mut record = LogRecord::now( + LogLevel::Info, + "saikuro.rust.provider", + "runtime closed connection", + ); + record.set_context("namespace", namespace.to_string()); + log.emit(&record).await; break; } Err(e) => { - error!(namespace = %namespace, error = %e, "recv error"); + let mut record = + LogRecord::now(LogLevel::Error, "saikuro.rust.provider", "recv error"); + record.set_context("namespace", namespace.to_string()); + record.set_context("error", alloc::format!("{e}")); + log.emit(&record).await; break; } }; @@ -212,31 +244,36 @@ impl Provider { let envelope = match Envelope::from_msgpack(&frame) { Ok(e) => e, Err(e) => { - warn!(error = %e, "malformed inbound envelope, skipping"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.rust.provider", + "malformed inbound envelope, skipping", + ); + record.set_context("error", alloc::format!("{e}")); + log.emit(&record).await; continue; } }; - // We handle dispatch inline (sequential per connection) because the - // transport is not Clone. Handlers that need true concurrency should - // use the runtime's in-process provider API instead. match envelope.invocation_type { InvocationType::Call => { - dispatch_call(envelope, &handlers, &mut *transport).await; + dispatch_call(envelope, &handlers, &mut *transport, &*log).await; } InvocationType::Cast => { - // Fire-and-forget: dispatch the handler but send no response. - dispatch_cast(envelope, &handlers).await; + dispatch_cast(envelope, &handlers, &*log).await; } InvocationType::Batch => { - dispatch_batch(envelope, &handlers, &mut *transport).await; + dispatch_batch(envelope, &handlers, &mut *transport, &*log).await; } other => { - warn!( - invocation_type = %other, - target = %envelope.target, - "provider received unsupported invocation type, skipping" + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.rust.provider", + "unsupported invocation type", ); + record.set_context("invocation_type", alloc::format!("{other}")); + record.set_context("target", envelope.target.clone()); + log.emit(&record).await; } } } @@ -248,19 +285,29 @@ impl Provider { // Announce async fn announce(&self, transport: &mut dyn AdapterTransport) -> Result<()> { - // A capacity overflow here means the announcement would be silently - // truncated; fail the announce instead of publishing a partial schema. let schema = match self.build_schema() { Ok(schema) => schema, Err(e) => { - warn!(error = %e, "failed to build schema announcement"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.rust.provider", + "failed to build schema announcement", + ); + record.set_context("error", alloc::format!("{e}")); + self.log.emit(&record).await; return Err(e); } }; let schema_value = match serde_json::to_value(&schema) { Ok(v) => json_to_core(v), Err(e) => { - warn!(error = %e, "failed to serialize schema for announcement"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.rust.provider", + "failed to serialize schema for announcement", + ); + record.set_context("error", alloc::format!("{e}")); + self.log.emit(&record).await; return Err(Error::Codec(e.to_string())); } }; @@ -269,55 +316,97 @@ impl Provider { let frame = match announce_env.to_msgpack() { Ok(b) => Bytes::from(b), Err(e) => { - warn!(error = %e, "failed to encode announce envelope"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.rust.provider", + "failed to encode announce envelope", + ); + record.set_context("error", alloc::format!("{e}")); + self.log.emit(&record).await; return Err(Error::Codec(e.to_string())); } }; if let Err(e) = transport.send(frame).await { - warn!(error = %e, "failed to send schema announce"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.rust.provider", + "failed to send schema announce", + ); + record.set_context("error", alloc::format!("{e}")); + self.log.emit(&record).await; return Err(Error::Transport(e.to_string())); } - // Wait for the runtime ack. The runtime must reply with ok_empty - // before the provider can start serving; a timed-out or rejected ack - // is non-fatal: the provider enters the serve loop regardless so that - // direct-transport test setups (no runtime) work without a 5-second - // delay. A real deployment failure is surfaced via the tracing warning. - match saikuro_exec::timeout(core::time::Duration::from_millis(500), transport.recv()).await { + match saikuro_exec::timeout(core::time::Duration::from_millis(500), transport.recv()).await + { Ok(Ok(Some(ack_frame))) => match ResponseEnvelope::from_msgpack(&ack_frame) { Ok(ack) if ack.ok => { - debug!(namespace = %self.namespace, "schema announce acknowledged"); + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.rust.provider", + "schema announce acknowledged", + ); + record.set_context("namespace", self.namespace.clone()); + self.log.emit(&record).await; } Ok(_) => { - warn!(namespace = %self.namespace, "schema announce rejected by runtime"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.rust.provider", + "schema announce rejected by runtime", + ); + record.set_context("namespace", self.namespace.clone()); + self.log.emit(&record).await; } Err(e) => { - warn!(error = %e, "could not decode schema announce ack"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.rust.provider", + "could not decode schema announce ack", + ); + record.set_context("error", alloc::format!("{e}")); + self.log.emit(&record).await; } }, Ok(Ok(None)) => { - warn!(namespace = %self.namespace, "transport closed after schema announce"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.rust.provider", + "transport closed after schema announce", + ); + record.set_context("namespace", self.namespace.clone()); + self.log.emit(&record).await; } Ok(Err(e)) => { - warn!(error = %e, "error receiving schema announce ack"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.rust.provider", + "error receiving schema announce ack", + ); + record.set_context("error", alloc::format!("{e}")); + self.log.emit(&record).await; } Err(_) => { - // Ack timed out. Acceptable for direct-transport test setups; - // in production this means the runtime is unresponsive. - debug!(namespace = %self.namespace, "schema announce ack timed out, continuing"); + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.rust.provider", + "schema announce ack timed out, continuing", + ); + record.set_context("namespace", self.namespace.clone()); + self.log.emit(&record).await; } } Ok(()) } } -// Dispatch helpers -/// Dispatch a `Call` envelope, send the response (ok or error) to the runtime. + async fn dispatch_call( envelope: Envelope, handlers: &HashMap, transport: &mut dyn AdapterTransport, + log: &dyn LogSink, ) { let id = envelope.id; let target = envelope.target.clone(); @@ -343,12 +432,9 @@ async fn dispatch_call( match handler(args).await { Ok(result) => { let response = ResponseEnvelope::ok(id, json_to_core(result)); - send_response(transport, &response).await; + send_response(transport, &response, log).await; } Err(Error::Remote { code, message, .. }) => { - // Re-map the adapter's Remote error back onto the wire. The code - // is a PascalCase string from the remote side; round-trip it through - // serde so unknown codes fall back to Internal. let error_code = parse_error_code(&code); send_error(transport, id, error_code, message).await; } @@ -358,40 +444,37 @@ async fn dispatch_call( } } -/// Dispatch a `Cast` envelope. Runs the handler but never sends a response. -async fn dispatch_cast(envelope: Envelope, handlers: &HashMap) { +async fn dispatch_cast( + envelope: Envelope, + handlers: &HashMap, + log: &dyn LogSink, +) { let fn_name = local_name(&envelope.target); let entry = match handlers.get(fn_name) { Some(e) => e, - None => { - // No handler: silently ignore. Casts are fire-and-forget; the - // caller does not expect a response or an error. - debug!(target = %envelope.target, "cast: no handler registered, ignoring"); - return; - } + None => return, }; let args: Vec = envelope.args.into_iter().map(core_to_json).collect(); let handler = entry.handler.clone(); if let Err(e) = handler(args).await { - // Log the error but do not surface it to the caller. - warn!(target = %envelope.target, error = %e, "cast handler returned error"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.rust.provider", + "cast handler returned error", + ); + record.set_context("target", envelope.target.clone()); + record.set_context("error", alloc::format!("{e}")); + log.emit(&record).await; } } -/// Dispatch a `Batch` envelope. -/// -/// Each item is dispatched in order. Items that fail produce a null result -/// entry in the array; a structured per-item error envelope is not part of the -/// current batch wire format. The batch as a whole always returns `ok`. -/// -/// Items that are not of type `Call` (e.g. casts nested in a batch) are -/// executed but produce `null` in the result array. async fn dispatch_batch( envelope: Envelope, handlers: &HashMap, transport: &mut dyn AdapterTransport, + log: &dyn LogSink, ) { use saikuro_event::Value as CoreValue; @@ -423,47 +506,37 @@ async fn dispatch_batch( match handler(args).await { Ok(v) => results.push(json_to_core(v)), Err(e) => { - // Per-item failure: record null and log; the - // batch as a whole is not aborted. - warn!( - target = %item.target, - error = %e, - "batch item handler error" + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.rust.provider", + "batch item handler error", ); + record.set_context("target", item.target.clone()); + record.set_context("error", alloc::format!("{e}")); + log.emit(&record).await; results.push(CoreValue::Null); } } } None => { - warn!( - target = %item.target, - "batch item: no handler registered" - ); results.push(CoreValue::Null); } } } InvocationType::Cast => { - // Execute the cast item but produce no result in the array. - dispatch_cast(item, handlers).await; + dispatch_cast(item, handlers, log).await; results.push(CoreValue::Null); } - other => { - warn!( - invocation_type = %other, - "batch item has unsupported type, skipping" - ); + _other => { results.push(CoreValue::Null); } } } let response = ResponseEnvelope::ok(id, CoreValue::Array(results)); - send_response(transport, &response).await; + send_response(transport, &response, log).await; } -// Wire helpers -/// Extract the local function name from a fully-qualified `"namespace.fn"` target. -/// If there is no dot, returns the whole string. + fn local_name(target: &str) -> &str { match target.rsplit_once('.') { Some((_, name)) => name, @@ -471,24 +544,35 @@ fn local_name(target: &str) -> &str { } } -/// Attempt to deserialise a PascalCase code string as an [`ErrorCode`]. -/// Falls back to [`ErrorCode::Internal`] for unknown strings. fn parse_error_code(s: &str) -> ErrorCode { - // ErrorCode serialises as PascalCase via serde. Wrap in a JSON string - // and deserialise so that new codes added to the enum in future are - // automatically handled without a match table here. serde_json::from_value(serde_json::Value::String(s.to_owned())).unwrap_or(ErrorCode::Internal) } -async fn send_response(transport: &mut dyn AdapterTransport, response: &ResponseEnvelope) { +async fn send_response( + transport: &mut dyn AdapterTransport, + response: &ResponseEnvelope, + log: &dyn LogSink, +) { match response.to_msgpack() { Ok(bytes) => { if let Err(e) = transport.send(Bytes::from(bytes)).await { - error!(error = %e, "failed to send response"); + let mut record = LogRecord::now( + LogLevel::Error, + "saikuro.rust.provider", + "failed to send response", + ); + record.set_context("error", alloc::format!("{e}")); + log.emit(&record).await; } } Err(e) => { - error!(error = %e, "failed to encode response"); + let mut record = LogRecord::now( + LogLevel::Error, + "saikuro.rust.provider", + "failed to encode response", + ); + record.set_context("error", alloc::format!("{e}")); + log.emit(&record).await; } } } @@ -501,5 +585,18 @@ async fn send_error( ) { let detail = ErrorDetail::new(code, message); let response = ResponseEnvelope::err(id, detail); - send_response(transport, &response).await; + let _ = send_response_raw(transport, &response).await; +} + +async fn send_response_raw( + transport: &mut dyn AdapterTransport, + response: &ResponseEnvelope, +) -> Result<()> { + let bytes = response + .to_msgpack() + .map_err(|e| Error::Codec(e.to_string()))?; + transport + .send(Bytes::from(bytes)) + .await + .map_err(|e| Error::Transport(e.to_string())) } diff --git a/Build/adapters/rust/src/schema.rs b/Build/adapters/rust/src/schema.rs index 3e6941f0..c9778ec9 100644 --- a/Build/adapters/rust/src/schema.rs +++ b/Build/adapters/rust/src/schema.rs @@ -3,12 +3,12 @@ //! Used by [`Provider`](crate::Provider) to construct the schema announcement //! envelope that it sends to the runtime when it first connects. -#[cfg(feature = "std")] -use std::collections::HashMap; #[cfg(not(feature = "std"))] use alloc::collections::BTreeMap as HashMap; #[cfg(not(feature = "std"))] use alloc::{boxed::Box, string::String, vec::Vec}; +#[cfg(feature = "std")] +use std::collections::HashMap; use crate::error::{Error, Result}; use saikuro_core::schema::{ diff --git a/Build/adapters/rust/src/storage.rs b/Build/adapters/rust/src/storage.rs index eddc8237..8944219a 100644 --- a/Build/adapters/rust/src/storage.rs +++ b/Build/adapters/rust/src/storage.rs @@ -212,9 +212,13 @@ pub async fn create_storage(config: &StorageConfig) -> Result { } match config.persistence { - PersistenceMode::Transient | PersistenceMode::BestEffort => Ok(Storage::InMemory( - InMemoryStorage::with_config(config.clone()), - )), + PersistenceMode::Transient | PersistenceMode::BestEffort => { + let log: std::sync::Arc = + std::sync::Arc::new(saikuro_event::NullSink); + Ok(Storage::InMemory( + InMemoryStorage::with_config(config.clone(), log).await, + )) + } PersistenceMode::Durable => Err(Error::Storage( "no durable storage backend selected; set `config.backend` to \ `BackendKind::Filesystem`, `Sled`, or `Sqlite` on native" diff --git a/Build/adapters/rust/src/transport.rs b/Build/adapters/rust/src/transport.rs index 3460cc61..70a7da23 100644 --- a/Build/adapters/rust/src/transport.rs +++ b/Build/adapters/rust/src/transport.rs @@ -7,8 +7,16 @@ use bytes::Bytes; use saikuro_transport::DEFAULT_CHANNEL_CAPACITY; use crate::error::{Error, Result}; + #[cfg(not(feature = "std"))] -use alloc::{boxed::Box, string::{String, ToString}}; +use alloc::{ + boxed::Box, + string::{String, ToString}, +}; + +#[allow(unused_imports)] +#[cfg(feature = "std")] +use std::sync::Arc; /// A URL-style address string understood by the Saikuro adapter. /// @@ -99,8 +107,10 @@ macro_rules! impl_adapter_transport { #[cfg(all(feature = "tcp", feature = "std"))] mod tcp_impl { use super::*; + use saikuro_transport::shared::traits::{ + TransportConnector, TransportReceiver, TransportSender, + }; use saikuro_transport::tcp::{TcpConnector, TcpReceiver, TcpSender}; - use saikuro_transport::shared::traits::{TransportConnector, TransportReceiver, TransportSender}; pub struct TcpAdapter { sender: TcpSender, @@ -110,7 +120,8 @@ mod tcp_impl { impl TcpAdapter { pub async fn connect(addr: std::net::SocketAddr) -> Result { use saikuro_transport::shared::traits::Transport; - let transport = TcpConnector::new(addr) + let log: Arc = Arc::new(saikuro_event::NullSink); + let transport = TcpConnector::new(addr, log) .connect() .await .map_err(|e| Error::Transport(e.to_string()))?; @@ -171,7 +182,8 @@ mod unix_impl { impl UnixAdapter { pub async fn connect(path: &str) -> Result { - let connector = UnixConnector::new(path); + let log: Arc = Arc::new(saikuro_event::NullSink); + let connector = UnixConnector::new(path, log); let transport = connector .connect() .await @@ -206,7 +218,8 @@ mod ws_impl { impl WsAdapter { pub async fn connect(url: &str) -> Result { - let transport = WebSocketTransport::connect(url) + let log: Arc = Arc::new(saikuro_event::NullSink); + let transport = WebSocketTransport::connect(url, log) .await .map_err(|e| Error::Transport(e.to_string()))?; let (sender, receiver) = transport.split(); @@ -226,15 +239,15 @@ mod ws_impl { #[cfg(all(feature = "wasm", target_arch = "wasm32"))] mod wasm_host_impl { use super::*; - use saikuro_transport::LocalTransport; - use saikuro_transport::WasmHostConnector; use saikuro_transport::shared::host::{WasmHostReceiver, WasmHostSender}; - use saikuro_transport::wasm::host_browser::{ - BroadcastChannelPipe, BroadcastChannelRecv, BroadcastChannelSend, - }; use saikuro_transport::shared::traits::{ LocalTransportConnector, LocalTransportReceiver, LocalTransportSender, }; + use saikuro_transport::wasm::host_browser::{ + BroadcastChannelPipe, BroadcastChannelRecv, BroadcastChannelSend, + }; + use saikuro_transport::LocalTransport; + use saikuro_transport::WasmHostConnector; const DEFAULT_WASM_HOST_CHANNEL: &str = "saikuro"; diff --git a/Build/adapters/rust/src/value.rs b/Build/adapters/rust/src/value.rs index 89319725..3c44c4d8 100644 --- a/Build/adapters/rust/src/value.rs +++ b/Build/adapters/rust/src/value.rs @@ -15,10 +15,7 @@ pub type Value = serde_json::Value; pub fn core_to_json(v: saikuro_event::Value) -> Value { match serde_json::to_value(&v) { Ok(j) => j, - Err(e) => { - tracing::warn!(error = %e, "core_to_json serialization failed"); - Value::Null - } + Err(_) => Value::Null, } } @@ -26,9 +23,6 @@ pub fn core_to_json(v: saikuro_event::Value) -> Value { pub fn json_to_core(v: Value) -> saikuro_event::Value { match serde_json::from_value(v) { Ok(c) => c, - Err(e) => { - tracing::warn!(error = %e, "json_to_core deserialization failed"); - saikuro_event::Value::Null - } + Err(_) => saikuro_event::Value::Null, } } diff --git a/Build/crates/saikuro-event/Cargo.toml b/Build/crates/saikuro-event/Cargo.toml index 89177544..1fb6e49c 100644 --- a/Build/crates/saikuro-event/Cargo.toml +++ b/Build/crates/saikuro-event/Cargo.toml @@ -34,6 +34,7 @@ heapless = { workspace = true } thiserror = { workspace = true, default-features = false } strum = { workspace = true } messagepack-serde = { workspace = true } +async-trait = { workspace = true } getrandom = { workspace = true, optional = true } tracing = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-event/log/embedded/serial.rs b/Build/crates/saikuro-event/log/embedded/serial.rs index ba6a85a8..a615d124 100644 --- a/Build/crates/saikuro-event/log/embedded/serial.rs +++ b/Build/crates/saikuro-event/log/embedded/serial.rs @@ -1,3 +1,10 @@ +#[cfg(not(feature = "std"))] +extern crate alloc; + +#[cfg(not(feature = "std"))] +use alloc::boxed::Box; + +use async_trait::async_trait; use embedded_io_async::Write; use heapless::String as HString; use spin::Mutex; @@ -22,6 +29,7 @@ impl SerialSink { } } +#[async_trait(?Send)] impl LogSink for SerialSink { async fn emit(&self, record: &LogRecord) { let mut buf = HString::<512>::new(); diff --git a/Build/crates/saikuro-event/log/native/stderr.rs b/Build/crates/saikuro-event/log/native/stderr.rs index 5b5d5dd0..a66af9a2 100644 --- a/Build/crates/saikuro-event/log/native/stderr.rs +++ b/Build/crates/saikuro-event/log/native/stderr.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use serde_json; use crate::record::LogRecord; @@ -6,6 +7,7 @@ use crate::sink::LogSink; /// A sink emitting [`LogRecord`]s as JSON lines on stderr. pub struct StderrSink; +#[async_trait] impl LogSink for StderrSink { async fn emit(&self, record: &LogRecord) { if let Ok(json) = serde_json::to_string(record) { diff --git a/Build/crates/saikuro-event/log/native/tracing.rs b/Build/crates/saikuro-event/log/native/tracing.rs index c79ab9bf..5d463789 100644 --- a/Build/crates/saikuro-event/log/native/tracing.rs +++ b/Build/crates/saikuro-event/log/native/tracing.rs @@ -1,3 +1,5 @@ +use async_trait::async_trait; + use crate::level::LogLevel; use crate::record::LogRecord; use crate::sink::LogSink; @@ -6,6 +8,7 @@ use crate::sink::LogSink; /// level. pub struct TracingSink; +#[async_trait] impl LogSink for TracingSink { async fn emit(&self, record: &LogRecord) { let line = format!("[{}] {}", record.name, record.msg); diff --git a/Build/crates/saikuro-event/log/record.rs b/Build/crates/saikuro-event/log/record.rs index 2687e833..a3c9718d 100644 --- a/Build/crates/saikuro-event/log/record.rs +++ b/Build/crates/saikuro-event/log/record.rs @@ -45,6 +45,39 @@ impl LogRecord { } } + /// Construct a log record with an auto-generated ISO-8601 timestamp. + /// + /// On `std` targets the current wall-clock time is used. On `no_std` / + /// `embedded` targets the timestamp is empty. + #[cfg(feature = "std")] + pub fn now(level: LogLevel, name: impl Into, msg: impl Into) -> Self { + use std::time::SystemTime; + let ts = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| { + let secs = d.as_secs(); + let millis = d.subsec_millis(); + format!("{secs:010}.{millis:03}") + }) + .unwrap_or_default(); + Self::new(ts, level, name, msg) + } + + /// Construct a log record with an auto-generated timestamp. + /// + /// On `no_std` / `embedded` targets the timestamp is empty. + #[cfg(not(feature = "std"))] + pub fn now(level: LogLevel, name: impl Into, msg: impl Into) -> Self { + Self::new("", level, name, msg) + } + + /// Add a structured field in-place, ignoring capacity errors. + /// + /// If the field bag is full the field is silently dropped. + pub fn set_context(&mut self, key: impl Into, value: impl Into) { + let _ = self.fields.insert(key.into(), value.into()); + } + /// Add a structured field and return `self` for chaining. /// /// Fails with [`SaikuroError::CapacityExceeded`] if the record is already at diff --git a/Build/crates/saikuro-event/log/ring.rs b/Build/crates/saikuro-event/log/ring.rs index f94d787e..6d57310c 100644 --- a/Build/crates/saikuro-event/log/ring.rs +++ b/Build/crates/saikuro-event/log/ring.rs @@ -1,4 +1,8 @@ +#[cfg(not(feature = "std"))] +use alloc::boxed::Box; + use alloc::vec::Vec; +use async_trait::async_trait; use spin::Mutex; use crate::record::LogRecord; @@ -28,6 +32,20 @@ impl RingSink { } } +#[cfg(feature = "embedded")] +#[async_trait(?Send)] +impl LogSink for RingSink { + async fn emit(&self, record: &LogRecord) { + let mut buf = self.buffer.lock(); + if buf.len() >= self.capacity { + buf.remove(0); + } + buf.push(record.clone()); + } +} + +#[cfg(not(feature = "embedded"))] +#[async_trait] impl LogSink for RingSink { async fn emit(&self, record: &LogRecord) { let mut buf = self.buffer.lock(); diff --git a/Build/crates/saikuro-event/log/sink.rs b/Build/crates/saikuro-event/log/sink.rs index abb5ce6b..51506aaf 100644 --- a/Build/crates/saikuro-event/log/sink.rs +++ b/Build/crates/saikuro-event/log/sink.rs @@ -1,9 +1,26 @@ +#[cfg(not(feature = "std"))] +extern crate alloc; + +#[cfg(not(feature = "std"))] +use alloc::boxed::Box; + +use async_trait::async_trait; + use crate::level::LogLevel; use crate::record::LogRecord; /// A destination for [`LogRecord`]s. -#[allow(async_fn_in_trait)] -pub trait LogSink { +#[cfg(feature = "embedded")] +#[async_trait(?Send)] +pub trait LogSink: Send + Sync { + /// Emit a single log record. + async fn emit(&self, record: &LogRecord); +} + +/// A destination for [`LogRecord`]s. +#[cfg(not(feature = "embedded"))] +#[async_trait] +pub trait LogSink: Send + Sync { /// Emit a single log record. async fn emit(&self, record: &LogRecord); } @@ -13,6 +30,14 @@ pub trait LogSink { /// Useful for benchmarks, silent embedded builds, and tests. pub struct NullSink; +#[cfg(feature = "embedded")] +#[async_trait(?Send)] +impl LogSink for NullSink { + async fn emit(&self, _record: &LogRecord) {} +} + +#[cfg(not(feature = "embedded"))] +#[async_trait] impl LogSink for NullSink { async fn emit(&self, _record: &LogRecord) {} } @@ -33,6 +58,18 @@ impl LevelFilterSink { } } +#[cfg(feature = "embedded")] +#[async_trait(?Send)] +impl LogSink for LevelFilterSink { + async fn emit(&self, record: &LogRecord) { + if record.level >= self.min_level { + self.inner.emit(record).await; + } + } +} + +#[cfg(not(feature = "embedded"))] +#[async_trait] impl LogSink for LevelFilterSink { async fn emit(&self, record: &LogRecord) { if record.level >= self.min_level { diff --git a/Build/crates/saikuro-event/log/wasm/console.rs b/Build/crates/saikuro-event/log/wasm/console.rs index e014a245..345ee5da 100644 --- a/Build/crates/saikuro-event/log/wasm/console.rs +++ b/Build/crates/saikuro-event/log/wasm/console.rs @@ -1,14 +1,23 @@ +#[cfg(not(feature = "std"))] +extern crate alloc; + +#[cfg(not(feature = "std"))] +use alloc::boxed::Box; + #[cfg(feature = "console")] use serde_json; #[cfg(feature = "console")] use wasm_bindgen::JsValue; +use async_trait::async_trait; + use crate::record::LogRecord; use crate::sink::LogSink; /// A sink emitting [`LogRecord`]s as JSON lines on the browser console. pub struct ConsoleSink; +#[async_trait] impl LogSink for ConsoleSink { async fn emit(&self, record: &LogRecord) { #[cfg(feature = "console")] diff --git a/Build/crates/saikuro-exec/Cargo.toml b/Build/crates/saikuro-exec/Cargo.toml index 1da99adb..db22fd83 100644 --- a/Build/crates/saikuro-exec/Cargo.toml +++ b/Build/crates/saikuro-exec/Cargo.toml @@ -68,3 +68,5 @@ embassy-net = { workspace = true, optional = true, features = [ ] } wasm-bindgen-futures = { workspace = true, optional = true } fluvio-wasm-timer = { workspace = true, optional = true } +portable-atomic-util = { workspace = true } +portable-atomic = { workspace = true } diff --git a/Build/crates/saikuro-exec/base/exec.rs b/Build/crates/saikuro-exec/base/exec.rs index 45446ce6..790932ec 100644 --- a/Build/crates/saikuro-exec/base/exec.rs +++ b/Build/crates/saikuro-exec/base/exec.rs @@ -1,6 +1,7 @@ #![cfg(any(feature = "wasm", feature = "no_std", feature = "embedded"))] use alloc::boxed::Box; +#[cfg(target_has_atomic = "ptr")] use alloc::sync::Arc; use alloc::vec::Vec; use core::cell::RefCell; @@ -9,16 +10,22 @@ use core::future::Future; use core::mem::transmute; use core::pin::Pin; use core::task::{Context, Poll}; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; #[cfg(feature = "no_std")] use core::ptr::null_mut; +#[cfg(not(target_has_atomic = "ptr"))] +use self::no_atomic_futures::FuturesUnordered; use embassy_executor::Spawner; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::blocking_mutex::CriticalSectionMutex; use embassy_sync::signal::Signal; use embassy_sync::waitqueue::MultiWakerRegistration; -use futures::stream::{FuturesUnordered, StreamExt}; +#[cfg(target_has_atomic = "ptr")] +use futures::stream::FuturesUnordered; +use futures::stream::StreamExt; use crate::shared::JoinError; @@ -322,3 +329,50 @@ impl Future for JoinHandle { } } } + +/// Minimal `FuturesUnordered` for targets without `target_has_atomic = "ptr"`. +/// +/// Polls all contained futures on every waker notification. +#[cfg(not(target_has_atomic = "ptr"))] +mod no_atomic_futures { + use alloc::vec::Vec; + use core::future::Future; + use core::pin::Pin; + use core::task::{Context, Poll}; + use futures::stream::Stream; + + pub(super) struct FuturesUnordered { + futures: Vec, + } + + impl FuturesUnordered { + pub fn new() -> Self { + Self { + futures: Vec::new(), + } + } + + pub fn push(&mut self, f: F) { + self.futures.push(f); + } + } + + impl Stream for FuturesUnordered { + type Item = F::Output; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = unsafe { self.get_unchecked_mut() }; + let mut i = this.futures.len(); + while i > 0 { + i -= 1; + if let Poll::Ready(output) = + unsafe { Pin::new_unchecked(&mut this.futures[i]) }.poll(cx) + { + this.futures.swap_remove(i); + return Poll::Ready(Some(output)); + } + } + Poll::Pending + } + } +} diff --git a/Build/crates/saikuro-exec/base/mod.rs b/Build/crates/saikuro-exec/base/mod.rs index 9f3c91d9..d091cc48 100644 --- a/Build/crates/saikuro-exec/base/mod.rs +++ b/Build/crates/saikuro-exec/base/mod.rs @@ -1,9 +1,12 @@ +#[cfg(target_has_atomic = "ptr")] use alloc::sync::Arc; use core::cell::RefCell; use core::future::{poll_fn, Future}; use core::pin::Pin; use core::task::{Context, Poll, Waker}; use core::time::Duration; +#[cfg(not(target_has_atomic = "ptr"))] +pub(crate) use portable_atomic_util::Arc; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::blocking_mutex::CriticalSectionMutex; diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index 5adb6428..bcfd72aa 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -52,6 +52,8 @@ saikuro-event = { workspace = true, default-features = false } async-trait = { workspace = true } thiserror = { workspace = true } +portable-atomic = { workspace = true } +portable-atomic-util = { workspace = true } [dev-dependencies] saikuro-exec = { workspace = true } diff --git a/Build/crates/saikuro-router/provider/provider.rs b/Build/crates/saikuro-router/provider/provider.rs index f8cd7d71..b09da6a1 100644 --- a/Build/crates/saikuro-router/provider/provider.rs +++ b/Build/crates/saikuro-router/provider/provider.rs @@ -1,7 +1,9 @@ -use alloc::{ - borrow::ToOwned, boxed::Box, collections::BTreeMap, string::String, sync::Arc, vec::Vec, -}; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; +use alloc::{borrow::ToOwned, boxed::Box, collections::BTreeMap, string::String, vec::Vec}; use async_trait::async_trait; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; use saikuro_core::{envelope::Envelope, RegistrationToken, ResponseEnvelope}; use saikuro_exec::sync::RwLock; use saikuro_exec::{mpsc, oneshot}; diff --git a/Build/crates/saikuro-router/router/router.rs b/Build/crates/saikuro-router/router/router.rs index 8bdcac8a..6fc0900c 100644 --- a/Build/crates/saikuro-router/router/router.rs +++ b/Build/crates/saikuro-router/router/router.rs @@ -1,6 +1,10 @@ //! Invocation router -use alloc::{borrow::ToOwned, boxed::Box, format, string::ToString, sync::Arc, vec::Vec}; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; +use alloc::{borrow::ToOwned, boxed::Box, format, string::ToString, vec::Vec}; use core::time::Duration; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; use saikuro_core::{ envelope::{Envelope, InvocationType}, invocation::InvocationId, diff --git a/Build/crates/saikuro-router/stream_state/stream_state.rs b/Build/crates/saikuro-router/stream_state/stream_state.rs index 90695e16..1b30256a 100644 --- a/Build/crates/saikuro-router/stream_state/stream_state.rs +++ b/Build/crates/saikuro-router/stream_state/stream_state.rs @@ -1,4 +1,8 @@ -use alloc::{collections::BTreeMap, sync::Arc}; +use alloc::collections::BTreeMap; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; use saikuro_core::invocation::InvocationId; use saikuro_core::ResponseEnvelope; use saikuro_exec::{ diff --git a/Build/crates/saikuro-runtime/Cargo.toml b/Build/crates/saikuro-runtime/Cargo.toml index cc12ae13..75a2e8cb 100644 --- a/Build/crates/saikuro-runtime/Cargo.toml +++ b/Build/crates/saikuro-runtime/Cargo.toml @@ -42,8 +42,10 @@ native = [ "saikuro-random/native", "saikuro-event/native", "saikuro-event/stderr", + "saikuro-event/tracing", "dep:anyhow", "dep:clap", + "dep:tracing", "dep:tracing-subscriber", ] no_std = [ @@ -105,15 +107,16 @@ saikuro-event = { path = "../saikuro-event", default-features = false } serde = { workspace = true } serde_json = { workspace = true, features = ["alloc"] } -bytes = { workspace = true, default-features = false } +bytes = { workspace = true, default-features = false, features = ["extra-platforms"] } async-trait = { workspace = true } futures = { workspace = true } tracing = { workspace = true, default-features = false, features = [ "log", "attributes", -] } +], optional = true } spin = { workspace = true } portable-atomic = { workspace = true } +portable-atomic-util = { workspace = true } wasi = { workspace = true, optional = true } embassy-executor = { workspace = true, optional = true } diff --git a/Build/crates/saikuro-runtime/native/mod.rs b/Build/crates/saikuro-runtime/native/mod.rs index 05f4a562..9dad003d 100644 --- a/Build/crates/saikuro-runtime/native/mod.rs +++ b/Build/crates/saikuro-runtime/native/mod.rs @@ -1,11 +1,12 @@ -use std::net::{IpAddr, SocketAddr}; +use std::net::IpAddr; use std::sync::Arc; use crate::config::RuntimeMode; use crate::SaikuroRuntime; use anyhow::{Context, Result}; use clap::Parser; -use saikuro_exec::{signal, spawn, timeout, watch}; +use saikuro_event::LogSink; +use saikuro_exec::{signal, timeout, watch}; use tracing::{error, info, warn}; // CLI @@ -90,6 +91,11 @@ async fn async_main() -> Result<()> { init_logging(&args.log_level, args.json_logs); + // Create a TracingSink that bridges structured logging into the tracing + // subscriber configured above. All runtime components receive this sink + // and emit structured log records through it. + let log: Arc = Arc::from(Box::new(saikuro_event::TracingSink) as Box); + info!( version = env!("CARGO_PKG_VERSION"), mode = ?args.mode, @@ -99,7 +105,8 @@ async fn async_main() -> Result<()> { // Build the runtime. let mut builder = SaikuroRuntime::builder() .mode(args.mode.into()) - .json_logs(args.json_logs); + .json_logs(args.json_logs) + .log_sink(log); // Load a baked-in schema from disk (native only). if let Some(schema_path) = &args.schema { @@ -116,14 +123,17 @@ async fn async_main() -> Result<()> { let (shutdown_tx, shutdown_rx) = watch::channel(false); // Each enabled listener type is driven by its own `serve` task. - let mut serve_tasks: Vec<_> = Vec::new(); + let mut serve_tasks: Vec> = Vec::new(); // TCP listener. #[cfg(feature = "tcp")] if !args.no_tcp { + use saikuro_exec::spawn; use saikuro_transport::tcp::TcpTransportListener; + use std::net::SocketAddr; let addr = SocketAddr::new(args.bind, args.tcp_port); - match TcpTransportListener::bind(addr).await { + let log = runtime.handle().log.clone(); + match TcpTransportListener::bind(addr, log).await { Ok(listener) => { info!(addr = %listener.local_addr(), "TCP listener ready"); let rt = runtime.clone(); @@ -142,9 +152,12 @@ async fn async_main() -> Result<()> { // WebSocket listener. #[cfg(feature = "ws")] if !args.no_ws { + use saikuro_exec::spawn; use saikuro_transport::websocket::WsTransportListener; + use std::net::SocketAddr; let addr = SocketAddr::new(args.bind, args.ws_port); - match WsTransportListener::bind(addr).await { + let log = runtime.handle().log.clone(); + match WsTransportListener::bind(addr, log).await { Ok(listener) => { info!(addr = %listener.local_addr(), "WebSocket listener ready"); let rt = runtime.clone(); @@ -163,8 +176,10 @@ async fn async_main() -> Result<()> { // Unix domain socket listener (Unix-only). #[cfg(all(feature = "unix", target_family = "unix"))] if let Some(unix_path) = &args.unix { + use saikuro_exec::spawn; use saikuro_transport::unix::UnixTransportListener; - match UnixTransportListener::bind(unix_path).await { + let log = runtime.handle().log.clone(); + match UnixTransportListener::bind(unix_path, log).await { Ok(listener) => { info!(path = %unix_path.display(), "Unix socket listener ready"); let rt = runtime.clone(); @@ -189,7 +204,7 @@ async fn async_main() -> Result<()> { info!("shutdown signal received; stopping listeners"); let _ = shutdown_tx.send(true); - runtime.shutdown(); + runtime.shutdown().await; // Allow the listener tasks to exit cleanly. for task in serve_tasks { diff --git a/Build/crates/saikuro-runtime/shared/connection.rs b/Build/crates/saikuro-runtime/shared/connection.rs index 04bdac7d..ef816121 100644 --- a/Build/crates/saikuro-runtime/shared/connection.rs +++ b/Build/crates/saikuro-runtime/shared/connection.rs @@ -2,8 +2,11 @@ use alloc::borrow::ToOwned; use alloc::boxed::Box; use alloc::collections::BTreeMap; use alloc::string::{String, ToString}; +#[cfg(target_has_atomic = "ptr")] use alloc::sync::Arc; use alloc::vec::Vec; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; use bytes::Bytes; use futures::future::FutureExt; @@ -14,7 +17,7 @@ use saikuro_core::{ schema::Schema, RegistrationToken, ResponseEnvelope, }; -use saikuro_event::{ErrorDetail, Value}; +use saikuro_event::{ErrorDetail, LogLevel, LogRecord, LogSink, Value}; use saikuro_exec::{mpsc, oneshot, spawn}; use saikuro_router::{ provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, @@ -27,7 +30,6 @@ use saikuro_schema::{ }; use serde::Serialize; use spin::Mutex; -use tracing::{debug, error, info, instrument, warn}; use crate::transport_adapter::{RuntimeReceiver, RuntimeSender}; @@ -68,6 +70,8 @@ where /// Provider registry shared with the runtime; used to register/deregister /// wire-forwarding provider handles when the peer announces its schema. pub provider_registry: ProviderRegistry, + /// Log sink for structured logging. + pub log: Arc, } impl ConnectionHandler @@ -94,9 +98,16 @@ where { /// Run the receive loop until the connection is closed or an unrecoverable /// error occurs. - #[instrument(skip(self), fields(peer = %self.peer_id))] pub async fn run(mut self) { - info!(peer = %self.peer_id, "connection established"); + { + let mut record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime.connection", + "connection established", + ); + record.set_context("peer", self.peer_id.clone()); + self.log.emit(&record).await; + } // Shared pending-call map: ForwardTask writes response_tx into this; // the recv loop reads it when a ResponseEnvelope arrives from the peer. @@ -115,12 +126,25 @@ where match frame_opt { Some(frame) => { if let Err(e) = self.sender.send(frame).await { - error!(peer = %self.peer_id, "send error on forwarded call: {e}"); + let mut record = LogRecord::now( + LogLevel::Error, + "saikuro.runtime.connection", + "send error on forwarded call", + ); + record.set_context("peer", self.peer_id.clone()); + record.set_context("error", alloc::format!("{e}")); + self.log.emit(&record).await; break; } } None => { - info!(peer = %self.peer_id, "forward channel closed"); + let mut record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime.connection", + "forward channel closed", + ); + record.set_context("peer", self.peer_id.clone()); + self.log.emit(&record).await; break; } } @@ -136,11 +160,24 @@ where } } Ok(None) => { - info!(peer = %self.peer_id, "connection closed by peer"); + let mut record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime.connection", + "connection closed by peer", + ); + record.set_context("peer", self.peer_id.clone()); + self.log.emit(&record).await; break; } Err(e) => { - error!(peer = %self.peer_id, "recv error: {e}"); + let mut record = LogRecord::now( + LogLevel::Error, + "saikuro.runtime.connection", + "recv error", + ); + record.set_context("peer", self.peer_id.clone()); + record.set_context("error", alloc::format!("{e}")); + self.log.emit(&record).await; break; } } @@ -156,7 +193,15 @@ where .deregister_provider(&self.peer_id, self.registration_token) .await; - info!(peer = %self.peer_id, "connection handler exiting"); + { + let mut record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime.connection", + "connection handler exiting", + ); + record.set_context("peer", self.peer_id.clone()); + self.log.emit(&record).await; + } } /// Decode, validate, check capabilities, and route a single frame. @@ -172,14 +217,24 @@ where forward_tx: &mpsc::Sender, ) -> Option<(ResponseEnvelope, Option)> { // 1. Decode the MessagePack envelope. - let envelope = match self.decode_envelope(&frame) { + let envelope = match self.decode_envelope(&frame).await { Ok(e) => e, Err(Some(resp)) => return Some((*resp, None)), Err(None) => return None, }; let id = envelope.id; - debug!(peer = %self.peer_id, %id, target = %envelope.target, "received envelope"); + { + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.runtime.connection", + "received envelope", + ); + record.set_context("peer", self.peer_id.clone()); + record.set_context("id", alloc::format!("{}", id)); + record.set_context("target", envelope.target.clone()); + self.log.emit(&record).await; + } // 2. Handle system envelopes before schema validation. match envelope.invocation_type { @@ -238,15 +293,31 @@ where /// Decode a MessagePack frame into an [`Envelope`], or return an error /// response on failure. - fn decode_envelope(&self, frame: &[u8]) -> Result>> { + async fn decode_envelope( + &self, + frame: &[u8], + ) -> Result>> { match saikuro_core::msgpack::from_slice(frame) { Ok(env) => Ok(env), Err(e) => { - warn!(peer = %self.peer_id, "envelope decode failed: {e}"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.runtime.connection", + "envelope decode failed", + ); + record.set_context("peer", self.peer_id.clone()); + record.set_context("error", alloc::format!("{e}")); + self.log.emit(&record).await; let id = match InvocationId::new() { Ok(id) => id, - Err(error) => { - error!(peer = %self.peer_id, %error, "cannot generate malformed-envelope response ID"); + Err(_error) => { + let mut record = LogRecord::now( + LogLevel::Error, + "saikuro.runtime.connection", + "cannot generate malformed-envelope response ID", + ); + record.set_context("peer", self.peer_id.clone()); + self.log.emit(&record).await; return Err(None); } }; @@ -279,8 +350,14 @@ where ); let id = match InvocationId::new() { Ok(id) => id, - Err(error) => { - error!(peer = %self.peer_id, %error, "cannot generate oversized-frame response ID"); + Err(_error) => { + let mut record = LogRecord::now( + LogLevel::Error, + "saikuro.runtime.connection", + "cannot generate oversized-frame response ID", + ); + record.set_context("peer", self.peer_id.clone()); + self.log.emit(&record).await; return false; } }; @@ -303,13 +380,24 @@ where }; if let Err(e) = self.send_response(response).await { - error!(peer = %self.peer_id, "send error: {e}"); + let mut record = + LogRecord::now(LogLevel::Error, "saikuro.runtime.connection", "send error"); + record.set_context("peer", self.peer_id.clone()); + record.set_context("error", alloc::format!("{e}")); + self.log.emit(&record).await; return false; } if let Some(filtered) = sandbox_schema { if let Err(e) = self.push_sandbox_schema(filtered).await { - error!(peer = %self.peer_id, "failed to push sandbox schema: {e}"); + let mut record = LogRecord::now( + LogLevel::Error, + "saikuro.runtime.connection", + "failed to push sandbox schema", + ); + record.set_context("peer", self.peer_id.clone()); + record.set_context("error", alloc::format!("{e}")); + self.log.emit(&record).await; return false; } } @@ -342,11 +430,16 @@ where .await { Ok(()) => { - info!( - peer = %self.peer_id, - namespaces = ns_count, - "schema announced and merged" - ); + { + let mut record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime.connection", + "schema announced and merged", + ); + record.set_context("peer", self.peer_id.clone()); + record.set_context("namespaces", alloc::format!("{ns_count}")); + self.log.emit(&record).await; + } // Register a wire-forwarding provider handle so the // router can dispatch calls to this peer. @@ -356,7 +449,14 @@ where ResponseEnvelope::ok_empty(id) } Err(e) => { - warn!(peer = %self.peer_id, "schema merge failed: {e}"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.runtime.connection", + "schema merge failed", + ); + record.set_context("peer", self.peer_id.clone()); + record.set_context("error", alloc::format!("{e}")); + self.log.emit(&record).await; ResponseEnvelope::err( id, ErrorDetail::new( @@ -368,7 +468,13 @@ where } } None => { - warn!(peer = %self.peer_id, "announce envelope has no valid Schema in args[0]"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.runtime.connection", + "announce envelope has no valid Schema in args[0]", + ); + record.set_context("peer", self.peer_id.clone()); + self.log.emit(&record).await; ResponseEnvelope::err( id, ErrorDetail::new( @@ -401,13 +507,21 @@ where let pending_clone = pending.clone(); let forward_tx_clone = forward_tx.clone(); let peer_id = self.peer_id.clone(); + let log = self.log.clone(); spawn(async move { while let Some(item) = work_rx.recv().await { let frame = match encode_bytes(&item.envelope) { Ok(bytes) => bytes, Err(e) => { - warn!(peer = %peer_id, "failed to encode forwarded call: {e}"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.runtime.connection", + "failed to encode forwarded call", + ); + record.set_context("peer", peer_id.clone()); + record.set_context("error", alloc::format!("{e}")); + log.emit(&record).await; if let Some(tx) = item.response_tx { let _ = tx.send(ResponseEnvelope::err( item.envelope.id, @@ -430,12 +544,24 @@ where // Send the frame to the peer (via the connection handler's sender). if forward_tx_clone.send(frame).await.is_err() { - warn!(peer = %peer_id, "forward channel closed; provider disconnected"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.runtime.connection", + "forward channel closed; provider disconnected", + ); + record.set_context("peer", peer_id.clone()); + log.emit(&record).await; pending_clone.lock().remove(&item.envelope.id); break; } } - debug!(peer = %peer_id, "wire-forward task exiting"); + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.runtime.connection", + "wire-forward task exiting", + ); + record.set_context("peer", peer_id.clone()); + log.emit(&record).await; }); } @@ -447,7 +573,14 @@ where let full = match self.schema_registry.snapshot().await { Ok(schema) => schema, Err(e) => { - error!(peer = %self.peer_id, error = %e, "schema snapshot capacity exceeded"); + let mut record = LogRecord::now( + LogLevel::Error, + "saikuro.runtime.connection", + "schema snapshot capacity exceeded", + ); + record.set_context("peer", self.peer_id.clone()); + record.set_context("error", alloc::format!("{}", e)); + self.log.emit(&record).await; return None; } }; @@ -500,7 +633,15 @@ where .map_err(|e| format!("announce invocation ID error: {e}"))?; let frame = encode_bytes(&announce).map_err(|e| format!("announce frame encode error: {e}"))?; - info!(peer = %self.peer_id, "pushing sandbox-filtered schema to peer"); + { + let mut record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime.connection", + "pushing sandbox-filtered schema to peer", + ); + record.set_context("peer", self.peer_id.clone()); + self.log.emit(&record).await; + } self.sender.send(frame).await.map_err(|e| e.to_string()) } diff --git a/Build/crates/saikuro-runtime/shared/handle.rs b/Build/crates/saikuro-runtime/shared/handle.rs index a63e2bdd..537bf073 100644 --- a/Build/crates/saikuro-runtime/shared/handle.rs +++ b/Build/crates/saikuro-runtime/shared/handle.rs @@ -1,11 +1,15 @@ use alloc::string::{String, ToString}; +#[cfg(target_has_atomic = "ptr")] use alloc::sync::Arc; use alloc::vec::Vec; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; use saikuro_core::{ capability::CapabilitySet, envelope::Envelope, schema::Schema, RegistrationToken, ResponseEnvelope, }; +use saikuro_event::{LogLevel, LogRecord, LogSink}; use saikuro_exec::mpsc; use saikuro_router::{ provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, @@ -17,7 +21,6 @@ use saikuro_schema::{ validator::InvocationValidator, }; use spin::RwLock; -use tracing::{debug, info}; use crate::config::RuntimeConfig; use crate::connection::ConnectionHandler; @@ -34,6 +37,7 @@ pub struct RuntimeHandle { pub(crate) capability_engine: CapabilityEngine, pub(crate) config: RuntimeConfig, pub(crate) shutdown: Arc>, + pub(crate) log: Arc, } impl RuntimeHandle { @@ -178,10 +182,21 @@ impl RuntimeHandle { max_message_size: self.config.max_message_size, schema_registry: self.schema_registry.clone(), provider_registry: self.provider_registry.clone(), + log: self.log.clone(), }; - info!(peer = %peer_id, "spawning connection handler"); - saikuro_exec::spawn(handler.run()); + let log = self.log.clone(); + let peer_id_clone = peer_id.clone(); + saikuro_exec::spawn(async move { + let mut record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime", + "spawning connection handler", + ); + record.set_context("peer", peer_id_clone); + log.emit(&record).await; + handler.run().await; + }); } // In-process provider registration @@ -217,7 +232,15 @@ impl RuntimeHandle { let handler = Arc::new(handler); - debug!(provider = %provider_id, "in-process provider registered"); + { + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.runtime", + "in-process provider registered", + ); + record.set_context("provider", provider_id.clone()); + self.log.emit(&record).await; + } saikuro_exec::spawn(async move { while let Some(item) = work_rx.recv().await { diff --git a/Build/crates/saikuro-runtime/shared/runtime.rs b/Build/crates/saikuro-runtime/shared/runtime.rs index d7bfc080..66734aeb 100644 --- a/Build/crates/saikuro-runtime/shared/runtime.rs +++ b/Build/crates/saikuro-runtime/shared/runtime.rs @@ -1,18 +1,22 @@ +use alloc::boxed::Box; +#[cfg(target_has_atomic = "ptr")] use alloc::sync::Arc; use alloc::vec::Vec; use core::sync::atomic::Ordering; use core::time::Duration; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; use portable_atomic::AtomicU64; use saikuro_core::capability::CapabilitySet; use saikuro_core::schema::Schema; +use saikuro_event::{LogLevel, LogRecord, LogSink}; use saikuro_exec::{sleep, spawn, timeout, watch}; use saikuro_router::provider::ProviderRegistry; use saikuro_schema::{ capability_engine::CapabilityEngine, registry::SchemaRegistry, validator::InvocationValidator, }; use spin::RwLock; -use tracing::{error, info}; use crate::transport_adapter::RuntimeListener; use crate::{config::RuntimeConfig, handle::RuntimeHandle}; @@ -34,12 +38,14 @@ fn next_peer_id() -> alloc::string::String { /// Fluent builder for [`SaikuroRuntime`]. pub struct RuntimeBuilder { config: RuntimeConfig, + log: Arc, } impl RuntimeBuilder { fn new() -> Self { Self { config: RuntimeConfig::default(), + log: Arc::from(Box::new(saikuro_event::NullSink) as Box), } } @@ -70,11 +76,17 @@ impl RuntimeBuilder { self } + /// Set the log sink for the runtime. + pub fn log_sink(mut self, log: Arc) -> Self { + self.log = log; + self + } + /// Build the runtime. This does not start any listener loops; use /// [`RuntimeHandle`] methods to attach transports, or [`SaikuroRuntime::serve`] /// to run a set of listeners until shutdown. pub async fn build(self) -> SaikuroRuntime { - SaikuroRuntime::from_config(self.config).await + SaikuroRuntime::from_config(self.config, self.log).await } } @@ -88,6 +100,7 @@ pub struct SaikuroRuntime { provider_registry: ProviderRegistry, capability_engine: CapabilityEngine, shutdown: Arc>, + log: Arc, } impl SaikuroRuntime { @@ -95,7 +108,7 @@ impl SaikuroRuntime { RuntimeBuilder::new() } - async fn from_config(config: RuntimeConfig) -> Self { + async fn from_config(config: RuntimeConfig, log: Arc) -> Self { let schema_bytes = config.schema_bytes; let schema_registry = SchemaRegistry::new(); @@ -105,6 +118,7 @@ impl SaikuroRuntime { provider_registry: ProviderRegistry::new(), capability_engine: CapabilityEngine::new(), shutdown: Arc::new(RwLock::new(false)), + log: log.clone(), }; // Register a baked-in schema (embedded / wasm / WASI) or a schema the @@ -113,10 +127,24 @@ impl SaikuroRuntime { match serde_json::from_slice::(bytes) { Ok(schema) => { if let Err(e) = runtime.schema_registry.merge_schema(schema, "static").await { - error!(error = %e, "failed to merge static schema"); + let mut record = LogRecord::now( + LogLevel::Error, + "saikuro.runtime", + "failed to merge static schema", + ); + record.set_context("error", alloc::format!("{}", e)); + log.emit(&record).await; } } - Err(e) => error!(error = %e, "failed to parse static schema"), + Err(e) => { + let mut record = LogRecord::now( + LogLevel::Error, + "saikuro.runtime", + "failed to parse static schema", + ); + record.set_context("error", alloc::format!("{}", e)); + log.emit(&record).await; + } } } @@ -156,13 +184,19 @@ impl SaikuroRuntime { capability_engine: self.capability_engine.clone(), config: self.config.clone(), shutdown: self.shutdown.clone(), + log: self.log.clone(), } } /// Signal a graceful shutdown. - pub fn shutdown(&self) { + pub async fn shutdown(&self) { *self.shutdown.write() = true; - info!("saikuro runtime shutting down"); + let record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime", + "saikuro runtime shutting down", + ); + self.log.emit(&record).await; } pub fn is_shutdown(&self) -> bool { @@ -179,6 +213,7 @@ impl SaikuroRuntime { for mut listener in listeners { let handle = self.handle(); let mut rx = shutdown.clone(); + let log = self.log.clone(); tasks.push(spawn(async move { loop { saikuro_exec::select! { @@ -186,22 +221,44 @@ impl SaikuroRuntime { match result { Ok(Some(transport)) => { let id = next_peer_id(); - info!(peer = %id, "connection accepted"); + let mut record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime", + "connection accepted", + ); + record.set_context("peer", id.clone()); + log.emit(&record).await; handle.accept_transport(transport, id, CapabilitySet::default()); } Ok(None) => { - info!("listener closed"); + let record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime", + "listener closed", + ); + log.emit(&record).await; break; } Err(e) => { - error!(error = %e, "accept error"); + let mut record = LogRecord::now( + LogLevel::Error, + "saikuro.runtime", + "accept error", + ); + record.set_context("error", alloc::format!("{}", e)); + log.emit(&record).await; sleep(Duration::from_millis(ACCEPT_BACKOFF_MS)).await; } } } changed = rx.changed() => { if changed.is_err() || rx.borrow() { - info!("listener shutting down"); + let record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime", + "listener shutting down", + ); + log.emit(&record).await; break; } } @@ -223,6 +280,11 @@ impl SaikuroRuntime { let _ = timeout(Duration::from_secs(5), task).await; } - info!("saikuro runtime listener set stopped"); + let record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime", + "saikuro runtime listener set stopped", + ); + self.log.emit(&record).await; } } diff --git a/Build/crates/saikuro-schema/Cargo.toml b/Build/crates/saikuro-schema/Cargo.toml index 7c46e9cc..347c8f67 100644 --- a/Build/crates/saikuro-schema/Cargo.toml +++ b/Build/crates/saikuro-schema/Cargo.toml @@ -24,4 +24,6 @@ saikuro-core = { path = "../saikuro-core", default-features = false } saikuro-exec = { path = "../saikuro-exec", default-features = false } saikuro-event = { workspace = true, default-features = false } +portable-atomic = { workspace = true } +portable-atomic-util = { workspace = true } thiserror = { workspace = true } diff --git a/Build/crates/saikuro-schema/registry/registry.rs b/Build/crates/saikuro-schema/registry/registry.rs index fd66af29..8de5b46d 100644 --- a/Build/crates/saikuro-schema/registry/registry.rs +++ b/Build/crates/saikuro-schema/registry/registry.rs @@ -1,4 +1,8 @@ -use alloc::{borrow::ToOwned, collections::BTreeMap, string::String, sync::Arc, vec::Vec}; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; +use alloc::{borrow::ToOwned, collections::BTreeMap, string::String, vec::Vec}; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; use saikuro_core::schema::{ FunctionSchema, NamespaceSchema, Schema, TypeDefinition, SCHEMA_NAMESPACES_CAPACITY, SCHEMA_TYPES_CAPACITY, diff --git a/Build/crates/saikuro-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index 458592d3..f5ceb2ff 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -25,6 +25,7 @@ native = [ "saikuro-core/std", "saikuro-exec/native", "saikuro-event/native", + "saikuro-event/std", ] no_std = [ "sqlite", @@ -77,7 +78,6 @@ serde_json = { workspace = true, features = ["alloc"] } bytes = { workspace = true } futures = { workspace = true } thiserror = { workspace = true } -tracing = { workspace = true } async-trait = { workspace = true } dashmap = { workspace = true, optional = true } @@ -127,6 +127,5 @@ web-sys = { workspace = true, optional = true, features = [ [dev-dependencies] saikuro-exec = { workspace = true, features = ["native"] } -tracing-subscriber = { workspace = true } futures-executor = { workspace = true } embedded-storage-async = { workspace = true } diff --git a/Build/crates/saikuro-storage/common/inmemory.rs b/Build/crates/saikuro-storage/common/inmemory.rs index a1587ce6..653ec48e 100644 --- a/Build/crates/saikuro-storage/common/inmemory.rs +++ b/Build/crates/saikuro-storage/common/inmemory.rs @@ -2,7 +2,7 @@ use alloc::sync::Arc; use bytes::Bytes; use dashmap::DashMap; -use tracing::debug; +use saikuro_event::{LogLevel, LogRecord, LogSink}; use crate::config::StorageConfig; use crate::traits::{KeyValueBackend, StorageBackend}; @@ -18,27 +18,58 @@ type NamespaceStore = DashMap; pub struct InMemoryStorage { config: StorageConfig, namespaces: DashMap>, + log: Arc, } impl InMemoryStorage { /// Create a new in-memory storage backend with default configuration. pub fn new() -> Self { - Self::with_config(StorageConfig::default()) + Self { + config: StorageConfig::default(), + namespaces: DashMap::new(), + log: Arc::from(Box::new(saikuro_event::NullSink) as Box), + } + } + + /// Create a new in-memory storage backend with a custom log sink. + pub fn with_log(log: Arc) -> Self { + Self { + config: StorageConfig::default(), + namespaces: DashMap::new(), + log, + } } /// Create a new in-memory storage backend with custom configuration. - pub fn with_config(config: StorageConfig) -> Self { + pub async fn with_config(config: StorageConfig, log: Arc) -> Self { let namespaces = DashMap::new(); if config.cleanup != crate::config::CleanupPolicy::Never { - debug!("in-memory backend does not enforce cleanup (TTL/Age/LRU); configured cleanup settings are ignored"); + let record = LogRecord::now( + LogLevel::Debug, + "saikuro.storage.inmemory", + "in-memory backend does not enforce cleanup (TTL/Age/LRU); configured cleanup settings are ignored", + ); + log.emit(&record).await; } - debug!( - persistence = ?config.persistence, - "in-memory storage backend initialized" + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.storage.inmemory", + "in-memory storage backend initialized", ); + record.set_context("persistence", alloc::format!("{:?}", config.persistence)); + log.emit(&record).await; + + Self { + config, + namespaces, + log, + } + } - Self { config, namespaces } + /// Return a reference to the log sink for this storage backend. + pub fn log(&self) -> &Arc { + &self.log } /// Prefix a logical namespace name with the configured prefix. diff --git a/Build/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index 4a5704ee..684e52a2 100644 --- a/Build/crates/saikuro-transport/Cargo.toml +++ b/Build/crates/saikuro-transport/Cargo.toml @@ -19,13 +19,15 @@ native = [ "saikuro-core/native", "saikuro-net/native", "saikuro-exec/native", + "saikuro-event/native", "dep:tokio-tungstenite", ] -no_std = ["saikuro-core/no_std", "saikuro-net/no_std", "saikuro-exec/no_std"] +no_std = ["saikuro-core/no_std", "saikuro-net/no_std", "saikuro-exec/no_std", "saikuro-event/no_std"] wasm = [ "saikuro-core/wasm", "saikuro-net/wasm", "saikuro-exec/wasm", + "saikuro-event/wasm", "dep:wasm-bindgen", "dep:js-sys", "dep:web-sys", @@ -36,6 +38,7 @@ embedded = [ "saikuro-core/embedded", "saikuro-net/embedded", "saikuro-exec/embedded", + "saikuro-event/embedded", "dep:embedded-io-async", "dep:embassy-sync", ] @@ -56,13 +59,15 @@ saikuro-core = { path = "../saikuro-core", default-features = false } saikuro-net = { path = "../saikuro-net", default-features = false } saikuro-exec = { path = "../saikuro-exec", default-features = false } saikuro-random = { path = "../saikuro-random", default-features = false } +saikuro-event = { path = "../saikuro-event", default-features = false } serde = { workspace = true } -bytes = { workspace = true, default-features = false } +bytes = { workspace = true, default-features = false, features = ["extra-platforms"] } async-trait = { workspace = true } futures = { workspace = true, default-features = false, features = ["async-await", "alloc"] } pin-project-lite = { workspace = true } +portable-atomic-util = { workspace = true } +portable-atomic = { workspace = true } thiserror = { workspace = true } -tracing = { workspace = true, default-features = false, features = ["log", "attributes"] } embedded-io-async = { workspace = true, optional = true } embassy-sync = { workspace = true, optional = true } tokio-tungstenite = { workspace = true, optional = true } @@ -89,5 +94,4 @@ wasi = { workspace = true, optional = true } wasip1 = { workspace = true, optional = true } [dev-dependencies] -tracing-subscriber = { workspace = true } futures = { workspace = true, default-features = false, features = ["async-await", "alloc", "executor"] } diff --git a/Build/crates/saikuro-transport/embedded/tcp.rs b/Build/crates/saikuro-transport/embedded/tcp.rs index 658cd372..a1a0c4ef 100644 --- a/Build/crates/saikuro-transport/embedded/tcp.rs +++ b/Build/crates/saikuro-transport/embedded/tcp.rs @@ -1,11 +1,14 @@ use alloc::boxed::Box; +#[cfg(target_has_atomic = "ptr")] use alloc::sync::Arc; use async_trait::async_trait; use bytes::Bytes; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; use embassy_sync::blocking_mutex::raw::NoopRawMutex; use embassy_sync::mutex::Mutex as AsyncMutex; -use tracing::debug; +use saikuro_event::{LogLevel, LogRecord}; use saikuro_net::net::tcp::TcpSocket; use saikuro_net::net::{IpEndpoint, IpListenEndpoint, Stack}; @@ -111,7 +114,13 @@ impl TransportConnector for TcpConnector { type Output = TcpTransport; async fn connect(&self) -> Result { - debug!(remote = ?self.remote, "embedded tcp connecting"); + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.embedded.tcp", + "embedded tcp connecting", + ); + record.set_context("remote", alloc::format!("{:?}", self.remote)); + // NOTE: no log sink available in embedded connector; record is unused let rx = unsafe { &mut *core::ptr::addr_of_mut!(CLIENT_RX) }; let tx = unsafe { &mut *core::ptr::addr_of_mut!(CLIENT_TX) }; let mut socket = TcpSocket::new(*self.stack, rx, tx); @@ -159,12 +168,10 @@ impl TransportListener for TcpTransportListener { }) .await .map_err(|e| TransportError::ConnectionRefused(alloc::format!("{:?}", e)))?; - debug!(local = ?self.local, "embedded tcp accepted connection"); Ok(Some(TcpTransport::new(socket))) } async fn close(&mut self) -> Result<()> { - debug!(local = ?self.local, "embedded tcp listener closing"); Ok(()) } } diff --git a/Build/crates/saikuro-transport/lib.rs b/Build/crates/saikuro-transport/lib.rs index 484278da..d69debec 100644 --- a/Build/crates/saikuro-transport/lib.rs +++ b/Build/crates/saikuro-transport/lib.rs @@ -94,12 +94,27 @@ macro_rules! impl_native_sender { #[async_trait::async_trait] impl $crate::shared::traits::TransportSender for $ty { async fn send(&mut self, frame: ::bytes::Bytes) -> $crate::shared::error::Result<()> { - tracing::trace!($addr = ?self.$addr, bytes = frame.len(), concat!($desc, " send")); + use ::saikuro_event::{LogLevel, LogRecord}; + let mut record = LogRecord::now( + LogLevel::Trace, + concat!("saikuro.transport.", $desc), + concat!($desc, " send"), + ); + record.set_context("_addr", ::alloc::format!("{:?}", self.$addr)); + record.set_context("bytes", frame.len() as u64); + self.log.emit(&record).await; $crate::shared::framing::write_frame(&mut self.inner, &frame).await } async fn close(&mut self) -> $crate::shared::error::Result<()> { - tracing::debug!($addr = ?self.$addr, concat!($desc, " sender closing")); + use ::saikuro_event::{LogLevel, LogRecord}; + let mut record = LogRecord::now( + LogLevel::Debug, + concat!("saikuro.transport.", $desc), + concat!($desc, " sender closing"), + ); + record.set_context("_addr", ::alloc::format!("{:?}", self.$addr)); + self.log.emit(&record).await; $crate::shared::framing::AsyncByteWrite::flush(&mut self.inner).await } } @@ -107,26 +122,38 @@ macro_rules! impl_native_sender { } /// Implements [`TransportReceiver`] for a native transport's receiving half. +/// +/// The concrete type must have a `log: Arc` field. #[macro_export] macro_rules! impl_native_receiver { ($ty:ty, $addr:ident, $desc:literal) => { #[async_trait::async_trait] impl $crate::shared::traits::TransportReceiver for $ty { - async fn recv( - &mut self, - ) -> $crate::shared::error::Result> { + async fn recv(&mut self) -> $crate::shared::error::Result> { match $crate::shared::framing::read_frame(&mut self.inner).await { Ok(bytes) => { match &bytes { - Some(b) => tracing::trace!( - $addr = ?self.$addr, - bytes = b.len(), - concat!($desc, " recv") - ), - None => tracing::debug!( - $addr = ?self.$addr, - concat!($desc, " connection closed by peer") - ), + Some(b) => { + use ::saikuro_event::{LogLevel, LogRecord}; + let mut record = LogRecord::now( + LogLevel::Trace, + concat!("saikuro.transport.", $desc), + concat!($desc, " recv"), + ); + record.set_context("_addr", ::alloc::format!("{:?}", self.$addr)); + record.set_context("bytes", b.len() as u64); + self.log.emit(&record).await; + } + None => { + use ::saikuro_event::{LogLevel, LogRecord}; + let mut record = LogRecord::now( + LogLevel::Debug, + concat!("saikuro.transport.", $desc), + concat!($desc, " connection closed by peer"), + ); + record.set_context("_addr", ::alloc::format!("{:?}", self.$addr)); + self.log.emit(&record).await; + } } Ok(bytes) } diff --git a/Build/crates/saikuro-transport/native/tcp.rs b/Build/crates/saikuro-transport/native/tcp.rs index e8673e05..19550c00 100644 --- a/Build/crates/saikuro-transport/native/tcp.rs +++ b/Build/crates/saikuro-transport/native/tcp.rs @@ -1,9 +1,13 @@ use crate::{impl_native_receiver, impl_native_sender}; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; use async_trait::async_trait; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; +use saikuro_event::{LogLevel, LogRecord}; use saikuro_net::io::{split, ReadHalf, WriteHalf}; use saikuro_net::net::{TcpListener, TcpStream}; use std::net::SocketAddr; -use tracing::debug; use crate::shared::{ error::Result, @@ -14,16 +18,21 @@ use crate::shared::{ pub struct TcpTransport { stream: TcpStream, peer_addr: SocketAddr, + log: Arc, } impl TcpTransport { /// Wrap an already-connected [`TcpStream`]. - pub fn new(stream: TcpStream) -> Result { + pub fn new(stream: TcpStream, log: Arc) -> Result { let peer_addr = stream.peer_addr()?; // Disable Nagle's algorithm: Saikuro sends complete frames and latency // matters more than segment coalescing. stream.set_nodelay(true)?; - Ok(Self { stream, peer_addr }) + Ok(Self { + stream, + peer_addr, + log, + }) } } @@ -34,14 +43,17 @@ impl Transport for TcpTransport { fn split(self) -> (Self::Sender, Self::Receiver) { let (read, write) = split(self.stream); let peer = self.peer_addr; + let log = self.log; ( TcpSender { inner: write, peer_addr: peer, + log: log.clone(), }, TcpReceiver { inner: read, peer_addr: peer, + log, }, ) } @@ -55,6 +67,7 @@ impl Transport for TcpTransport { pub struct TcpSender { inner: WriteHalf, peer_addr: SocketAddr, + log: Arc, } impl_native_sender!(TcpSender, peer_addr, "tcp"); @@ -62,6 +75,7 @@ impl_native_sender!(TcpSender, peer_addr, "tcp"); pub struct TcpReceiver { inner: ReadHalf, peer_addr: SocketAddr, + log: Arc, } impl_native_receiver!(TcpReceiver, peer_addr, "tcp"); @@ -69,11 +83,12 @@ impl_native_receiver!(TcpReceiver, peer_addr, "tcp"); /// Establishes outgoing TCP connections. pub struct TcpConnector { addr: SocketAddr, + log: Arc, } impl TcpConnector { - pub fn new(addr: SocketAddr) -> Self { - Self { addr } + pub fn new(addr: SocketAddr, log: Arc) -> Self { + Self { addr, log } } } @@ -82,9 +97,11 @@ impl TransportConnector for TcpConnector { type Output = TcpTransport; async fn connect(&self) -> Result { - debug!(addr = %self.addr, "tcp connecting"); + let mut record = LogRecord::now(LogLevel::Debug, "saikuro.transport.tcp", "tcp connecting"); + record.set_context("addr", alloc::format!("{}", self.addr)); + self.log.emit(&record).await; let stream = TcpStream::connect(self.addr).await?; - TcpTransport::new(stream) + TcpTransport::new(stream, self.log.clone()) } } @@ -92,15 +109,26 @@ impl TransportConnector for TcpConnector { pub struct TcpTransportListener { inner: TcpListener, local_addr: SocketAddr, + log: Arc, } impl TcpTransportListener { /// Bind a listener on the given address. - pub async fn bind(addr: SocketAddr) -> Result { + pub async fn bind(addr: SocketAddr, log: Arc) -> Result { let inner = TcpListener::bind(addr).await?; let local_addr = inner.local_addr()?; - debug!(%local_addr, "tcp listener bound"); - Ok(Self { inner, local_addr }) + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.tcp", + "tcp listener bound", + ); + record.set_context("local_addr", alloc::format!("{}", local_addr)); + log.emit(&record).await; + Ok(Self { + inner, + local_addr, + log, + }) } /// Return the address this listener is bound to. @@ -115,16 +143,28 @@ impl TransportListener for TcpTransportListener { async fn accept(&mut self) -> Result> { match self.inner.accept().await { - Ok((stream, _peer)) => { - debug!(peer = %_peer, "tcp accepted connection"); - Ok(Some(TcpTransport::new(stream)?)) + Ok((stream, peer)) => { + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.tcp", + "tcp accepted connection", + ); + record.set_context("peer", alloc::format!("{}", peer)); + self.log.emit(&record).await; + Ok(Some(TcpTransport::new(stream, self.log.clone())?)) } Err(e) => Err(e.into()), } } async fn close(&mut self) -> Result<()> { - debug!(local = %self.local_addr, "tcp listener closing"); + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.tcp", + "tcp listener closing", + ); + record.set_context("local_addr", alloc::format!("{}", self.local_addr)); + self.log.emit(&record).await; // TcpListener closes on drop. Ok(()) } diff --git a/Build/crates/saikuro-transport/native/unix.rs b/Build/crates/saikuro-transport/native/unix.rs index f70c3f99..5ecfe5df 100644 --- a/Build/crates/saikuro-transport/native/unix.rs +++ b/Build/crates/saikuro-transport/native/unix.rs @@ -1,9 +1,13 @@ use crate::{impl_native_receiver, impl_native_sender}; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; use async_trait::async_trait; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; +use saikuro_event::{LogLevel, LogRecord}; use saikuro_net::io::{split, ReadHalf, WriteHalf}; use saikuro_net::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; -use tracing::debug; use crate::shared::{ error::Result, @@ -14,12 +18,13 @@ use crate::shared::{ pub struct UnixTransport { stream: UnixStream, path: PathBuf, + log: Arc, } impl UnixTransport { /// Wrap an already-connected [`UnixStream`]. - pub fn new(stream: UnixStream, path: PathBuf) -> Self { - Self { stream, path } + pub fn new(stream: UnixStream, path: PathBuf, log: Arc) -> Self { + Self { stream, path, log } } } @@ -30,12 +35,18 @@ impl Transport for UnixTransport { fn split(self) -> (Self::Sender, Self::Receiver) { let (read, write) = split(self.stream); let path = self.path.clone(); + let log = self.log; ( UnixSender { inner: write, path: path.clone(), + log: log.clone(), + }, + UnixReceiver { + inner: read, + path, + log, }, - UnixReceiver { inner: read, path }, ) } @@ -48,6 +59,7 @@ impl Transport for UnixTransport { pub struct UnixSender { inner: WriteHalf, path: PathBuf, + log: Arc, } impl_native_sender!(UnixSender, path, "unix"); @@ -55,6 +67,7 @@ impl_native_sender!(UnixSender, path, "unix"); pub struct UnixReceiver { inner: ReadHalf, path: PathBuf, + log: Arc, } impl_native_receiver!(UnixReceiver, path, "unix"); @@ -62,12 +75,14 @@ impl_native_receiver!(UnixReceiver, path, "unix"); /// Establishes outgoing Unix socket connections. pub struct UnixConnector { path: PathBuf, + log: Arc, } impl UnixConnector { - pub fn new(path: impl AsRef) -> Self { + pub fn new(path: impl AsRef, log: Arc) -> Self { Self { path: path.as_ref().to_owned(), + log, } } } @@ -77,9 +92,16 @@ impl TransportConnector for UnixConnector { type Output = UnixTransport; async fn connect(&self) -> Result { - debug!(path = ?self.path, "unix connecting"); + let mut record = + LogRecord::now(LogLevel::Debug, "saikuro.transport.unix", "unix connecting"); + record.set_context("path", alloc::format!("{:?}", self.path)); + self.log.emit(&record).await; let stream = UnixStream::connect(&self.path).await?; - Ok(UnixTransport::new(stream, self.path.clone())) + Ok(UnixTransport::new( + stream, + self.path.clone(), + self.log.clone(), + )) } } @@ -87,21 +109,31 @@ impl TransportConnector for UnixConnector { pub struct UnixTransportListener { inner: UnixListener, path: PathBuf, + log: Arc, } impl UnixTransportListener { /// Bind a listener on the given socket path. /// /// If a stale socket file already exists at the path it is removed first. - pub async fn bind(path: impl AsRef) -> Result { + pub async fn bind( + path: impl AsRef, + log: Arc, + ) -> Result { let path = path.as_ref().to_owned(); // Remove any stale socket from a previous run. if path.exists() { std::fs::remove_file(&path)?; } let inner = UnixListener::bind(&path)?; - debug!(?path, "unix listener bound"); - Ok(Self { inner, path }) + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.unix", + "unix listener bound", + ); + record.set_context("path", alloc::format!("{:?}", path)); + log.emit(&record).await; + Ok(Self { inner, path, log }) } pub fn path(&self) -> &Path { @@ -123,15 +155,31 @@ impl TransportListener for UnixTransportListener { async fn accept(&mut self) -> Result> { match self.inner.accept().await { Ok((stream, _addr)) => { - debug!(path = ?self.path, "unix accepted connection"); - Ok(Some(UnixTransport::new(stream, self.path.clone()))) + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.unix", + "unix accepted connection", + ); + record.set_context("path", alloc::format!("{:?}", self.path)); + self.log.emit(&record).await; + Ok(Some(UnixTransport::new( + stream, + self.path.clone(), + self.log.clone(), + ))) } Err(e) => Err(e.into()), } } async fn close(&mut self) -> Result<()> { - debug!(path = ?self.path, "unix listener closing"); + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.unix", + "unix listener closing", + ); + record.set_context("path", alloc::format!("{:?}", self.path)); + self.log.emit(&record).await; Ok(()) } } diff --git a/Build/crates/saikuro-transport/native/websocket.rs b/Build/crates/saikuro-transport/native/websocket.rs index e73f5f42..0540de46 100644 --- a/Build/crates/saikuro-transport/native/websocket.rs +++ b/Build/crates/saikuro-transport/native/websocket.rs @@ -1,8 +1,12 @@ +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; use async_trait::async_trait; use bytes::Bytes; use futures::{SinkExt, StreamExt}; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; +use saikuro_event::{LogLevel, LogRecord}; use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; -use tracing::{debug, trace}; use saikuro_net::net::{TcpListener, TcpStream}; @@ -17,22 +21,44 @@ use crate::shared::{ pub struct WebSocketTransport { inner: WebSocketStream>, url: String, + log: Arc, } impl WebSocketTransport { /// Connect to a WebSocket server at `url` (e.g. `"ws://127.0.0.1:9000"`). - pub async fn connect(url: impl Into) -> Result { + pub async fn connect( + url: impl Into, + log: Arc, + ) -> Result { let url = url.into(); - debug!(%url, "websocket connecting"); + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.websocket", + "websocket connecting", + ); + record.set_context("url", url.clone()); + log.emit(&record).await; let (ws, _response) = connect_async(&url).await.map_err(|e| { TransportError::ConnectionRefused(format!("ws connect to {url} failed: {e}")) })?; - Ok(Self { inner: ws, url }) + Ok(Self { + inner: ws, + url, + log, + }) } /// Construct from an already-upgraded WebSocket stream (server-side accept path). - pub fn from_stream(ws: WebSocketStream>, url: String) -> Self { - Self { inner: ws, url } + pub fn from_stream( + ws: WebSocketStream>, + url: String, + log: Arc, + ) -> Self { + Self { + inner: ws, + url, + log, + } } } @@ -42,13 +68,19 @@ impl Transport for WebSocketTransport { fn split(self) -> (Self::Sender, Self::Receiver) { let url = self.url.clone(); + let log = self.log; let (sink, stream) = self.inner.split(); ( WebSocketSender { inner: sink, url: url.clone(), + log: log.clone(), + }, + WebSocketReceiver { + inner: stream, + url, + log, }, - WebSocketReceiver { inner: stream, url }, ) } @@ -62,17 +94,25 @@ impl Transport for WebSocketTransport { pub struct WsTransportListener { inner: Option, local_addr: SocketAddr, + log: Arc, } impl WsTransportListener { /// Bind a TCP listener on the given address for WebSocket upgrades. - pub async fn bind(addr: SocketAddr) -> Result { + pub async fn bind(addr: SocketAddr, log: Arc) -> Result { let inner = TcpListener::bind(addr).await?; let local_addr = inner.local_addr()?; - debug!(%local_addr, "ws listener bound"); + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.websocket", + "ws listener bound", + ); + record.set_context("local_addr", alloc::format!("{}", local_addr)); + log.emit(&record).await; Ok(Self { inner: Some(inner), local_addr, + log, }) } @@ -96,11 +136,28 @@ impl TransportListener for WsTransportListener { let maybe_tls = MaybeTlsStream::Plain(stream); match tokio_tungstenite::accept_async(maybe_tls).await { Ok(ws_stream) => { - debug!(peer = %peer_addr, "ws upgrade successful"); - Ok(Some(WebSocketTransport::from_stream(ws_stream, url))) + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.websocket", + "ws upgrade successful", + ); + record.set_context("peer", alloc::format!("{}", peer_addr)); + self.log.emit(&record).await; + Ok(Some(WebSocketTransport::from_stream( + ws_stream, + url, + self.log.clone(), + ))) } Err(e) => { - tracing::warn!(peer = %peer_addr, error = %e, "ws upgrade failed"); + let mut record = LogRecord::now( + LogLevel::Warn, + "saikuro.transport.websocket", + "ws upgrade failed", + ); + record.set_context("peer", alloc::format!("{}", peer_addr)); + record.set_context("error", alloc::format!("{}", e)); + self.log.emit(&record).await; Err(TransportError::ConnectionRefused(format!( "WebSocket upgrade from {peer_addr} failed: {e}" ))) @@ -109,7 +166,13 @@ impl TransportListener for WsTransportListener { } async fn close(&mut self) -> Result<()> { - debug!(local = %self.local_addr, "ws listener closing"); + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.websocket", + "ws listener closing", + ); + record.set_context("local_addr", alloc::format!("{}", self.local_addr)); + self.log.emit(&record).await; drop(self.inner.take()); Ok(()) } @@ -119,12 +182,16 @@ impl TransportListener for WsTransportListener { pub struct WebSocketSender { inner: futures::stream::SplitSink>, Message>, url: String, + log: Arc, } #[async_trait] impl TransportSender for WebSocketSender { async fn send(&mut self, frame: Bytes) -> Result<()> { - trace!(url = %self.url, bytes = frame.len(), "ws send"); + let mut record = LogRecord::now(LogLevel::Trace, "saikuro.transport.websocket", "ws send"); + record.set_context("url", self.url.clone()); + record.set_context("bytes", frame.len() as u64); + self.log.emit(&record).await; self.inner .send(Message::Binary(frame)) .await @@ -132,7 +199,13 @@ impl TransportSender for WebSocketSender { } async fn close(&mut self) -> Result<()> { - debug!(url = %self.url, "ws sender closing"); + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.websocket", + "ws sender closing", + ); + record.set_context("url", self.url.clone()); + self.log.emit(&record).await; self.inner .send(Message::Close(None)) .await @@ -144,6 +217,7 @@ impl TransportSender for WebSocketSender { pub struct WebSocketReceiver { inner: futures::stream::SplitStream>>, url: String, + log: Arc, } #[async_trait] @@ -152,18 +226,38 @@ impl TransportReceiver for WebSocketReceiver { loop { match self.inner.next().await { Some(Ok(Message::Binary(data))) => { - trace!(url = %self.url, bytes = data.len(), "ws recv binary"); + let mut record = LogRecord::now( + LogLevel::Trace, + "saikuro.transport.websocket", + "ws recv binary", + ); + record.set_context("url", self.url.clone()); + record.set_context("bytes", data.len() as u64); + self.log.emit(&record).await; return Ok(Some(data)); } Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => { continue; } Some(Ok(Message::Close(_))) => { - debug!(url = %self.url, "ws closed by peer"); + let mut record = LogRecord::now( + LogLevel::Debug, + "saikuro.transport.websocket", + "ws closed by peer", + ); + record.set_context("url", self.url.clone()); + self.log.emit(&record).await; return Ok(None); } Some(Ok(other)) => { - trace!(url = %self.url, "ws ignoring non-binary frame: {:?}", other); + let mut record = LogRecord::now( + LogLevel::Trace, + "saikuro.transport.websocket", + "ws ignoring non-binary frame", + ); + record.set_context("url", self.url.clone()); + record.set_context("frame_type", alloc::format!("{:?}", other)); + self.log.emit(&record).await; continue; } Some(Err(e)) => { diff --git a/Build/crates/saikuro-transport/shared/memory.rs b/Build/crates/saikuro-transport/shared/memory.rs index 47d2ede4..b3440fb5 100644 --- a/Build/crates/saikuro-transport/shared/memory.rs +++ b/Build/crates/saikuro-transport/shared/memory.rs @@ -1,9 +1,13 @@ use alloc::boxed::Box; use alloc::string::String; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; use async_trait::async_trait; use bytes::Bytes; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; +use saikuro_event::{LogLevel, LogRecord}; use saikuro_exec::mpsc; -use tracing::trace; use crate::shared::{ error::{Result, TransportError}, @@ -19,6 +23,7 @@ pub struct MemoryTransport { sender: mpsc::Sender, receiver: mpsc::Receiver, label: String, + log: Arc, } impl MemoryTransport { @@ -28,7 +33,11 @@ impl MemoryTransport { /// bytes sent on one will be received on the other. /// /// `label_a` and `label_b` are used only for log output. - pub fn pair(label_a: impl Into, label_b: impl Into) -> (Self, Self) { + pub fn pair( + label_a: impl Into, + label_b: impl Into, + log: Arc, + ) -> (Self, Self) { let (a_tx, b_rx) = mpsc::channel(DEFAULT_CHANNEL_CAPACITY); let (b_tx, a_rx) = mpsc::channel(DEFAULT_CHANNEL_CAPACITY); @@ -36,19 +45,21 @@ impl MemoryTransport { sender: a_tx, receiver: a_rx, label: label_a.into(), + log: log.clone(), }; let transport_b = Self { sender: b_tx, receiver: b_rx, label: label_b.into(), + log, }; (transport_a, transport_b) } /// Create a pair with the default labels `"client"` and `"server"`. - pub fn connected_pair() -> (Self, Self) { - Self::pair("client", "server") + pub fn connected_pair(log: Arc) -> (Self, Self) { + Self::pair("client", "server", log) } } @@ -61,10 +72,12 @@ impl Transport for MemoryTransport { MemorySender { inner: self.sender, label: self.label.clone(), + log: self.log.clone(), }, MemoryReceiver { inner: self.receiver, label: self.label, + log: self.log, }, ) } @@ -78,12 +91,14 @@ impl Transport for MemoryTransport { pub struct MemorySender { inner: mpsc::Sender, label: String, + log: Arc, } /// Receiving half of a [`MemoryTransport`]. pub struct MemoryReceiver { inner: mpsc::Receiver, label: String, + log: Arc, } #[cfg(feature = "native")] @@ -93,7 +108,11 @@ mod send_impls { #[async_trait] impl TransportSender for MemorySender { async fn send(&mut self, frame: Bytes) -> Result<()> { - trace!(label = %self.label, bytes = frame.len(), "memory send"); + let mut record = + LogRecord::now(LogLevel::Trace, "saikuro.transport.memory", "memory send"); + record.set_context("label", self.label.clone()); + record.set_context("bytes", frame.len() as u64); + self.log.emit(&record).await; self.inner.send(frame).await.map_err(|_| { TransportError::ConnectionLost(format!( "in-memory receiver dropped for '{}'", @@ -103,7 +122,13 @@ mod send_impls { } async fn close(&mut self) -> Result<()> { - trace!(label = %self.label, "memory sender closing"); + let mut record = LogRecord::now( + LogLevel::Trace, + "saikuro.transport.memory", + "memory sender closing", + ); + record.set_context("label", self.label.clone()); + self.log.emit(&record).await; Ok(()) } } @@ -113,8 +138,22 @@ mod send_impls { async fn recv(&mut self) -> Result> { let result = self.inner.recv().await; match &result { - Some(bytes) => trace!(label = %self.label, bytes = bytes.len(), "memory recv"), - None => trace!(label = %self.label, "memory channel closed"), + Some(bytes) => { + let mut record = + LogRecord::now(LogLevel::Trace, "saikuro.transport.memory", "memory recv"); + record.set_context("label", self.label.clone()); + record.set_context("bytes", bytes.len() as u64); + self.log.emit(&record).await; + } + None => { + let mut record = LogRecord::now( + LogLevel::Trace, + "saikuro.transport.memory", + "memory channel closed", + ); + record.set_context("label", self.label.clone()); + self.log.emit(&record).await; + } } Ok(result) } @@ -128,7 +167,11 @@ mod nosend_impls { #[async_trait(?Send)] impl TransportSender for MemorySender { async fn send(&mut self, frame: Bytes) -> Result<()> { - trace!(label = %self.label, bytes = frame.len(), "memory send"); + let mut record = + LogRecord::now(LogLevel::Trace, "saikuro.transport.memory", "memory send"); + record.set_context("label", self.label.clone()); + record.set_context("bytes", frame.len() as u64); + self.log.emit(&record).await; self.inner.send(frame).await.map_err(|_| { TransportError::ConnectionLost(format!( "in-memory receiver dropped for '{}'", @@ -138,7 +181,13 @@ mod nosend_impls { } async fn close(&mut self) -> Result<()> { - trace!(label = %self.label, "memory sender closing"); + let mut record = LogRecord::now( + LogLevel::Trace, + "saikuro.transport.memory", + "memory sender closing", + ); + record.set_context("label", self.label.clone()); + self.log.emit(&record).await; Ok(()) } } @@ -148,8 +197,22 @@ mod nosend_impls { async fn recv(&mut self) -> Result> { let result = self.inner.recv().await; match &result { - Some(bytes) => trace!(label = %self.label, bytes = bytes.len(), "memory recv"), - None => trace!(label = %self.label, "memory channel closed"), + Some(bytes) => { + let mut record = + LogRecord::now(LogLevel::Trace, "saikuro.transport.memory", "memory recv"); + record.set_context("label", self.label.clone()); + record.set_context("bytes", bytes.len() as u64); + self.log.emit(&record).await; + } + None => { + let mut record = LogRecord::now( + LogLevel::Trace, + "saikuro.transport.memory", + "memory channel closed", + ); + record.set_context("label", self.label.clone()); + self.log.emit(&record).await; + } } Ok(result) } diff --git a/Build/crates/saikuro-transport/wasi/preview1.rs b/Build/crates/saikuro-transport/wasi/preview1.rs index 7d218f6e..de59f1c1 100644 --- a/Build/crates/saikuro-transport/wasi/preview1.rs +++ b/Build/crates/saikuro-transport/wasi/preview1.rs @@ -1,4 +1,7 @@ +#[cfg(target_has_atomic = "ptr")] use alloc::sync::Arc; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; use crate::shared::error::{Result, TransportError}; use crate::wasi::tcp::{parse_addr, parse_ipv4, WasiConn}; diff --git a/Build/crates/saikuro-transport/wasi/preview2.rs b/Build/crates/saikuro-transport/wasi/preview2.rs index 0ec05ea1..e2c438fd 100644 --- a/Build/crates/saikuro-transport/wasi/preview2.rs +++ b/Build/crates/saikuro-transport/wasi/preview2.rs @@ -1,4 +1,7 @@ +#[cfg(target_has_atomic = "ptr")] use alloc::sync::Arc; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; use wasi::io::streams::{InputStream, OutputStream}; use wasi::sockets::instance_network::instance_network; diff --git a/Build/crates/saikuro-transport/wasi/tcp.rs b/Build/crates/saikuro-transport/wasi/tcp.rs index b2093cb5..1012a5dc 100644 --- a/Build/crates/saikuro-transport/wasi/tcp.rs +++ b/Build/crates/saikuro-transport/wasi/tcp.rs @@ -1,6 +1,9 @@ use alloc::boxed::Box; use alloc::string::{String, ToString}; +#[cfg(target_has_atomic = "ptr")] use alloc::sync::Arc; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; use async_trait::async_trait; use bytes::Bytes; diff --git a/Build/crates/saikuro-transport/wasi/websocket.rs b/Build/crates/saikuro-transport/wasi/websocket.rs index 68790648..45bf16bb 100644 --- a/Build/crates/saikuro-transport/wasi/websocket.rs +++ b/Build/crates/saikuro-transport/wasi/websocket.rs @@ -1,7 +1,10 @@ use alloc::boxed::Box; use alloc::format; use alloc::string::String; +#[cfg(target_has_atomic = "ptr")] use alloc::sync::Arc; +#[cfg(not(target_has_atomic = "ptr"))] +use portable_atomic_util::Arc; use async_trait::async_trait; use bytes::Bytes; diff --git a/Build/crates/saikuro-transport/wasm/host_browser.rs b/Build/crates/saikuro-transport/wasm/host_browser.rs index 46551f9e..37d12f44 100644 --- a/Build/crates/saikuro-transport/wasm/host_browser.rs +++ b/Build/crates/saikuro-transport/wasm/host_browser.rs @@ -8,7 +8,6 @@ use core::fmt::Write; use core::time::Duration; use js_sys::{ArrayBuffer, Reflect, Uint8Array}; use send_wrapper::SendWrapper; -use tracing::trace; use wasm_bindgen::{closure::Closure, JsCast, JsValue}; use web_sys::{BroadcastChannel, Crypto, MessageEvent}; @@ -58,7 +57,6 @@ impl HostPipeFactory for BroadcastChannelPipe { #[async_trait(?Send)] impl HostPipeSend for BroadcastChannelSend { async fn send(&mut self, frame: &[u8]) -> Result<()> { - trace!(bytes = frame.len(), "wasm-host send"); send_buffer(&self.channel, frame) } } @@ -67,14 +65,8 @@ impl HostPipeSend for BroadcastChannelSend { impl HostPipeRecv for BroadcastChannelRecv { async fn recv(&mut self) -> Result>> { match self.rx.recv().await { - Some(bytes) => { - trace!(bytes = bytes.len(), "wasm-host recv"); - Ok(Some(bytes.to_vec())) - } - None => { - trace!("wasm-host channel closed"); - Ok(None) - } + Some(bytes) => Ok(Some(bytes.to_vec())), + None => Ok(None), } } } diff --git a/Build/crates/saikuro-transport/wasm/websocket.rs b/Build/crates/saikuro-transport/wasm/websocket.rs index 53eeb1ed..23aab24f 100644 --- a/Build/crates/saikuro-transport/wasm/websocket.rs +++ b/Build/crates/saikuro-transport/wasm/websocket.rs @@ -5,7 +5,6 @@ use core::cell::RefCell; use async_trait::async_trait; use bytes::Bytes; use send_wrapper::SendWrapper; -use tracing::{debug, trace}; use wasm_bindgen::{closure::Closure, JsCast}; use web_sys::{BinaryType, CloseEvent, ErrorEvent, Event, MessageEvent}; @@ -27,7 +26,6 @@ impl WebSocketTransport { /// Connect to a WebSocket server using the browser WebSocket API. pub async fn connect(url: impl Into) -> Result { let url = url.into(); - debug!(%url, "wasm websocket connecting"); let ws = web_sys::WebSocket::new(&url) .map_err(|e| TransportError::ConnectionRefused(format!("{e:?}")))?; @@ -64,13 +62,10 @@ impl WebSocketTransport { ws.set_onerror(None); match result { - Ok(Ok(())) => { - debug!(%url, "wasm websocket connected"); - Ok(Self { - ws: SendWrapper::new(ws), - url, - }) - } + Ok(Ok(())) => Ok(Self { + ws: SendWrapper::new(ws), + url, + }), Ok(Err(e)) => { ws.close().ok(); Err(e) @@ -160,7 +155,6 @@ impl TransportSender for WebSocketSender { use js_sys::{ArrayBuffer, Uint8Array}; use wasm_bindgen::JsValue; - trace!(url = %self.url, bytes = frame.len(), "wasm ws send"); let len = frame.len() as u32; let buffer = ArrayBuffer::new(len); let dst = Uint8Array::new(&buffer); @@ -172,7 +166,6 @@ impl TransportSender for WebSocketSender { } async fn close(&mut self) -> Result<()> { - debug!(url = %self.url, "wasm ws sender closing"); self.ws .close() .map_err(|e| TransportError::SendFailed(format!("{e:?}"))) @@ -206,14 +199,7 @@ impl Drop for WebSocketReceiver { impl TransportReceiver for WebSocketReceiver { async fn recv(&mut self) -> Result> { match self.rx.recv().await { - Some(Ok(opt)) => { - if opt.is_some() { - trace!(url = %self.url, bytes = opt.as_ref().unwrap().len(), "wasm ws recv"); - } else { - debug!(url = %self.url, "wasm ws closed by peer"); - } - Ok(opt) - } + Some(Ok(opt)) => Ok(opt), Some(Err(e)) => Err(e), None => Ok(None), } diff --git a/Build/scripts/check_adapter_matrix.py b/Build/scripts/check_adapter_matrix.py index cbe73d9e..0d67ac67 100755 --- a/Build/scripts/check_adapter_matrix.py +++ b/Build/scripts/check_adapter_matrix.py @@ -74,10 +74,40 @@ class Combo: "no_std wasm", ), Combo( - "embedded", + "embedded (no_std) (Cortex-M3 / RP2350-class)", "thumbv7m-none-eabi", ["--no-default-features", "--features", "embedded,tcp"], - "no_std embedded (thumbv7m)", + "no_std embedded", + ), + Combo( + "embedded (no_std) (Cortex-M0+ / RP2040)", + "thumbv6m-none-eabi", + ["--no-default-features", "--features", "embedded,tcp"], + "no_std embedded", + ), + Combo( + "embedded (no_std) (Cortex-M33 / RP2350)", + "thumbv8m.main-none-eabihf", + ["--no-default-features", "--features", "embedded,tcp"], + "no_std embedded", + ), + Combo( + "embedded (no_std) (AArch64 bare-metal)", + "aarch64-unknown-none", + ["--no-default-features", "--features", "embedded,tcp"], + "no_std embedded", + ), + Combo( + "embedded (no_std) (RISC-V 32 IMAC)", + "riscv32imac-unknown-none-elf", + ["--no-default-features", "--features", "embedded,tcp"], + "no_std embedded", + ), + Combo( + "embedded (no_std) (RISC-V 32 IMC / ESP32-C3)", + "riscv32imc-unknown-none-elf", + ["--no-default-features", "--features", "embedded,tcp"], + "no_std embedded", ), Combo( "wasi preview1 (no_std)", diff --git a/Build/scripts/check_matrix.py b/Build/scripts/check_matrix.py old mode 100644 new mode 100755 index c9fd50e7..5bd748e0 --- a/Build/scripts/check_matrix.py +++ b/Build/scripts/check_matrix.py @@ -72,11 +72,41 @@ class Combo: "no_std wasm", ), Combo( - "embedded (no_std)", + "embedded (no_std) (Cortex-M3 / RP2350-class)", "thumbv7m-none-eabi", ["--no-default-features", "--features", "embedded,tcp"], "no_std embedded", ), + Combo( + "embedded (no_std) (Cortex-M0+ / RP2040)", + "thumbv6m-none-eabi", + ["--no-default-features", "--features", "embedded,tcp"], + "no_std embedded", + ), + Combo( + "embedded (no_std) (Cortex-M33 / RP2350)", + "thumbv8m.main-none-eabihf", + ["--no-default-features", "--features", "embedded,tcp"], + "no_std embedded", + ), + Combo( + "embedded (no_std) (AArch64 bare-metal)", + "aarch64-unknown-none", + ["--no-default-features", "--features", "embedded,tcp"], + "no_std embedded", + ), + Combo( + "embedded (no_std) (RISC-V 32 IMAC)", + "riscv32imac-unknown-none-elf", + ["--no-default-features", "--features", "embedded,tcp"], + "no_std embedded", + ), + Combo( + "embedded (no_std) (RISC-V 32 IMC / ESP32-C3)", + "riscv32imc-unknown-none-elf", + ["--no-default-features", "--features", "embedded,tcp"], + "no_std embedded", + ), Combo( "wasi preview1 (no_std)", "wasm32-wasip1", diff --git a/Build/tests/Cargo.toml b/Build/tests/Cargo.toml index 2361fa09..137e1a5f 100644 --- a/Build/tests/Cargo.toml +++ b/Build/tests/Cargo.toml @@ -7,6 +7,9 @@ authors.workspace = true license.workspace = true publish = false +[lib] +path = "lib.rs" + [dependencies] saikuro-core = { workspace = true } saikuro-schema = { workspace = true } diff --git a/Build/tests/lib.rs b/Build/tests/lib.rs index 12274288..0c2147b5 100644 --- a/Build/tests/lib.rs +++ b/Build/tests/lib.rs @@ -1 +1,81 @@ -// Placeholder so the crate compiles +mod common; + +#[path = "saikuro-codegen"] +mod saikuro_codegen { + mod c_cpp_codegen; + mod codegen_output; +} + +#[path = "saikuro-core"] +mod saikuro_core { + mod cross_language_wire; + mod envelope_roundtrip; + mod error_propagation; + mod invocation; + mod resource; + mod value; +} + +#[path = "saikuro-exec"] +mod saikuro_exec { + mod embassy_cancellation; + mod embassy_executor; + mod exec_channels; + mod exec_concurrency; + mod exec_select; +} + +#[path = "saikuro-net"] +mod saikuro_net { + mod embassy_net_loopback; +} + +#[path = "saikuro-random"] +mod saikuro_random { + mod drbg; + mod drbg_unseeded; + mod os_backend; +} + +#[path = "saikuro-router"] +mod saikuro_router { + mod announce_dispatch; + mod batch_dispatch; + mod call_dispatch; + mod channel_dispatch; + mod log_dispatch; + mod provider_registry; + mod resource_dispatch; + mod sandbox_dispatch; + mod stream_dispatch; +} + +#[path = "saikuro-runtime"] +mod saikuro_runtime { + mod config_capacity; + mod schema_registration; +} + +#[path = "saikuro-schema"] +mod saikuro_schema { + mod capability_enforcement; + mod registry; + mod schema_validation; + mod validator; +} + +#[path = "saikuro-storage"] +mod saikuro_storage { + mod flash; + mod inmemory; + mod util; +} + +#[path = "saikuro-transport"] +mod saikuro_transport { + mod embedded_io; + mod transport_compliance; + mod transport_framing; + mod transport_memory_stress; + mod transport_wasm_host; +} diff --git a/Build/tests/saikuro-router/announce_dispatch.rs b/Build/tests/saikuro-router/announce_dispatch.rs index f47c4792..31aa699d 100644 --- a/Build/tests/saikuro-router/announce_dispatch.rs +++ b/Build/tests/saikuro-router/announce_dispatch.rs @@ -23,7 +23,7 @@ use saikuro_transport::{ traits::{Transport, TransportReceiver, TransportSender}, }; -mod common; +use crate::common; use common::{make_announce_envelope, round_trip_via_handler, simple_schema}; diff --git a/Build/tests/saikuro-router/channel_dispatch.rs b/Build/tests/saikuro-router/channel_dispatch.rs index 792fd4cf..6ccf7368 100644 --- a/Build/tests/saikuro-router/channel_dispatch.rs +++ b/Build/tests/saikuro-router/channel_dispatch.rs @@ -14,7 +14,7 @@ use saikuro_router::router::InvocationRouter; use saikuro_router::stream_state::{ChannelState, DeliveryOutcome}; use std::task::Poll; -mod common; +use crate::common; fn channel_item(id: InvocationId, seq: u64, value: Value) -> ResponseEnvelope { ResponseEnvelope { diff --git a/Build/tests/saikuro-router/resource_dispatch.rs b/Build/tests/saikuro-router/resource_dispatch.rs index f0d694b5..7624d612 100644 --- a/Build/tests/saikuro-router/resource_dispatch.rs +++ b/Build/tests/saikuro-router/resource_dispatch.rs @@ -14,7 +14,7 @@ use saikuro_router::{ }; use saikuro_schema::registry::SchemaRegistry; -mod common; +use crate::common; // Helpers diff --git a/Build/tests/saikuro-router/sandbox_dispatch.rs b/Build/tests/saikuro-router/sandbox_dispatch.rs index 57672576..9cc23cfe 100644 --- a/Build/tests/saikuro-router/sandbox_dispatch.rs +++ b/Build/tests/saikuro-router/sandbox_dispatch.rs @@ -2,34 +2,26 @@ use bytes::Bytes; use saikuro_core::{ - capability::{ CapabilitySet, CapabilityToken }, - envelope::{ Envelope, InvocationType }, + capability::{CapabilitySet, CapabilityToken}, + envelope::{Envelope, InvocationType}, schema::{ - FunctionMap, - FunctionSchema, - NamespaceMap, - NamespaceSchema, - PrimitiveType, - Schema, - TypeDescriptor, - TypeMap, - Visibility, + FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, + TypeDescriptor, TypeMap, Visibility, }, value::Value, - InvocationId, - ResponseEnvelope, - PROTOCOL_VERSION, + InvocationId, ResponseEnvelope, PROTOCOL_VERSION, +}; +use saikuro_router::{ + provider::ProviderRegistry, + router::{InvocationRouter, RouterConfig}, }; -use saikuro_router::{ provider::ProviderRegistry, router::{ InvocationRouter, RouterConfig } }; use saikuro_runtime::connection::ConnectionHandler; use saikuro_schema::{ - capability_engine::CapabilityEngine, - registry::SchemaRegistry, - validator::InvocationValidator, + capability_engine::CapabilityEngine, registry::SchemaRegistry, validator::InvocationValidator, }; use saikuro_transport::{ memory::MemoryTransport, - traits::{ Transport, TransportReceiver, TransportSender }, + traits::{Transport, TransportReceiver, TransportSender}, }; // Helpers @@ -37,51 +29,66 @@ use saikuro_transport::{ fn build_schema() -> Schema { let mut functions = FunctionMap::new(); functions - .insert("public_fn".to_owned(), FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }) + .insert( + "public_fn".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) .ok(); functions - .insert("internal_fn".to_owned(), FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Internal, - capabilities: vec![], - idempotent: false, - doc: None, - }) + .insert( + "internal_fn".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Internal, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) .ok(); functions - .insert("private_fn".to_owned(), FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Private, - capabilities: vec![], - idempotent: false, - doc: None, - }) + .insert( + "private_fn".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Private, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) .ok(); functions - .insert("guarded_fn".to_owned(), FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Public, - capabilities: vec![CapabilityToken::new("special.cap")], - idempotent: false, - doc: None, - }) + .insert( + "guarded_fn".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Public, + capabilities: vec![CapabilityToken::new("special.cap")], + idempotent: false, + doc: None, + }, + ) .ok(); let mut namespaces = NamespaceMap::new(); namespaces - .insert("svc".to_owned(), NamespaceSchema { - functions: Box::new(functions), - doc: None, - }) + .insert( + "svc".to_owned(), + NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + ) .ok(); Schema { version: 1, @@ -108,7 +115,7 @@ async fn run_and_collect( schema_registry: SchemaRegistry, peer_capabilities: CapabilitySet, sandbox: bool, - envelope: Envelope + envelope: Envelope, ) -> Vec { let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler"); let (handler_sender, handler_receiver) = handler_transport.split(); @@ -194,11 +201,13 @@ fn sandbox_filtered_schema_excludes_internal_functions() { let push: Envelope = rmp_serde::from_slice(&frames[1]).expect("decode pushed announce"); let schema_value = push.args.into_iter().next().expect("args[0] must exist"); let schema_bytes = rmp_serde::to_vec_named(&schema_value).expect("re-encode"); - let filtered: Schema = rmp_serde - ::from_slice(&schema_bytes) - .expect("decode filtered schema"); + let filtered: Schema = + rmp_serde::from_slice(&schema_bytes).expect("decode filtered schema"); - let svc = filtered.namespaces.get("svc").expect("svc namespace must be present"); + let svc = filtered + .namespaces + .get("svc") + .expect("svc namespace must be present"); assert!( !svc.functions.contains_key("internal_fn"), "Internal functions must be excluded from sandbox schema" @@ -220,9 +229,8 @@ fn sandbox_filtered_schema_excludes_private_functions() { let push: Envelope = rmp_serde::from_slice(&frames[1]).expect("decode pushed announce"); let schema_value = push.args.into_iter().next().expect("args[0]"); let schema_bytes = rmp_serde::to_vec_named(&schema_value).expect("re-encode"); - let filtered: Schema = rmp_serde - ::from_slice(&schema_bytes) - .expect("decode filtered schema"); + let filtered: Schema = + rmp_serde::from_slice(&schema_bytes).expect("decode filtered schema"); let svc = filtered.namespaces.get("svc").expect("svc namespace"); assert!( @@ -246,9 +254,8 @@ fn sandbox_filtered_schema_includes_public_no_cap_functions() { let push: Envelope = rmp_serde::from_slice(&frames[1]).expect("decode pushed announce"); let schema_value = push.args.into_iter().next().expect("args[0]"); let schema_bytes = rmp_serde::to_vec_named(&schema_value).expect("re-encode"); - let filtered: Schema = rmp_serde - ::from_slice(&schema_bytes) - .expect("decode filtered schema"); + let filtered: Schema = + rmp_serde::from_slice(&schema_bytes).expect("decode filtered schema"); let svc = filtered.namespaces.get("svc").expect("svc namespace"); assert!( @@ -273,9 +280,8 @@ fn sandbox_filtered_schema_excludes_functions_peer_lacks_caps_for() { let push: Envelope = rmp_serde::from_slice(&frames[1]).expect("decode pushed announce"); let schema_value = push.args.into_iter().next().expect("args[0]"); let schema_bytes = rmp_serde::to_vec_named(&schema_value).expect("re-encode"); - let filtered: Schema = rmp_serde - ::from_slice(&schema_bytes) - .expect("decode filtered schema"); + let filtered: Schema = + rmp_serde::from_slice(&schema_bytes).expect("decode filtered schema"); let svc = filtered.namespaces.get("svc").expect("svc namespace"); assert!( @@ -300,9 +306,8 @@ fn sandbox_filtered_schema_includes_functions_peer_has_caps_for() { let push: Envelope = rmp_serde::from_slice(&frames[1]).expect("decode pushed announce"); let schema_value = push.args.into_iter().next().expect("args[0]"); let schema_bytes = rmp_serde::to_vec_named(&schema_value).expect("re-encode"); - let filtered: Schema = rmp_serde - ::from_slice(&schema_bytes) - .expect("decode filtered schema"); + let filtered: Schema = + rmp_serde::from_slice(&schema_bytes).expect("decode filtered schema"); let svc = filtered.namespaces.get("svc").expect("svc namespace"); assert!( @@ -340,7 +345,9 @@ fn sandbox_handler_denies_internal_function_invocation() { let schema = build_schema(); // Pre-register the schema so the validator can find it. - registry.merge_schema(schema.clone(), "test-provider").expect("merge schema"); + registry + .merge_schema(schema.clone(), "test-provider") + .expect("merge schema"); // Build the Invoke envelope for the internal function. let invoke_env = Envelope { @@ -360,7 +367,10 @@ fn sandbox_handler_denies_internal_function_invocation() { assert_eq!(frames.len(), 1); let resp = ResponseEnvelope::from_msgpack(&frames[0]).expect("decode response"); - assert!(!resp.ok, "internal function invocation must be denied in sandbox mode"); + assert!( + !resp.ok, + "internal function invocation must be denied in sandbox mode" + ); let err = resp.error.expect("error detail must be present"); assert_eq!( err.code, diff --git a/Build/tests/saikuro-router/stream_dispatch.rs b/Build/tests/saikuro-router/stream_dispatch.rs index 575ea0a1..327339d3 100644 --- a/Build/tests/saikuro-router/stream_dispatch.rs +++ b/Build/tests/saikuro-router/stream_dispatch.rs @@ -1,8 +1,8 @@ //! Stream dispatch tests. -use futures::{ pin_mut, poll }; +use futures::{pin_mut, poll}; use saikuro_core::{ - envelope::{ Envelope, StreamControl }, + envelope::{Envelope, StreamControl}, error::ErrorCode, invocation::InvocationId, value::Value, @@ -10,10 +10,10 @@ use saikuro_core::{ }; use saikuro_router::provider::ProviderRegistry; use saikuro_router::router::InvocationRouter; -use saikuro_router::stream_state::{ DeliveryOutcome, StreamState }; +use saikuro_router::stream_state::{DeliveryOutcome, StreamState}; use std::task::Poll; -mod common; +use crate::common; // Tests @@ -23,15 +23,11 @@ fn stream_open_returns_ok_empty() { let (registry, mut work_rx) = common::make_provider("events"); // Consume work items (provider side). - saikuro_exec::spawn(async move { - while work_rx.recv().await.is_some() {} - }); + saikuro_exec::spawn(async move { while work_rx.recv().await.is_some() {} }); let router = InvocationRouter::with_providers(registry); - let env = Envelope::stream_open( - "events.subscribe", - vec![Value::String("topic".into())] - ).expect("entropy available"); + let env = Envelope::stream_open("events.subscribe", vec![Value::String("topic".into())]) + .expect("entropy available"); let resp = router.dispatch(env).await; assert!(resp.ok, "stream open should return ok"); @@ -52,9 +48,7 @@ fn route_stream_item_delivers_to_state() { let open_env = Envelope::stream_open("data.feed", vec![]).expect("entropy available"); let stream_id = open_env.id; - saikuro_exec::spawn(async move { - while work_rx.recv().await.is_some() {} - }); + saikuro_exec::spawn(async move { while work_rx.recv().await.is_some() {} }); let open_resp = router.dispatch(open_env).await; assert!(open_resp.ok); @@ -75,9 +69,7 @@ fn route_stream_end_removes_state() { let open_env = Envelope::stream_open("fin.feed", vec![]).expect("entropy available"); let stream_id = open_env.id; - saikuro_exec::spawn(async move { - while work_rx.recv().await.is_some() {} - }); + saikuro_exec::spawn(async move { while work_rx.recv().await.is_some() {} }); router.dispatch(open_env).await; @@ -127,9 +119,7 @@ fn multiple_streams_are_independent() { let (registry, mut work_rx) = common::make_provider("multi"); let router = InvocationRouter::with_providers(registry); - saikuro_exec::spawn(async move { - while work_rx.recv().await.is_some() {} - }); + saikuro_exec::spawn(async move { while work_rx.recv().await.is_some() {} }); // Open two streams. let env1 = Envelope::stream_open("multi.s1", vec![]).expect("entropy available"); @@ -166,9 +156,7 @@ fn out_of_order_item_is_dropped_not_panicked() { let (registry, mut work_rx) = common::make_provider("ooo"); let router = InvocationRouter::with_providers(registry); - saikuro_exec::spawn(async move { - while work_rx.recv().await.is_some() {} - }); + saikuro_exec::spawn(async move { while work_rx.recv().await.is_some() {} }); let env = Envelope::stream_open("ooo.feed", vec![]).expect("entropy available"); let id = env.id; @@ -191,9 +179,7 @@ fn stream_abort_control_removes_state() { let (registry, mut work_rx) = common::make_provider("abort"); let router = InvocationRouter::with_providers(registry); - saikuro_exec::spawn(async move { - while work_rx.recv().await.is_some() {} - }); + saikuro_exec::spawn(async move { while work_rx.recv().await.is_some() {} }); let env = Envelope::stream_open("abort.feed", vec![]).expect("entropy available"); let id = env.id; @@ -223,7 +209,9 @@ fn concurrent_stream_delivery_preserves_order_and_terminal_closure() { saikuro_exec::block_on(async { let id = InvocationId::new().expect("entropy available"); let (tx, mut rx) = saikuro_exec::mpsc::channel(saikuro_exec::ChannelCapacity::MIN); - tx.send(ResponseEnvelope::ok_empty(id)).await.expect("receiver remains open"); + tx.send(ResponseEnvelope::ok_empty(id)) + .await + .expect("receiver remains open"); let state = StreamState::new(tx); let first = state.deliver(ResponseEnvelope::stream_item(id, 0, Value::Int(0))); @@ -236,19 +224,21 @@ fn concurrent_stream_delivery_preserves_order_and_terminal_closure() { assert!(rx.recv().await.is_some()); assert_eq!(first.await, DeliveryOutcome::Delivered); - assert_eq!( - rx.recv().await.and_then(|response| response.seq), - Some(0) - ); + assert_eq!(rx.recv().await.and_then(|response| response.seq), Some(0)); assert_eq!(terminal.await, DeliveryOutcome::Terminal); let end = rx.recv().await.expect("terminal frame is delivered"); assert_eq!(end.seq, Some(1)); assert_eq!(end.stream_control, Some(StreamControl::End)); assert_eq!( - state.deliver(ResponseEnvelope::stream_item(id, 2, Value::Int(2))).await, + state + .deliver(ResponseEnvelope::stream_item(id, 2, Value::Int(2))) + .await, DeliveryOutcome::Closed ); - assert!(rx.try_recv().is_err(), "post-terminal frame was not delivered"); + assert!( + rx.try_recv().is_err(), + "post-terminal frame was not delivered" + ); }) } diff --git a/Build/tests/saikuro-storage/flash.rs b/Build/tests/saikuro-storage/flash.rs index 58a5ac3a..65cbb1ed 100644 --- a/Build/tests/saikuro-storage/flash.rs +++ b/Build/tests/saikuro-storage/flash.rs @@ -72,8 +72,16 @@ mod mock { } async fn write(&self, offset: u32, bytes: &[u8]) -> Result<(), Self::Error> { - assert_eq!(offset as usize % WRITE_SIZE, 0, "write must be word-aligned"); - assert_eq!(bytes.len() % WRITE_SIZE, 0, "write length must be a word multiple"); + assert_eq!( + offset as usize % WRITE_SIZE, + 0, + "write must be word-aligned" + ); + assert_eq!( + bytes.len() % WRITE_SIZE, + 0, + "write length must be a word multiple" + ); let mut cells = self.cells.borrow_mut(); let mut written = self.written.borrow_mut(); for (i, &b) in bytes.iter().enumerate() { @@ -378,7 +386,10 @@ fn quota_exceeded_when_region_full_then_recoverable() { } assert!(count < 1000, "never hit the quota"); } - assert!(count >= 5, "expected a handful of items before full, got {count}"); + assert!( + count >= 5, + "expected a handful of items before full, got {count}" + ); // Freeing space makes the region writable again. for i in 0..count / 2 { store.delete("ns", &format!("k{i}")).await.unwrap(); diff --git a/Build/tests/saikuro-transport/transport_memory_stress.rs b/Build/tests/saikuro-transport/transport_memory_stress.rs index 7fdf124b..580b3723 100644 --- a/Build/tests/saikuro-transport/transport_memory_stress.rs +++ b/Build/tests/saikuro-transport/transport_memory_stress.rs @@ -4,13 +4,10 @@ //! concurrency, rapid connect-disconnect cycles, and backpressure //! scenarios specific to the in-memory channel backend. -use bytes::Bytes; -use saikuro_exec::sync::Barrier; use saikuro_transport::{ memory::MemoryTransport, traits::{Transport, TransportReceiver, TransportSender}, }; -use std::sync::Arc; // HIGH-VOLUME THROUGHPUT From 47df699669febdbb6eb3733a9742d19c58241314 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Wed, 19 Aug 2026 12:01:47 -0600 Subject: [PATCH 41/43] Tests pass --- Build/Cargo.lock | 7 +- Build/tests/Cargo.toml | 18 +- Build/tests/common/mod.rs | 25 +- Build/tests/lib.rs | 24 +- Build/tests/saikuro-codegen/c_cpp_codegen.rs | 5 +- Build/tests/saikuro-codegen/codegen_output.rs | 7 +- .../tests/saikuro-core/cross_language_wire.rs | 208 +++++++----- .../tests/saikuro-core/envelope_roundtrip.rs | 6 +- Build/tests/saikuro-core/error_propagation.rs | 16 +- Build/tests/saikuro-exec/exec_channels.rs | 14 +- .../tests/saikuro-router/announce_dispatch.rs | 24 +- Build/tests/saikuro-router/batch_dispatch.rs | 19 +- Build/tests/saikuro-router/call_dispatch.rs | 25 +- .../tests/saikuro-router/channel_dispatch.rs | 59 ++-- Build/tests/saikuro-router/log_dispatch.rs | 59 ++-- .../tests/saikuro-router/provider_registry.rs | 119 ++++--- .../tests/saikuro-router/resource_dispatch.rs | 21 +- .../tests/saikuro-router/sandbox_dispatch.rs | 13 +- Build/tests/saikuro-router/stream_dispatch.rs | 19 +- .../saikuro-runtime/schema_registration.rs | 152 +++++---- Build/tests/saikuro-schema/registry.rs | 85 +++-- .../tests/saikuro-schema/schema_validation.rs | 318 ++++++++++-------- Build/tests/saikuro-schema/validator.rs | 18 +- Build/tests/saikuro-storage/inmemory.rs | 44 ++- .../saikuro-transport/transport_compliance.rs | 15 +- .../saikuro-transport/transport_framing.rs | 36 +- .../transport_memory_stress.rs | 34 +- 27 files changed, 787 insertions(+), 603 deletions(-) diff --git a/Build/Cargo.lock b/Build/Cargo.lock index 88d88954..9580798a 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -1660,8 +1660,11 @@ dependencies = [ name = "saikuro-tests" version = "0.1.0" dependencies = [ + "async-trait", "bytes", + "embedded-storage-async", "futures", + "futures-executor", "js-sys", "rmp-serde", "saikuro", @@ -1673,11 +1676,11 @@ dependencies = [ "saikuro-router", "saikuro-runtime", "saikuro-schema", + "saikuro-storage", "saikuro-transport", "serde", "serde_json", - "tracing", - "tracing-subscriber", + "tokio", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", diff --git a/Build/tests/Cargo.toml b/Build/tests/Cargo.toml index 137e1a5f..b089adb7 100644 --- a/Build/tests/Cargo.toml +++ b/Build/tests/Cargo.toml @@ -10,26 +10,34 @@ publish = false [lib] path = "lib.rs" +[features] +flash = ["saikuro-storage/embedded", "dep:embedded-storage-async", "dep:futures-executor"] + [dependencies] saikuro-core = { workspace = true } saikuro-schema = { workspace = true } saikuro-router = { workspace = true } saikuro-codegen = { workspace = true } -saikuro-event = { workspace = true } -saikuro = { workspace = true } -saikuro-exec = { workspace = true } +saikuro-event = { workspace = true } +saikuro = { workspace = true, features = ["std"] } +saikuro-exec = { workspace = true, features = ["native"] } +saikuro-storage = { workspace = true, features = ["inmemory", "std"] } bytes = { workspace = true } futures = { workspace = true } rmp-serde = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } +async-trait = { workspace = true } + +embedded-storage-async = { workspace = true, optional = true } +futures-executor = { workspace = true, optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] saikuro-transport = { workspace = true, features = ["tcp", "unix"] } saikuro-runtime = { workspace = true, features = ["native"] } +saikuro-random = { workspace = true } +tokio = { workspace = true, features = ["net", "io-util", "rt", "macros"] } [target.'cfg(target_arch = "wasm32")'.dependencies] saikuro-core = { workspace = true, features = ["wasm"] } diff --git a/Build/tests/common/mod.rs b/Build/tests/common/mod.rs index d62a2faa..eb1c83ed 100644 --- a/Build/tests/common/mod.rs +++ b/Build/tests/common/mod.rs @@ -6,9 +6,9 @@ use saikuro_core::{ FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, TypeMap, Visibility, }, - value::Value, RegistrationToken, ResponseEnvelope, }; +use saikuro_event::Value; use saikuro_exec::mpsc; use saikuro_router::{ provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, @@ -18,12 +18,16 @@ use saikuro_runtime::connection::ConnectionHandler; use saikuro_schema::{ capability_engine::CapabilityEngine, registry::SchemaRegistry, validator::InvocationValidator, }; -use saikuro_transport::{ - memory::MemoryTransport, - traits::{Transport, TransportReceiver, TransportSender}, -}; +use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; +use std::sync::Arc; + +pub fn null_log() -> Arc { + Arc::from(Box::new(saikuro_event::NullSink) as Box) +} -pub fn make_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver) { +pub async fn make_provider( + namespace: &str, +) -> (ProviderRegistry, mpsc::Receiver) { let (work_tx, work_rx) = mpsc::channel::( saikuro_exec::ChannelCapacity::try_from(64).expect("64 is a valid channel capacity"), ); @@ -33,7 +37,7 @@ pub fn make_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver Envelope { Envelope::announce(schema_to_value(schema)).expect("entropy available") } -pub fn register_namespace(registry: &SchemaRegistry, namespace: &str, function: &str) { +pub async fn register_namespace(registry: &SchemaRegistry, namespace: &str, function: &str) { registry .merge_schema(simple_schema(namespace, function), "test-provider") + .await .expect("merge_schema must succeed"); } @@ -89,7 +94,8 @@ pub async fn round_trip_via_handler( provider_registry: ProviderRegistry, envelope: Envelope, ) -> ResponseEnvelope { - let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler"); + let log = null_log(); + let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler", log.clone()); let (handler_sender, handler_receiver) = handler_transport.split(); let (mut test_sender, mut test_receiver) = test_transport.split(); @@ -109,6 +115,7 @@ pub async fn round_trip_via_handler( max_message_size: 4 * 1024 * 1024, schema_registry, provider_registry, + log, }; let frame = Bytes::from(envelope.to_msgpack().expect("encode envelope")); diff --git a/Build/tests/lib.rs b/Build/tests/lib.rs index 0c2147b5..a0a32e7a 100644 --- a/Build/tests/lib.rs +++ b/Build/tests/lib.rs @@ -1,13 +1,13 @@ mod common; #[path = "saikuro-codegen"] -mod saikuro_codegen { +mod codegen_tests { mod c_cpp_codegen; mod codegen_output; } #[path = "saikuro-core"] -mod saikuro_core { +mod core_tests { mod cross_language_wire; mod envelope_roundtrip; mod error_propagation; @@ -17,7 +17,7 @@ mod saikuro_core { } #[path = "saikuro-exec"] -mod saikuro_exec { +mod exec_tests { mod embassy_cancellation; mod embassy_executor; mod exec_channels; @@ -26,19 +26,19 @@ mod saikuro_exec { } #[path = "saikuro-net"] -mod saikuro_net { +mod net_tests { mod embassy_net_loopback; } #[path = "saikuro-random"] -mod saikuro_random { +mod random_tests { mod drbg; mod drbg_unseeded; mod os_backend; } #[path = "saikuro-router"] -mod saikuro_router { +mod router_tests { mod announce_dispatch; mod batch_dispatch; mod call_dispatch; @@ -51,13 +51,13 @@ mod saikuro_router { } #[path = "saikuro-runtime"] -mod saikuro_runtime { +mod runtime_tests { mod config_capacity; mod schema_registration; } #[path = "saikuro-schema"] -mod saikuro_schema { +mod schema_tests { mod capability_enforcement; mod registry; mod schema_validation; @@ -65,17 +65,19 @@ mod saikuro_schema { } #[path = "saikuro-storage"] -mod saikuro_storage { +mod storage_tests { + #[cfg(feature = "flash")] mod flash; mod inmemory; mod util; } #[path = "saikuro-transport"] -mod saikuro_transport { +mod transport_tests { + #[cfg(feature = "embedded-io")] mod embedded_io; mod transport_compliance; - mod transport_framing; mod transport_memory_stress; + #[cfg(target_arch = "wasm32")] mod transport_wasm_host; } diff --git a/Build/tests/saikuro-codegen/c_cpp_codegen.rs b/Build/tests/saikuro-codegen/c_cpp_codegen.rs index 58372bdb..37728b74 100644 --- a/Build/tests/saikuro-codegen/c_cpp_codegen.rs +++ b/Build/tests/saikuro-codegen/c_cpp_codegen.rs @@ -1,6 +1,5 @@ -use saikuro_codegen::{ - c::CGenerator, cpp::CppGenerator, generator::BindingGenerator, GeneratorOutput, -}; +use saikuro_codegen::language::{c::CGenerator, cpp::CppGenerator}; +use saikuro_codegen::{BindingGenerator, GeneratorOutput}; use saikuro_core::schema::{ FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility, }; diff --git a/Build/tests/saikuro-codegen/codegen_output.rs b/Build/tests/saikuro-codegen/codegen_output.rs index bbf75c60..ae021de7 100644 --- a/Build/tests/saikuro-codegen/codegen_output.rs +++ b/Build/tests/saikuro-codegen/codegen_output.rs @@ -1,9 +1,10 @@ //! Code generation output tests -use saikuro_codegen::{ - csharp::CSharpGenerator, generator::BindingGenerator, python::PythonGenerator, - rust::RustGenerator, typescript::TypeScriptGenerator, +use saikuro_codegen::language::{ + csharp::CSharpGenerator, python::PythonGenerator, rust::RustGenerator, + typescript::TypeScriptGenerator, }; +use saikuro_codegen::BindingGenerator; use saikuro_core::schema::{ ArgumentDescriptor, FieldDescriptor, FieldMap, FunctionMap, FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDefinition, TypeDescriptor, Visibility, diff --git a/Build/tests/saikuro-core/cross_language_wire.rs b/Build/tests/saikuro-core/cross_language_wire.rs index e28fb3cb..9280986d 100644 --- a/Build/tests/saikuro-core/cross_language_wire.rs +++ b/Build/tests/saikuro-core/cross_language_wire.rs @@ -4,19 +4,15 @@ use bytes::Bytes; use saikuro_core::{ capability::CapabilitySet, envelope::{Envelope, InvocationType}, - error::ErrorCode, schema::{ - FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, - TypeDescriptor, TypeMap, Visibility, + ArgumentDescriptor, FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, + PrimitiveType, Schema, TypeDescriptor, TypeMap, Visibility, }, - value::Value, InvocationId, ResponseEnvelope, PROTOCOL_VERSION, }; -use saikuro_runtime::runtime::SaikuroRuntime; -use saikuro_transport::{ - memory::MemoryTransport, - traits::{Transport, TransportReceiver, TransportSender}, -}; +use saikuro_event::{ErrorCode, Value}; +use saikuro_runtime::SaikuroRuntime; +use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; // Shared helpers @@ -42,7 +38,6 @@ fn decode_envelope(frame: Bytes) -> Envelope { /// caller sends, which keeps the helper broadly reusable across tests that /// focus on routing / wire fidelity rather than argument validation. fn make_schema_with_args(namespace: &str, function: &str, n_args: usize) -> Schema { - use saikuro_core::schema::ArgumentDescriptor; let args = (0..n_args) .map(|i| ArgumentDescriptor { name: format!("arg{i}"), @@ -106,8 +101,10 @@ fn connect_simulated_peer( impl TransportSender + 'static, impl TransportReceiver + 'static, ) { + let log: std::sync::Arc = + std::sync::Arc::from(Box::new(saikuro_event::NullSink) as Box); let (test_transport, runtime_transport) = - MemoryTransport::pair(peer_id, format!("{peer_id}-runtime")); + MemoryTransport::pair(peer_id, format!("{peer_id}-runtime"), log); let (test_sender, test_receiver) = test_transport.split(); handle.accept_transport( runtime_transport, @@ -125,26 +122,29 @@ fn connect_simulated_peer( #[test] fn a_rust_provider_simulated_client_call() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); // 1: Register a Rust in-process provider for `math`. let schema = make_schema_with_args("math", "add", 2); handle .register_schema(schema, "math-provider") + .await .expect("register schema"); - handle.register_fn_provider("math-provider", vec!["math".to_owned()], |env| async move { - let a = match env.args.first() { - Some(Value::Int(n)) => *n, - _ => 0, - }; - let b = match env.args.get(1) { - Some(Value::Int(n)) => *n, - _ => 0, - }; - ResponseEnvelope::ok(env.id, Value::Int(a + b)) - }); + handle + .register_fn_provider("math-provider", vec!["math".to_owned()], |env| async move { + let a = match env.args.first() { + Some(Value::Int(n)) => *n, + _ => 0, + }; + let b = match env.args.get(1) { + Some(Value::Int(n)) => *n, + _ => 0, + }; + ResponseEnvelope::ok(env.id, Value::Int(a + b)) + }) + .await; // 2: Connect a simulated adapter peer. let (mut tx, mut rx) = connect_simulated_peer(&handle, "py-client"); @@ -174,22 +174,25 @@ fn a_rust_provider_simulated_client_call() { #[test] fn l_csharp_style_client_wire_fidelity() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); // Register a provider that returns the length of a byte slice. let schema = make_schema_with_args("buf", "len", 1); handle .register_schema(schema, "buf-provider") + .await .expect("register schema"); - handle.register_fn_provider("buf-provider", vec!["buf".to_owned()], |env| async move { - let n = match env.args.first() { - Some(Value::Bytes(b)) => b.len() as i64, - Some(Value::String(s)) => s.len() as i64, - _ => 0, - }; - ResponseEnvelope::ok(env.id, Value::Int(n)) - }); + handle + .register_fn_provider("buf-provider", vec!["buf".to_owned()], |env| async move { + let n = match env.args.first() { + Some(Value::Bytes(b)) => b.len() as i64, + Some(Value::String(s)) => s.len() as i64, + _ => 0, + }; + ResponseEnvelope::ok(env.id, Value::Int(n)) + }) + .await; let (mut tx, mut rx) = connect_simulated_peer(&handle, "cs-client"); @@ -221,28 +224,34 @@ fn m_rust_adapter_client_calls_runtime_provider() { use saikuro::transport::InMemoryTransport; use saikuro::Client; - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); // Register a Rust in-process provider for `nums.negate`. let schema = make_schema_with_args("nums", "negate", 1); handle .register_schema(schema, "nums-provider") + .await .expect("register schema"); - handle.register_fn_provider("nums-provider", vec!["nums".to_owned()], |env| async move { - let n = match env.args.first() { - Some(Value::Int(n)) => *n, - _ => 0, - }; - ResponseEnvelope::ok(env.id, Value::Int(-n)) - }); + handle + .register_fn_provider("nums-provider", vec!["nums".to_owned()], |env| async move { + let n = match env.args.first() { + Some(Value::Int(n)) => *n, + _ => 0, + }; + ResponseEnvelope::ok(env.id, Value::Int(-n)) + }) + .await; // Create an InMemoryTransport pair and bridge to the runtime. let (client_side, bridge_side) = InMemoryTransport::pair(); let (mut bridge_sender, mut bridge_receiver) = { + let bridge_log: std::sync::Arc = std::sync::Arc::from( + Box::new(saikuro_event::NullSink) as Box, + ); let (ts, tr) = - saikuro_transport::memory::MemoryTransport::pair("m-bridge", "m-bridge-rt"); + saikuro_transport::MemoryTransport::pair("m-bridge", "m-bridge-rt", bridge_log); handle.accept_transport( tr, "m-rust-client".to_owned(), @@ -302,7 +311,7 @@ fn n_rust_adapter_provider_serves_simulated_client() { use saikuro::{ArgDescriptor, FunctionSchema, Provider, RegisterOptions}; use saikuro_core::schema::{PrimitiveType, TypeDescriptor, Visibility}; - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); // Create an InMemoryTransport pair for provider <-> runtime communication. @@ -310,8 +319,11 @@ fn n_rust_adapter_provider_serves_simulated_client() { // Bridge the InMemoryTransport to the runtime's MemoryTransport. let (mut bridge_sender, mut bridge_receiver) = { + let bridge_log: std::sync::Arc = std::sync::Arc::from( + Box::new(saikuro_event::NullSink) as Box, + ); let (ts, tr) = - saikuro_transport::memory::MemoryTransport::pair("n-bridge", "n-bridge-rt"); + saikuro_transport::MemoryTransport::pair("n-bridge", "n-bridge-rt", bridge_log); handle.accept_transport( tr, "n-rust-provider".to_owned(), @@ -416,7 +428,7 @@ fn n_rust_adapter_provider_serves_simulated_client() { #[test] fn b_simulated_provider_rust_client_dispatch() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); // 1: Connect a simulated Python provider. @@ -478,17 +490,20 @@ fn b_simulated_provider_rust_client_dispatch() { #[test] fn c_rust_and_simulated_providers_coexist() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); // Rust provider for `svc` let svc_schema = make_schema("svc", "ping"); handle .register_schema(svc_schema, "svc-provider") + .await .expect("register svc schema"); - handle.register_fn_provider("svc-provider", vec!["svc".to_owned()], |env| async move { - ResponseEnvelope::ok(env.id, Value::String("pong".into())) - }); + handle + .register_fn_provider("svc-provider", vec!["svc".to_owned()], |env| async move { + ResponseEnvelope::ok(env.id, Value::String("pong".into())) + }) + .await; // Simulated external provider for `ext` let (mut ext_tx, mut ext_rx) = connect_simulated_peer(&handle, "ext-provider"); @@ -559,22 +574,25 @@ fn c_rust_and_simulated_providers_coexist() { #[test] fn d_batch_call_from_simulated_client() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); // Register a simple identity provider for `items`. let schema = make_schema_with_args("items", "get", 1); handle .register_schema(schema, "items-provider") + .await .expect("register schema"); - handle.register_fn_provider( - "items-provider", - vec!["items".to_owned()], - |env| async move { - let val = env.args.first().cloned().unwrap_or(Value::Null); - ResponseEnvelope::ok(env.id, val) - }, - ); + handle + .register_fn_provider( + "items-provider", + vec!["items".to_owned()], + |env| async move { + let val = env.args.first().cloned().unwrap_or(Value::Null); + ResponseEnvelope::ok(env.id, val) + }, + ) + .await; let (mut tx, mut rx) = connect_simulated_peer(&handle, "batch-client"); @@ -623,7 +641,7 @@ fn d_batch_call_from_simulated_client() { #[test] fn e_call_unknown_namespace_returns_error_on_wire() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); let (mut tx, mut rx) = connect_simulated_peer(&handle, "err-client"); @@ -655,7 +673,7 @@ fn e_call_unknown_namespace_returns_error_on_wire() { #[test] fn e_malformed_frame_returns_error_on_wire() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); let (mut tx, mut rx) = connect_simulated_peer(&handle, "bad-client"); @@ -687,7 +705,7 @@ fn e_malformed_frame_returns_error_on_wire() { #[test] fn f_announce_then_client_call_round_trip() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); // Simulated provider @@ -748,18 +766,21 @@ fn f_announce_then_client_call_round_trip() { #[test] fn g_concurrent_simulated_clients() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); // Register a provider that returns the input value. let schema = make_schema_with_args("echo", "run", 1); handle .register_schema(schema, "echo-provider") + .await .expect("register schema"); - handle.register_fn_provider("echo-provider", vec!["echo".to_owned()], |env| async move { - let val = env.args.first().cloned().unwrap_or(Value::Null); - ResponseEnvelope::ok(env.id, val) - }); + handle + .register_fn_provider("echo-provider", vec!["echo".to_owned()], |env| async move { + let val = env.args.first().cloned().unwrap_or(Value::Null); + ResponseEnvelope::ok(env.id, val) + }) + .await; const N: usize = 10; let mut tasks = Vec::with_capacity(N); @@ -793,22 +814,25 @@ fn g_concurrent_simulated_clients() { #[test] fn h_cast_fire_and_forget_returns_ok_empty() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); // Register a no-op provider so the schema validator can find the function. let schema = make_schema_with_args("logger", "info", 1); handle .register_schema(schema, "logger-provider") + .await .expect("register schema"); - handle.register_fn_provider( - "logger-provider", - vec!["logger".to_owned()], - |env| async move { - // Cast providers receive the work item but do not need to respond. - ResponseEnvelope::ok_empty(env.id) - }, - ); + handle + .register_fn_provider( + "logger-provider", + vec!["logger".to_owned()], + |env| async move { + // Cast providers receive the work item but do not need to respond. + ResponseEnvelope::ok_empty(env.id) + }, + ) + .await; let (mut tx, mut rx) = connect_simulated_peer(&handle, "cast-client"); @@ -834,7 +858,7 @@ fn h_cast_fire_and_forget_returns_ok_empty() { #[test] fn i_provider_reconnect_and_reannounce() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); // First provider instance @@ -926,21 +950,24 @@ fn i_provider_reconnect_and_reannounce() { #[test] fn j_typescript_style_client_wire_fidelity() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); // Register a provider that uppercases a string. let schema = make_schema_with_args("str", "upper", 1); handle .register_schema(schema, "str-provider") + .await .expect("register schema"); - handle.register_fn_provider("str-provider", vec!["str".to_owned()], |env| async move { - let s = match env.args.first() { - Some(Value::String(s)) => s.to_uppercase(), - _ => String::new(), - }; - ResponseEnvelope::ok(env.id, Value::String(s)) - }); + handle + .register_fn_provider("str-provider", vec!["str".to_owned()], |env| async move { + let s = match env.args.first() { + Some(Value::String(s)) => s.to_uppercase(), + _ => String::new(), + }; + ResponseEnvelope::ok(env.id, Value::String(s)) + }) + .await; let (mut tx, mut rx) = connect_simulated_peer(&handle, "ts-client"); @@ -969,18 +996,21 @@ fn j_typescript_style_client_wire_fidelity() { #[test] fn k_response_id_always_matches_request_id() { saikuro_exec::block_on(async { - let runtime = SaikuroRuntime::builder().build(); + let runtime = SaikuroRuntime::builder().build().await; let handle = runtime.handle(); let schema = make_schema("id_check", "fn"); handle .register_schema(schema, "idcheck-provider") + .await .expect("register schema"); - handle.register_fn_provider( - "idcheck-provider", - vec!["id_check".to_owned()], - |env| async move { ResponseEnvelope::ok(env.id, Value::Null) }, - ); + handle + .register_fn_provider( + "idcheck-provider", + vec!["id_check".to_owned()], + |env| async move { ResponseEnvelope::ok(env.id, Value::Null) }, + ) + .await; let (mut tx, mut rx) = connect_simulated_peer(&handle, "id-client"); diff --git a/Build/tests/saikuro-core/envelope_roundtrip.rs b/Build/tests/saikuro-core/envelope_roundtrip.rs index 59fd611a..57658b6f 100644 --- a/Build/tests/saikuro-core/envelope_roundtrip.rs +++ b/Build/tests/saikuro-core/envelope_roundtrip.rs @@ -3,11 +3,9 @@ use saikuro_core::{ capability::CapabilityToken, envelope::{Envelope, InvocationType, ResponseEnvelope, StreamControl}, - error::{ErrorCode, ErrorDetail}, - invocation::InvocationId, - value::{Value, ValueMap}, - PROTOCOL_VERSION, + InvocationId, PROTOCOL_VERSION, }; +use saikuro_event::{ErrorCode, ErrorDetail, Value, ValueMap}; // Helpers diff --git a/Build/tests/saikuro-core/error_propagation.rs b/Build/tests/saikuro-core/error_propagation.rs index 181045f0..8da1bab0 100644 --- a/Build/tests/saikuro-core/error_propagation.rs +++ b/Build/tests/saikuro-core/error_propagation.rs @@ -1,11 +1,7 @@ //! Error propagation tests -use saikuro_core::{ - error::{ErrorCode, ErrorDetail, SaikuroError}, - invocation::InvocationId, - value::Value, - ResponseEnvelope, -}; +use saikuro_core::{InvocationId, ResponseEnvelope}; +use saikuro_event::{ErrorCode, ErrorDetail, SaikuroError, Value}; // SaikuroError -> ErrorDetail conversion @@ -156,9 +152,9 @@ fn internal_error_maps_correctly() { #[test] fn error_detail_with_detail_accumulates_entries() { let detail = ErrorDetail::new(ErrorCode::ProviderError, "something went wrong") - .with_detail("field", Value::String("arg_a".into())) + .with_context("field", Value::String("arg_a".into())) .unwrap() - .with_detail("line", Value::Int(42)) + .with_context("line", Value::Int(42)) .unwrap(); assert_eq!(detail.details["field"], Value::String("arg_a".into())); @@ -179,7 +175,7 @@ fn error_detail_display_includes_code_and_message() { fn error_response_survives_msgpack_roundtrip() { let id = InvocationId::new().expect("entropy available"); let detail = ErrorDetail::new(ErrorCode::InvalidArguments, "bad types") - .with_detail("arg", Value::String("x".into())) + .with_context("arg", Value::String("x".into())) .unwrap(); let resp = ResponseEnvelope::err(id, detail.clone()); @@ -250,7 +246,7 @@ fn provider_returns_error_response_to_caller() { ); let handle = ProviderHandle::new("failing", vec!["fail".to_owned()], work_tx); let registry = ProviderRegistry::new(); - registry.register(handle); + registry.register(handle).await; // Provider always returns an error. saikuro_exec::spawn(async move { diff --git a/Build/tests/saikuro-exec/exec_channels.rs b/Build/tests/saikuro-exec/exec_channels.rs index c9738bbe..d39d8a68 100644 --- a/Build/tests/saikuro-exec/exec_channels.rs +++ b/Build/tests/saikuro-exec/exec_channels.rs @@ -281,7 +281,7 @@ fn watch_send_and_borrow() { saikuro_exec::block_on(async { let (tx, rx) = watch::channel(0u32); tx.send(42).unwrap(); - assert_eq!(*rx.borrow(), 42); + assert_eq!(rx.borrow(), 42); }) } @@ -292,7 +292,7 @@ fn watch_send_and_changed() { tx.send(1).unwrap(); // changed() should return immediately since the value has changed. rx.changed().await.unwrap(); - assert_eq!(*rx.borrow(), 1); + assert_eq!(rx.borrow(), 1); }) } @@ -308,7 +308,7 @@ fn watch_changed_blocks_until_next_update() { }); // This should block until the spawned task sends. rx.changed().await.unwrap(); - assert_eq!(*rx.borrow(), 99); + assert_eq!(rx.borrow(), 99); handle.await.unwrap(); }) } @@ -317,7 +317,7 @@ fn watch_changed_blocks_until_next_update() { fn watch_initial_value_available() { saikuro_exec::block_on(async { let (_tx, rx) = watch::channel("hello"); - assert_eq!(*rx.borrow(), "hello"); + assert_eq!(rx.borrow(), "hello"); }) } @@ -327,8 +327,8 @@ fn watch_multiple_receivers() { let (tx, rx1) = watch::channel(0i32); let rx2 = rx1.clone(); tx.send(10).unwrap(); - assert_eq!(*rx1.borrow(), 10); - assert_eq!(*rx2.borrow(), 10); + assert_eq!(rx1.borrow(), 10); + assert_eq!(rx2.borrow(), 10); }) } @@ -349,6 +349,6 @@ fn watch_borrow_returns_last_value() { let (tx, rx) = watch::channel(1u64); tx.send(2).unwrap(); tx.send(3).unwrap(); - assert_eq!(*rx.borrow(), 3); + assert_eq!(rx.borrow(), 3); }) } diff --git a/Build/tests/saikuro-router/announce_dispatch.rs b/Build/tests/saikuro-router/announce_dispatch.rs index 31aa699d..22fcb2e8 100644 --- a/Build/tests/saikuro-router/announce_dispatch.rs +++ b/Build/tests/saikuro-router/announce_dispatch.rs @@ -4,9 +4,9 @@ use bytes::Bytes; use saikuro_core::{ capability::CapabilitySet, envelope::{Envelope, InvocationType}, - value::Value, InvocationId, ResponseEnvelope, PROTOCOL_VERSION, }; +use saikuro_event::Value; use saikuro_exec::mpsc; use saikuro_router::{ provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, @@ -18,10 +18,7 @@ use saikuro_schema::{ registry::{RegistryMode, SchemaRegistry}, validator::InvocationValidator, }; -use saikuro_transport::{ - memory::MemoryTransport, - traits::{Transport, TransportReceiver, TransportSender}, -}; +use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; use crate::common; @@ -38,7 +35,9 @@ async fn round_trip_while_alive( provider_registry: ProviderRegistry, envelope: Envelope, ) -> ResponseEnvelope { - let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler"); + let log: std::sync::Arc = + std::sync::Arc::from(Box::new(saikuro_event::NullSink) as Box); + let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler", log.clone()); let (handler_sender, handler_receiver) = handler_transport.split(); let (mut test_sender, mut test_receiver) = test_transport.split(); @@ -58,6 +57,7 @@ async fn round_trip_while_alive( max_message_size: 4 * 1024 * 1024, schema_registry, provider_registry, + log, }; // Spawn the handler so we can interleave reads/writes. @@ -92,7 +92,7 @@ fn announce_registers_namespace_in_schema() { let providers = ProviderRegistry::new(); assert!( - !registry.has_namespace("math"), + !registry.has_namespace("math").await, "registry must be empty before announce" ); @@ -146,8 +146,8 @@ fn announce_allows_subsequent_calls_to_not_fail_schema_validation() { fn announce_in_production_mode_returns_error() { saikuro_exec::block_on(async { let registry = SchemaRegistry::new(); - registry.freeze(); // switch to production mode - assert_eq!(registry.mode(), RegistryMode::Production); + registry.freeze().await; // switch to production mode + assert_eq!(registry.mode().await, RegistryMode::Production); let providers = ProviderRegistry::new(); let schema = simple_schema("frozen", "op"); @@ -166,7 +166,7 @@ fn announce_in_production_mode_returns_error() { ); // Namespace must not have been registered. assert!( - !registry.has_namespace("frozen"), + !registry.has_namespace("frozen").await, "namespace must not appear after a rejected announce" ); }) @@ -254,7 +254,7 @@ fn announce_does_not_route_to_provider() { ); let handle = ProviderHandle::new("interceptor", vec!["$saikuro".to_owned()], work_tx); let providers = ProviderRegistry::new(); - providers.register(handle); + providers.register(handle).await; let schema = simple_schema("intercept_test", "fn"); let env = make_announce_envelope(&schema); @@ -266,7 +266,7 @@ fn announce_does_not_route_to_provider() { resp.error ); assert!( - work_rx.try_recv().is_err(), + matches!(work_rx.recv().await, None), "announce must NOT be forwarded to any provider channel" ); }) diff --git a/Build/tests/saikuro-router/batch_dispatch.rs b/Build/tests/saikuro-router/batch_dispatch.rs index 39c6f0b1..d57853c3 100644 --- a/Build/tests/saikuro-router/batch_dispatch.rs +++ b/Build/tests/saikuro-router/batch_dispatch.rs @@ -2,10 +2,9 @@ use saikuro_core::{ envelope::{Envelope, InvocationType}, - error::ErrorCode, - value::Value, ResponseEnvelope, }; +use saikuro_event::{ErrorCode, Value}; use saikuro_exec::mpsc; use saikuro_router::{ provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, @@ -14,7 +13,7 @@ use saikuro_router::{ // Helpers -fn register_echo_provider(registry: &ProviderRegistry, namespace: &str, response: Value) { +async fn register_echo_provider(registry: &ProviderRegistry, namespace: &str, response: Value) { let (work_tx, work_rx) = mpsc::channel::( saikuro_exec::ChannelCapacity::try_from(64).expect("64 is a valid channel capacity"), ); @@ -23,7 +22,7 @@ fn register_echo_provider(registry: &ProviderRegistry, namespace: &str, response vec![namespace.to_owned()], work_tx, ); - registry.register(handle); + registry.register(handle).await; // Spawn a background responder. saikuro_exec::spawn({ @@ -45,7 +44,7 @@ fn register_echo_provider(registry: &ProviderRegistry, namespace: &str, response fn batch_with_single_item_succeeds() { saikuro_exec::block_on(async { let registry = ProviderRegistry::new(); - register_echo_provider(®istry, "math", Value::Int(7)); + register_echo_provider(®istry, "math", Value::Int(7)).await; let router = InvocationRouter::with_providers(registry); @@ -71,7 +70,7 @@ fn batch_with_single_item_succeeds() { fn batch_with_multiple_items_returns_all_results() { saikuro_exec::block_on(async { let registry = ProviderRegistry::new(); - register_echo_provider(®istry, "svc", Value::Int(42)); + register_echo_provider(®istry, "svc", Value::Int(42)).await; let router = InvocationRouter::with_providers(registry); @@ -119,8 +118,8 @@ fn batch_with_no_items_field_returns_malformed() { fn batch_items_targeting_different_namespaces() { saikuro_exec::block_on(async { let registry = ProviderRegistry::new(); - register_echo_provider(®istry, "ns_a", Value::Bool(true)); - register_echo_provider(®istry, "ns_b", Value::Int(0)); + register_echo_provider(®istry, "ns_a", Value::Bool(true)).await; + register_echo_provider(®istry, "ns_b", Value::Int(0)).await; let router = InvocationRouter::with_providers(registry); @@ -153,7 +152,7 @@ fn batch_item_to_unknown_namespace_returns_null_in_result() { // Per our router implementation, failed batch items produce Null in the // results array (not an error on the whole batch). let registry = ProviderRegistry::new(); - register_echo_provider(®istry, "known", Value::Int(1)); + register_echo_provider(®istry, "known", Value::Int(1)).await; let router = InvocationRouter::with_providers(registry); @@ -189,7 +188,7 @@ fn batch_result_is_ordered_array() { saikuro_exec::ChannelCapacity::try_from(64).expect("64 is a valid channel capacity"), ); let handle = ProviderHandle::new("ordered", vec!["ord".to_owned()], work_tx); - registry.register(handle); + registry.register(handle).await; saikuro_exec::spawn(async move { while let Some(item) = work_rx.recv().await { diff --git a/Build/tests/saikuro-router/call_dispatch.rs b/Build/tests/saikuro-router/call_dispatch.rs index 22d4fd73..f8696fc1 100644 --- a/Build/tests/saikuro-router/call_dispatch.rs +++ b/Build/tests/saikuro-router/call_dispatch.rs @@ -1,6 +1,7 @@ //! Call and cast dispatch integration tests -use saikuro_core::{envelope::Envelope, error::ErrorCode, value::Value, ResponseEnvelope}; +use saikuro_core::{envelope::Envelope, ResponseEnvelope}; +use saikuro_event::{ErrorCode, Value}; use saikuro_exec::mpsc; use saikuro_router::{ provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, @@ -14,7 +15,9 @@ use std::time::Duration; /// /// Returns the [`ProviderRegistry`] with the provider registered, plus a /// join handle so callers can wait for completion. -fn make_echo_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver) { +async fn make_echo_provider( + namespace: &str, +) -> (ProviderRegistry, mpsc::Receiver) { let (work_tx, work_rx) = mpsc::channel::( saikuro_exec::ChannelCapacity::try_from(64).expect("64 is a valid channel capacity"), ); @@ -24,7 +27,7 @@ fn make_echo_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver(saikuro_exec::ChannelCapacity::MIN); let handle = ProviderHandle::new("gone", vec!["svc".to_owned()], work_tx); let registry = ProviderRegistry::new(); - registry.register(handle); + registry.register(handle).await; // Drop the receiver: the provider is "gone". drop(work_rx); @@ -141,7 +144,7 @@ fn call_to_dropped_provider_returns_unavailable() { #[test] fn call_times_out_when_provider_does_not_respond() { saikuro_exec::block_on(async { - let (registry, work_rx) = make_echo_provider("slow"); + let (registry, work_rx) = make_echo_provider("slow").await; let _silent = spawn_silent_responder(work_rx); let config = RouterConfig { @@ -162,7 +165,7 @@ fn call_times_out_when_provider_does_not_respond() { #[test] fn multiple_sequential_calls_all_succeed() { saikuro_exec::block_on(async { - let (registry, work_rx) = make_echo_provider("counter"); + let (registry, work_rx) = make_echo_provider("counter").await; let _responder = spawn_responder(work_rx, Value::Bool(true)); let router = InvocationRouter::with_providers(registry); @@ -178,7 +181,7 @@ fn multiple_sequential_calls_all_succeed() { #[test] fn concurrent_calls_all_succeed() { saikuro_exec::block_on(async { - let (registry, work_rx) = make_echo_provider("parallel"); + let (registry, work_rx) = make_echo_provider("parallel").await; let _responder = spawn_responder(work_rx, Value::Int(0)); let router = InvocationRouter::with_providers(registry); @@ -211,7 +214,9 @@ fn call_with_null_target_returns_malformed_or_no_provider() { assert!(!resp.ok); let err = resp.error.unwrap(); assert!( - err.code == ErrorCode::MalformedEnvelope || err.code == ErrorCode::NoProvider, + err.code == ErrorCode::MalformedTarget + || err.code == ErrorCode::MalformedEnvelope + || err.code == ErrorCode::NoProvider, "unexpected code {:?}", err.code ); diff --git a/Build/tests/saikuro-router/channel_dispatch.rs b/Build/tests/saikuro-router/channel_dispatch.rs index 6ccf7368..6fa2f9d3 100644 --- a/Build/tests/saikuro-router/channel_dispatch.rs +++ b/Build/tests/saikuro-router/channel_dispatch.rs @@ -3,11 +3,10 @@ use futures::{pin_mut, poll}; use saikuro_core::{ envelope::{Envelope, StreamControl}, - error::ErrorCode, invocation::InvocationId, - value::Value, ResponseEnvelope, }; +use saikuro_event::{ErrorCode, Value}; use saikuro_exec::mpsc; use saikuro_router::provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}; use saikuro_router::router::InvocationRouter; @@ -54,7 +53,7 @@ fn channel_abort(id: InvocationId, seq: u64) -> ResponseEnvelope { #[test] fn channel_open_returns_ok_empty() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("chat"); + let (registry, mut work_rx) = common::make_provider("chat").await; saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); @@ -87,7 +86,7 @@ fn channel_open_to_unknown_namespace_returns_no_provider() { #[test] fn route_channel_inbound_delivers_to_state() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("pipe"); + let (registry, mut work_rx) = common::make_provider("pipe").await; let router = InvocationRouter::with_providers(registry); let open_env = Envelope::channel_open("pipe.connect", vec![]).expect("entropy available"); @@ -102,6 +101,7 @@ fn route_channel_inbound_delivers_to_state() { let mut inbound_rx = router .streams() .take_channel_inbound_receiver(&channel_id) + .await .expect("inbound receiver must exist after channel open"); // Route an inbound item from the client. @@ -114,7 +114,8 @@ fn route_channel_inbound_delivers_to_state() { // Confirm the item arrived on the inbound queue. let received = inbound_rx - .try_recv() + .recv() + .await .expect("inbound item should be buffered"); assert_eq!(received.result, Some(Value::String("hello".into()))); }) @@ -123,7 +124,7 @@ fn route_channel_inbound_delivers_to_state() { #[test] fn route_channel_outbound_delivers_to_state() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("pipe2"); + let (registry, mut work_rx) = common::make_provider("pipe2").await; let router = InvocationRouter::with_providers(registry); let open_env = Envelope::channel_open("pipe2.connect", vec![]).expect("entropy available"); @@ -137,6 +138,7 @@ fn route_channel_outbound_delivers_to_state() { let mut outbound_rx = router .streams() .take_channel_outbound_receiver(&channel_id) + .await .expect("outbound receiver must exist after channel open"); // Provider pushes a message to the client. @@ -148,7 +150,8 @@ fn route_channel_outbound_delivers_to_state() { ); let received = outbound_rx - .try_recv() + .recv() + .await .expect("outbound item should be buffered"); assert_eq!(received.result, Some(Value::Int(42))); }) @@ -157,7 +160,7 @@ fn route_channel_outbound_delivers_to_state() { #[test] fn route_channel_inbound_end_removes_state() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("fin_chan"); + let (registry, mut work_rx) = common::make_provider("fin_chan").await; let router = InvocationRouter::with_providers(registry); let open_env = Envelope::channel_open("fin_chan.open", vec![]).expect("entropy available"); @@ -167,7 +170,10 @@ fn route_channel_inbound_end_removes_state() { router.dispatch(open_env).await; // Consume the receiver so sends don't fail. - let _rx = router.streams().take_channel_inbound_receiver(&channel_id); + let _rx = router + .streams() + .take_channel_inbound_receiver(&channel_id) + .await; // Send end-of-channel from the client side. let end = channel_end(channel_id, 0); @@ -184,7 +190,7 @@ fn route_channel_inbound_end_removes_state() { #[test] fn route_channel_outbound_end_removes_state() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("fin_out"); + let (registry, mut work_rx) = common::make_provider("fin_out").await; let router = InvocationRouter::with_providers(registry); let open_env = Envelope::channel_open("fin_out.open", vec![]).expect("entropy available"); @@ -193,7 +199,10 @@ fn route_channel_outbound_end_removes_state() { saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); router.dispatch(open_env).await; - let _rx = router.streams().take_channel_outbound_receiver(&channel_id); + let _rx = router + .streams() + .take_channel_outbound_receiver(&channel_id) + .await; let end = channel_end(channel_id, 0); let result = router.route_channel_outbound(end).await; @@ -211,7 +220,7 @@ fn route_channel_outbound_end_removes_state() { #[test] fn route_channel_abort_removes_state() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("abort_chan"); + let (registry, mut work_rx) = common::make_provider("abort_chan").await; let router = InvocationRouter::with_providers(registry); let open_env = @@ -221,7 +230,10 @@ fn route_channel_abort_removes_state() { saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); router.dispatch(open_env).await; - let _rx = router.streams().take_channel_inbound_receiver(&channel_id); + let _rx = router + .streams() + .take_channel_inbound_receiver(&channel_id) + .await; let abort = channel_abort(channel_id, 0); let result = router.route_channel_inbound(abort).await; @@ -266,7 +278,7 @@ fn route_channel_outbound_to_unknown_channel_fails() { #[test] fn multiple_channels_are_independent() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("multi_chan"); + let (registry, mut work_rx) = common::make_provider("multi_chan").await; let router = InvocationRouter::with_providers(registry); saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); @@ -279,8 +291,8 @@ fn multiple_channels_are_independent() { router.dispatch(env1).await; router.dispatch(env2).await; - let _rx1_in = router.streams().take_channel_inbound_receiver(&id1); - let _rx2_in = router.streams().take_channel_inbound_receiver(&id2); + let _rx1_in = router.streams().take_channel_inbound_receiver(&id1).await; + let _rx2_in = router.streams().take_channel_inbound_receiver(&id2).await; // Route items to channel 1. let item1 = channel_item(id1, 0, Value::Int(1)); @@ -313,7 +325,7 @@ fn channel_open_to_dropped_provider_returns_unavailable() { work_tx, ); let registry = ProviderRegistry::new(); - registry.register(handle); + registry.register(handle).await; // Drop the receiver: provider is now unavailable. drop(work_rx); @@ -335,7 +347,7 @@ fn channel_open_to_dropped_provider_returns_unavailable() { #[test] fn channel_pause_resume_round_trips() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("bpressure"); + let (registry, mut work_rx) = common::make_provider("bpressure").await; let router = InvocationRouter::with_providers(registry); let open_env = @@ -348,6 +360,7 @@ fn channel_pause_resume_round_trips() { let mut outbound_rx = router .streams() .take_channel_outbound_receiver(&channel_id) + .await .expect("outbound receiver must exist"); // Provider sends a Pause control frame to signal backpressure. @@ -362,7 +375,8 @@ fn channel_pause_resume_round_trips() { assert!(router.route_channel_outbound(pause).await.is_ok()); let received = outbound_rx - .try_recv() + .recv() + .await .expect("pause frame should be buffered"); assert_eq!(received.stream_control, Some(StreamControl::Pause)); @@ -381,7 +395,8 @@ fn channel_pause_resume_round_trips() { ); let received2 = outbound_rx - .try_recv() + .recv() + .await .expect("resume frame should be buffered"); assert_eq!(received2.stream_control, Some(StreamControl::Resume)); }) @@ -425,8 +440,10 @@ fn concurrent_channel_delivery_preserves_order_and_terminal_closure() { .await, DeliveryOutcome::Closed ); + let recv_fut = outbound_rx.recv(); + pin_mut!(recv_fut); assert!( - outbound_rx.try_recv().is_err(), + matches!(poll!(recv_fut.as_mut()), Poll::Pending), "post-terminal frame was not delivered" ); }) diff --git a/Build/tests/saikuro-router/log_dispatch.rs b/Build/tests/saikuro-router/log_dispatch.rs index 8a70b2b2..6d85b61e 100644 --- a/Build/tests/saikuro-router/log_dispatch.rs +++ b/Build/tests/saikuro-router/log_dispatch.rs @@ -1,27 +1,38 @@ //! Log-envelope dispatch tests +use futures::{pin_mut, poll}; use saikuro_core::{ envelope::{Envelope, InvocationType}, - log::{LogLevel, LogRecord, LogSink}, - value::Value, InvocationId, PROTOCOL_VERSION, }; +use saikuro_event::{LogLevel, LogRecord, LogSink, Value}; use saikuro_exec::mpsc; use saikuro_router::{ provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, router::{InvocationRouter, RouterConfig}, }; use std::sync::{Arc, Mutex}; +use std::task::Poll; // Helpers +struct CapturingSink { + captured: Arc>>, +} + +#[async_trait::async_trait] +impl LogSink for CapturingSink { + async fn emit(&self, record: &LogRecord) { + self.captured.lock().unwrap().push(record.clone()); + } +} + /// Build a capturing log sink that records every [`LogRecord`] it receives. -fn capturing_sink() -> (LogSink, Arc>>) { +fn capturing_sink() -> (CapturingSink, Arc>>) { let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); - let cap_clone = Arc::clone(&captured); - let sink: LogSink = Box::new(move |record: LogRecord| { - cap_clone.lock().unwrap().push(record); - }); + let sink = CapturingSink { + captured: captured.clone(), + }; (sink, captured) } @@ -46,9 +57,9 @@ fn make_log_envelope(level: LogLevel, name: &str, msg: &str) -> Envelope { } } -fn make_router_with_sink(sink: LogSink) -> InvocationRouter { +fn make_router_with_sink(sink: CapturingSink) -> InvocationRouter { let registry = ProviderRegistry::new(); // no providers needed for log tests - InvocationRouter::with_log_sink(registry, RouterConfig::default(), sink) + InvocationRouter::::with_log_sink(registry, RouterConfig::default(), sink) } // Tests @@ -62,10 +73,14 @@ fn log_envelope_is_not_routed_to_provider() { ); let handle = ProviderHandle::new("logger", vec!["$log".to_owned()], work_tx); let registry = ProviderRegistry::new(); - registry.register(handle); + registry.register(handle).await; let (sink, _captured) = capturing_sink(); - let router = InvocationRouter::with_log_sink(registry, RouterConfig::default(), sink); + let router = InvocationRouter::::with_log_sink( + registry, + RouterConfig::default(), + sink, + ); let env = make_log_envelope(LogLevel::Info, "test.logger", "hello from test"); let resp = router.dispatch(env).await; @@ -74,8 +89,10 @@ fn log_envelope_is_not_routed_to_provider() { assert!(resp.ok, "log dispatch should return ok_empty"); // Provider channel must be empty: log was NOT forwarded to it. + let recv_fut = work_rx.recv(); + pin_mut!(recv_fut); assert!( - work_rx.try_recv().is_err(), + matches!(poll!(recv_fut.as_mut()), Poll::Pending), "log envelope must not be forwarded to any provider" ); }) @@ -153,10 +170,10 @@ fn log_envelope_with_no_args_returns_ok_without_panicking() { // Must not panic; ok_empty is returned. assert!(resp.ok, "malformed log should still return ok"); - // Nothing was delivered to the sink. + // The router logs a warning about the malformed record. assert!( - captured.lock().unwrap().is_empty(), - "malformed log should not reach sink" + !captured.lock().unwrap().is_empty(), + "malformed log should emit a warning to sink" ); }) } @@ -184,8 +201,8 @@ fn log_envelope_with_invalid_args_returns_ok_without_panicking() { assert!(resp.ok, "invalid log args should still return ok"); assert!( - captured.lock().unwrap().is_empty(), - "invalid log args should not reach sink" + !captured.lock().unwrap().is_empty(), + "invalid log args should emit a warning to sink" ); }) } @@ -199,7 +216,7 @@ fn router_with_custom_sink_still_routes_calls() { ); let handle = ProviderHandle::new("math", vec!["math".to_owned()], work_tx); let registry = ProviderRegistry::new(); - registry.register(handle); + registry.register(handle).await; // Spawn an auto-responder. saikuro_exec::spawn(async move { @@ -215,7 +232,11 @@ fn router_with_custom_sink_still_routes_calls() { }); let (sink, _captured) = capturing_sink(); - let router = InvocationRouter::with_log_sink(registry, RouterConfig::default(), sink); + let router = InvocationRouter::::with_log_sink( + registry, + RouterConfig::default(), + sink, + ); let env = Envelope::call("math.compute", vec![]).expect("entropy available"); let resp = router.dispatch(env).await; diff --git a/Build/tests/saikuro-router/provider_registry.rs b/Build/tests/saikuro-router/provider_registry.rs index 7c5b96bd..c943600a 100644 --- a/Build/tests/saikuro-router/provider_registry.rs +++ b/Build/tests/saikuro-router/provider_registry.rs @@ -23,72 +23,91 @@ fn handle_with_token( #[test] fn stale_same_id_deregistration_preserves_new_registration() { - let registry = ProviderRegistry::new(); - let old_token = RegistrationToken::new(); - let new_token = RegistrationToken::new(); - let (old_sender, _old_receiver) = saikuro_exec::mpsc::channel::( - saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), - ); - let (new_sender, _new_receiver) = saikuro_exec::mpsc::channel::( - saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), - ); + saikuro_exec::block_on(async { + let registry = ProviderRegistry::new(); + let old_token = RegistrationToken::new(); + let new_token = RegistrationToken::new(); + let (old_sender, _old_receiver) = saikuro_exec::mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), + ); + let (new_sender, _new_receiver) = saikuro_exec::mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), + ); - registry.register(ProviderHandle::with_registration_token( - "p", - old_token, - vec!["service".into()], - old_sender, - )); - registry.register(ProviderHandle::with_registration_token( - "p", - new_token, - vec!["service".into()], - new_sender, - )); - registry.deregister("p", old_token); + registry + .register(ProviderHandle::with_registration_token( + "p", + old_token, + vec!["service".into()], + old_sender, + )) + .await; + registry + .register(ProviderHandle::with_registration_token( + "p", + new_token, + vec!["service".into()], + new_sender, + )) + .await; + registry.deregister("p", old_token).await; - let provider = registry - .get("service") - .expect("new provider registration remains routed"); - assert_eq!(provider.id(), "p"); - assert_eq!(provider.registration_token(), new_token); + let provider = registry + .get("service") + .await + .expect("new provider registration remains routed"); + assert_eq!(provider.id(), "p"); + assert_eq!(provider.registration_token(), new_token); + }) } /// Re-registering the same provider with fewer namespaces must release the /// routes it no longer owns. #[test] fn register_with_fewer_namespaces_releases_dropped_routes() { - let registry = ProviderRegistry::new(); - let registration_token = RegistrationToken::new(); + saikuro_exec::block_on(async { + let registry = ProviderRegistry::new(); + let registration_token = RegistrationToken::new(); - registry.register(handle_with_token("p", registration_token, &["a", "b"])); - assert!(registry.get("a").is_some()); - assert!(registry.get("b").is_some()); + registry + .register(handle_with_token("p", registration_token, &["a", "b"])) + .await; + assert!(registry.get("a").await.is_some()); + assert!(registry.get("b").await.is_some()); - registry.register(handle_with_token("p", registration_token, &["a"])); - assert!( - registry.get("b").is_none(), - "dropped namespace 'b' still routed after re-register" - ); - assert!(registry.get("a").is_some()); + registry + .register(handle_with_token("p", registration_token, &["a"])) + .await; + assert!( + registry.get("b").await.is_none(), + "dropped namespace 'b' still routed after re-register" + ); + assert!(registry.get("a").await.is_some()); + }) } /// A dropped namespace that a newer provider took over must not be released; /// only the still-owned route is removed. #[test] fn register_with_fewer_namespaces_keeps_taken_over_routes() { - let registry = ProviderRegistry::new(); - let registration_token = RegistrationToken::new(); + saikuro_exec::block_on(async { + let registry = ProviderRegistry::new(); + let registration_token = RegistrationToken::new(); - registry.register(handle_with_token("p", registration_token, &["a", "b"])); - registry.register(handle("q", &["b"])); - registry.register(handle_with_token("p", registration_token, &["a"])); + registry + .register(handle_with_token("p", registration_token, &["a", "b"])) + .await; + registry.register(handle("q", &["b"])).await; + registry + .register(handle_with_token("p", registration_token, &["a"])) + .await; - assert!(registry.get("a").is_some()); - let b = registry.get("b").expect("'b' is owned by q"); - assert_eq!( - b.id(), - "q", - "taken-over namespace 'b' must still route to q" - ); + assert!(registry.get("a").await.is_some()); + let b = registry.get("b").await.expect("'b' is owned by q"); + assert_eq!( + b.id(), + "q", + "taken-over namespace 'b' must still route to q" + ); + }) } diff --git a/Build/tests/saikuro-router/resource_dispatch.rs b/Build/tests/saikuro-router/resource_dispatch.rs index 7624d612..722841bd 100644 --- a/Build/tests/saikuro-router/resource_dispatch.rs +++ b/Build/tests/saikuro-router/resource_dispatch.rs @@ -2,11 +2,10 @@ use saikuro_core::{ envelope::{Envelope, InvocationType}, - error::ErrorCode, resource::ResourceHandle, - value::Value, ResponseEnvelope, }; +use saikuro_event::{ErrorCode, Value}; use saikuro_exec::mpsc; use saikuro_router::{ provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, @@ -19,7 +18,7 @@ use crate::common; // Helpers /// Build a `ProviderRegistry` with a single provider subscribed to `namespace`. -fn make_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver) { +async fn make_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver) { let (work_tx, work_rx) = mpsc::channel::( saikuro_exec::ChannelCapacity::try_from(64).expect("64 is a valid channel capacity"), ); @@ -29,7 +28,7 @@ fn make_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver(saikuro_exec::ChannelCapacity::MIN); let handle = ProviderHandle::new("gone", vec!["blobs".to_owned()], work_tx); let registry = ProviderRegistry::new(); - registry.register(handle); + registry.register(handle).await; drop(work_rx); // provider vanished let router = InvocationRouter::with_providers(registry); @@ -220,11 +219,11 @@ fn resource_dispatch_through_connection_handler() { .with_size(128); let result_value = handle_to_value(&handle); - let (provider_registry, work_rx) = make_provider("docs"); + let (provider_registry, work_rx) = make_provider("docs").await; let _responder = spawn_responder(work_rx, result_value.clone()); let schema_registry = SchemaRegistry::new(); - common::register_namespace(&schema_registry, "docs", "fetch"); + common::register_namespace(&schema_registry, "docs", "fetch").await; let env = Envelope::resource("docs.fetch", vec![]).expect("entropy available"); @@ -273,7 +272,7 @@ fn resource_response_id_matches_request_id() { let handle = ResourceHandle::new("corr-001"); let result_value = handle_to_value(&handle); - let (registry, work_rx) = make_provider("corr"); + let (registry, work_rx) = make_provider("corr").await; let _responder = spawn_responder(work_rx, result_value); let router = InvocationRouter::with_providers(registry); @@ -297,7 +296,7 @@ fn concurrent_resource_invocations_all_succeed() { let handle = ResourceHandle::new("concurrent-test"); let result_value = handle_to_value(&handle); - let (registry, work_rx) = make_provider("bulk"); + let (registry, work_rx) = make_provider("bulk").await; let _responder = spawn_responder(work_rx, result_value); let router = InvocationRouter::with_providers(registry); diff --git a/Build/tests/saikuro-router/sandbox_dispatch.rs b/Build/tests/saikuro-router/sandbox_dispatch.rs index 9cc23cfe..0839637d 100644 --- a/Build/tests/saikuro-router/sandbox_dispatch.rs +++ b/Build/tests/saikuro-router/sandbox_dispatch.rs @@ -8,9 +8,9 @@ use saikuro_core::{ FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, TypeMap, Visibility, }, - value::Value, InvocationId, ResponseEnvelope, PROTOCOL_VERSION, }; +use saikuro_event::Value; use saikuro_router::{ provider::ProviderRegistry, router::{InvocationRouter, RouterConfig}, @@ -19,10 +19,7 @@ use saikuro_runtime::connection::ConnectionHandler; use saikuro_schema::{ capability_engine::CapabilityEngine, registry::SchemaRegistry, validator::InvocationValidator, }; -use saikuro_transport::{ - memory::MemoryTransport, - traits::{Transport, TransportReceiver, TransportSender}, -}; +use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; // Helpers @@ -117,7 +114,9 @@ async fn run_and_collect( sandbox: bool, envelope: Envelope, ) -> Vec { - let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler"); + let log: std::sync::Arc = + std::sync::Arc::from(Box::new(saikuro_event::NullSink) as Box); + let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler", log.clone()); let (handler_sender, handler_receiver) = handler_transport.split(); let (mut test_sender, mut test_receiver) = test_transport.split(); @@ -142,6 +141,7 @@ async fn run_and_collect( max_message_size: 4 * 1024 * 1024, schema_registry, provider_registry: providers, + log, }; let frame = Bytes::from(envelope.to_msgpack().expect("encode envelope")); @@ -347,6 +347,7 @@ fn sandbox_handler_denies_internal_function_invocation() { // Pre-register the schema so the validator can find it. registry .merge_schema(schema.clone(), "test-provider") + .await .expect("merge schema"); // Build the Invoke envelope for the internal function. diff --git a/Build/tests/saikuro-router/stream_dispatch.rs b/Build/tests/saikuro-router/stream_dispatch.rs index 327339d3..19ec7a78 100644 --- a/Build/tests/saikuro-router/stream_dispatch.rs +++ b/Build/tests/saikuro-router/stream_dispatch.rs @@ -3,11 +3,10 @@ use futures::{pin_mut, poll}; use saikuro_core::{ envelope::{Envelope, StreamControl}, - error::ErrorCode, invocation::InvocationId, - value::Value, ResponseEnvelope, }; +use saikuro_event::{ErrorCode, Value}; use saikuro_router::provider::ProviderRegistry; use saikuro_router::router::InvocationRouter; use saikuro_router::stream_state::{DeliveryOutcome, StreamState}; @@ -20,7 +19,7 @@ use crate::common; #[test] fn stream_open_returns_ok_empty() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("events"); + let (registry, mut work_rx) = common::make_provider("events").await; // Consume work items (provider side). saikuro_exec::spawn(async move { while work_rx.recv().await.is_some() {} }); @@ -39,7 +38,7 @@ fn stream_open_returns_ok_empty() { #[test] fn route_stream_item_delivers_to_state() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("data"); + let (registry, mut work_rx) = common::make_provider("data").await; // The provider will send items back via route_stream_item. let router = InvocationRouter::with_providers(registry); @@ -63,7 +62,7 @@ fn route_stream_item_delivers_to_state() { #[test] fn route_stream_end_removes_state() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("fin"); + let (registry, mut work_rx) = common::make_provider("fin").await; let router = InvocationRouter::with_providers(registry); let open_env = Envelope::stream_open("fin.feed", vec![]).expect("entropy available"); @@ -116,7 +115,7 @@ fn stream_open_to_unknown_namespace_returns_no_provider() { #[test] fn multiple_streams_are_independent() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("multi"); + let (registry, mut work_rx) = common::make_provider("multi").await; let router = InvocationRouter::with_providers(registry); saikuro_exec::spawn(async move { while work_rx.recv().await.is_some() {} }); @@ -153,7 +152,7 @@ fn multiple_streams_are_independent() { #[test] fn out_of_order_item_is_dropped_not_panicked() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("ooo"); + let (registry, mut work_rx) = common::make_provider("ooo").await; let router = InvocationRouter::with_providers(registry); saikuro_exec::spawn(async move { while work_rx.recv().await.is_some() {} }); @@ -176,7 +175,7 @@ fn out_of_order_item_is_dropped_not_panicked() { #[test] fn stream_abort_control_removes_state() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = common::make_provider("abort"); + let (registry, mut work_rx) = common::make_provider("abort").await; let router = InvocationRouter::with_providers(registry); saikuro_exec::spawn(async move { while work_rx.recv().await.is_some() {} }); @@ -236,8 +235,10 @@ fn concurrent_stream_delivery_preserves_order_and_terminal_closure() { .await, DeliveryOutcome::Closed ); + let recv_fut = rx.recv(); + pin_mut!(recv_fut); assert!( - rx.try_recv().is_err(), + matches!(poll!(recv_fut.as_mut()), Poll::Pending), "post-terminal frame was not delivered" ); }) diff --git a/Build/tests/saikuro-runtime/schema_registration.rs b/Build/tests/saikuro-runtime/schema_registration.rs index 96cffeae..f1cb7184 100644 --- a/Build/tests/saikuro-runtime/schema_registration.rs +++ b/Build/tests/saikuro-runtime/schema_registration.rs @@ -8,88 +8,102 @@ use saikuro_runtime::SaikuroRuntime; /// Smoke test: build a runtime, register a schema, verify lookup works. #[test] fn schema_registration_roundtrip() { - let rt = SaikuroRuntime::builder().build(); + saikuro_exec::block_on(async { + let rt = SaikuroRuntime::builder().build().await; - let mut functions = FunctionMap::new(); - functions - .insert( - "ping".to_owned(), - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::String), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: true, - doc: Some("Returns 'pong'".to_owned()), - }, - ) - .ok(); + let mut functions = FunctionMap::new(); + functions + .insert( + "ping".to_owned(), + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::String), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: true, + doc: Some("Returns 'pong'".to_owned()), + }, + ) + .ok(); - let ns = NamespaceSchema { - functions: Box::new(functions), - doc: None, - }; + let ns = NamespaceSchema { + functions: Box::new(functions), + doc: None, + }; - let mut schema = Schema::new(); - schema.namespaces.insert("health".to_owned(), ns).ok(); + let mut schema = Schema::new(); + schema.namespaces.insert("health".to_owned(), ns).ok(); - rt.schema_registry() - .merge_schema(schema, "test-provider") - .expect("merge failed"); + rt.schema_registry() + .merge_schema(schema, "test-provider") + .await + .expect("merge failed"); - let func_ref = rt - .schema_registry() - .lookup_function("health.ping") - .expect("lookup failed"); + let func_ref = rt + .schema_registry() + .lookup_function("health.ping") + .await + .expect("lookup failed"); - assert_eq!(func_ref.function, "ping"); - assert_eq!(func_ref.provider_id, "test-provider"); + assert_eq!(func_ref.function, "ping"); + assert_eq!(func_ref.provider_id, "test-provider"); + }); } #[test] fn stale_same_id_cleanup_preserves_new_provider_and_schema() { - let runtime = SaikuroRuntime::builder().build(); - let handle = runtime.handle(); - let old_token = RegistrationToken::new(); - let new_token = RegistrationToken::new(); - let (old_sender, _old_receiver) = saikuro_exec::mpsc::channel::( - saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), - ); - let (new_sender, _new_receiver) = saikuro_exec::mpsc::channel::( - saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), - ); + saikuro_exec::block_on(async { + let runtime = SaikuroRuntime::builder().build().await; + let handle = runtime.handle(); + let old_token = RegistrationToken::new(); + let new_token = RegistrationToken::new(); + let (old_sender, _old_receiver) = saikuro_exec::mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), + ); + let (new_sender, _new_receiver) = saikuro_exec::mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(4).expect("4 is a valid channel capacity"), + ); - handle.register_provider(ProviderHandle::with_registration_token( - "provider", - old_token, - vec!["service".into()], - old_sender, - )); - handle - .register_schema_with_token(schema_for("service", "old"), "provider", old_token) - .expect("old schema registers"); - handle.register_provider(ProviderHandle::with_registration_token( - "provider", - new_token, - vec!["service".into()], - new_sender, - )); - handle - .register_schema_with_token(schema_for("service", "new"), "provider", new_token) - .expect("new schema registers"); + handle + .register_provider(ProviderHandle::with_registration_token( + "provider", + old_token, + vec!["service".into()], + old_sender, + )) + .await; + handle + .register_schema_with_token(schema_for("service", "old"), "provider", old_token) + .await + .expect("old schema registers"); + handle + .register_provider(ProviderHandle::with_registration_token( + "provider", + new_token, + vec!["service".into()], + new_sender, + )) + .await; + handle + .register_schema_with_token(schema_for("service", "new"), "provider", new_token) + .await + .expect("new schema registers"); - handle.deregister_provider("provider", old_token); + handle.deregister_provider("provider", old_token).await; - let provider = runtime - .provider_registry() - .get("service") - .expect("new provider remains routed"); - assert_eq!(provider.id(), "provider"); - assert_eq!(provider.registration_token(), new_token); - assert!(runtime - .schema_registry() - .lookup_function("service.new") - .is_ok()); + let provider = runtime + .provider_registry() + .get("service") + .await + .expect("new provider remains routed"); + assert_eq!(provider.id(), "provider"); + assert_eq!(provider.registration_token(), new_token); + assert!(runtime + .schema_registry() + .lookup_function("service.new") + .await + .is_ok()); + }); } fn schema_for(namespace: &str, function: &str) -> Schema { diff --git a/Build/tests/saikuro-schema/registry.rs b/Build/tests/saikuro-schema/registry.rs index 799c32dc..46e23018 100644 --- a/Build/tests/saikuro-schema/registry.rs +++ b/Build/tests/saikuro-schema/registry.rs @@ -5,50 +5,61 @@ use saikuro_schema::registry::SchemaRegistry; #[test] fn frozen_registry_rejects_type_only_merge() { - let registry = SchemaRegistry::from_frozen_schema(Schema::new()); - let mut update = Schema::new(); - update - .types - .insert( - "UserId".into(), - TypeDefinition::Alias { - inner: TypeDescriptor::primitive(PrimitiveType::String), - }, - ) - .expect("type fits"); + saikuro_exec::block_on(async { + let registry = SchemaRegistry::from_frozen_schema(Schema::new()); + let mut update = Schema::new(); + update + .types + .insert( + "UserId".into(), + TypeDefinition::Alias { + inner: TypeDescriptor::primitive(PrimitiveType::String), + }, + ) + .expect("type fits"); - assert!(matches!( - registry.merge_schema(update, "provider"), - Err(SaikuroError::FrozenSchema(_)) - )); - assert!(registry.snapshot().expect("snapshot").types.is_empty()); + assert!(matches!( + registry.merge_schema(update, "provider").await, + Err(SaikuroError::FrozenSchema(_)) + )); + assert!(registry + .snapshot() + .await + .expect("snapshot") + .types + .is_empty()); + }); } #[test] fn stale_same_id_deregistration_preserves_new_schema() { - let registry = SchemaRegistry::new(); - let old_token = RegistrationToken::new(); - let new_token = RegistrationToken::new(); - let mut old_schema = Schema::new(); - old_schema - .namespaces - .insert("service".into(), empty_namespace()) - .expect("namespace fits"); - let mut new_schema = Schema::new(); - new_schema - .namespaces - .insert("service".into(), empty_namespace()) - .expect("namespace fits"); + saikuro_exec::block_on(async { + let registry = SchemaRegistry::new(); + let old_token = RegistrationToken::new(); + let new_token = RegistrationToken::new(); + let mut old_schema = Schema::new(); + old_schema + .namespaces + .insert("service".into(), empty_namespace()) + .expect("namespace fits"); + let mut new_schema = Schema::new(); + new_schema + .namespaces + .insert("service".into(), empty_namespace()) + .expect("namespace fits"); - registry - .merge_schema_with_token(old_schema, "provider", old_token) - .expect("old schema registers"); - registry - .merge_schema_with_token(new_schema, "provider", new_token) - .expect("new schema registers"); - registry.deregister_provider("provider", old_token); + registry + .merge_schema_with_token(old_schema, "provider", old_token) + .await + .expect("old schema registers"); + registry + .merge_schema_with_token(new_schema, "provider", new_token) + .await + .expect("new schema registers"); + registry.deregister_provider("provider", old_token).await; - assert!(registry.has_namespace("service")); + assert!(registry.has_namespace("service").await); + }); } fn empty_namespace() -> saikuro_core::schema::NamespaceSchema { diff --git a/Build/tests/saikuro-schema/schema_validation.rs b/Build/tests/saikuro-schema/schema_validation.rs index c9b8f16f..36647675 100644 --- a/Build/tests/saikuro-schema/schema_validation.rs +++ b/Build/tests/saikuro-schema/schema_validation.rs @@ -52,7 +52,7 @@ fn unit_fn() -> FunctionSchema { } } -fn make_registry_with_math() -> SchemaRegistry { +async fn make_registry_with_math() -> SchemaRegistry { let registry = SchemaRegistry::new(); let mut functions = FunctionMap::new(); functions @@ -84,212 +84,236 @@ fn make_registry_with_math() -> SchemaRegistry { provider_id: "provider-1".into(), registration_token: saikuro_core::RegistrationToken::new(), }) + .await .unwrap(); registry } -// Tests - #[test] fn lookup_existing_function() { - let registry = make_registry_with_math(); - let func = registry.lookup_function("math.add"); - assert!(func.is_ok(), "math.add should exist"); - assert_eq!(func.unwrap().schema.args.len(), 2); + saikuro_exec::block_on(async { + let registry = make_registry_with_math().await; + let func = registry.lookup_function("math.add").await; + assert!(func.is_ok(), "math.add should exist"); + assert_eq!(func.unwrap().schema.args.len(), 2); + }); } #[test] fn lookup_unknown_namespace() { - let registry = make_registry_with_math(); - let result = registry.lookup_function("unknown.fn"); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("namespace not found") || err.contains("unknown"), - "{err}" - ); + saikuro_exec::block_on(async { + let registry = make_registry_with_math().await; + let result = registry.lookup_function("unknown.fn").await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("namespace not found") || err.contains("unknown"), + "{err}" + ); + }); } #[test] fn lookup_unknown_function_in_known_namespace() { - let registry = make_registry_with_math(); - let result = registry.lookup_function("math.nonexistent"); - assert!(result.is_err()); + saikuro_exec::block_on(async { + let registry = make_registry_with_math().await; + let result = registry.lookup_function("math.nonexistent").await; + assert!(result.is_err()); + }); } #[test] fn valid_call_passes_validation() { - let registry = make_registry_with_math(); - let validator = InvocationValidator::new(registry); - let env = - Envelope::call("math.add", vec![Value::Int(1), Value::Int(2)]).expect("entropy available"); - assert!(validator.validate(&env).is_ok()); + saikuro_exec::block_on(async { + let registry = make_registry_with_math().await; + let validator = InvocationValidator::new(registry); + let env = Envelope::call("math.add", vec![Value::Int(1), Value::Int(2)]) + .expect("entropy available"); + assert!(validator.validate(&env).await.is_ok()); + }); } #[test] fn wrong_arity_fails_validation() { - let registry = make_registry_with_math(); - let validator = InvocationValidator::new(registry); + saikuro_exec::block_on(async { + let registry = make_registry_with_math().await; + let validator = InvocationValidator::new(registry); - // too few args - let env_few = Envelope::call("math.add", vec![Value::Int(1)]).expect("entropy available"); - let err = validator.validate(&env_few).unwrap_err(); - assert!(matches!(err, SaikuroError::ArgumentArity { .. })); - assert_eq!(err.error_code(), ErrorCode::InvalidArguments); + // too few args + let env_few = Envelope::call("math.add", vec![Value::Int(1)]).expect("entropy available"); + let err = validator.validate(&env_few).await.unwrap_err(); + assert!(matches!(err, SaikuroError::ArgumentArity { .. })); + assert_eq!(err.error_code(), ErrorCode::InvalidArguments); - // too many args - let env_many = Envelope::call( - "math.add", - vec![Value::Int(1), Value::Int(2), Value::Int(3)], - ) - .expect("entropy available"); - let err = validator.validate(&env_many).unwrap_err(); - assert!(matches!(err, SaikuroError::ArgumentArity { .. })); + // too many args + let env_many = Envelope::call( + "math.add", + vec![Value::Int(1), Value::Int(2), Value::Int(3)], + ) + .expect("entropy available"); + let err = validator.validate(&env_many).await.unwrap_err(); + assert!(matches!(err, SaikuroError::ArgumentArity { .. })); + }); } #[test] fn wrong_type_fails_validation() { - let registry = make_registry_with_math(); - let validator = InvocationValidator::new(registry); + saikuro_exec::block_on(async { + let registry = make_registry_with_math().await; + let validator = InvocationValidator::new(registry); - // "hello" is not an integer - let env = Envelope::call( - "math.add", - vec![Value::String("hello".into()), Value::Int(2)], - ) - .expect("entropy available"); - let err = validator.validate(&env).unwrap_err(); - assert!( - matches!(err, SaikuroError::ArgumentType { .. }), - "expected ArgumentType, got {err:?}" - ); - assert_eq!(err.error_code(), ErrorCode::InvalidArguments); + // "hello" is not an integer + let env = Envelope::call( + "math.add", + vec![Value::String("hello".into()), Value::Int(2)], + ) + .expect("entropy available"); + let err = validator.validate(&env).await.unwrap_err(); + assert!( + matches!(err, SaikuroError::ArgumentType { .. }), + "expected ArgumentType, got {err:?}" + ); + assert_eq!(err.error_code(), ErrorCode::InvalidArguments); + }); } #[test] fn internal_visibility_denied_for_external_callers() { - let registry = make_registry_with_math(); - let validator = InvocationValidator::new(registry); + saikuro_exec::block_on(async { + let registry = make_registry_with_math().await; + let validator = InvocationValidator::new(registry); - let env = Envelope::call("math.internal_op", vec![]).expect("entropy available"); - let err = validator.validate(&env).unwrap_err(); - assert!( - matches!(err, SaikuroError::VisibilityDenied { .. }), - "expected VisibilityDenied, got {err:?}" - ); - assert_eq!(err.error_code(), ErrorCode::CapabilityDenied); + let env = Envelope::call("math.internal_op", vec![]).expect("entropy available"); + let err = validator.validate(&env).await.unwrap_err(); + assert!( + matches!(err, SaikuroError::VisibilityDenied { .. }), + "expected VisibilityDenied, got {err:?}" + ); + assert_eq!(err.error_code(), ErrorCode::CapabilityDenied); + }); } #[test] fn private_function_denied_for_external_callers() { - let registry = make_registry_with_math(); - let validator = InvocationValidator::new(registry); + saikuro_exec::block_on(async { + let registry = make_registry_with_math().await; + let validator = InvocationValidator::new(registry); - let env = Envelope::call("math.secret", vec![]).expect("entropy available"); - let err = validator.validate(&env).unwrap_err(); - assert!( - matches!(err, SaikuroError::VisibilityDenied { .. }), - "expected VisibilityDenied for private fn, got {err:?}" - ); + let env = Envelope::call("math.secret", vec![]).expect("entropy available"); + let err = validator.validate(&env).await.unwrap_err(); + assert!( + matches!(err, SaikuroError::VisibilityDenied { .. }), + "expected VisibilityDenied for private fn, got {err:?}" + ); + }); } #[test] fn batch_with_no_items_fails() { - let registry = make_registry_with_math(); - let validator = InvocationValidator::new(registry); + saikuro_exec::block_on(async { + let registry = make_registry_with_math().await; + let validator = InvocationValidator::new(registry); - let mut env = Envelope::call("", vec![]).expect("entropy available"); - env.invocation_type = InvocationType::Batch; - env.target = String::new(); - env.batch_items = None; + let mut env = Envelope::call("", vec![]).expect("entropy available"); + env.invocation_type = InvocationType::Batch; + env.target = String::new(); + env.batch_items = None; - let err = validator.validate(&env).unwrap_err(); - assert!( - matches!(err, SaikuroError::MissingBatch), - "expected MissingBatch, got {err:?}" - ); - assert_eq!(err.error_code(), ErrorCode::MalformedEnvelope); + let err = validator.validate(&env).await.unwrap_err(); + assert!( + matches!(err, SaikuroError::MissingBatch), + "expected MissingBatch, got {err:?}" + ); + assert_eq!(err.error_code(), ErrorCode::MalformedEnvelope); + }); } #[test] fn batch_with_empty_items_fails() { - let registry = make_registry_with_math(); - let validator = InvocationValidator::new(registry); + saikuro_exec::block_on(async { + let registry = make_registry_with_math().await; + let validator = InvocationValidator::new(registry); - let mut env = Envelope::call("", vec![]).expect("entropy available"); - env.invocation_type = InvocationType::Batch; - env.target = String::new(); - env.batch_items = Some(vec![]); + let mut env = Envelope::call("", vec![]).expect("entropy available"); + env.invocation_type = InvocationType::Batch; + env.target = String::new(); + env.batch_items = Some(vec![]); - let err = validator.validate(&env).unwrap_err(); - assert!(matches!(err, SaikuroError::EmptyBatch)); + let err = validator.validate(&env).await.unwrap_err(); + assert!(matches!(err, SaikuroError::EmptyBatch)); + }); } #[test] fn malformed_target_without_dot_fails() { - let registry = make_registry_with_math(); - let validator = InvocationValidator::new(registry); + saikuro_exec::block_on(async { + let registry = make_registry_with_math().await; + let validator = InvocationValidator::new(registry); - let env = Envelope::call("nofunctionpart", vec![]).expect("entropy available"); - let err = validator.validate(&env).unwrap_err(); - assert!( - matches!(err, SaikuroError::MalformedEnvelope(_)), - "expected MalformedEnvelope, got {err:?}" - ); + let env = Envelope::call("nofunctionpart", vec![]).expect("entropy available"); + let err = validator.validate(&env).await.unwrap_err(); + assert!( + matches!(err, SaikuroError::MalformedEnvelope(_)), + "expected MalformedEnvelope, got {err:?}" + ); + }); } #[test] fn optional_argument_may_be_omitted() { - // Register a function with one required and one optional argument. - let registry = SchemaRegistry::new(); - let mut functions = FunctionMap::new(); - functions - .insert( - "greet".into(), - FunctionSchema { - args: vec![ - ArgumentDescriptor { - name: "name".into(), - r#type: TypeDescriptor::primitive(PrimitiveType::String), - optional: false, - default: None, - doc: None, - }, - ArgumentDescriptor { - name: "greeting".into(), - r#type: TypeDescriptor::primitive(PrimitiveType::String), - optional: true, - default: Some(Value::String("Hello".into())), - doc: None, - }, - ], - returns: TypeDescriptor::primitive(PrimitiveType::String), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - }, - ) - .ok(); - registry - .register(NamespaceRegistration { - namespace: "greet".into(), - schema: NamespaceSchema { - functions: Box::new(functions), - doc: None, - }, - provider_id: "p".into(), - registration_token: saikuro_core::RegistrationToken::new(), - }) - .unwrap(); + saikuro_exec::block_on(async { + // Register a function with one required and one optional argument. + let registry = SchemaRegistry::new(); + let mut functions = FunctionMap::new(); + functions + .insert( + "greet".into(), + FunctionSchema { + args: vec![ + ArgumentDescriptor { + name: "name".into(), + r#type: TypeDescriptor::primitive(PrimitiveType::String), + optional: false, + default: None, + doc: None, + }, + ArgumentDescriptor { + name: "greeting".into(), + r#type: TypeDescriptor::primitive(PrimitiveType::String), + optional: true, + default: Some(Value::String("Hello".into())), + doc: None, + }, + ], + returns: TypeDescriptor::primitive(PrimitiveType::String), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + }, + ) + .ok(); + registry + .register(NamespaceRegistration { + namespace: "greet".into(), + schema: NamespaceSchema { + functions: Box::new(functions), + doc: None, + }, + provider_id: "p".into(), + registration_token: saikuro_core::RegistrationToken::new(), + }) + .await + .unwrap(); - let validator = InvocationValidator::new(registry); - // Providing only the required argument should pass. - let env = Envelope::call("greet.greet", vec![Value::String("Alice".into())]) - .expect("entropy available"); - assert!( - validator.validate(&env).is_ok(), - "one-arg call to two-arg fn (second optional) should pass" - ); + let validator = InvocationValidator::new(registry); + // Providing only the required argument should pass. + let env = Envelope::call("greet.greet", vec![Value::String("Alice".into())]) + .expect("entropy available"); + assert!( + validator.validate(&env).await.is_ok(), + "one-arg call to two-arg fn (second optional) should pass" + ); + }); } diff --git a/Build/tests/saikuro-schema/validator.rs b/Build/tests/saikuro-schema/validator.rs index 0e3844bb..19e904a8 100644 --- a/Build/tests/saikuro-schema/validator.rs +++ b/Build/tests/saikuro-schema/validator.rs @@ -5,14 +5,16 @@ use saikuro_schema::validator::InvocationValidator; #[test] fn batch_with_empty_items_returns_empty_batch_error() { - let registry = SchemaRegistry::new(); - let validator = InvocationValidator::new(registry); + saikuro_exec::block_on(async { + let registry = SchemaRegistry::new(); + let validator = InvocationValidator::new(registry); - let mut batch = Envelope::call("", vec![]).expect("entropy available"); - batch.invocation_type = InvocationType::Batch; - batch.target = String::new(); - batch.batch_items = Some(vec![]); + let mut batch = Envelope::call("", vec![]).expect("entropy available"); + batch.invocation_type = InvocationType::Batch; + batch.target = String::new(); + batch.batch_items = Some(vec![]); - let result = validator.validate(&batch); - assert!(matches!(result, Err(SaikuroError::EmptyBatch))); + let result = validator.validate(&batch).await; + assert!(matches!(result, Err(SaikuroError::EmptyBatch))); + }); } diff --git a/Build/tests/saikuro-storage/inmemory.rs b/Build/tests/saikuro-storage/inmemory.rs index 58b6c5a3..1bb1910e 100644 --- a/Build/tests/saikuro-storage/inmemory.rs +++ b/Build/tests/saikuro-storage/inmemory.rs @@ -15,11 +15,17 @@ fn new_creates_empty_store() { assert_eq!(s.config(), &StorageConfig::default()); } +fn null_log() -> std::sync::Arc { + std::sync::Arc::from(Box::new(saikuro_event::NullSink) as Box) +} + #[test] fn with_config_applies_config() { - let cfg = StorageConfig::durable().with_prefix("test"); - let s = InMemoryStorage::with_config(cfg.clone()); - assert_eq!(s.config(), &cfg); + saikuro_exec::block_on(async { + let cfg = StorageConfig::durable().with_prefix("test"); + let s = InMemoryStorage::with_config(cfg.clone(), null_log()).await; + assert_eq!(s.config(), &cfg); + }) } // put / get / exists @@ -68,7 +74,7 @@ fn exists_errors_on_missing_namespace() { namespace_prefix: Some("x".into()), ..Default::default() }; - let s = InMemoryStorage::with_config(cfg); + let s = InMemoryStorage::with_config(cfg, null_log()).await; let r = s.exists("nonexistent", "k").await; assert!(r.is_err()); }) @@ -255,7 +261,7 @@ fn put_fails_when_auto_create_disabled() { auto_create_namespaces: false, ..Default::default() }; - let s = InMemoryStorage::with_config(cfg); + let s = InMemoryStorage::with_config(cfg, null_log()).await; let r = s.put("manual", "k", Bytes::from("v")).await; assert!(r.is_err()); }) @@ -264,10 +270,14 @@ fn put_fails_when_auto_create_disabled() { #[test] fn get_fails_on_missing_namespace_without_auto_create() { saikuro_exec::block_on(async { - let s = InMemoryStorage::with_config(StorageConfig { - auto_create_namespaces: false, - ..Default::default() - }); + let s = InMemoryStorage::with_config( + StorageConfig { + auto_create_namespaces: false, + ..Default::default() + }, + null_log(), + ) + .await; let r = s.get("nowhere", "k").await; assert!(r.is_err()); }) @@ -278,8 +288,16 @@ fn get_fails_on_missing_namespace_without_auto_create() { #[test] fn namespace_prefix_isolates_storage() { saikuro_exec::block_on(async { - let a = InMemoryStorage::with_config(StorageConfig::default().with_prefix("tenant_a")); - let b = InMemoryStorage::with_config(StorageConfig::default().with_prefix("tenant_b")); + let a = InMemoryStorage::with_config( + StorageConfig::default().with_prefix("tenant_a"), + null_log(), + ) + .await; + let b = InMemoryStorage::with_config( + StorageConfig::default().with_prefix("tenant_b"), + null_log(), + ) + .await; a.put("ns", "k", Bytes::from("from_a")).await.unwrap(); b.put("ns", "k", Bytes::from("from_b")).await.unwrap(); @@ -292,7 +310,9 @@ fn namespace_prefix_isolates_storage() { #[test] fn namespace_prefix_list_namespaces_is_stripped() { saikuro_exec::block_on(async { - let s = InMemoryStorage::with_config(StorageConfig::default().with_prefix("app")); + let s = + InMemoryStorage::with_config(StorageConfig::default().with_prefix("app"), null_log()) + .await; s.put("myns", "k", Bytes::from("v")).await.unwrap(); let nss = s.list_namespaces().await.unwrap(); assert_eq!(nss, vec!["myns"]); diff --git a/Build/tests/saikuro-transport/transport_compliance.rs b/Build/tests/saikuro-transport/transport_compliance.rs index 6df46f35..5d1fe694 100644 --- a/Build/tests/saikuro-transport/transport_compliance.rs +++ b/Build/tests/saikuro-transport/transport_compliance.rs @@ -10,12 +10,15 @@ use bytes::Bytes; use saikuro_exec::sync::Barrier; use saikuro_exec::{block_on, spawn, yield_now}; -use saikuro_transport::memory::MemoryTransport; -use saikuro_transport::traits::{Transport, TransportReceiver, TransportSender}; +use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; use std::sync::Arc; // COMPLIANCE TEST SUITE +fn null_log() -> Arc { + Arc::from(Box::new(saikuro_event::NullSink) as Box) +} + /// Run the full compliance suite against a transport pair factory. /// /// `factory` must return a connected `(transport_a, transport_b)` pair @@ -210,10 +213,14 @@ fn many_sequential_transports_correct(pair: (MemoryTransport, MemoryTransport)) #[test] fn memory_transport_compliance() { - run_transport_compliance(MemoryTransport::connected_pair); + let log = null_log(); + run_transport_compliance(move || MemoryTransport::connected_pair(log.clone())); } #[test] fn memory_transport_compliance_labeled() { - run_transport_compliance(|| MemoryTransport::pair("compliance-a", "compliance-b")); + let log = null_log(); + run_transport_compliance(move || { + MemoryTransport::pair("compliance-a", "compliance-b", log.clone()) + }); } diff --git a/Build/tests/saikuro-transport/transport_framing.rs b/Build/tests/saikuro-transport/transport_framing.rs index 77426909..b9ec4763 100644 --- a/Build/tests/saikuro-transport/transport_framing.rs +++ b/Build/tests/saikuro-transport/transport_framing.rs @@ -3,9 +3,9 @@ use bytes::{BufMut, Bytes, BytesMut}; use futures::{SinkExt, StreamExt}; use saikuro_exec::block_on; -use saikuro_exec::io::AsyncWriteExt; -use saikuro_transport::error::TransportError; -use saikuro_transport::framing::{FramedStream, LengthPrefixedCodec}; +use saikuro_transport::shared::framing::{FramedStream, LengthPrefixedCodec}; +use saikuro_transport::TransportError; +use tokio::io::AsyncWriteExt; fn encode_frames(items: &[Bytes]) -> BytesMut { let mut codec = LengthPrefixedCodec::new(); @@ -100,7 +100,7 @@ fn codec_encode_rejects_oversized_frame() { #[test] fn framed_stream_roundtrips_multiple_frames() { block_on(async { - let (client, server) = saikuro_exec::io::duplex(1024 * 1024); + let (client, server) = tokio::io::duplex(1024 * 1024); let framed_client = FramedStream::new(client); let (mut tx, _rx) = framed_client.split(); let mut framed_server = FramedStream::new(server); @@ -127,11 +127,11 @@ fn framed_stream_roundtrips_multiple_frames() { #[test] fn framed_stream_truncated_frame_errors() { block_on(async { - let (client, server) = saikuro_exec::io::duplex(4096); + let (client, server) = tokio::io::duplex(4096); // Write a length header promising 100 bytes, then only 3 bytes, and // drop the write half: the reader must report a framing error, not // silently return a short frame or hang. - let (_rx, mut tx) = saikuro_exec::io::split(client); + let (_rx, mut tx) = tokio::io::split(client); let mut framed_server = FramedStream::new(server); let mut partial = BytesMut::new(); @@ -155,8 +155,8 @@ fn framed_stream_truncated_frame_errors() { #[test] fn framed_stream_rejects_header_only_eof() { block_on(async { - let (client, server) = saikuro_exec::io::duplex(4096); - let (_rx, mut tx) = saikuro_exec::io::split(client); + let (client, server) = tokio::io::duplex(4096); + let (_rx, mut tx) = tokio::io::split(client); let mut framed_server = FramedStream::new(server); let mut header = BytesMut::new(); @@ -176,8 +176,8 @@ fn framed_stream_rejects_header_only_eof() { #[test] fn framed_stream_stays_terminal_after_oversized_frame_error() { block_on(async { - let (client, server) = saikuro_exec::io::duplex(4096); - let (_rx, mut tx) = saikuro_exec::io::split(client); + let (client, server) = tokio::io::duplex(4096); + let (_rx, mut tx) = tokio::io::split(client); let mut framed_server = FramedStream::new(server); // Forge an oversized length header. The byte stream is unaligned @@ -200,7 +200,7 @@ fn framed_stream_stays_terminal_after_oversized_frame_error() { #[test] fn framed_stream_supports_bidirectional_use() { block_on(async { - let (client, server) = saikuro_exec::io::duplex(4096); + let (client, server) = tokio::io::duplex(4096); let (mut client_tx, mut client_rx) = FramedStream::new(client).split(); let (mut server_tx, mut server_rx) = FramedStream::new(server).split(); @@ -217,7 +217,7 @@ fn framed_stream_supports_bidirectional_use() { #[test] fn framed_stream_tcp_roundtrip_concurrent() { block_on(async { - let listener = saikuro_exec::net::TcpListener::bind("127.0.0.1:0") + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let addr = listener.local_addr().expect("local addr"); @@ -233,9 +233,7 @@ fn framed_stream_tcp_roundtrip_concurrent() { }); let client_task = saikuro_exec::spawn(async move { - let stream = saikuro_exec::net::TcpStream::connect(addr) - .await - .expect("connect"); + let stream = tokio::net::TcpStream::connect(addr).await.expect("connect"); let (mut tx, _rx) = FramedStream::new(stream).split(); for i in 0..5 { let payload = Bytes::from(vec![i as u8; 300_000]); @@ -263,7 +261,7 @@ fn framed_stream_tcp_raw_writer() { // Server side uses FramedStream; client writes pre-encoded wire bytes // directly, isolating the read path. block_on(async { - let listener = saikuro_exec::net::TcpListener::bind("127.0.0.1:0") + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let addr = listener.local_addr().expect("local addr"); @@ -279,10 +277,8 @@ fn framed_stream_tcp_raw_writer() { }); let client_task = saikuro_exec::spawn(async move { - let stream = saikuro_exec::net::TcpStream::connect(addr) - .await - .expect("connect"); - let (_r, mut w) = saikuro_exec::io::split(stream); + let stream = tokio::net::TcpStream::connect(addr).await.expect("connect"); + let (_r, mut w) = tokio::io::split(stream); let mut codec = LengthPrefixedCodec::new(); let mut wire = BytesMut::new(); for i in 0..5 { diff --git a/Build/tests/saikuro-transport/transport_memory_stress.rs b/Build/tests/saikuro-transport/transport_memory_stress.rs index 580b3723..5f9f2552 100644 --- a/Build/tests/saikuro-transport/transport_memory_stress.rs +++ b/Build/tests/saikuro-transport/transport_memory_stress.rs @@ -4,17 +4,21 @@ //! concurrency, rapid connect-disconnect cycles, and backpressure //! scenarios specific to the in-memory channel backend. -use saikuro_transport::{ - memory::MemoryTransport, - traits::{Transport, TransportReceiver, TransportSender}, -}; +use bytes::Bytes; +use saikuro_exec::sync::Barrier; +use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; +use std::sync::Arc; + +fn null_log() -> Arc { + Arc::from(Box::new(saikuro_event::NullSink) as Box) +} // HIGH-VOLUME THROUGHPUT #[test] fn ten_thousand_frames_in_order() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(); + let (a, b) = MemoryTransport::connected_pair(null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -42,7 +46,7 @@ fn ten_thousand_frames_in_order() { #[test] fn concurrent_bidirectional_stress() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(); + let (a, b) = MemoryTransport::connected_pair(null_log()); let (mut a_tx, mut a_rx) = a.split(); let (mut b_tx, mut b_rx) = b.split(); @@ -79,7 +83,7 @@ fn concurrent_bidirectional_stress() { #[test] fn backpressure_sender_blocks_until_drain() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(); + let (a, b) = MemoryTransport::connected_pair(null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -115,7 +119,7 @@ fn backpressure_sender_blocks_until_drain() { fn rapid_connect_disconnect_cycles() { saikuro_exec::block_on(async { for _ in 0..100 { - let (a, b) = MemoryTransport::connected_pair(); + let (a, b) = MemoryTransport::connected_pair(null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -133,7 +137,7 @@ fn rapid_connect_disconnect_cycles() { #[test] fn max_size_frame_just_under_limit() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(); + let (a, b) = MemoryTransport::connected_pair(null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -149,7 +153,7 @@ fn max_size_frame_just_under_limit() { #[test] fn zero_length_frames_dont_confuse_ordering() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(); + let (a, b) = MemoryTransport::connected_pair(null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -169,7 +173,7 @@ fn zero_length_frames_dont_confuse_ordering() { #[test] fn many_concurrent_senders_single_receiver() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(); + let (a, b) = MemoryTransport::connected_pair(null_log()); let (mut sender_base, _) = a.split(); let (_, mut receiver) = b.split(); @@ -179,7 +183,7 @@ fn many_concurrent_senders_single_receiver() { // Since TransportSender::send takes &mut self, each sender must be // used from one task. Create multiple transports for parallelism. for i in 0..n { - let (a_i, b_i) = MemoryTransport::connected_pair(); + let (a_i, b_i) = MemoryTransport::connected_pair(null_log()); let (mut tx_i, _) = a_i.split(); let (_, mut rx_i) = b_i.split(); handles.push(saikuro_exec::spawn(async move { @@ -205,7 +209,7 @@ fn many_concurrent_senders_single_receiver() { #[test] fn drop_receiver_while_sender_is_sending() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(); + let (a, b) = MemoryTransport::connected_pair(null_log()); let (mut sender, _) = a.split(); let (_, receiver) = b.split(); @@ -240,8 +244,8 @@ fn drop_receiver_while_sender_is_sending() { #[test] fn labels_do_not_cross_transports() { saikuro_exec::block_on(async { - let (a1, b1) = MemoryTransport::pair("sys-A", "sys-B"); - let (a2, b2) = MemoryTransport::pair("sys-C", "sys-D"); + let (a1, b1) = MemoryTransport::pair("sys-A", "sys-B", null_log()); + let (a2, b2) = MemoryTransport::pair("sys-C", "sys-D", null_log()); let (mut a1_tx, _) = a1.split(); let (_, mut b1_rx) = b1.split(); From 783841eb9e6b8367143c1f1d3b9a8d70d5775002 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Thu, 20 Aug 2026 14:51:19 -0600 Subject: [PATCH 42/43] All tests pass? --- Build/adapters/c/src/lib.rs | 518 +++++++++++++++++- Build/adapters/c/tests/c_api_runtime.rs | 12 +- Build/adapters/c/tests/c_api_smoke.rs | 7 +- Build/adapters/c/tests/c_api_validation.rs | 5 + Build/adapters/c/tests/common/mod.rs | 7 + Build/adapters/rust/tests/integration.rs | 24 +- Build/crates/saikuro-exec/native/watch.rs | 23 +- Build/crates/saikuro-random/base/mod.rs | 3 + Build/crates/saikuro-random/native/mod.rs | 3 + Build/crates/saikuro-random/shared/mod.rs | 16 +- Build/crates/saikuro-random/wasm/mod.rs | 3 + Build/crates/saikuro-runtime/native/mod.rs | 2 + Build/tests/Cargo.toml | 7 + Build/tests/lib.rs | 2 + .../tests/saikuro-core/cross_language_wire.rs | 12 +- 15 files changed, 614 insertions(+), 30 deletions(-) diff --git a/Build/adapters/c/src/lib.rs b/Build/adapters/c/src/lib.rs index 74384b84..878bbf81 100644 --- a/Build/adapters/c/src/lib.rs +++ b/Build/adapters/c/src/lib.rs @@ -114,6 +114,12 @@ mod exec { .get_or_init(|| TokioRuntime::new().expect("saikuro-c: failed to start tokio runtime")); rt.handle().spawn(fut); } + + pub(super) fn block_on(fut: F) -> F::Output { + let rt = RT + .get_or_init(|| TokioRuntime::new().expect("saikuro-c: failed to start tokio runtime")); + rt.block_on(fut) + } } #[cfg(feature = "native")] @@ -124,6 +130,11 @@ where exec::spawn(fut); } +#[cfg(feature = "native")] +fn block_on_future(fut: F) -> F::Output { + exec::block_on(fut) +} + #[cfg(not(feature = "native"))] fn spawn_future(fut: F) where @@ -432,10 +443,11 @@ pub extern "C" fn saikuro_client_close_async( return; } - let handle = unsafe { Box::from_raw(handle as *mut ClientHandle) }; + let handle_ref = unsafe { &mut *(handle as *mut ClientHandle) }; + let client = handle_ref.client.take(); let user_data_addr = user_data as usize; spawn_future(async move { - let status = match handle.client { + let status = match client { Some(client) => int_saikuro(client.close().await, "close"), None => 0, }; @@ -1350,3 +1362,505 @@ pub extern "C" fn saikuro_provider_free(handle: *mut c_void) { let mut boxed = unsafe { Box::from_raw(handle as *mut ProviderHandle) }; let _ = boxed.provider.take(); } + +// Synchronous blocking API +// These block the calling thread on the global tokio runtime. + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_client_connect(address: *const c_char) -> *mut c_void { + clear_last_error(); + let address = match cstr_to_string(address, "address") { + Ok(s) => s, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + + let client = match block_on_future(saikuro::Client::connect(address)) { + Ok(c) => c, + Err(e) => { + set_last_error(format!("failed to connect client: {e}")); + return ptr::null_mut(); + } + }; + + Box::into_raw(Box::new(ClientHandle { + client: Some(client), + })) as *mut c_void +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_client_close(handle: *mut c_void) -> c_int { + clear_last_error(); + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + return 1; + } + let handle_ref = unsafe { &mut *(handle as *mut ClientHandle) }; + let client = match handle_ref.client.take() { + Some(c) => c, + None => return 0, + }; + int_saikuro(block_on_future(client.close()), "close") +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_client_call_json( + handle: *mut c_void, + target: *const c_char, + args_json: *const c_char, +) -> *mut c_char { + clear_last_error(); + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + return ptr::null_mut(); + } + let (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(v) => v, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + ptr_saikuro(block_on_future(h.client().call(target, args)), "call") +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_client_call_json_timeout( + handle: *mut c_void, + target: *const c_char, + args_json: *const c_char, + timeout_ms: c_int, +) -> *mut c_char { + clear_last_error(); + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + return ptr::null_mut(); + } + if timeout_ms < 0 { + set_last_error("timeout_ms must be non-negative"); + return ptr::null_mut(); + } + let (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(v) => v, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + let timeout = core::time::Duration::from_millis(timeout_ms as u64); + let h = unsafe { &*(handle as *const ClientHandle) }; + ptr_saikuro( + block_on_future(h.client().call_with_timeout(target, args, Some(timeout))), + "call", + ) +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_client_cast_json( + handle: *mut c_void, + target: *const c_char, + args_json: *const c_char, +) -> c_int { + clear_last_error(); + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + return 1; + } + let (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(v) => v, + Err(e) => { + set_last_error(e); + return 1; + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + int_saikuro(block_on_future(h.client().cast(target, args)), "cast") +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_client_batch_json( + handle: *mut c_void, + calls_json: *const c_char, +) -> *mut c_char { + clear_last_error(); + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + return ptr::null_mut(); + } + let raw = match cstr_to_string(calls_json, "calls_json") { + Ok(s) => s, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + let calls = match parse_batch_calls(&raw) { + Ok(c) => c, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + let res = block_on_future(h.client().batch(calls)); + match res { + Ok(v) => match serde_json::to_string(&v) { + Ok(json) => into_c_string_ptr(&json), + Err(e) => { + set_last_error(format!("failed to serialize result: {e}")); + ptr::null_mut() + } + }, + Err(e) => { + set_last_error(format!("batch failed: {e}")); + ptr::null_mut() + } + } +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_client_stream_json( + handle: *mut c_void, + target: *const c_char, + args_json: *const c_char, +) -> *mut c_void { + clear_last_error(); + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + return ptr::null_mut(); + } + let (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(v) => v, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + match block_on_future(h.client().stream(target, args)) { + Ok(stream) => Box::into_raw(Box::new(StreamHandle { stream })) as *mut c_void, + Err(e) => { + set_last_error(format!("stream open failed: {e}")); + ptr::null_mut() + } + } +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_client_channel_json( + handle: *mut c_void, + target: *const c_char, + args_json: *const c_char, +) -> *mut c_void { + clear_last_error(); + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + return ptr::null_mut(); + } + let (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(v) => v, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + match block_on_future(h.client().channel(target, args)) { + Ok(channel) => Box::into_raw(Box::new(ChannelHandle { channel })) as *mut c_void, + Err(e) => { + set_last_error(format!("channel open failed: {e}")); + ptr::null_mut() + } + } +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_channel_send_json( + channel: *mut c_void, + item_json: *const c_char, +) -> c_int { + clear_last_error(); + if channel.is_null() { + set_last_error("channel must not be null"); + return 1; + } + let item_json = match cstr_to_string(item_json, "item_json") { + Ok(s) => s, + Err(e) => { + set_last_error(e); + return 1; + } + }; + let item: Value = match serde_json::from_str(&item_json) { + Ok(v) => v, + Err(e) => { + set_last_error(format!("item_json must be valid JSON: {e}")); + return 1; + } + }; + let c = unsafe { &mut *(channel as *mut ChannelHandle) }; + int_saikuro(block_on_future(c.channel.send(item)), "channel send") +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_channel_close(channel: *mut c_void) -> c_int { + clear_last_error(); + if channel.is_null() { + set_last_error("channel must not be null"); + return 1; + } + let c = unsafe { &mut *(channel as *mut ChannelHandle) }; + int_saikuro(block_on_future(c.channel.close()), "channel close") +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_channel_abort(channel: *mut c_void) -> c_int { + clear_last_error(); + if channel.is_null() { + set_last_error("channel must not be null"); + return 1; + } + let c = unsafe { &mut *(channel as *mut ChannelHandle) }; + int_saikuro(block_on_future(c.channel.abort()), "channel abort") +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_channel_next_json( + channel: *mut c_void, + out_item_json: *mut *mut c_char, + out_done: *mut c_int, +) -> c_int { + clear_last_error(); + if channel.is_null() { + set_last_error("channel must not be null"); + return 1; + } + let c = unsafe { &mut *(channel as *mut ChannelHandle) }; + match block_on_future(c.channel.next()) { + Some(Ok(value)) => match serde_json::to_string(&value) { + Ok(json) => { + unsafe { + *out_item_json = into_c_string_ptr(&json); + *out_done = 0; + } + 0 + } + Err(e) => { + set_last_error(format!("failed to serialize channel item: {e}")); + unsafe { + *out_item_json = ptr::null_mut(); + *out_done = 1; + } + 1 + } + }, + Some(Err(e)) => { + set_last_error(format!("channel receive failed: {e}")); + unsafe { + *out_item_json = ptr::null_mut(); + *out_done = 1; + } + 1 + } + None => { + unsafe { + *out_item_json = ptr::null_mut(); + *out_done = 1; + } + 0 + } + } +} + +#[no_mangle] +pub extern "C" fn saikuro_channel_free(channel: *mut c_void) { + if channel.is_null() { + return; + } + let _ = unsafe { Box::from_raw(channel as *mut ChannelHandle) }; +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_stream_next_json( + stream: *mut c_void, + out_item_json: *mut *mut c_char, + out_done: *mut c_int, +) -> c_int { + clear_last_error(); + if stream.is_null() { + set_last_error("stream must not be null"); + return 1; + } + let s = unsafe { &mut *(stream as *mut StreamHandle) }; + match block_on_future(s.stream.next()) { + Some(Ok(value)) => match serde_json::to_string(&value) { + Ok(json) => { + unsafe { + *out_item_json = into_c_string_ptr(&json); + *out_done = 0; + } + 0 + } + Err(e) => { + set_last_error(format!("failed to serialize stream item: {e}")); + unsafe { + *out_item_json = ptr::null_mut(); + *out_done = 1; + } + 1 + } + }, + Some(Err(e)) => { + set_last_error(format!("stream receive failed: {e}")); + unsafe { + *out_item_json = ptr::null_mut(); + *out_done = 1; + } + 1 + } + None => { + unsafe { + *out_item_json = ptr::null_mut(); + *out_done = 1; + } + 0 + } + } +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_client_resource_json( + handle: *mut c_void, + target: *const c_char, + args_json: *const c_char, +) -> *mut c_char { + clear_last_error(); + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + return ptr::null_mut(); + } + let (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(v) => v, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + ptr_saikuro( + block_on_future(h.client().resource(target, args)), + "resource", + ) +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_client_log( + handle: *mut c_void, + level: *const c_char, + name: *const c_char, + msg: *const c_char, + fields_json: *const c_char, +) -> c_int { + clear_last_error(); + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + return 1; + } + let (level, name, msg) = match cstr_to_string(level, "level") + .and_then(|l| cstr_to_string(name, "name").map(|n| (l, n))) + .and_then(|(l, n)| cstr_to_string(msg, "msg").map(|m| (l, n, m))) + { + Ok(v) => v, + Err(e) => { + set_last_error(e); + return 1; + } + }; + let fields = if fields_json.is_null() { + None + } else { + match cstr_to_string(fields_json, "fields_json") + .and_then(|raw| parse_json_object_arg(&raw, "fields_json").map(Value::Object)) + { + Ok(v) => Some(v), + Err(e) => { + set_last_error(e); + return 1; + } + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + int_saikuro( + block_on_future(h.client().log(level, name, msg, fields)), + "log", + ) +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_provider_serve(handle: *mut c_void, address: *const c_char) -> c_int { + clear_last_error(); + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + return 1; + } + let address = match cstr_to_string(address, "address") { + Ok(s) => s, + Err(e) => { + set_last_error(e); + return 1; + } + }; + let handle_ref = unsafe { &mut *(handle as *mut ProviderHandle) }; + let provider = match handle_ref.provider.take() { + Some(p) => p, + None => { + set_last_error("provider has already started serving"); + return 1; + } + }; + int_saikuro(block_on_future(provider.serve(address)), "provider serve") +} + +#[cfg(all(feature = "std", feature = "native"))] +#[no_mangle] +pub extern "C" fn saikuro_provider_close(handle: *mut c_void) -> c_int { + clear_last_error(); + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + return 1; + } + let handle_ref = unsafe { &mut *(handle as *mut ProviderHandle) }; + let _ = handle_ref.provider.take(); + 0 +} diff --git a/Build/adapters/c/tests/c_api_runtime.rs b/Build/adapters/c/tests/c_api_runtime.rs index 77f1684c..942b1e75 100644 --- a/Build/adapters/c/tests/c_api_runtime.rs +++ b/Build/adapters/c/tests/c_api_runtime.rs @@ -1,6 +1,6 @@ use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::thread; use std::time::Duration; @@ -179,9 +179,14 @@ impl Drop for RuntimeHarness { } } +fn shared_runtime() -> &'static RuntimeHarness { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| RuntimeHarness::start()) +} + #[test] fn c_client_call_cast_batch_roundtrip_with_runtime() { - let runtime = RuntimeHarness::start(); + let runtime = shared_runtime(); // Connect. let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_void>(); @@ -265,7 +270,8 @@ fn c_client_call_cast_batch_roundtrip_with_runtime() { #[test] fn c_client_reports_transport_error_when_namespace_missing() { - let runtime = RuntimeHarness::start(); + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); + let runtime = shared_runtime(); // Connect. let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_void>(); diff --git a/Build/adapters/c/tests/c_api_smoke.rs b/Build/adapters/c/tests/c_api_smoke.rs index 191ba364..9e92eabd 100644 --- a/Build/adapters/c/tests/c_api_smoke.rs +++ b/Build/adapters/c/tests/c_api_smoke.rs @@ -28,13 +28,14 @@ fn string_dup_roundtrip() { #[test] fn client_connect_rejects_null_address() { - // Null address is validated synchronously; callback is never called. + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); saikuro_client_connect_async(ptr::null(), Some(common::noop_connect_cb), ptr::null_mut()); assert!(common::take_error().contains("address must not be null")); } #[test] fn provider_register_rejects_null_callback() { + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); let ns = CString::new("math").expect("CString should be created"); let provider = saikuro_provider_new(ns.as_ptr()); assert!(!provider.is_null()); @@ -51,6 +52,7 @@ fn provider_register_rejects_null_callback() { #[test] fn batch_rejects_null_handle() { + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); let calls = CString::new("{}").expect("CString should be created"); let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_char>(); saikuro_client_batch_json_async( @@ -66,6 +68,7 @@ fn batch_rejects_null_handle() { #[test] fn stream_rejects_null_stream_handle() { + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); // Null client handle on open. let (rx, user_data) = common::channel_pair::<*mut c_void>(); saikuro_client_stream_json_async( @@ -91,6 +94,7 @@ fn stream_rejects_null_stream_handle() { #[test] fn channel_calls_reject_null_handles() { + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); // Null client handle on channel open. let (rx, user_data) = common::channel_pair::<*mut c_void>(); saikuro_client_channel_json_async( @@ -130,6 +134,7 @@ fn channel_calls_reject_null_handles() { #[test] fn resource_and_log_reject_null_handles() { + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); let target = CString::new("files.open").expect("CString should be created"); let args = CString::new("[]").expect("CString should be created"); diff --git a/Build/adapters/c/tests/c_api_validation.rs b/Build/adapters/c/tests/c_api_validation.rs index 6bfd65ba..8fc2c56e 100644 --- a/Build/adapters/c/tests/c_api_validation.rs +++ b/Build/adapters/c/tests/c_api_validation.rs @@ -29,12 +29,14 @@ fn string_helpers_work_and_null_is_safe() { #[test] fn client_connect_requires_non_null_address() { + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); saikuro_client_connect_async(ptr::null(), Some(common::noop_connect_cb), ptr::null_mut()); assert!(common::take_error().contains("address must not be null")); } #[test] fn call_cast_batch_require_non_null_handle() { + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); let target = common::c("math.add"); let args = common::c("[1,2]"); @@ -89,6 +91,7 @@ fn call_cast_batch_require_non_null_handle() { #[test] fn stream_and_channel_null_handle_paths_are_safe() { + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); // Null client handle on stream open. let (rx, user_data) = common::channel_pair::<*mut std::ffi::c_void>(); saikuro_client_stream_json_async( @@ -162,6 +165,7 @@ fn stream_and_channel_null_handle_paths_are_safe() { #[test] fn resource_and_log_require_non_null_handle() { + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); let target = common::c("files.open"); let args = common::c("[]"); @@ -205,6 +209,7 @@ unsafe extern "C" fn add_handler( #[test] fn provider_registration_accepts_valid_callback() { + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); let provider = saikuro_provider_new(common::c("math").as_ptr()); assert!(!provider.is_null()); diff --git a/Build/adapters/c/tests/common/mod.rs b/Build/adapters/c/tests/common/mod.rs index f59b2b79..65673045 100644 --- a/Build/adapters/c/tests/common/mod.rs +++ b/Build/adapters/c/tests/common/mod.rs @@ -1,11 +1,18 @@ +#![allow(dead_code)] + use std::ffi::{c_int, c_void, CStr, CString}; use std::sync::mpsc; +use std::sync::Mutex; use std::time::Duration; use saikuro_c::{saikuro_last_error_message, saikuro_string_free}; pub const CALLBACK_TIMEOUT: Duration = Duration::from_secs(5); +/// Serializes access to the global `LAST_ERROR` so parallel tests don't stomp +/// on each other's error messages. +pub static LAST_ERROR_LOCK: Mutex<()> = Mutex::new(()); + pub fn c(text: &str) -> CString { CString::new(text).expect("CString should be created") } diff --git a/Build/adapters/rust/tests/integration.rs b/Build/adapters/rust/tests/integration.rs index 584ed039..9902c49d 100644 --- a/Build/adapters/rust/tests/integration.rs +++ b/Build/adapters/rust/tests/integration.rs @@ -740,11 +740,15 @@ fn create_storage_backend_kind_web_storage_returns_in_memory_on_native() { use saikuro_storage::{BackendKind, StorageConfig}; let cfg = StorageConfig::default().with_backend(BackendKind::WebStorage); - let store = saikuro::create_storage(&cfg).await.expect("web storage"); - // WebStorage on native is an InMemoryStorage alias that does round-trip - store.put("ns", "k", bytes::Bytes::from("v")).await.unwrap(); - let v = store.get("ns", "k").await.unwrap(); - assert_eq!(v, Some(bytes::Bytes::from("v"))); + let result = saikuro::create_storage(&cfg).await; + let err = match result { + Err(e) => e.to_string(), + Ok(_) => panic!("expected error, got Ok"), + }; + assert!( + err.contains("browser") || err.contains("wasm"), + "error mentions browser/wasm: {err}" + ); }) } @@ -759,7 +763,10 @@ fn create_storage_backend_kind_indexeddb_errors_on_native() { Err(e) => e.to_string(), Ok(_) => panic!("expected error, got Ok"), }; - assert!(err.contains("IndexedDB"), "error mentions IndexedDB: {err}"); + assert!( + err.contains("browser") || err.contains("wasm") || err.contains("IndexedDB"), + "error mentions IndexedDB or browser: {err}" + ); }) } @@ -774,7 +781,10 @@ fn create_storage_backend_kind_opfs_errors_on_native() { Err(e) => e.to_string(), Ok(_) => panic!("expected error, got Ok"), }; - assert!(err.contains("OPFS"), "error mentions OPFS: {err}"); + assert!( + err.contains("browser") || err.contains("wasm") || err.contains("OPFS"), + "error mentions OPFS or browser: {err}" + ); }) } diff --git a/Build/crates/saikuro-exec/native/watch.rs b/Build/crates/saikuro-exec/native/watch.rs index 874108a1..8b333512 100644 --- a/Build/crates/saikuro-exec/native/watch.rs +++ b/Build/crates/saikuro-exec/native/watch.rs @@ -1,4 +1,5 @@ use core::future::Future; +use core::marker::PhantomData; use core::pin::Pin; use core::task::{Context, Poll}; @@ -44,23 +45,31 @@ impl Receiver { (*self.inner.borrow()).clone() } - pub fn changed(&mut self) -> ChangedFuture<'_, T> { - ChangedFuture { receiver: self } + pub fn changed(&mut self) -> ChangedFuture<'_, T> + where + T: Send + Sync, + { + ChangedFuture { + inner: Box::pin(self.inner.changed()), + _marker: PhantomData, + } } } pub struct ChangedFuture<'a, T> { - receiver: &'a mut Receiver, + inner: + Pin> + Send + 'a>>, + _marker: PhantomData, } -impl Future for ChangedFuture<'_, T> { +impl Unpin for ChangedFuture<'_, T> {} + +impl Future for ChangedFuture<'_, T> { type Output = Result<(), RecvError>; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let this = self.get_mut(); - let fut = this.receiver.inner.changed(); - tokio::pin!(fut); - match fut.as_mut().poll(cx) { + match this.inner.as_mut().poll(cx) { Poll::Ready(Ok(())) => Poll::Ready(Ok(())), Poll::Ready(Err(_)) => Poll::Ready(Err(RecvError)), Poll::Pending => Poll::Pending, diff --git a/Build/crates/saikuro-random/base/mod.rs b/Build/crates/saikuro-random/base/mod.rs index 35305859..5509b9e9 100644 --- a/Build/crates/saikuro-random/base/mod.rs +++ b/Build/crates/saikuro-random/base/mod.rs @@ -26,5 +26,8 @@ pub fn init_default() -> Result<(), SaikuroError> { #[cfg(feature = "no_std")] #[doc(hidden)] pub fn try_auto_seed() -> Result<(), SaikuroError> { + if crate::shared::is_seeded() { + return Ok(()); + } init(&WasiEntropy) } diff --git a/Build/crates/saikuro-random/native/mod.rs b/Build/crates/saikuro-random/native/mod.rs index ae05683e..13a89b4f 100644 --- a/Build/crates/saikuro-random/native/mod.rs +++ b/Build/crates/saikuro-random/native/mod.rs @@ -21,5 +21,8 @@ pub fn init_default() -> Result<(), SaikuroError> { /// don't have to seed explicitly. #[doc(hidden)] pub fn try_auto_seed() -> Result<(), SaikuroError> { + if crate::shared::is_seeded() { + return Ok(()); + } init(&OsEntropy) } diff --git a/Build/crates/saikuro-random/shared/mod.rs b/Build/crates/saikuro-random/shared/mod.rs index f8fa2317..3dabfde3 100644 --- a/Build/crates/saikuro-random/shared/mod.rs +++ b/Build/crates/saikuro-random/shared/mod.rs @@ -177,11 +177,19 @@ pub fn seed_from_slice(seed: &[u8]) -> Result<(), SaikuroError> { "DRBG seed must be at least {SEED_LEN} bytes" ))); } - if SEEDED.load(Ordering::Acquire) - || INITIALIZING - .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) - .is_err() + if SEEDED.load(Ordering::Acquire) { + return Ok(()); + } + if INITIALIZING + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() { + for _ in 0..1_000_000u32 { + if SEEDED.load(Ordering::Acquire) { + return Ok(()); + } + core::hint::spin_loop(); + } return Err(SaikuroError::Entropy("DRBG has already been seeded".into())); } for (i, word) in SEED.iter().enumerate() { diff --git a/Build/crates/saikuro-random/wasm/mod.rs b/Build/crates/saikuro-random/wasm/mod.rs index 9fa5392d..7c1bac0d 100644 --- a/Build/crates/saikuro-random/wasm/mod.rs +++ b/Build/crates/saikuro-random/wasm/mod.rs @@ -20,5 +20,8 @@ pub fn init_default() -> Result<(), SaikuroError> { /// Called automatically by [`crate::fill`] on first use. #[doc(hidden)] pub fn try_auto_seed() -> Result<(), SaikuroError> { + if crate::shared::is_seeded() { + return Ok(()); + } init(&JsEntropy) } diff --git a/Build/crates/saikuro-runtime/native/mod.rs b/Build/crates/saikuro-runtime/native/mod.rs index 9dad003d..c9ab8fcb 100644 --- a/Build/crates/saikuro-runtime/native/mod.rs +++ b/Build/crates/saikuro-runtime/native/mod.rs @@ -120,9 +120,11 @@ async fn async_main() -> Result<()> { let runtime = Arc::new(builder.build().await); // Set up graceful shutdown channel. + #[allow(unused_variables)] let (shutdown_tx, shutdown_rx) = watch::channel(false); // Each enabled listener type is driven by its own `serve` task. + #[allow(unused_mut)] let mut serve_tasks: Vec> = Vec::new(); // TCP listener. diff --git a/Build/tests/Cargo.toml b/Build/tests/Cargo.toml index b089adb7..673151ac 100644 --- a/Build/tests/Cargo.toml +++ b/Build/tests/Cargo.toml @@ -12,6 +12,13 @@ path = "lib.rs" [features] flash = ["saikuro-storage/embedded", "dep:embedded-storage-async", "dep:futures-executor"] +embassy-test = [] +drbg = [] +os = [] +wasm = [] +custom = [] +embedded-io = [] +net = [] [dependencies] saikuro-core = { workspace = true } diff --git a/Build/tests/lib.rs b/Build/tests/lib.rs index a0a32e7a..1fb51214 100644 --- a/Build/tests/lib.rs +++ b/Build/tests/lib.rs @@ -1,3 +1,5 @@ +#![allow(unused_imports, dead_code)] + mod common; #[path = "saikuro-codegen"] diff --git a/Build/tests/saikuro-core/cross_language_wire.rs b/Build/tests/saikuro-core/cross_language_wire.rs index 9280986d..0e5dcec1 100644 --- a/Build/tests/saikuro-core/cross_language_wire.rs +++ b/Build/tests/saikuro-core/cross_language_wire.rs @@ -297,7 +297,7 @@ fn m_rust_adapter_client_calls_runtime_provider() { assert_eq!(result, serde_json::json!(-42), "negate(42) must return -42"); client.close().await.expect("close"); - bridge.abort(); + let _ = bridge.abort(); }) } @@ -418,8 +418,8 @@ fn n_rust_adapter_provider_serves_simulated_client() { ); drop(client_tx); - serve_task.abort(); - bridge.abort(); + let _ = serve_task.abort(); + let _ = bridge.abort(); }) } @@ -564,7 +564,7 @@ fn c_rust_and_simulated_providers_coexist() { assert_eq!(echo_resp.result, Some(Value::String("hello".into()))); drop(client_tx); - ext_loop.abort(); + let _ = ext_loop.abort(); }) } @@ -756,7 +756,7 @@ fn f_announce_then_client_call_round_trip() { assert_eq!(resp.result, Some(Value::Int(81)), "9² = 81"); drop(cli_tx); - prov_loop.abort(); + let _ = prov_loop.abort(); }) } @@ -939,7 +939,7 @@ fn i_provider_reconnect_and_reannounce() { assert_eq!(resp2.result, Some(Value::Int(2)), "v2 provider must answer"); drop(cli2_tx); - prov2_loop.abort(); + let _ = prov2_loop.abort(); }) } From 748694b5710a1edcfac023090ae7308bd26db296 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Thu, 20 Aug 2026 22:17:13 -0600 Subject: [PATCH 43/43] idk anymore, i'm taking a break --- Build/Cargo.lock | 3 - Build/adapters/c/src/lib.rs | 670 ++++++++++-------- Build/adapters/c/tests/c_api_runtime.rs | 1 + .../adapters/cpp/include/saikuro/saikuro.hpp | 12 +- Build/adapters/rust/Cargo.toml | 7 - Build/adapters/rust/src/client.rs | 178 ++--- Build/adapters/rust/src/error.rs | 4 - Build/adapters/rust/src/lib.rs | 2 +- Build/adapters/rust/src/provider.rs | 5 - Build/adapters/rust/src/schema.rs | 23 +- Build/adapters/rust/src/transport.rs | 29 +- Build/adapters/rust/tests/schema_capacity.rs | 17 +- .../saikuro-runtime/shared/connection.rs | 6 +- Build/crates/saikuro-runtime/shared/handle.rs | 10 +- Build/tests/Cargo.toml | 6 - Build/tests/common/mod.rs | 51 +- Build/tests/lib.rs | 17 - .../tests/saikuro-core/cross_language_wire.rs | 22 +- .../saikuro-exec/embassy_cancellation.rs | 203 ------ Build/tests/saikuro-exec/embassy_executor.rs | 131 ---- .../tests/saikuro-net/embassy_net_loopback.rs | 153 ---- Build/tests/saikuro-random/drbg.rs | 109 --- Build/tests/saikuro-random/drbg_unseeded.rs | 14 - Build/tests/saikuro-random/os_backend.rs | 39 - .../tests/saikuro-router/announce_dispatch.rs | 36 +- Build/tests/saikuro-router/call_dispatch.rs | 31 +- .../tests/saikuro-router/resource_dispatch.rs | 25 +- .../tests/saikuro-router/sandbox_dispatch.rs | 69 +- Build/tests/saikuro-schema/validator.rs | 20 - Build/tests/saikuro-storage/inmemory.rs | 26 +- .../saikuro-transport/transport_compliance.rs | 10 +- .../saikuro-transport/transport_framing.rs | 305 -------- .../transport_memory_stress.rs | 26 +- 33 files changed, 577 insertions(+), 1683 deletions(-) delete mode 100644 Build/tests/saikuro-exec/embassy_cancellation.rs delete mode 100644 Build/tests/saikuro-exec/embassy_executor.rs delete mode 100644 Build/tests/saikuro-net/embassy_net_loopback.rs delete mode 100644 Build/tests/saikuro-random/drbg.rs delete mode 100644 Build/tests/saikuro-random/drbg_unseeded.rs delete mode 100644 Build/tests/saikuro-random/os_backend.rs delete mode 100644 Build/tests/saikuro-schema/validator.rs delete mode 100644 Build/tests/saikuro-transport/transport_framing.rs diff --git a/Build/Cargo.lock b/Build/Cargo.lock index 9580798a..fab2daaf 100644 --- a/Build/Cargo.lock +++ b/Build/Cargo.lock @@ -1446,15 +1446,12 @@ dependencies = [ "clap", "dashmap 6.2.1", "futures", - "portable-atomic", "portable-atomic-util", "saikuro-core", "saikuro-event", "saikuro-exec", - "saikuro-random", "saikuro-storage", "saikuro-transport", - "serde", "serde_json", "syn", "thiserror", diff --git a/Build/adapters/c/src/lib.rs b/Build/adapters/c/src/lib.rs index 878bbf81..9e753199 100644 --- a/Build/adapters/c/src/lib.rs +++ b/Build/adapters/c/src/lib.rs @@ -162,28 +162,53 @@ pub type SaikuroStatusCb = extern "C" fn(c_int, *mut c_void); pub type SaikuroItemCb = extern "C" fn(*mut c_char, c_int, *mut c_void); // Handles. +// +// Every opaque C handle boxes exactly one Rust object; C callers release them +// with the matching `saikuro_*_free` function. -#[cfg(feature = "std")] -struct ClientHandle { - client: Option, -} +mod handles { + use super::*; -#[cfg(feature = "std")] -impl ClientHandle { - fn client(&self) -> &Client { - self.client.as_ref().expect("client already closed") + /// Owned client connection. `client` is `None` while a connect is in + /// flight and again once a close has been requested. + #[cfg(feature = "std")] + pub(crate) struct ClientHandle { + pub(crate) client: Option, } -} -#[cfg(feature = "std")] -struct StreamHandle { - stream: SaikuroStream, + #[cfg(feature = "std")] + impl ClientHandle { + pub(crate) fn client(&self) -> &Client { + self.client.as_ref().expect("client already closed") + } + } + + #[cfg(feature = "std")] + pub(crate) struct StreamHandle { + pub(crate) stream: SaikuroStream, + } + + #[cfg(feature = "std")] + pub(crate) struct ChannelHandle { + pub(crate) channel: SaikuroChannel, + } + + pub(crate) struct ProviderHandle { + pub(crate) provider: Option, + } + + // Lifetime-safe shared accessor for spawned futures: the C contract + // requires the handle to outlive every callback it spawned, so the + // unbounded borrow cannot dangle in practice. + #[cfg(feature = "std")] + pub(crate) fn client_ref(h: *mut c_void) -> &'static ClientHandle { + unsafe { &*(h as *const ClientHandle) } + } } +use handles::ProviderHandle; #[cfg(feature = "std")] -struct ChannelHandle { - channel: SaikuroChannel, -} +use handles::{client_ref, ChannelHandle, ClientHandle, StreamHandle}; /// C callback for provider functions. /// @@ -195,10 +220,6 @@ struct ChannelHandle { /// ownership does not match `CString::from_raw` expectations. type ProviderHandler = unsafe extern "C" fn(*mut c_void, *const c_char) -> *mut c_char; -struct ProviderHandle { - provider: Option, -} - // Parsing / serialisation helpers. fn cstr_to_string(ptr: *const c_char, arg_name: &str) -> Result { @@ -211,11 +232,19 @@ fn cstr_to_string(ptr: *const c_char, arg_name: &str) -> Result Ok(s.to_owned()) } +/// Copy `s` into a freshly allocated C string, transferring ownership to the +/// caller. +/// +/// Interior NUL bytes cannot round-trip through a C string, so instead of +/// silently corrupting the value the allocation fails, the reason is recorded +/// in the last-error slot, and null is returned. fn into_c_string_ptr(s: &str) -> *mut c_char { - let sanitized = s.replace('\0', " "); - match CString::new(sanitized) { + match CString::new(s) { Ok(cs) => cs.into_raw(), - Err(_) => ptr::null_mut(), + Err(_) => { + set_last_error("value contains null byte"); + ptr::null_mut() + } } } @@ -322,10 +351,209 @@ fn int_saikuro(result: Result<(), saikuro::Error>, op: &str) -> c_int { } } -// Lifetime-safe handle accessors for spawned futures. +// Shared client operation bodies. +// +// The blocking (`saikuro_*`) and callback-based (`saikuro_*_async`) entry +// points both delegate to these futures so each operation's invoke / serialize / +// error-recording logic exists exactly once. Parameters are already-decoded +// Rust values; the C-facing wrappers own pointer validation, string decoding, +// and result plumbing (return value or callback invocation). + +/// Outcome of a single `next()` poll on a stream or channel. +#[cfg(feature = "std")] +enum NextOutcome { + /// A value arrived; carries its JSON encoding. + Item(String), + /// The source closed cleanly. + Done, + /// Receive or serialisation failed; `LAST_ERROR` records why. + Failed, +} + +/// Map a [`NextOutcome`] to the `(item, done)` pair the C API reports. +#[cfg(feature = "std")] +fn next_outcome_parts(outcome: NextOutcome) -> (*mut c_char, c_int) { + match outcome { + NextOutcome::Item(json) => { + let item = into_c_string_ptr(&json); + let done = c_int::from(item.is_null()); + (item, done) + } + NextOutcome::Done | NextOutcome::Failed => (ptr::null_mut(), 1), + } +} + +/// Write a [`NextOutcome`] to the blocking API's out-parameters and produce its +/// return code (0 = ok, including clean end-of-stream; 1 = failure). +/// +/// # Safety +/// `out_item_json` and `out_done` must be valid writable pointers. +#[cfg(feature = "std")] +unsafe fn next_outcome_to_out_params( + outcome: NextOutcome, + out_item_json: *mut *mut c_char, + out_done: *mut c_int, +) -> c_int { + let failed = matches!(outcome, NextOutcome::Failed); + let (item, done) = next_outcome_parts(outcome); + unsafe { + *out_item_json = item; + *out_done = done; + } + c_int::from(failed) +} + +#[cfg(feature = "std")] +async fn client_inner_call_json(client: &Client, target: String, args: Vec) -> *mut c_char { + ptr_saikuro(client.call(target, args).await, "call") +} + +#[cfg(feature = "std")] +async fn client_inner_call_json_timeout( + client: &Client, + target: String, + args: Vec, + timeout_ms: i64, +) -> *mut c_char { + if timeout_ms < 0 { + set_last_error("timeout_ms must be non-negative"); + return ptr::null_mut(); + } + let timeout = core::time::Duration::from_millis(timeout_ms as u64); + ptr_saikuro( + client.call_with_timeout(target, args, Some(timeout)).await, + "call", + ) +} + +#[cfg(feature = "std")] +async fn client_inner_cast_json(client: &Client, target: String, args: Vec) -> c_int { + int_saikuro(client.cast(target, args).await, "cast") +} + +#[cfg(feature = "std")] +async fn client_inner_batch_json(client: &Client, calls_json: &str) -> *mut c_char { + let calls = match parse_batch_calls(calls_json) { + Ok(calls) => calls, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + match client.batch(calls).await { + Ok(values) => match serde_json::to_string(&values) { + Ok(json) => into_c_string_ptr(&json), + Err(e) => { + set_last_error(format!("failed to serialize result: {e}")); + ptr::null_mut() + } + }, + Err(e) => { + set_last_error(format!("batch failed: {e}")); + ptr::null_mut() + } + } +} + +#[cfg(feature = "std")] +async fn client_inner_stream_json( + client: &Client, + target: String, + args: Vec, +) -> *mut c_void { + match client.stream(target, args).await { + Ok(stream) => Box::into_raw(Box::new(StreamHandle { stream })) as *mut c_void, + Err(e) => { + set_last_error(format!("stream open failed: {e}")); + ptr::null_mut() + } + } +} + +#[cfg(feature = "std")] +async fn client_inner_channel_json( + client: &Client, + target: String, + args: Vec, +) -> *mut c_void { + match client.channel(target, args).await { + Ok(channel) => Box::into_raw(Box::new(ChannelHandle { channel })) as *mut c_void, + Err(e) => { + set_last_error(format!("channel open failed: {e}")); + ptr::null_mut() + } + } +} + +#[cfg(feature = "std")] +async fn client_inner_channel_send_json(channel: &SaikuroChannel, item: Value) -> c_int { + int_saikuro(channel.send(item).await, "channel send") +} + +#[cfg(feature = "std")] +async fn client_inner_channel_close(channel: &SaikuroChannel) -> c_int { + int_saikuro(channel.close().await, "channel close") +} + +#[cfg(feature = "std")] +async fn client_inner_channel_abort(channel: &SaikuroChannel) -> c_int { + int_saikuro(channel.abort().await, "channel abort") +} + +#[cfg(feature = "std")] +async fn client_inner_channel_next_json(channel: &mut SaikuroChannel) -> NextOutcome { + match channel.next().await { + Some(Ok(value)) => match serde_json::to_string(&value) { + Ok(json) => NextOutcome::Item(json), + Err(e) => { + set_last_error(format!("failed to serialize channel item: {e}")); + NextOutcome::Failed + } + }, + Some(Err(e)) => { + set_last_error(format!("channel receive failed: {e}")); + NextOutcome::Failed + } + None => NextOutcome::Done, + } +} + #[cfg(feature = "std")] -fn client_ref(h: *mut c_void) -> &'static ClientHandle { - unsafe { &*(h as *const ClientHandle) } +async fn client_inner_stream_next_json(stream: &mut SaikuroStream) -> NextOutcome { + match stream.next().await { + Some(Ok(value)) => match serde_json::to_string(&value) { + Ok(json) => NextOutcome::Item(json), + Err(e) => { + set_last_error(format!("failed to serialize stream item: {e}")); + NextOutcome::Failed + } + }, + Some(Err(e)) => { + set_last_error(format!("stream receive failed: {e}")); + NextOutcome::Failed + } + None => NextOutcome::Done, + } +} + +#[cfg(feature = "std")] +async fn client_inner_resource_json( + client: &Client, + target: String, + args: Vec, +) -> *mut c_char { + ptr_saikuro(client.resource(target, args).await, "resource") +} + +#[cfg(feature = "std")] +async fn client_inner_log( + client: &Client, + level: String, + name: String, + msg: String, + fields: Option, +) -> c_int { + int_saikuro(client.log(level, name, msg, fields).await, "log") } // String lifecycle @@ -384,6 +612,7 @@ pub extern "C" fn saikuro_client_connect_async( Ok(s) => s, Err(e) => { set_last_error(e); + cb(ptr::null_mut(), user_data); return; } }; @@ -403,7 +632,7 @@ pub extern "C" fn saikuro_client_connect_async( Err(e) => { set_last_error(format!("failed to connect client: {e}")); unsafe { - let _ = Box::from_raw(handle_addr as *mut ClientHandle); + drop(Box::from_raw(handle_addr as *mut ClientHandle)); } cb(ptr::null_mut(), user_data_addr as *mut c_void); } @@ -481,24 +710,22 @@ pub extern "C" fn saikuro_client_call_json_async( cb(ptr::null_mut(), user_data); return; } - let h = match cstr_to_string(target, "target") + let (target, args) = match cstr_to_string(target, "target") .and_then(|t| c_json_array(args_json).map(|a| (t, a))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); + cb(ptr::null_mut(), user_data); return; } }; - let (target, args) = h; let handle_addr = handle as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let handle = handle_addr as *mut c_void; - let h = client_ref(handle); - let res = h.client().call(target, args).await; - let out = ptr_saikuro(res, "call"); + let h = client_ref(handle_addr as *mut c_void); + let out = client_inner_call_json(h.client(), target, args).await; cb(out, user_data_addr as *mut c_void); }); } @@ -528,32 +755,23 @@ pub extern "C" fn saikuro_client_call_json_timeout_async( cb(ptr::null_mut(), user_data); return; } - if timeout_ms < 0 { - set_last_error("timeout_ms must be non-negative"); - return; - } - let parsed = match cstr_to_string(target, "target") + let (target, args) = match cstr_to_string(target, "target") .and_then(|t| c_json_array(args_json).map(|a| (t, a))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); + cb(ptr::null_mut(), user_data); return; } }; - let (target, args) = parsed; - let timeout = core::time::Duration::from_millis(timeout_ms as u64); + let timeout_ms = i64::from(timeout_ms); let handle_addr = handle as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let handle = handle_addr as *mut c_void; - let h = client_ref(handle); - let res = h - .client() - .call_with_timeout(target, args, Some(timeout)) - .await; - let out = ptr_saikuro(res, "call"); + let h = client_ref(handle_addr as *mut c_void); + let out = client_inner_call_json_timeout(h.client(), target, args, timeout_ms).await; cb(out, user_data_addr as *mut c_void); }); } @@ -582,25 +800,23 @@ pub extern "C" fn saikuro_client_cast_json_async( cb(1, user_data); return; } - let parsed = match cstr_to_string(target, "target") + let (target, args) = match cstr_to_string(target, "target") .and_then(|t| c_json_array(args_json).map(|a| (t, a))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); cb(1, user_data); return; } }; - let (target, args) = parsed; let handle_addr = handle as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let handle = handle_addr as *mut c_void; - let h = client_ref(handle); - let res = h.client().cast(target, args).await; - cb(int_saikuro(res, "cast"), user_data_addr as *mut c_void); + let h = client_ref(handle_addr as *mut c_void); + let status = client_inner_cast_json(h.client(), target, args).await; + cb(status, user_data_addr as *mut c_void); }); } @@ -631,13 +847,7 @@ pub extern "C" fn saikuro_client_batch_json_async( Ok(s) => s, Err(e) => { set_last_error(e); - return; - } - }; - let calls = match parse_batch_calls(&raw) { - Ok(c) => c, - Err(e) => { - set_last_error(e); + cb(ptr::null_mut(), user_data); return; } }; @@ -645,21 +855,9 @@ pub extern "C" fn saikuro_client_batch_json_async( let handle_addr = handle as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let handle = handle_addr as *mut c_void; - let h = client_ref(handle); - match h.client().batch(calls).await { - Ok(v) => match serde_json::to_string(&v) { - Ok(json) => cb(into_c_string_ptr(&json), user_data_addr as *mut c_void), - Err(e) => { - set_last_error(format!("failed to serialize result: {e}")); - cb(ptr::null_mut(), user_data_addr as *mut c_void); - } - }, - Err(e) => { - set_last_error(format!("batch failed: {e}")); - cb(ptr::null_mut(), user_data_addr as *mut c_void); - } - } + let h = client_ref(handle_addr as *mut c_void); + let out = client_inner_batch_json(h.client(), &raw).await; + cb(out, user_data_addr as *mut c_void); }); } @@ -687,24 +885,22 @@ pub extern "C" fn saikuro_client_resource_json_async( cb(ptr::null_mut(), user_data); return; } - let parsed = match cstr_to_string(target, "target") + let (target, args) = match cstr_to_string(target, "target") .and_then(|t| c_json_array(args_json).map(|a| (t, a))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); + cb(ptr::null_mut(), user_data); return; } }; - let (target, args) = parsed; let handle_addr = handle as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let handle = handle_addr as *mut c_void; - let h = client_ref(handle); - let res = h.client().resource(target, args).await; - let out = ptr_saikuro(res, "resource"); + let h = client_ref(handle_addr as *mut c_void); + let out = client_inner_resource_json(h.client(), target, args).await; cb(out, user_data_addr as *mut c_void); }); } @@ -735,18 +931,17 @@ pub extern "C" fn saikuro_client_log_async( cb(1, user_data); return; } - let parsed = match cstr_to_string(level, "level") + let (level, name, msg) = match cstr_to_string(level, "level") .and_then(|l| cstr_to_string(name, "name").map(|n| (l, n))) .and_then(|(l, n)| cstr_to_string(msg, "msg").map(|m| (l, n, m))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); cb(1, user_data); return; } }; - let (level, name, msg) = parsed; let fields = if fields_json.is_null() { None } else { @@ -765,10 +960,9 @@ pub extern "C" fn saikuro_client_log_async( let handle_addr = handle as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let handle = handle_addr as *mut c_void; - let h = client_ref(handle); - let res = h.client().log(level, name, msg, fields).await; - cb(int_saikuro(res, "log"), user_data_addr as *mut c_void); + let h = client_ref(handle_addr as *mut c_void); + let status = client_inner_log(h.client(), level, name, msg, fields).await; + cb(status, user_data_addr as *mut c_void); }); } @@ -798,32 +992,23 @@ pub extern "C" fn saikuro_client_stream_json_async( cb(ptr::null_mut(), user_data); return; } - let parsed = match cstr_to_string(target, "target") + let (target, args) = match cstr_to_string(target, "target") .and_then(|t| c_json_array(args_json).map(|a| (t, a))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); + cb(ptr::null_mut(), user_data); return; } }; - let (target, args) = parsed; let handle_addr = handle as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let handle = handle_addr as *mut c_void; - let h = client_ref(handle); - match h.client().stream(target, args).await { - Ok(stream) => { - let sh = Box::into_raw(Box::new(StreamHandle { stream })); - cb(sh as *mut c_void, user_data_addr as *mut c_void); - } - Err(e) => { - set_last_error(format!("stream open failed: {e}")); - cb(ptr::null_mut(), user_data_addr as *mut c_void); - } - } + let h = client_ref(handle_addr as *mut c_void); + let stream = client_inner_stream_json(h.client(), target, args).await; + cb(stream, user_data_addr as *mut c_void); }); } @@ -857,28 +1042,17 @@ pub unsafe extern "C" fn saikuro_stream_next_json_async( }; if stream.is_null() { set_last_error("stream must not be null"); + cb(ptr::null_mut(), 1, user_data); return; } let stream_addr = stream as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let stream = stream_addr as *mut StreamHandle; - let s = unsafe { &mut *stream }; - match s.stream.next().await { - Some(Ok(value)) => match serde_json::to_string(&value) { - Ok(json) => cb(into_c_string_ptr(&json), 0, user_data_addr as *mut c_void), - Err(e) => { - set_last_error(format!("failed to serialize stream item: {e}")); - cb(ptr::null_mut(), 1, user_data_addr as *mut c_void); - } - }, - Some(Err(e)) => { - set_last_error(format!("stream receive failed: {e}")); - cb(ptr::null_mut(), 1, user_data_addr as *mut c_void); - } - None => cb(ptr::null_mut(), 1, user_data_addr as *mut c_void), - } + let s = unsafe { &mut *(stream_addr as *mut StreamHandle) }; + let outcome = client_inner_stream_next_json(&mut s.stream).await; + let (item, done) = next_outcome_parts(outcome); + cb(item, done, user_data_addr as *mut c_void); }); } @@ -908,32 +1082,23 @@ pub extern "C" fn saikuro_client_channel_json_async( cb(ptr::null_mut(), user_data); return; } - let parsed = match cstr_to_string(target, "target") + let (target, args) = match cstr_to_string(target, "target") .and_then(|t| c_json_array(args_json).map(|a| (t, a))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); + cb(ptr::null_mut(), user_data); return; } }; - let (target, args) = parsed; let handle_addr = handle as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let handle = handle_addr as *mut c_void; - let h = client_ref(handle); - match h.client().channel(target, args).await { - Ok(channel) => { - let ch = Box::into_raw(Box::new(ChannelHandle { channel })); - cb(ch as *mut c_void, user_data_addr as *mut c_void); - } - Err(e) => { - set_last_error(format!("channel open failed: {e}")); - cb(ptr::null_mut(), user_data_addr as *mut c_void); - } - } + let h = client_ref(handle_addr as *mut c_void); + let channel = client_inner_channel_json(h.client(), target, args).await; + cb(channel, user_data_addr as *mut c_void); }); } @@ -980,16 +1145,17 @@ pub extern "C" fn saikuro_channel_send_json_async( let channel_addr = channel as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let channel = channel_addr as *mut ChannelHandle; - let c = unsafe { &mut *channel }; - let res = c.channel.send(item).await; - cb( - int_saikuro(res, "channel send"), - user_data_addr as *mut c_void, - ); + let c = unsafe { &mut *(channel_addr as *mut ChannelHandle) }; + let status = client_inner_channel_send_json(&c.channel, item).await; + cb(status, user_data_addr as *mut c_void); }); } +/// Request graceful closure of the channel. The handle stays valid afterwards +/// and must still be released with `saikuro_channel_free`. +/// +/// # Safety +/// `cb` must not be null. The channel handle must remain valid until `cb` fires. #[cfg(feature = "std")] #[no_mangle] pub extern "C" fn saikuro_channel_close_async( @@ -1011,17 +1177,20 @@ pub extern "C" fn saikuro_channel_close_async( return; } - let channel = unsafe { Box::from_raw(channel as *mut ChannelHandle) }; + let channel_addr = channel as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let res = channel.channel.close().await; - cb( - int_saikuro(res, "channel close"), - user_data_addr as *mut c_void, - ); + let c = unsafe { &mut *(channel_addr as *mut ChannelHandle) }; + let status = client_inner_channel_close(&c.channel).await; + cb(status, user_data_addr as *mut c_void); }); } +/// Abort the channel. The handle stays valid afterwards and must still be +/// released with `saikuro_channel_free`. +/// +/// # Safety +/// `cb` must not be null. The channel handle must remain valid until `cb` fires. #[cfg(feature = "std")] #[no_mangle] pub extern "C" fn saikuro_channel_abort_async( @@ -1043,14 +1212,12 @@ pub extern "C" fn saikuro_channel_abort_async( return; } - let channel = unsafe { Box::from_raw(channel as *mut ChannelHandle) }; + let channel_addr = channel as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let res = channel.channel.abort().await; - cb( - int_saikuro(res, "channel abort"), - user_data_addr as *mut c_void, - ); + let c = unsafe { &mut *(channel_addr as *mut ChannelHandle) }; + let status = client_inner_channel_abort(&c.channel).await; + cb(status, user_data_addr as *mut c_void); }); } @@ -1075,28 +1242,17 @@ pub unsafe extern "C" fn saikuro_channel_next_json_async( }; if channel.is_null() { set_last_error("channel must not be null"); + cb(ptr::null_mut(), 1, user_data); return; } let channel_addr = channel as usize; let user_data_addr = user_data as usize; spawn_future(async move { - let channel = channel_addr as *mut ChannelHandle; - let c = unsafe { &mut *channel }; - match c.channel.next().await { - Some(Ok(value)) => match serde_json::to_string(&value) { - Ok(json) => cb(into_c_string_ptr(&json), 0, user_data_addr as *mut c_void), - Err(e) => { - set_last_error(format!("failed to serialize channel item: {e}")); - cb(ptr::null_mut(), 1, user_data_addr as *mut c_void); - } - }, - Some(Err(e)) => { - set_last_error(format!("channel receive failed: {e}")); - cb(ptr::null_mut(), 1, user_data_addr as *mut c_void); - } - None => cb(ptr::null_mut(), 1, user_data_addr as *mut c_void), - } + let c = unsafe { &mut *(channel_addr as *mut ChannelHandle) }; + let outcome = client_inner_channel_next_json(&mut c.channel).await; + let (item, done) = next_outcome_parts(outcome); + cb(item, done, user_data_addr as *mut c_void); }); } @@ -1364,7 +1520,9 @@ pub extern "C" fn saikuro_provider_free(handle: *mut c_void) { } // Synchronous blocking API -// These block the calling thread on the global tokio runtime. +// These block the calling thread on the global tokio runtime. Each entry point +// validates pointers, decodes the C strings, then delegates to the same inner +// helpers the async entry points use. #[cfg(all(feature = "std", feature = "native"))] #[no_mangle] @@ -1422,14 +1580,14 @@ pub extern "C" fn saikuro_client_call_json( let (target, args) = match cstr_to_string(target, "target") .and_then(|t| c_json_array(args_json).map(|a| (t, a))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); return ptr::null_mut(); } }; let h = unsafe { &*(handle as *const ClientHandle) }; - ptr_saikuro(block_on_future(h.client().call(target, args)), "call") + block_on_future(client_inner_call_json(h.client(), target, args)) } #[cfg(all(feature = "std", feature = "native"))] @@ -1445,25 +1603,22 @@ pub extern "C" fn saikuro_client_call_json_timeout( set_last_error(ERR_HANDLE_NULL); return ptr::null_mut(); } - if timeout_ms < 0 { - set_last_error("timeout_ms must be non-negative"); - return ptr::null_mut(); - } let (target, args) = match cstr_to_string(target, "target") .and_then(|t| c_json_array(args_json).map(|a| (t, a))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); return ptr::null_mut(); } }; - let timeout = core::time::Duration::from_millis(timeout_ms as u64); let h = unsafe { &*(handle as *const ClientHandle) }; - ptr_saikuro( - block_on_future(h.client().call_with_timeout(target, args, Some(timeout))), - "call", - ) + block_on_future(client_inner_call_json_timeout( + h.client(), + target, + args, + i64::from(timeout_ms), + )) } #[cfg(all(feature = "std", feature = "native"))] @@ -1481,14 +1636,14 @@ pub extern "C" fn saikuro_client_cast_json( let (target, args) = match cstr_to_string(target, "target") .and_then(|t| c_json_array(args_json).map(|a| (t, a))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); return 1; } }; let h = unsafe { &*(handle as *const ClientHandle) }; - int_saikuro(block_on_future(h.client().cast(target, args)), "cast") + block_on_future(client_inner_cast_json(h.client(), target, args)) } #[cfg(all(feature = "std", feature = "native"))] @@ -1509,28 +1664,8 @@ pub extern "C" fn saikuro_client_batch_json( return ptr::null_mut(); } }; - let calls = match parse_batch_calls(&raw) { - Ok(c) => c, - Err(e) => { - set_last_error(e); - return ptr::null_mut(); - } - }; let h = unsafe { &*(handle as *const ClientHandle) }; - let res = block_on_future(h.client().batch(calls)); - match res { - Ok(v) => match serde_json::to_string(&v) { - Ok(json) => into_c_string_ptr(&json), - Err(e) => { - set_last_error(format!("failed to serialize result: {e}")); - ptr::null_mut() - } - }, - Err(e) => { - set_last_error(format!("batch failed: {e}")); - ptr::null_mut() - } - } + block_on_future(client_inner_batch_json(h.client(), &raw)) } #[cfg(all(feature = "std", feature = "native"))] @@ -1548,20 +1683,14 @@ pub extern "C" fn saikuro_client_stream_json( let (target, args) = match cstr_to_string(target, "target") .and_then(|t| c_json_array(args_json).map(|a| (t, a))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); return ptr::null_mut(); } }; let h = unsafe { &*(handle as *const ClientHandle) }; - match block_on_future(h.client().stream(target, args)) { - Ok(stream) => Box::into_raw(Box::new(StreamHandle { stream })) as *mut c_void, - Err(e) => { - set_last_error(format!("stream open failed: {e}")); - ptr::null_mut() - } - } + block_on_future(client_inner_stream_json(h.client(), target, args)) } #[cfg(all(feature = "std", feature = "native"))] @@ -1579,20 +1708,14 @@ pub extern "C" fn saikuro_client_channel_json( let (target, args) = match cstr_to_string(target, "target") .and_then(|t| c_json_array(args_json).map(|a| (t, a))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); return ptr::null_mut(); } }; let h = unsafe { &*(handle as *const ClientHandle) }; - match block_on_future(h.client().channel(target, args)) { - Ok(channel) => Box::into_raw(Box::new(ChannelHandle { channel })) as *mut c_void, - Err(e) => { - set_last_error(format!("channel open failed: {e}")); - ptr::null_mut() - } - } + block_on_future(client_inner_channel_json(h.client(), target, args)) } #[cfg(all(feature = "std", feature = "native"))] @@ -1621,9 +1744,11 @@ pub extern "C" fn saikuro_channel_send_json( } }; let c = unsafe { &mut *(channel as *mut ChannelHandle) }; - int_saikuro(block_on_future(c.channel.send(item)), "channel send") + block_on_future(client_inner_channel_send_json(&c.channel, item)) } +/// Close the channel. The handle stays valid afterwards and must still be +/// released with `saikuro_channel_free`. #[cfg(all(feature = "std", feature = "native"))] #[no_mangle] pub extern "C" fn saikuro_channel_close(channel: *mut c_void) -> c_int { @@ -1633,9 +1758,11 @@ pub extern "C" fn saikuro_channel_close(channel: *mut c_void) -> c_int { return 1; } let c = unsafe { &mut *(channel as *mut ChannelHandle) }; - int_saikuro(block_on_future(c.channel.close()), "channel close") + block_on_future(client_inner_channel_close(&c.channel)) } +/// Abort the channel. The handle stays valid afterwards and must still be +/// released with `saikuro_channel_free`. #[cfg(all(feature = "std", feature = "native"))] #[no_mangle] pub extern "C" fn saikuro_channel_abort(channel: *mut c_void) -> c_int { @@ -1645,11 +1772,12 @@ pub extern "C" fn saikuro_channel_abort(channel: *mut c_void) -> c_int { return 1; } let c = unsafe { &mut *(channel as *mut ChannelHandle) }; - int_saikuro(block_on_future(c.channel.abort()), "channel abort") + block_on_future(client_inner_channel_abort(&c.channel)) } #[cfg(all(feature = "std", feature = "native"))] #[no_mangle] +#[allow(clippy::not_unsafe_ptr_arg_deref)] pub extern "C" fn saikuro_channel_next_json( channel: *mut c_void, out_item_json: *mut *mut c_char, @@ -1661,42 +1789,11 @@ pub extern "C" fn saikuro_channel_next_json( return 1; } let c = unsafe { &mut *(channel as *mut ChannelHandle) }; - match block_on_future(c.channel.next()) { - Some(Ok(value)) => match serde_json::to_string(&value) { - Ok(json) => { - unsafe { - *out_item_json = into_c_string_ptr(&json); - *out_done = 0; - } - 0 - } - Err(e) => { - set_last_error(format!("failed to serialize channel item: {e}")); - unsafe { - *out_item_json = ptr::null_mut(); - *out_done = 1; - } - 1 - } - }, - Some(Err(e)) => { - set_last_error(format!("channel receive failed: {e}")); - unsafe { - *out_item_json = ptr::null_mut(); - *out_done = 1; - } - 1 - } - None => { - unsafe { - *out_item_json = ptr::null_mut(); - *out_done = 1; - } - 0 - } - } + let outcome = block_on_future(client_inner_channel_next_json(&mut c.channel)); + unsafe { next_outcome_to_out_params(outcome, out_item_json, out_done) } } +#[cfg(feature = "std")] #[no_mangle] pub extern "C" fn saikuro_channel_free(channel: *mut c_void) { if channel.is_null() { @@ -1707,6 +1804,7 @@ pub extern "C" fn saikuro_channel_free(channel: *mut c_void) { #[cfg(all(feature = "std", feature = "native"))] #[no_mangle] +#[allow(clippy::not_unsafe_ptr_arg_deref)] pub extern "C" fn saikuro_stream_next_json( stream: *mut c_void, out_item_json: *mut *mut c_char, @@ -1718,40 +1816,8 @@ pub extern "C" fn saikuro_stream_next_json( return 1; } let s = unsafe { &mut *(stream as *mut StreamHandle) }; - match block_on_future(s.stream.next()) { - Some(Ok(value)) => match serde_json::to_string(&value) { - Ok(json) => { - unsafe { - *out_item_json = into_c_string_ptr(&json); - *out_done = 0; - } - 0 - } - Err(e) => { - set_last_error(format!("failed to serialize stream item: {e}")); - unsafe { - *out_item_json = ptr::null_mut(); - *out_done = 1; - } - 1 - } - }, - Some(Err(e)) => { - set_last_error(format!("stream receive failed: {e}")); - unsafe { - *out_item_json = ptr::null_mut(); - *out_done = 1; - } - 1 - } - None => { - unsafe { - *out_item_json = ptr::null_mut(); - *out_done = 1; - } - 0 - } - } + let outcome = block_on_future(client_inner_stream_next_json(&mut s.stream)); + unsafe { next_outcome_to_out_params(outcome, out_item_json, out_done) } } #[cfg(all(feature = "std", feature = "native"))] @@ -1769,17 +1835,14 @@ pub extern "C" fn saikuro_client_resource_json( let (target, args) = match cstr_to_string(target, "target") .and_then(|t| c_json_array(args_json).map(|a| (t, a))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); return ptr::null_mut(); } }; let h = unsafe { &*(handle as *const ClientHandle) }; - ptr_saikuro( - block_on_future(h.client().resource(target, args)), - "resource", - ) + block_on_future(client_inner_resource_json(h.client(), target, args)) } #[cfg(all(feature = "std", feature = "native"))] @@ -1800,7 +1863,7 @@ pub extern "C" fn saikuro_client_log( .and_then(|l| cstr_to_string(name, "name").map(|n| (l, n))) .and_then(|(l, n)| cstr_to_string(msg, "msg").map(|m| (l, n, m))) { - Ok(v) => v, + Ok(parsed) => parsed, Err(e) => { set_last_error(e); return 1; @@ -1820,10 +1883,7 @@ pub extern "C" fn saikuro_client_log( } }; let h = unsafe { &*(handle as *const ClientHandle) }; - int_saikuro( - block_on_future(h.client().log(level, name, msg, fields)), - "log", - ) + block_on_future(client_inner_log(h.client(), level, name, msg, fields)) } #[cfg(all(feature = "std", feature = "native"))] diff --git a/Build/adapters/c/tests/c_api_runtime.rs b/Build/adapters/c/tests/c_api_runtime.rs index 942b1e75..18cfd4b6 100644 --- a/Build/adapters/c/tests/c_api_runtime.rs +++ b/Build/adapters/c/tests/c_api_runtime.rs @@ -186,6 +186,7 @@ fn shared_runtime() -> &'static RuntimeHarness { #[test] fn c_client_call_cast_batch_roundtrip_with_runtime() { + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); let runtime = shared_runtime(); // Connect. diff --git a/Build/adapters/cpp/include/saikuro/saikuro.hpp b/Build/adapters/cpp/include/saikuro/saikuro.hpp index 621d760f..613b96e3 100644 --- a/Build/adapters/cpp/include/saikuro/saikuro.hpp +++ b/Build/adapters/cpp/include/saikuro/saikuro.hpp @@ -84,11 +84,7 @@ class Client : public MoveOnlyHandle { public: class Stream : public MoveOnlyHandle { public: - explicit Stream(saikuro_stream_t handle) : MoveOnlyHandle(handle) { - if (handle_ == nullptr) { - throw Error(last_error()); - } - } + explicit Stream(saikuro_stream_t handle) : MoveOnlyHandle(handle) {} Stream(Stream &&other) noexcept : MoveOnlyHandle(std::move(other)) {} @@ -112,11 +108,7 @@ class Client : public MoveOnlyHandle { class Channel : public MoveOnlyHandle { public: - explicit Channel(saikuro_channel_t handle) : MoveOnlyHandle(handle) { - if (handle_ == nullptr) { - throw Error(last_error()); - } - } + explicit Channel(saikuro_channel_t handle) : MoveOnlyHandle(handle) {} void send_json(const std::string &item_json) { if (!open_) { diff --git a/Build/adapters/rust/Cargo.toml b/Build/adapters/rust/Cargo.toml index a5ad901d..784e49bb 100644 --- a/Build/adapters/rust/Cargo.toml +++ b/Build/adapters/rust/Cargo.toml @@ -22,7 +22,6 @@ native = [ "std", "saikuro-core/native", "saikuro-transport/native", - "saikuro-random/native", "saikuro-exec/native", "saikuro-event/native", "saikuro-event/std", @@ -36,21 +35,18 @@ wasm = [ "saikuro-core/wasm", "saikuro-transport/wasm", "saikuro-transport/wasm-host", - "saikuro-random/wasm", "saikuro-exec/wasm", "saikuro-event/wasm", ] embedded = [ "saikuro-core/embedded", "saikuro-transport/embedded", - "saikuro-random/embedded", "saikuro-exec/embedded", "saikuro-event/embedded", ] no_std = [ "saikuro-core/no_std", "saikuro-transport/no_std", - "saikuro-random/no_std", "saikuro-exec/no_std", "saikuro-event/no_std", ] @@ -73,14 +69,11 @@ wasm-storage = ["saikuro-storage/wasm"] saikuro-core = { path = "../../crates/saikuro-core", default-features = false } saikuro-storage = { path = "../../crates/saikuro-storage", default-features = false, optional = true } saikuro-transport = { path = "../../crates/saikuro-transport", default-features = false } -saikuro-random = { path = "../../crates/saikuro-random", default-features = false } saikuro-event = { path = "../../crates/saikuro-event", default-features = false } anyhow = { workspace = true, optional = true } -serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["alloc"] } bytes = { workspace = true } -portable-atomic = { workspace = true } portable-atomic-util = { workspace = true, features = ["alloc"] } saikuro-exec = { workspace = true, default-features = false } futures = { workspace = true, features = ["async-await"] } diff --git a/Build/adapters/rust/src/client.rs b/Build/adapters/rust/src/client.rs index e5c03579..25a7373b 100644 --- a/Build/adapters/rust/src/client.rs +++ b/Build/adapters/rust/src/client.rs @@ -116,7 +116,6 @@ impl SaikuroChannel { InvocationType::Channel, "", vec![], - None, Some(self.next_seq()), ); envelope.stream_control = Some(StreamControl::End); @@ -130,7 +129,6 @@ impl SaikuroChannel { InvocationType::Channel, "", vec![], - None, Some(self.next_seq()), ); envelope.stream_control = Some(StreamControl::Abort); @@ -144,7 +142,6 @@ impl SaikuroChannel { InvocationType::Channel, "", vec![value], - None, Some(self.next_seq()), ); self.send_channel_envelope(&envelope).await @@ -353,7 +350,7 @@ impl Client { timeout: Option, ) -> Result { let target = target.into(); - let envelope = make_envelope(InvocationType::Call, &target, args, None)?; + let envelope = make_envelope(InvocationType::Call, &target, args)?; let id = envelope.id; let (tx, rx) = oneshot::channel(); @@ -385,7 +382,7 @@ impl Client { /// Fire-and-forget invocation. No response is expected. pub async fn cast(&self, target: impl Into, args: Vec) -> Result<()> { let target = target.into(); - let envelope = make_envelope(InvocationType::Cast, &target, args, None)?; + let envelope = make_envelope(InvocationType::Cast, &target, args)?; self.send_envelope(&envelope).await } @@ -398,7 +395,7 @@ impl Client { args: Vec, ) -> Result { let target = target.into(); - let envelope = make_envelope(InvocationType::Stream, &target, args, None)?; + let envelope = make_envelope(InvocationType::Stream, &target, args)?; let id = envelope.id; let (tx, rx) = mpsc::channel(STREAM_CHANNEL_CAPACITY); @@ -419,7 +416,7 @@ impl Client { pub async fn batch(&self, calls: Vec<(String, Vec)>) -> Result> { let batch_items: Vec = calls .into_iter() - .map(|(target, args)| make_envelope(InvocationType::Call, &target, args, None)) + .map(|(target, args)| make_envelope(InvocationType::Call, &target, args)) .collect::>()?; let batch_env = Envelope { @@ -465,7 +462,7 @@ impl Client { args: Vec, ) -> Result { let target = target.into(); - let envelope = make_envelope(InvocationType::Channel, &target, args, None)?; + let envelope = make_envelope(InvocationType::Channel, &target, args)?; let id = envelope.id; let (tx, rx) = mpsc::channel(STREAM_CHANNEL_CAPACITY); @@ -484,7 +481,7 @@ impl Client { /// Invoke a resource-producing function and return the resource payload. pub async fn resource(&self, target: impl Into, args: Vec) -> Result { let target = target.into(); - let envelope = make_envelope(InvocationType::Resource, &target, args, None)?; + let envelope = make_envelope(InvocationType::Resource, &target, args)?; let id = envelope.id; let (tx, rx) = oneshot::channel(); @@ -520,12 +517,7 @@ impl Client { record.insert("fields".to_owned(), extra); } - let envelope = make_envelope( - InvocationType::Log, - "$log", - vec![Value::Object(record)], - None, - )?; + let envelope = make_envelope(InvocationType::Log, "$log", vec![Value::Object(record)])?; self.send_envelope(&envelope).await } @@ -541,43 +533,21 @@ impl Client { } } // Background task helpers -/// Drain any announce envelopes that have already arrived on the transport. -/// -/// This is called once at the start of the I/O task, before the main select -/// loop. In a real runtime deployment, announce frames are sent by the -/// *provider* to the runtime, never to the client; this path is only active -/// when a provider and client are wired together directly via -/// [`InMemoryTransport`](crate::transport::InMemoryTransport) in tests. -/// -/// We process announces using a non-blocking `try_recv`-style loop: poll the -/// transport with a short deadline and ack any announce frames, stopping as -/// soon as nothing is immediately available. This avoids an indefinite wait -/// on a connection where the provider has not yet sent its announce. + +/// Drain announce envelopes that arrived before the client's I/O task starts. async fn drain_announces(transport: &mut dyn AdapterTransport) { - // Use a very short deadline per poll so that on real runtime connections - // (where no announce will ever arrive on the client side) we escape - // immediately after the first timeout. const POLL_TIMEOUT: Duration = Duration::from_millis(20); while let Ok(Ok(Some(frame))) = saikuro_exec::timeout(POLL_TIMEOUT, transport.recv()).await { - // Check if this is an announce. If it is, ack it and continue - // draining. If it is a normal response, we cannot put it back - // into the transport; log an unexpected-frame warning and drop - // it. In practice this should never happen: no pending call - // exists yet when this runs. if let Ok(env) = Envelope::from_msgpack(&frame) { if env.invocation_type == InvocationType::Announce { let ack = ResponseEnvelope::ok_empty(env.id); if let Ok(ack_bytes) = ack.to_msgpack() { let _ = transport.send(Bytes::from(ack_bytes)).await; } - // Continue: there could be more queued frames - // (unlikely, but be thorough). continue; } } - // Non-announce frame arrived before any pending slot exists; - // this is unexpected. } } @@ -606,7 +576,6 @@ async fn handle_inbound( let _ = transport.send(Bytes::from(ack_bytes)).await; } } - return; } } @@ -622,82 +591,70 @@ async fn route_response( .is_some_and(|c| matches!(c, StreamControl::End | StreamControl::Abort)); let is_error = !resp.ok; - let slot_type = pending.get(&id).map(|s| match s.value() { - PendingSlot::Call(_) => "call", - PendingSlot::Stream(_) => "stream", - PendingSlot::Channel(_) => "channel", - }); - - match slot_type { - Some("call") => { + let slot = pending.get(&id); + match slot.as_deref() { + Some(PendingSlot::Call(_)) => { + drop(slot); if let Some((_, PendingSlot::Call(tx))) = pending.remove(&id) { let _ = tx.send(resp); } } - Some("stream") => { - if let Some(slot) = pending.get(&id) { - if let PendingSlot::Stream(tx) = slot.value() { - let tx = tx.clone(); - drop(slot); - if is_stream_end { - pending.remove(&id); - } else if is_error { - let detail = resp.error.unwrap_or_else(|| { - ErrorDetail::new(ErrorCode::Internal, "stream error") - }); - let _ = tx - .send(Err(Error::remote( - detail.code.to_string(), - detail.message, - None, - ))) - .await; - pending.remove(&id); - } else { - let value = resp.result.map(core_to_json).unwrap_or(Value::Null); - if tx.send(Ok(value)).await.is_err() { - pending.remove(&id); - } - } + Some(PendingSlot::Stream(tx)) => { + let tx = tx.clone(); + drop(slot); + if is_stream_end { + pending.remove(&id); + } else if is_error { + let detail = resp + .error + .unwrap_or_else(|| ErrorDetail::new(ErrorCode::Internal, "stream error")); + let _ = tx + .send(Err(Error::remote( + detail.code.to_string(), + detail.message, + None, + ))) + .await; + pending.remove(&id); + } else { + let value = resp.result.map(core_to_json).unwrap_or(Value::Null); + if tx.send(Ok(value)).await.is_err() { + pending.remove(&id); } } } - Some("channel") => { - if let Some(slot) = pending.get(&id) { - if let PendingSlot::Channel(tx) = slot.value() { - let tx = tx.clone(); - drop(slot); - if is_stream_end { - pending.remove(&id); - if let Some((_, sender)) = channel_senders.remove(&id) { - let _ = sender.lock().await.take(); - } - } else if is_error { - let detail = resp.error.unwrap_or_else(|| { - ErrorDetail::new(ErrorCode::Internal, "channel error") - }); - let _ = tx.try_send(Err(Error::remote( - detail.code.to_string(), - detail.message, - None, - ))); - pending.remove(&id); - if let Some((_, sender)) = channel_senders.remove(&id) { - let _ = sender.lock().await.take(); - } - } else { - let value = resp.result.map(core_to_json).unwrap_or(Value::Null); - if tx.try_send(Ok(value)).is_err() { - pending.remove(&id); - if let Some((_, sender)) = channel_senders.remove(&id) { - let _ = sender.lock().await.take(); - } - } + Some(PendingSlot::Channel(tx)) => { + let tx = tx.clone(); + drop(slot); + if is_stream_end { + pending.remove(&id); + if let Some((_, sender)) = channel_senders.remove(&id) { + let _ = sender.lock().await.take(); + } + } else if is_error { + let detail = resp + .error + .unwrap_or_else(|| ErrorDetail::new(ErrorCode::Internal, "channel error")); + let _ = tx.try_send(Err(Error::remote( + detail.code.to_string(), + detail.message, + None, + ))); + pending.remove(&id); + if let Some((_, sender)) = channel_senders.remove(&id) { + let _ = sender.lock().await.take(); + } + } else { + let value = resp.result.map(core_to_json).unwrap_or(Value::Null); + if tx.try_send(Ok(value)).is_err() { + pending.remove(&id); + if let Some((_, sender)) = channel_senders.remove(&id) { + let _ = sender.lock().await.take(); } } } } - _ => {} + None => {} } } @@ -718,18 +675,12 @@ fn teardown_pending(pending: &DashMap) { } } // Helpers -fn make_envelope( - inv_type: InvocationType, - target: &str, - args: Vec, - capability: Option, -) -> Result { +fn make_envelope(inv_type: InvocationType, target: &str, args: Vec) -> Result { Ok(make_envelope_with_id( InvocationId::new()?, inv_type, target, args, - capability, None, )) } @@ -739,7 +690,6 @@ fn make_envelope_with_id( inv_type: InvocationType, target: &str, args: Vec, - capability: Option, seq: Option, ) -> Envelope { let core_args: Vec = args.into_iter().map(json_to_core).collect(); @@ -750,7 +700,7 @@ fn make_envelope_with_id( target: target.to_owned(), args: core_args, meta: Default::default(), - capability, + capability: None, batch_items: None, stream_control: None, seq, diff --git a/Build/adapters/rust/src/error.rs b/Build/adapters/rust/src/error.rs index 1c1e22bc..7d782505 100644 --- a/Build/adapters/rust/src/error.rs +++ b/Build/adapters/rust/src/error.rs @@ -33,10 +33,6 @@ pub enum Error { #[error("call to '{target}' timed out after {ms}ms")] Timeout { target: String, ms: u64 }, - /// A response arrived for an unknown invocation ID. - #[error("unexpected response for id '{0}'")] - UnexpectedResponse(String), - /// Serialization or deserialization failed. #[error("codec error: {0}")] Codec(String), diff --git a/Build/adapters/rust/src/lib.rs b/Build/adapters/rust/src/lib.rs index a7dd4307..395d4479 100644 --- a/Build/adapters/rust/src/lib.rs +++ b/Build/adapters/rust/src/lib.rs @@ -25,7 +25,7 @@ pub mod storage; pub use client::{Client, ClientOptions, SaikuroChannel, SaikuroStream}; pub use error::{Error, Result}; pub use provider::{HandlerArgs, Provider, RegisterOptions}; -pub use saikuro_core::schema::{PrimitiveType, TypeDescriptor}; +pub use saikuro_core::schema::{PrimitiveType, TypeDescriptor, Visibility}; pub use schema::{ArgDescriptor, FunctionSchema, NamespaceSchema}; pub use transport::InMemoryTransport; pub use value::Value; diff --git a/Build/adapters/rust/src/provider.rs b/Build/adapters/rust/src/provider.rs index 8eb60db4..e628093a 100644 --- a/Build/adapters/rust/src/provider.rs +++ b/Build/adapters/rust/src/provider.rs @@ -67,7 +67,6 @@ struct HandlerEntry { pub struct Provider { namespace: String, handlers: HashMap, - extra_namespaces: HashMap, log: Arc, } @@ -77,7 +76,6 @@ impl Provider { Self { namespace: namespace.into(), handlers: HashMap::new(), - extra_namespaces: HashMap::new(), log: Arc::from(Box::new(saikuro_event::NullSink) as Box), } } @@ -174,9 +172,6 @@ impl Provider { let mut all_ns = HashMap::new(); all_ns.insert(self.namespace.clone(), ns_schema); - for (name, ns) in &self.extra_namespaces { - all_ns.insert(name.clone(), ns.clone()); - } build_schema(&all_ns) } diff --git a/Build/adapters/rust/src/schema.rs b/Build/adapters/rust/src/schema.rs index c9778ec9..fc959c46 100644 --- a/Build/adapters/rust/src/schema.rs +++ b/Build/adapters/rust/src/schema.rs @@ -109,10 +109,7 @@ impl NamespaceSchema { /// /// Fails when the namespace count exceeds the core schema's fixed map /// capacity, so a provider never announces a silently truncated schema. -/// -/// Internal helper exposed for the crate's integration tests. -#[doc(hidden)] -pub fn build_schema(namespaces: &HashMap) -> Result { +pub(crate) fn build_schema(namespaces: &HashMap) -> Result { let mut schema = Schema::new(); for (ns_name, ns) in namespaces { schema @@ -122,3 +119,21 @@ pub fn build_schema(namespaces: &HashMap) -> Result for Address { - fn from(s: String) -> Self { - Self(s) - } -} - -impl From<&str> for Address { - fn from(s: &str) -> Self { - Self(s.to_string()) - } -} - /// A trait-object-compatible trait for sending and receiving framed byte buffers. /// /// This is a thin adapter over the underlying saikuro-transport types so that @@ -111,6 +85,7 @@ mod tcp_impl { TransportConnector, TransportReceiver, TransportSender, }; use saikuro_transport::tcp::{TcpConnector, TcpReceiver, TcpSender}; + use std::sync::Arc; pub struct TcpAdapter { sender: TcpSender, @@ -174,6 +149,7 @@ mod unix_impl { shared::traits::{Transport, TransportReceiver, TransportSender}, unix::{UnixReceiver, UnixSender}, }; + use std::sync::Arc; pub struct UnixAdapter { sender: UnixSender, @@ -210,6 +186,7 @@ mod ws_impl { websocket::{WebSocketReceiver, WebSocketSender}, WebSocketTransport, }; + use std::sync::Arc; pub struct WsAdapter { sender: WebSocketSender, diff --git a/Build/adapters/rust/tests/schema_capacity.rs b/Build/adapters/rust/tests/schema_capacity.rs index 9078384f..5ef18843 100644 --- a/Build/adapters/rust/tests/schema_capacity.rs +++ b/Build/adapters/rust/tests/schema_capacity.rs @@ -1,7 +1,6 @@ -use saikuro::schema::{build_schema, FunctionSchema, NamespaceSchema}; +use saikuro::schema::{FunctionSchema, NamespaceSchema}; use saikuro::Error; -use saikuro_core::schema::{SCHEMA_FUNCTIONS_CAPACITY, SCHEMA_NAMESPACES_CAPACITY}; -use std::collections::HashMap; +use saikuro_core::schema::SCHEMA_FUNCTIONS_CAPACITY; #[test] fn to_core_overflow_functions_returns_capacity_error() { @@ -12,15 +11,3 @@ fn to_core_overflow_functions_returns_capacity_error() { let err = ns.to_core().unwrap_err(); assert!(matches!(err, Error::SchemaCapacityExceeded)); } - -#[test] -fn build_schema_overflow_namespaces_returns_capacity_error() { - let mut namespaces = HashMap::new(); - for i in 0..=SCHEMA_NAMESPACES_CAPACITY { - let mut ns = NamespaceSchema::new(); - ns.insert("f", FunctionSchema::default()); - namespaces.insert(format!("ns_{i}"), ns); - } - let err = build_schema(&namespaces).unwrap_err(); - assert!(matches!(err, Error::SchemaCapacityExceeded)); -} diff --git a/Build/crates/saikuro-runtime/shared/connection.rs b/Build/crates/saikuro-runtime/shared/connection.rs index ef816121..d3b18654 100644 --- a/Build/crates/saikuro-runtime/shared/connection.rs +++ b/Build/crates/saikuro-runtime/shared/connection.rs @@ -383,7 +383,7 @@ where let mut record = LogRecord::now(LogLevel::Error, "saikuro.runtime.connection", "send error"); record.set_context("peer", self.peer_id.clone()); - record.set_context("error", alloc::format!("{e}")); + record.set_context("error", e.to_string()); self.log.emit(&record).await; return false; } @@ -396,7 +396,7 @@ where "failed to push sandbox schema", ); record.set_context("peer", self.peer_id.clone()); - record.set_context("error", alloc::format!("{e}")); + record.set_context("error", e.to_string()); self.log.emit(&record).await; return false; } @@ -520,7 +520,7 @@ where "failed to encode forwarded call", ); record.set_context("peer", peer_id.clone()); - record.set_context("error", alloc::format!("{e}")); + record.set_context("error", e.to_string()); log.emit(&record).await; if let Some(tx) = item.response_tx { let _ = tx.send(ResponseEnvelope::err( diff --git a/Build/crates/saikuro-runtime/shared/handle.rs b/Build/crates/saikuro-runtime/shared/handle.rs index 537bf073..edb7dcce 100644 --- a/Build/crates/saikuro-runtime/shared/handle.rs +++ b/Build/crates/saikuro-runtime/shared/handle.rs @@ -49,10 +49,7 @@ impl RuntimeHandle { schema: Schema, provider_id: impl Into, ) -> Result<()> { - self.schema_registry - .merge_schema(schema, provider_id) - .await - .map_err(Into::into) + self.schema_registry.merge_schema(schema, provider_id).await } /// Register or merge a schema under an existing provider registration. @@ -65,12 +62,11 @@ impl RuntimeHandle { self.schema_registry .merge_schema_with_token(schema, provider_id, registration_token) .await - .map_err(Into::into) } /// Register a single namespace from a provider. pub async fn register_namespace(&self, reg: NamespaceRegistration) -> Result<()> { - self.schema_registry.register(reg).await.map_err(Into::into) + self.schema_registry.register(reg).await } /// Deregister all schemas owned by a provider (called on disconnect). @@ -86,7 +82,7 @@ impl RuntimeHandle { /// Export a snapshot of the current schema state. pub async fn schema_snapshot(&self) -> Result { - self.schema_registry.snapshot().await.map_err(Into::into) + self.schema_registry.snapshot().await } // Providers diff --git a/Build/tests/Cargo.toml b/Build/tests/Cargo.toml index 673151ac..832493e7 100644 --- a/Build/tests/Cargo.toml +++ b/Build/tests/Cargo.toml @@ -12,13 +12,7 @@ path = "lib.rs" [features] flash = ["saikuro-storage/embedded", "dep:embedded-storage-async", "dep:futures-executor"] -embassy-test = [] -drbg = [] -os = [] -wasm = [] -custom = [] embedded-io = [] -net = [] [dependencies] saikuro-core = { workspace = true } diff --git a/Build/tests/common/mod.rs b/Build/tests/common/mod.rs index eb1c83ed..36d62f89 100644 --- a/Build/tests/common/mod.rs +++ b/Build/tests/common/mod.rs @@ -18,6 +18,7 @@ use saikuro_runtime::connection::ConnectionHandler; use saikuro_schema::{ capability_engine::CapabilityEngine, registry::SchemaRegistry, validator::InvocationValidator, }; +use saikuro_transport::shared::memory::{MemoryReceiver, MemorySender}; use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; use std::sync::Arc; @@ -89,6 +90,36 @@ pub async fn register_namespace(registry: &SchemaRegistry, namespace: &str, func .expect("merge_schema must succeed"); } +/// Build a `ConnectionHandler` over the runtime side of a +/// `MemoryTransport::pair` with the standard test configuration: default +/// router config, non-sandbox capability engine, and empty peer capabilities. +/// +/// Tests needing sandbox mode or specific peer capabilities mutate the +/// returned handler's public fields (or call `ConnectionHandler::sandboxed`). +pub fn make_handler( + peer_id: &str, + schema_registry: SchemaRegistry, + provider_registry: ProviderRegistry, + log: Arc, + handler_transport: MemoryTransport, +) -> ConnectionHandler { + let (handler_sender, handler_receiver) = handler_transport.split(); + ConnectionHandler { + peer_id: peer_id.to_owned(), + registration_token: RegistrationToken::new(), + sender: handler_sender, + receiver: handler_receiver, + validator: InvocationValidator::new(schema_registry.clone()), + capability_engine: CapabilityEngine::default(), + router: InvocationRouter::new(provider_registry.clone(), RouterConfig::default()), + peer_capabilities: CapabilitySet::empty(), + max_message_size: 4 * 1024 * 1024, + schema_registry, + provider_registry, + log, + } +} + pub async fn round_trip_via_handler( schema_registry: SchemaRegistry, provider_registry: ProviderRegistry, @@ -96,27 +127,15 @@ pub async fn round_trip_via_handler( ) -> ResponseEnvelope { let log = null_log(); let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler", log.clone()); - let (handler_sender, handler_receiver) = handler_transport.split(); let (mut test_sender, mut test_receiver) = test_transport.split(); - let router = InvocationRouter::new(provider_registry.clone(), RouterConfig::default()); - let validator = InvocationValidator::new(schema_registry.clone()); - let capability_engine = CapabilityEngine::default(); - - let handler = ConnectionHandler { - peer_id: "test-peer".to_owned(), - registration_token: RegistrationToken::new(), - sender: handler_sender, - receiver: handler_receiver, - validator, - capability_engine, - router, - peer_capabilities: CapabilitySet::empty(), - max_message_size: 4 * 1024 * 1024, + let handler = make_handler( + "test-peer", schema_registry, provider_registry, log, - }; + handler_transport, + ); let frame = Bytes::from(envelope.to_msgpack().expect("encode envelope")); test_sender.send(frame).await.expect("send frame"); diff --git a/Build/tests/lib.rs b/Build/tests/lib.rs index 1fb51214..8d32d9c0 100644 --- a/Build/tests/lib.rs +++ b/Build/tests/lib.rs @@ -1,5 +1,3 @@ -#![allow(unused_imports, dead_code)] - mod common; #[path = "saikuro-codegen"] @@ -20,25 +18,11 @@ mod core_tests { #[path = "saikuro-exec"] mod exec_tests { - mod embassy_cancellation; - mod embassy_executor; mod exec_channels; mod exec_concurrency; mod exec_select; } -#[path = "saikuro-net"] -mod net_tests { - mod embassy_net_loopback; -} - -#[path = "saikuro-random"] -mod random_tests { - mod drbg; - mod drbg_unseeded; - mod os_backend; -} - #[path = "saikuro-router"] mod router_tests { mod announce_dispatch; @@ -63,7 +47,6 @@ mod schema_tests { mod capability_enforcement; mod registry; mod schema_validation; - mod validator; } #[path = "saikuro-storage"] diff --git a/Build/tests/saikuro-core/cross_language_wire.rs b/Build/tests/saikuro-core/cross_language_wire.rs index 0e5dcec1..8e27adb6 100644 --- a/Build/tests/saikuro-core/cross_language_wire.rs +++ b/Build/tests/saikuro-core/cross_language_wire.rs @@ -1,5 +1,6 @@ //! Cross-language wire-protocol integration tests +use crate::common; use bytes::Bytes; use saikuro_core::{ capability::CapabilitySet, @@ -83,12 +84,6 @@ fn make_schema(namespace: &str, function: &str) -> Schema { make_schema_with_args(namespace, function, 0) } -/// Serialise a [`Schema`] into a [`Value`] suitable for `Envelope::announce`. -fn schema_to_value(schema: &Schema) -> Value { - let bytes = rmp_serde::to_vec_named(schema).expect("serialize schema"); - rmp_serde::from_slice::(&bytes).expect("re-decode schema as Value") -} - /// Wire the "simulated adapter" side: returns `(sender, receiver)` for the /// test to drive, while the runtime's `ConnectionHandler` is spawned in the /// background. @@ -436,7 +431,8 @@ fn b_simulated_provider_rust_client_dispatch() { // 2: Send an Announce so the runtime learns about `greeter.hello`. let schema = make_schema("greeter", "hello"); - let announce = Envelope::announce(schema_to_value(&schema)).expect("entropy available"); + let announce = + Envelope::announce(common::schema_to_value(&schema)).expect("entropy available"); provider_tx .send(encode_envelope(&announce)) .await @@ -509,7 +505,8 @@ fn c_rust_and_simulated_providers_coexist() { let (mut ext_tx, mut ext_rx) = connect_simulated_peer(&handle, "ext-provider"); let ext_schema = make_schema_with_args("ext", "echo", 1); - let announce = Envelope::announce(schema_to_value(&ext_schema)).expect("entropy available"); + let announce = + Envelope::announce(common::schema_to_value(&ext_schema)).expect("entropy available"); ext_tx .send(encode_envelope(&announce)) .await @@ -712,7 +709,8 @@ fn f_announce_then_client_call_round_trip() { let (mut prov_tx, mut prov_rx) = connect_simulated_peer(&handle, "prov-f"); let schema = make_schema_with_args("calc", "square", 1); - let announce = Envelope::announce(schema_to_value(&schema)).expect("entropy available"); + let announce = + Envelope::announce(common::schema_to_value(&schema)).expect("entropy available"); prov_tx .send(encode_envelope(&announce)) .await @@ -866,7 +864,8 @@ fn i_provider_reconnect_and_reannounce() { let (mut prov_tx, mut prov_rx) = connect_simulated_peer(&handle, "reconnect-prov-v1"); let schema = make_schema("svc2", "op"); - let announce = Envelope::announce(schema_to_value(&schema)).expect("entropy available"); + let announce = + Envelope::announce(common::schema_to_value(&schema)).expect("entropy available"); prov_tx .send(encode_envelope(&announce)) .await @@ -907,7 +906,8 @@ fn i_provider_reconnect_and_reannounce() { let (mut prov2_tx, mut prov2_rx) = connect_simulated_peer(&handle, "reconnect-prov-v2"); let schema2 = make_schema("svc2", "op"); - let announce2 = Envelope::announce(schema_to_value(&schema2)).expect("entropy available"); + let announce2 = + Envelope::announce(common::schema_to_value(&schema2)).expect("entropy available"); prov2_tx .send(encode_envelope(&announce2)) .await diff --git a/Build/tests/saikuro-exec/embassy_cancellation.rs b/Build/tests/saikuro-exec/embassy_cancellation.rs deleted file mode 100644 index 06d5c1ae..00000000 --- a/Build/tests/saikuro-exec/embassy_cancellation.rs +++ /dev/null @@ -1,203 +0,0 @@ -#![cfg(feature = "embassy-test")] - -use std::future::Future; -use std::pin::Pin; -use std::task::Poll; -use std::time::Duration; - -use futures::future::poll_fn; -use futures_executor::block_on; -use saikuro_exec::{mpsc, oneshot, sync, watch, ChannelCapacity}; - -/// Poll `fut` once with the surrounding executor's waker and assert it is -/// still pending. The future parks exactly like an `.await` would, so a later -/// external event that wakes it is observable. -async fn assert_pending(fut: &mut F) { - poll_fn(|cx| { - assert!( - Pin::new(&mut *fut).poll(cx).is_pending(), - "expected the future to be pending" - ); - Poll::Ready(()) - }) - .await; -} - -/// Await `fut` with a fail-on-timeout guard. -async fn guarded(fut: F) -> F::Output { - saikuro_exec::timeout(Duration::from_secs(5), fut) - .await - .expect("test future timed out") -} - -fn capacity(n: usize) -> ChannelCapacity { - ChannelCapacity::new(n).expect("valid capacity") -} - -#[test] -fn mpsc_sender_blocked_on_full_errors_when_receiver_dropped() { - block_on(async { - let (tx, rx) = mpsc::channel::(capacity(2)); - tx.send(1).await.expect("send first value"); - tx.send(2).await.expect("send second value"); - - let mut send = Box::pin(tx.send(3)); - assert_pending(&mut send).await; - - drop(rx); - - let err = guarded(send).await.expect_err("receiver was dropped"); - assert_eq!(err.0, 3, "the undelivered value is returned"); - }); -} - -#[test] -fn mpsc_sender_blocked_on_full_completes_when_capacity_frees() { - block_on(async { - let (tx, mut rx) = mpsc::channel::(capacity(2)); - tx.send(1).await.expect("send first value"); - tx.send(2).await.expect("send second value"); - - let mut send = Box::pin(tx.send(3)); - assert_pending(&mut send).await; - - assert_eq!(rx.recv().await, Some(1)); - guarded(send) - .await - .expect("sender proceeds once a slot frees"); - assert_eq!(rx.recv().await, Some(2)); - assert_eq!(rx.recv().await, Some(3)); - }); -} - -#[test] -fn mpsc_receiver_blocked_on_empty_returns_none_when_senders_dropped() { - block_on(async { - let (tx, mut rx) = mpsc::channel::(capacity(2)); - - let mut recv = Box::pin(rx.recv()); - assert_pending(&mut recv).await; - - drop(tx); - - assert_eq!(guarded(recv).await, None, "channel closes with senders"); - }); -} - -#[test] -fn mpsc_receiver_cancelled_then_resumed_receives_sent_value() { - block_on(async { - let (tx, mut rx) = mpsc::channel::(capacity(2)); - - { - let mut recv = Box::pin(rx.recv()); - assert_pending(&mut recv).await; - // Cancel the blocked receiver; its waker registration goes stale. - } - - tx.send(7).await.expect("send after cancellation"); - assert_eq!(rx.recv().await, Some(7)); - }); -} - -#[test] -fn mpsc_cancelled_blocked_sender_does_not_corrupt_channel() { - block_on(async { - let (tx, mut rx) = mpsc::channel::(capacity(2)); - tx.send(1).await.expect("send first value"); - tx.send(2).await.expect("send second value"); - - { - let mut send = Box::pin(tx.send(3)); - assert_pending(&mut send).await; - // Cancel the blocked sender; the undelivered value drops with it. - } - - assert_eq!(rx.recv().await, Some(1)); - tx.send(4).await.expect("channel still accepts sends"); - assert_eq!(rx.recv().await, Some(2)); - assert_eq!(rx.recv().await, Some(4)); - }); -} - -#[test] -fn oneshot_receiver_pending_completes_when_sent() { - block_on(async { - let (tx, mut rx) = oneshot::channel(); - assert_pending(&mut rx).await; - - tx.send(42).expect("receiver still alive"); - assert_eq!(rx.await.expect("value delivered"), 42); - }); -} - -#[test] -fn oneshot_send_returns_value_when_receiver_dropped() { - let (tx, rx) = oneshot::channel(); - drop(rx); - - let err = tx.send(42).expect_err("receiver was dropped"); - assert_eq!(err, 42, "the undelivered value is returned"); -} - -#[test] -fn watch_receiver_cancelled_then_resumed_sees_new_value() { - block_on(async { - let (tx, mut rx) = watch::channel(0_u32); - - { - let mut changed = rx.changed(); - assert_pending(&mut changed).await; - // Cancel the blocked change future; the observed version is stale. - } - - tx.send(1).expect("receiver still alive"); - assert!(rx.changed().await.is_ok(), "change is reported"); - assert_eq!(rx.borrow(), 1); - }); -} - -#[test] -fn watch_receiver_changed_errors_when_senders_dropped() { - block_on(async { - let (tx, mut rx) = watch::channel(0_u32); - - { - let mut changed = rx.changed(); - assert_pending(&mut changed).await; - } - - drop(tx); - - assert_eq!(rx.changed().await, Err(watch::RecvError)); - }); -} - -#[test] -fn barrier_releases_all_waiters_when_last_arrives() { - block_on(async { - let barrier = sync::Barrier::new(2); - - let mut first = Box::pin(barrier.wait()); - assert_pending(&mut first).await; - - barrier.wait().await; - guarded(first).await; - }); -} - -#[test] -fn barrier_cancelled_waiter_arrival_still_counts_toward_release() { - block_on(async { - let barrier = sync::Barrier::new(2); - - { - let mut first = Box::pin(barrier.wait()); - assert_pending(&mut first).await; - // Cancel after arriving; the arrival is not reclaimed. - } - - // One fresh arrival brings the tally to the release threshold. - guarded(barrier.wait()).await; - }); -} diff --git a/Build/tests/saikuro-exec/embassy_executor.rs b/Build/tests/saikuro-exec/embassy_executor.rs deleted file mode 100644 index 3ab7f721..00000000 --- a/Build/tests/saikuro-exec/embassy_executor.rs +++ /dev/null @@ -1,131 +0,0 @@ -#![cfg(feature = "embassy-test")] - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::thread; -use std::time::{Duration, Instant}; - -use embassy_executor::raw; -use embassy_executor::Spawner; - -use saikuro_exec::{mpsc, oneshot, sleep, timeout, watch, ChannelCapacity}; - -const TEST_TIMEOUT: Duration = Duration::from_secs(30); - -type Done = Arc; - -/// Wake hook for the raw executor. -/// -/// The executor is driven by a busy-poll loop in `run_until`, so the pender -/// does not need to wake anything; woken tasks are enqueued by `wake_task` -/// regardless. On host this is the equivalent of an interrupt pender. -#[no_mangle] -fn __pender(_context: *mut ()) {} - -#[embassy_executor::task] -async fn producer(tx: mpsc::Sender, done_tx: oneshot::Sender<()>) { - for i in 0..5 { - tx.send(i).await.expect("consumer dropped the channel"); - } - let _ = done_tx.send(()); -} - -#[embassy_executor::task] -async fn consumer(mut rx: mpsc::Receiver, done_rx: oneshot::Receiver<()>, done: Done) { - let mut got = Vec::new(); - while let Some(value) = rx.recv().await { - got.push(value); - } - assert_eq!(got, vec![0, 1, 2, 3, 4]); - assert!(done_rx.await.is_ok(), "producer did not signal completion"); - done.store(true, Ordering::SeqCst); -} - -#[embassy_executor::task] -async fn timer_task(done: Done) { - let start = Instant::now(); - sleep(Duration::from_millis(50)).await; - assert!( - start.elapsed() >= Duration::from_millis(40), - "timer woke too early: {:?}", - start.elapsed() - ); - - let expired = timeout( - Duration::from_millis(10), - sleep(Duration::from_millis(1000)), - ) - .await; - assert!( - expired.is_err(), - "timeout should have fired before the sleep" - ); - - done.store(true, Ordering::SeqCst); -} - -#[embassy_executor::task] -async fn watch_writer(tx: watch::Sender) { - tx.send(42).expect("reader was dropped"); -} - -#[embassy_executor::task] -async fn watch_reader(mut rx: watch::Receiver, done: Done) { - rx.changed().await.expect("watch closed before the update"); - assert_eq!(rx.borrow(), 42); - done.store(true, Ordering::SeqCst); -} - -/// Drive a raw executor on the current thread until `done` is set, then return. -/// Task panics propagate out of `poll` and fail the test. -fn run_until(done: &Done, init: impl FnOnce(&Spawner)) { - let executor: &'static raw::Executor = - Box::leak(Box::new(raw::Executor::new(core::ptr::null_mut()))); - let spawner = executor.spawner(); - init(&spawner); - - let deadline = Instant::now() + TEST_TIMEOUT; - while Instant::now() < deadline { - unsafe { executor.poll() }; - if done.load(Ordering::SeqCst) { - return; - } - thread::sleep(Duration::from_millis(1)); - } - panic!("executor integration test timed out"); -} - -#[test] -fn mpsc_and_oneshot_between_spawned_tasks() { - let done: Done = Arc::new(AtomicBool::new(false)); - let done_clone = done.clone(); - let (tx, rx) = mpsc::channel::(ChannelCapacity::new(4).expect("valid capacity")); - let (done_tx, done_rx) = oneshot::channel::<()>(); - - run_until(&done, move |spawner| { - spawner.must_spawn(producer(tx, done_tx)); - spawner.must_spawn(consumer(rx, done_rx, done_clone)); - }); -} - -#[test] -fn timers_and_timeout_on_app_executor() { - let done: Done = Arc::new(AtomicBool::new(false)); - let done_clone = done.clone(); - - run_until(&done, move |spawner| { - spawner.must_spawn(timer_task(done_clone)); - }); -} - -#[test] -fn watch_channel_between_spawned_tasks() { - let done: Done = Arc::new(AtomicBool::new(false)); - let done_clone = done.clone(); - let (tx, rx) = watch::channel::(0); - - run_until(&done, move |spawner| { - spawner.must_spawn(watch_writer(tx)); - spawner.must_spawn(watch_reader(rx, done_clone)); - }); -} diff --git a/Build/tests/saikuro-net/embassy_net_loopback.rs b/Build/tests/saikuro-net/embassy_net_loopback.rs deleted file mode 100644 index 0983381b..00000000 --- a/Build/tests/saikuro-net/embassy_net_loopback.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! Host-run loopback test for the Embassy net facade (`saikuro_exec::net`). -//! -//! Two `embassy-net` stacks are bridged back to back through in-memory -//! `embassy-net-driver-channel` devices. A TCP connection is opened between -//! them and data flows in both directions. This exercises the re-exported -//! surface (config, addresses, sockets) on the host, without any hardware. -//! -//! Run with: `cargo test -p saikuro-exec --no-default-features --features embassy-test,net` - -#![cfg(all(feature = "embassy-test", feature = "net"))] - -use core::time::Duration as CoreDuration; - -use std::future::Future; -use std::pin::Pin; -use std::task::Poll; - -use embassy_net_driver_channel::driver::{HardwareAddress, LinkState}; -use embassy_net_driver_channel::{RxRunner, State, TxRunner}; - -use saikuro_exec::net::{ - tcp, Config, IpAddress, IpEndpoint, Ipv4Address, Ipv4Cidr, StackResources, StaticConfigV4, -}; -use saikuro_exec::timeout; - -const MTU: usize = 1500; -const CHAN_RX: usize = 4; -const CHAN_TX: usize = 4; -const SOCKET_BUFFER: usize = 4096; -const TEST_TIMEOUT: CoreDuration = CoreDuration::from_secs(30); -const PORT: u16 = 4242; - -const ADDR_A: Ipv4Address = Ipv4Address::new(10, 0, 0, 1); -const ADDR_B: Ipv4Address = Ipv4Address::new(10, 0, 0, 2); - -/// Copy every outbound packet of the source stack into the inbound path of the -/// destination stack. Runs forever; dropped when the test body completes. -async fn bridge(src_tx: &mut TxRunner<'_, M>, dst_rx: &mut RxRunner<'_, M>) { - loop { - let len = { - let pkt = src_tx.tx_buf().await; - let len = pkt.len(); - let dst = dst_rx.rx_buf().await; - dst[..len].copy_from_slice(&pkt[..len]); - len - }; - dst_rx.rx_done(len); - src_tx.tx_done(); - } -} - -#[test] -fn tcp_loopback_between_two_stacks() { - futures_executor::block_on(async { - let outcome = timeout(TEST_TIMEOUT, async { - let mut state_a = State::::new(); - let mut state_b = State::::new(); - - let (mut chan_runner_a, device_a) = - embassy_net_driver_channel::new(&mut state_a, HardwareAddress::Ip); - let (mut chan_runner_b, device_b) = - embassy_net_driver_channel::new(&mut state_b, HardwareAddress::Ip); - chan_runner_a.set_link_state(LinkState::Up); - chan_runner_b.set_link_state(LinkState::Up); - - let mut resources_a = StackResources::<2>::new(); - let mut resources_b = StackResources::<2>::new(); - - let config_a = Config::ipv4_static(StaticConfigV4 { - address: Ipv4Cidr::new(ADDR_A, 24), - gateway: None, - dns_servers: Default::default(), - }); - let config_b = Config::ipv4_static(StaticConfigV4 { - address: Ipv4Cidr::new(ADDR_B, 24), - gateway: None, - dns_servers: Default::default(), - }); - - let (stack_a, mut stack_runner_a) = - saikuro_exec::net::new(device_a, config_a, &mut resources_a, 1234); - let (stack_b, mut stack_runner_b) = - saikuro_exec::net::new(device_b, config_b, &mut resources_b, 4321); - - let (_state_runner_a, mut rx_runner_a, mut tx_runner_a) = chan_runner_a.split(); - let (_state_runner_b, mut rx_runner_b, mut tx_runner_b) = chan_runner_b.split(); - - let mut sock_a_rx = [0u8; SOCKET_BUFFER]; - let mut sock_a_tx = [0u8; SOCKET_BUFFER]; - let mut sock_b_rx = [0u8; SOCKET_BUFFER]; - let mut sock_b_tx = [0u8; SOCKET_BUFFER]; - - let body = async { - stack_a.wait_config_up().await; - stack_b.wait_config_up().await; - - let mut sock_a = tcp::TcpSocket::new(stack_a, &mut sock_a_rx, &mut sock_a_tx); - let mut sock_b = tcp::TcpSocket::new(stack_b, &mut sock_b_rx, &mut sock_b_tx); - - // accept() waits for the first connection, so it must be - // driven concurrently with the peer's connect(). - let server = IpEndpoint::new(IpAddress::Ipv4(ADDR_A), PORT); - let (accept_res, connect_res) = - futures::join!(sock_a.accept(PORT), sock_b.connect(server)); - accept_res.expect("bind + listen"); - connect_res.expect("connect"); - - let mut ping = [0u8; 4]; - sock_b.write(b"ping").await.expect("write ping"); - sock_b.flush().await.expect("flush ping"); - let n = sock_a.read(&mut ping).await.expect("read ping"); - assert_eq!(n, 4); - assert_eq!(&ping, b"ping"); - - sock_a.write(&ping).await.expect("write echo"); - sock_a.flush().await.expect("flush echo"); - let mut echo = [0u8; 4]; - let n = sock_b.read(&mut echo).await.expect("read echo"); - assert_eq!(n, 4); - assert_eq!(&echo, b"ping"); - }; - - // Poll the two stack runners, the two bridges, and the test body - // together. The runners and bridges never complete; the future - // resolves once the body finishes. - let mut body = Box::pin(body); - let mut runner_a = Box::pin(stack_runner_a.run()); - let mut runner_b = Box::pin(stack_runner_b.run()); - let mut bridge_ab = Box::pin(bridge(&mut tx_runner_a, &mut rx_runner_b)); - let mut bridge_ba = Box::pin(bridge(&mut tx_runner_b, &mut rx_runner_a)); - - std::future::poll_fn(move |cx| { - let mut finished = false; - if let Poll::Ready(_) = Pin::new(&mut body).poll(cx) { - finished = true; - } - let _ = Pin::new(&mut runner_a).poll(cx); - let _ = Pin::new(&mut runner_b).poll(cx); - let _ = Pin::new(&mut bridge_ab).poll(cx); - let _ = Pin::new(&mut bridge_ba).poll(cx); - if finished { - Poll::Ready(()) - } else { - Poll::Pending - } - }) - .await - }) - .await; - - outcome.expect("net loopback test timed out") - }); -} diff --git a/Build/tests/saikuro-random/drbg.rs b/Build/tests/saikuro-random/drbg.rs deleted file mode 100644 index bc158041..00000000 --- a/Build/tests/saikuro-random/drbg.rs +++ /dev/null @@ -1,109 +0,0 @@ -#![cfg(feature = "drbg")] - -use chacha20::cipher::{KeyIvInit, StreamCipher}; -use chacha20::XChaCha20; -use saikuro_random::{fill, is_seeded, seed_from_slice, Drbg, Error}; - -const SEED_LEN: usize = 56; -const KEY_LEN: usize = 32; -const BLOCK_LEN: usize = 64; - -const SEED_ONE: [u8; SEED_LEN] = [ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, - 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, - 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, - 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, -]; - -#[test] -fn same_seed_is_reproducible() { - let mut a = Drbg::from_seed(&SEED_ONE).expect("valid seed"); - let mut b = Drbg::from_seed(&SEED_ONE).expect("valid seed"); - let mut buf_a = [0u8; 128]; - let mut buf_b = [0u8; 128]; - a.fill(&mut buf_a).expect("fill ok"); - b.fill(&mut buf_b).expect("fill ok"); - assert_eq!(buf_a, buf_b); -} - -#[test] -fn different_seeds_diverge() { - let mut a = Drbg::from_seed(&SEED_ONE).expect("valid seed"); - let mut b = Drbg::from_seed(&[0xee; SEED_LEN]).expect("valid seed"); - let mut buf_a = [0u8; 64]; - let mut buf_b = [0u8; 64]; - a.fill(&mut buf_a).expect("fill ok"); - b.fill(&mut buf_b).expect("fill ok"); - assert_ne!(buf_a, buf_b); -} - -#[test] -fn drbg_matches_reference_stream() { - let mut drbg = Drbg::from_seed(&SEED_ONE).expect("valid seed"); - let mut buf = [0u8; 128]; - drbg.fill(&mut buf).expect("fill ok"); - - let key = &SEED_ONE[..KEY_LEN]; - let nonce = &SEED_ONE[KEY_LEN..SEED_LEN]; - let mut cipher = XChaCha20::new_from_slices(key, nonce).expect("valid lengths"); - let mut block_zero = [0u8; BLOCK_LEN]; - cipher.apply_keystream(&mut block_zero); - assert_eq!( - buf[..BLOCK_LEN], - block_zero, - "seek(0) must equal the first keystream block" - ); - let mut block_one = [0u8; BLOCK_LEN]; - cipher.apply_keystream(&mut block_one); - assert_eq!( - buf[BLOCK_LEN..], - block_one, - "sequential blocks must be contiguous" - ); -} - -#[test] -fn short_seed_is_rejected() { - assert_eq!(Drbg::from_seed(&[0u8; 8]), Err(Error::InvalidSeed)); -} - -#[test] -fn global_stream_matches_a_seeded_local_drbg_and_advances() { - seed_from_slice(&SEED_ONE).expect("valid seed"); - assert_eq!(seed_from_slice(&SEED_ONE), Err(Error::AlreadySeeded)); - assert!(is_seeded()); - - let mut first = [0u8; 32]; - fill(&mut first).expect("seeded fill ok"); - let mut second = [0u8; 32]; - fill(&mut second).expect("seeded fill ok"); - - let mut drbg = Drbg::from_seed(&SEED_ONE).expect("valid seed"); - let mut expected = [0u8; 128]; - drbg.fill(&mut expected).expect("fill ok"); - assert_eq!( - &first[..], - &expected[..32], - "first draw must match the head of the stream" - ); - assert_eq!( - &second[..], - &expected[64..96], - "second draw must continue the stream at the next block" - ); -} - -#[test] -fn fill_uninit_initializes_every_byte() { - let mut drbg = Drbg::from_seed(&SEED_ONE).expect("valid seed"); - let mut buf = [core::mem::MaybeUninit::::uninit(); 16]; - drbg.fill_uninit(&mut buf).expect("fill ok"); - let buf = buf.map(|slot| { - // SAFETY: fill_uninit initialized every slot on Ok. - unsafe { slot.assume_init() } - }); - let mut expected = [0u8; 16]; - let mut probe = Drbg::from_seed(&SEED_ONE).expect("valid seed"); - probe.fill(&mut expected).expect("fill ok"); - assert_eq!(&buf[..], &expected[..]); -} diff --git a/Build/tests/saikuro-random/drbg_unseeded.rs b/Build/tests/saikuro-random/drbg_unseeded.rs deleted file mode 100644 index e3a9828b..00000000 --- a/Build/tests/saikuro-random/drbg_unseeded.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! The process-wide DRBG must refuse to draw before seeding. -//! -//! This lives in its own test binary so its statics start unseeded btw - -#![cfg(feature = "drbg")] - -use saikuro_random::{fill, is_seeded, Error}; - -#[test] -fn unseeded_fill_errors() { - assert!(!is_seeded()); - let mut buf = [0u8; 16]; - assert_eq!(fill(&mut buf), Err(Error::DrbgNotSeeded)); -} diff --git a/Build/tests/saikuro-random/os_backend.rs b/Build/tests/saikuro-random/os_backend.rs deleted file mode 100644 index 2de2fd4a..00000000 --- a/Build/tests/saikuro-random/os_backend.rs +++ /dev/null @@ -1,39 +0,0 @@ -#![cfg(all( - not(feature = "drbg"), - any(feature = "os", feature = "wasm", feature = "custom") -))] - -use saikuro_random::{u32, u64, uuid_v4}; - -#[test] -fn uuid_v4_sets_version_and_variant_bits() { - let uuid = uuid_v4().expect("entropy available"); - assert_eq!(uuid.get_version_num(), 4); - let bytes = uuid.as_bytes(); - assert_eq!(bytes[6] >> 4, 4); - assert_eq!(bytes[8] & 0xc0, 0x80); -} - -#[test] -fn generated_uuids_are_unique() { - let mut seen = std::collections::BTreeSet::new(); - for _ in 0..64 { - let uuid = uuid_v4().expect("entropy available"); - assert!(seen.insert(uuid), "duplicate uuid {uuid}"); - } -} - -#[test] -fn u32_and_u64_draws_are_sane() { - let mut words = std::collections::BTreeSet::new(); - for _ in 0..4 { - words.insert(u32().expect("entropy available")); - } - assert!( - words.len() >= 2, - "four draws all colliding is statistically impossible" - ); - - let x = u64().expect("entropy available"); - assert!(x != 0 || u64().expect("entropy available") != 0); -} diff --git a/Build/tests/saikuro-router/announce_dispatch.rs b/Build/tests/saikuro-router/announce_dispatch.rs index 22fcb2e8..f527dc2d 100644 --- a/Build/tests/saikuro-router/announce_dispatch.rs +++ b/Build/tests/saikuro-router/announce_dispatch.rs @@ -2,22 +2,13 @@ use bytes::Bytes; use saikuro_core::{ - capability::CapabilitySet, envelope::{Envelope, InvocationType}, InvocationId, ResponseEnvelope, PROTOCOL_VERSION, }; use saikuro_event::Value; use saikuro_exec::mpsc; -use saikuro_router::{ - provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, - router::{InvocationRouter, RouterConfig}, -}; -use saikuro_runtime::connection::ConnectionHandler; -use saikuro_schema::{ - capability_engine::CapabilityEngine, - registry::{RegistryMode, SchemaRegistry}, - validator::InvocationValidator, -}; +use saikuro_router::provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}; +use saikuro_schema::registry::{RegistryMode, SchemaRegistry}; use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; use crate::common; @@ -35,30 +26,17 @@ async fn round_trip_while_alive( provider_registry: ProviderRegistry, envelope: Envelope, ) -> ResponseEnvelope { - let log: std::sync::Arc = - std::sync::Arc::from(Box::new(saikuro_event::NullSink) as Box); + let log = common::null_log(); let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler", log.clone()); - let (handler_sender, handler_receiver) = handler_transport.split(); let (mut test_sender, mut test_receiver) = test_transport.split(); - let router = InvocationRouter::new(provider_registry.clone(), RouterConfig::default()); - let validator = InvocationValidator::new(schema_registry.clone()); - let capability_engine = CapabilityEngine::default(); - - let handler = ConnectionHandler { - peer_id: "test-peer".to_owned(), - registration_token: saikuro_core::RegistrationToken::new(), - sender: handler_sender, - receiver: handler_receiver, - validator, - capability_engine, - router, - peer_capabilities: CapabilitySet::empty(), - max_message_size: 4 * 1024 * 1024, + let handler = common::make_handler( + "test-peer", schema_registry, provider_registry, log, - }; + handler_transport, + ); // Spawn the handler so we can interleave reads/writes. let task = saikuro_exec::spawn(handler.run()); diff --git a/Build/tests/saikuro-router/call_dispatch.rs b/Build/tests/saikuro-router/call_dispatch.rs index f8696fc1..155e98c4 100644 --- a/Build/tests/saikuro-router/call_dispatch.rs +++ b/Build/tests/saikuro-router/call_dispatch.rs @@ -1,5 +1,6 @@ //! Call and cast dispatch integration tests +use crate::common; use saikuro_core::{envelope::Envelope, ResponseEnvelope}; use saikuro_event::{ErrorCode, Value}; use saikuro_exec::mpsc; @@ -11,26 +12,6 @@ use std::time::Duration; // Helpers -/// Spawn a minimal provider task that automatically echoes every Call. -/// -/// Returns the [`ProviderRegistry`] with the provider registered, plus a -/// join handle so callers can wait for completion. -async fn make_echo_provider( - namespace: &str, -) -> (ProviderRegistry, mpsc::Receiver) { - let (work_tx, work_rx) = mpsc::channel::( - saikuro_exec::ChannelCapacity::try_from(64).expect("64 is a valid channel capacity"), - ); - let handle = ProviderHandle::new( - format!("{namespace}-provider"), - vec![namespace.to_owned()], - work_tx, - ); - let registry = ProviderRegistry::new(); - registry.register(handle).await; - (registry, work_rx) -} - /// Spawn a background task that answers every work item with the given value. fn spawn_responder( mut work_rx: mpsc::Receiver, @@ -69,7 +50,7 @@ fn spawn_silent_responder( #[test] fn call_returns_provider_response() { saikuro_exec::block_on(async { - let (registry, work_rx) = make_echo_provider("math").await; + let (registry, work_rx) = common::make_provider("math").await; let _responder = spawn_responder(work_rx, Value::Int(42)); let router = InvocationRouter::with_providers(registry); @@ -85,7 +66,7 @@ fn call_returns_provider_response() { #[test] fn cast_returns_ok_empty_immediately() { saikuro_exec::block_on(async { - let (registry, mut work_rx) = make_echo_provider("logger").await; + let (registry, mut work_rx) = common::make_provider("logger").await; // Consume work items so the channel doesn't fill up, but never respond. saikuro_exec::spawn(async move { while (work_rx.recv().await).is_some() {} }); @@ -144,7 +125,7 @@ fn call_to_dropped_provider_returns_unavailable() { #[test] fn call_times_out_when_provider_does_not_respond() { saikuro_exec::block_on(async { - let (registry, work_rx) = make_echo_provider("slow").await; + let (registry, work_rx) = common::make_provider("slow").await; let _silent = spawn_silent_responder(work_rx); let config = RouterConfig { @@ -165,7 +146,7 @@ fn call_times_out_when_provider_does_not_respond() { #[test] fn multiple_sequential_calls_all_succeed() { saikuro_exec::block_on(async { - let (registry, work_rx) = make_echo_provider("counter").await; + let (registry, work_rx) = common::make_provider("counter").await; let _responder = spawn_responder(work_rx, Value::Bool(true)); let router = InvocationRouter::with_providers(registry); @@ -181,7 +162,7 @@ fn multiple_sequential_calls_all_succeed() { #[test] fn concurrent_calls_all_succeed() { saikuro_exec::block_on(async { - let (registry, work_rx) = make_echo_provider("parallel").await; + let (registry, work_rx) = common::make_provider("parallel").await; let _responder = spawn_responder(work_rx, Value::Int(0)); let router = InvocationRouter::with_providers(registry); diff --git a/Build/tests/saikuro-router/resource_dispatch.rs b/Build/tests/saikuro-router/resource_dispatch.rs index 722841bd..64b035bc 100644 --- a/Build/tests/saikuro-router/resource_dispatch.rs +++ b/Build/tests/saikuro-router/resource_dispatch.rs @@ -17,21 +17,6 @@ use crate::common; // Helpers -/// Build a `ProviderRegistry` with a single provider subscribed to `namespace`. -async fn make_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver) { - let (work_tx, work_rx) = mpsc::channel::( - saikuro_exec::ChannelCapacity::try_from(64).expect("64 is a valid channel capacity"), - ); - let handle = ProviderHandle::new( - format!("{namespace}-provider"), - vec![namespace.to_owned()], - work_tx, - ); - let registry = ProviderRegistry::new(); - registry.register(handle).await; - (registry, work_rx) -} - /// Spawn a background task that answers every work item with `result_value`. fn spawn_responder( mut work_rx: mpsc::Receiver, @@ -75,7 +60,7 @@ fn resource_envelope_routes_as_call() { .with_uri("saikuro://res/abc-001"); let result_value = handle_to_value(&handle); - let (registry, work_rx) = make_provider("files").await; + let (registry, work_rx) = common::make_provider("files").await; let _responder = spawn_responder(work_rx, result_value); let router = InvocationRouter::with_providers(registry); @@ -102,7 +87,7 @@ fn resource_envelope_returns_handle_from_provider() { .with_uri("https://storage.example.com/blobs/xyz-999"); let result_value = handle_to_value(&original_handle); - let (registry, work_rx) = make_provider("storage").await; + let (registry, work_rx) = common::make_provider("storage").await; let _responder = spawn_responder(work_rx, result_value); let router = InvocationRouter::with_providers(registry); @@ -219,7 +204,7 @@ fn resource_dispatch_through_connection_handler() { .with_size(128); let result_value = handle_to_value(&handle); - let (provider_registry, work_rx) = make_provider("docs").await; + let (provider_registry, work_rx) = common::make_provider("docs").await; let _responder = spawn_responder(work_rx, result_value.clone()); let schema_registry = SchemaRegistry::new(); @@ -272,7 +257,7 @@ fn resource_response_id_matches_request_id() { let handle = ResourceHandle::new("corr-001"); let result_value = handle_to_value(&handle); - let (registry, work_rx) = make_provider("corr").await; + let (registry, work_rx) = common::make_provider("corr").await; let _responder = spawn_responder(work_rx, result_value); let router = InvocationRouter::with_providers(registry); @@ -296,7 +281,7 @@ fn concurrent_resource_invocations_all_succeed() { let handle = ResourceHandle::new("concurrent-test"); let result_value = handle_to_value(&handle); - let (registry, work_rx) = make_provider("bulk").await; + let (registry, work_rx) = common::make_provider("bulk").await; let _responder = spawn_responder(work_rx, result_value); let router = InvocationRouter::with_providers(registry); diff --git a/Build/tests/saikuro-router/sandbox_dispatch.rs b/Build/tests/saikuro-router/sandbox_dispatch.rs index 0839637d..39de259e 100644 --- a/Build/tests/saikuro-router/sandbox_dispatch.rs +++ b/Build/tests/saikuro-router/sandbox_dispatch.rs @@ -10,17 +10,12 @@ use saikuro_core::{ }, InvocationId, ResponseEnvelope, PROTOCOL_VERSION, }; -use saikuro_event::Value; -use saikuro_router::{ - provider::ProviderRegistry, - router::{InvocationRouter, RouterConfig}, -}; -use saikuro_runtime::connection::ConnectionHandler; -use saikuro_schema::{ - capability_engine::CapabilityEngine, registry::SchemaRegistry, validator::InvocationValidator, -}; +use saikuro_router::provider::ProviderRegistry; +use saikuro_schema::registry::SchemaRegistry; use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; +use crate::common; + // Helpers fn build_schema() -> Schema { @@ -94,15 +89,6 @@ fn build_schema() -> Schema { } } -fn schema_to_value(schema: &Schema) -> Value { - let bytes = rmp_serde::to_vec_named(schema).expect("serialize schema"); - rmp_serde::from_slice::(&bytes).expect("deserialize schema to Value") -} - -fn make_announce(schema: &Schema) -> Envelope { - Envelope::announce(schema_to_value(schema)).expect("entropy available") -} - /// Send `envelope` through a `ConnectionHandler` (optionally sandboxed) and /// collect all frames the handler pushes back. /// @@ -114,35 +100,22 @@ async fn run_and_collect( sandbox: bool, envelope: Envelope, ) -> Vec { - let log: std::sync::Arc = - std::sync::Arc::from(Box::new(saikuro_event::NullSink) as Box); + let log = common::null_log(); let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler", log.clone()); - let (handler_sender, handler_receiver) = handler_transport.split(); let (mut test_sender, mut test_receiver) = test_transport.split(); let providers = ProviderRegistry::new(); - let router = InvocationRouter::new(providers.clone(), RouterConfig::default()); - let validator = InvocationValidator::new(schema_registry.clone()); - let capability_engine = if sandbox { - CapabilityEngine::sandboxed() - } else { - CapabilityEngine::new() - }; - - let handler = ConnectionHandler { - peer_id: "sandbox-peer".to_owned(), - registration_token: saikuro_core::RegistrationToken::new(), - sender: handler_sender, - receiver: handler_receiver, - validator, - capability_engine, - router, - peer_capabilities, - max_message_size: 4 * 1024 * 1024, + let mut handler = common::make_handler( + "sandbox-peer", schema_registry, - provider_registry: providers, + providers, log, - }; + handler_transport, + ); + if sandbox { + handler = handler.sandboxed(); + } + handler.peer_capabilities = peer_capabilities; let frame = Bytes::from(envelope.to_msgpack().expect("encode envelope")); test_sender.send(frame).await.expect("send frame"); @@ -166,7 +139,7 @@ fn sandbox_announce_pushes_filtered_schema_frame() { saikuro_exec::block_on(async { let registry = SchemaRegistry::new(); let schema = build_schema(); - let env = make_announce(&schema); + let env = common::make_announce_envelope(&schema); let frames = run_and_collect(registry, CapabilitySet::empty(), true, env).await; @@ -193,7 +166,7 @@ fn sandbox_filtered_schema_excludes_internal_functions() { saikuro_exec::block_on(async { let registry = SchemaRegistry::new(); let schema = build_schema(); - let env = make_announce(&schema); + let env = common::make_announce_envelope(&schema); let frames = run_and_collect(registry, CapabilitySet::empty(), true, env).await; assert_eq!(frames.len(), 2); @@ -221,7 +194,7 @@ fn sandbox_filtered_schema_excludes_private_functions() { saikuro_exec::block_on(async { let registry = SchemaRegistry::new(); let schema = build_schema(); - let env = make_announce(&schema); + let env = common::make_announce_envelope(&schema); let frames = run_and_collect(registry, CapabilitySet::empty(), true, env).await; assert_eq!(frames.len(), 2); @@ -246,7 +219,7 @@ fn sandbox_filtered_schema_includes_public_no_cap_functions() { saikuro_exec::block_on(async { let registry = SchemaRegistry::new(); let schema = build_schema(); - let env = make_announce(&schema); + let env = common::make_announce_envelope(&schema); let frames = run_and_collect(registry, CapabilitySet::empty(), true, env).await; assert_eq!(frames.len(), 2); @@ -271,7 +244,7 @@ fn sandbox_filtered_schema_excludes_functions_peer_lacks_caps_for() { saikuro_exec::block_on(async { let registry = SchemaRegistry::new(); let schema = build_schema(); - let env = make_announce(&schema); + let env = common::make_announce_envelope(&schema); // Peer has no capabilities. let frames = run_and_collect(registry, CapabilitySet::empty(), true, env).await; @@ -297,7 +270,7 @@ fn sandbox_filtered_schema_includes_functions_peer_has_caps_for() { saikuro_exec::block_on(async { let registry = SchemaRegistry::new(); let schema = build_schema(); - let env = make_announce(&schema); + let env = common::make_announce_envelope(&schema); let caps = CapabilitySet::from_tokens([CapabilityToken::new("special.cap")]).unwrap(); let frames = run_and_collect(registry, caps, true, env).await; @@ -323,7 +296,7 @@ fn non_sandbox_announce_produces_single_response_frame() { saikuro_exec::block_on(async { let registry = SchemaRegistry::new(); let schema = build_schema(); - let env = make_announce(&schema); + let env = common::make_announce_envelope(&schema); let frames = run_and_collect(registry, CapabilitySet::empty(), false, env).await; diff --git a/Build/tests/saikuro-schema/validator.rs b/Build/tests/saikuro-schema/validator.rs deleted file mode 100644 index 19e904a8..00000000 --- a/Build/tests/saikuro-schema/validator.rs +++ /dev/null @@ -1,20 +0,0 @@ -use saikuro_core::envelope::{Envelope, InvocationType}; -use saikuro_event::SaikuroError; -use saikuro_schema::registry::SchemaRegistry; -use saikuro_schema::validator::InvocationValidator; - -#[test] -fn batch_with_empty_items_returns_empty_batch_error() { - saikuro_exec::block_on(async { - let registry = SchemaRegistry::new(); - let validator = InvocationValidator::new(registry); - - let mut batch = Envelope::call("", vec![]).expect("entropy available"); - batch.invocation_type = InvocationType::Batch; - batch.target = String::new(); - batch.batch_items = Some(vec![]); - - let result = validator.validate(&batch).await; - assert!(matches!(result, Err(SaikuroError::EmptyBatch))); - }); -} diff --git a/Build/tests/saikuro-storage/inmemory.rs b/Build/tests/saikuro-storage/inmemory.rs index 1bb1910e..2f9a3006 100644 --- a/Build/tests/saikuro-storage/inmemory.rs +++ b/Build/tests/saikuro-storage/inmemory.rs @@ -7,6 +7,8 @@ use bytes::Bytes; use saikuro_storage::{InMemoryStorage, KeyValueBackend, StorageBackend, StorageConfig}; +use crate::common; + // Construction #[test] @@ -15,15 +17,11 @@ fn new_creates_empty_store() { assert_eq!(s.config(), &StorageConfig::default()); } -fn null_log() -> std::sync::Arc { - std::sync::Arc::from(Box::new(saikuro_event::NullSink) as Box) -} - #[test] fn with_config_applies_config() { saikuro_exec::block_on(async { let cfg = StorageConfig::durable().with_prefix("test"); - let s = InMemoryStorage::with_config(cfg.clone(), null_log()).await; + let s = InMemoryStorage::with_config(cfg.clone(), common::null_log()).await; assert_eq!(s.config(), &cfg); }) } @@ -74,7 +72,7 @@ fn exists_errors_on_missing_namespace() { namespace_prefix: Some("x".into()), ..Default::default() }; - let s = InMemoryStorage::with_config(cfg, null_log()).await; + let s = InMemoryStorage::with_config(cfg, common::null_log()).await; let r = s.exists("nonexistent", "k").await; assert!(r.is_err()); }) @@ -261,7 +259,7 @@ fn put_fails_when_auto_create_disabled() { auto_create_namespaces: false, ..Default::default() }; - let s = InMemoryStorage::with_config(cfg, null_log()).await; + let s = InMemoryStorage::with_config(cfg, common::null_log()).await; let r = s.put("manual", "k", Bytes::from("v")).await; assert!(r.is_err()); }) @@ -275,7 +273,7 @@ fn get_fails_on_missing_namespace_without_auto_create() { auto_create_namespaces: false, ..Default::default() }, - null_log(), + common::null_log(), ) .await; let r = s.get("nowhere", "k").await; @@ -290,12 +288,12 @@ fn namespace_prefix_isolates_storage() { saikuro_exec::block_on(async { let a = InMemoryStorage::with_config( StorageConfig::default().with_prefix("tenant_a"), - null_log(), + common::null_log(), ) .await; let b = InMemoryStorage::with_config( StorageConfig::default().with_prefix("tenant_b"), - null_log(), + common::null_log(), ) .await; @@ -310,9 +308,11 @@ fn namespace_prefix_isolates_storage() { #[test] fn namespace_prefix_list_namespaces_is_stripped() { saikuro_exec::block_on(async { - let s = - InMemoryStorage::with_config(StorageConfig::default().with_prefix("app"), null_log()) - .await; + let s = InMemoryStorage::with_config( + StorageConfig::default().with_prefix("app"), + common::null_log(), + ) + .await; s.put("myns", "k", Bytes::from("v")).await.unwrap(); let nss = s.list_namespaces().await.unwrap(); assert_eq!(nss, vec!["myns"]); diff --git a/Build/tests/saikuro-transport/transport_compliance.rs b/Build/tests/saikuro-transport/transport_compliance.rs index 5d1fe694..4505f4eb 100644 --- a/Build/tests/saikuro-transport/transport_compliance.rs +++ b/Build/tests/saikuro-transport/transport_compliance.rs @@ -13,11 +13,9 @@ use saikuro_exec::{block_on, spawn, yield_now}; use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; use std::sync::Arc; -// COMPLIANCE TEST SUITE +use crate::common; -fn null_log() -> Arc { - Arc::from(Box::new(saikuro_event::NullSink) as Box) -} +// COMPLIANCE TEST SUITE /// Run the full compliance suite against a transport pair factory. /// @@ -213,13 +211,13 @@ fn many_sequential_transports_correct(pair: (MemoryTransport, MemoryTransport)) #[test] fn memory_transport_compliance() { - let log = null_log(); + let log = common::null_log(); run_transport_compliance(move || MemoryTransport::connected_pair(log.clone())); } #[test] fn memory_transport_compliance_labeled() { - let log = null_log(); + let log = common::null_log(); run_transport_compliance(move || { MemoryTransport::pair("compliance-a", "compliance-b", log.clone()) }); diff --git a/Build/tests/saikuro-transport/transport_framing.rs b/Build/tests/saikuro-transport/transport_framing.rs deleted file mode 100644 index b9ec4763..00000000 --- a/Build/tests/saikuro-transport/transport_framing.rs +++ /dev/null @@ -1,305 +0,0 @@ -#![cfg(not(target_arch = "wasm32"))] - -use bytes::{BufMut, Bytes, BytesMut}; -use futures::{SinkExt, StreamExt}; -use saikuro_exec::block_on; -use saikuro_transport::shared::framing::{FramedStream, LengthPrefixedCodec}; -use saikuro_transport::TransportError; -use tokio::io::AsyncWriteExt; - -fn encode_frames(items: &[Bytes]) -> BytesMut { - let mut codec = LengthPrefixedCodec::new(); - let mut out = BytesMut::new(); - for item in items { - codec.encode(item.clone(), &mut out).expect("encode"); - } - out -} - -#[test] -fn codec_roundtrip_preserves_frames() { - let items = vec![ - Bytes::from_static(b"hello"), - Bytes::new(), - Bytes::from(vec![0xAB; 100_000]), - Bytes::from_static(b"goodbye"), - ]; - let mut wire = encode_frames(&items); - - let mut codec = LengthPrefixedCodec::new(); - for expected in &items { - let got = codec.decode(&mut wire).expect("decode").expect("frame"); - assert_eq!(&got, expected); - } - // Every byte should have been consumed. - assert!(wire.is_empty()); - // Decoding an empty buffer yields nothing, not an error. - assert!(codec.decode(&mut wire).expect("decode").is_none()); -} - -#[test] -fn codec_handles_partial_input() { - let wire = encode_frames(&[Bytes::from_static(b"ping")]); - let mut codec = LengthPrefixedCodec::new(); - - // Feed the wire bytes one at a time; only the final byte completes a frame. - let mut buf = BytesMut::new(); - let mut remaining = wire; - let got = loop { - if !remaining.is_empty() { - let byte = remaining.split_to(1); - buf.extend_from_slice(&byte); - } - match codec.decode(&mut buf) { - Ok(Some(frame)) => break frame, - Ok(None) if remaining.is_empty() => { - panic!("frame never completed"); - } - Ok(None) => continue, - Err(e) => panic!("unexpected decode error: {e}"), - } - }; - assert_eq!(got, Bytes::from_static(b"ping")); -} - -#[test] -fn codec_rejects_oversized_frame_then_recovers() { - // Forge a length header just over MAX_FRAME_SIZE and retain the declared - // trailing payload on the wire. The codec must swallow exactly that many - // bytes so they are not misread as a fresh header, then resynchronize at - // the valid frame that follows on the same buffer. - let forged = saikuro_transport::MAX_FRAME_SIZE as u32 + 3; - let valid = encode_frames(&[Bytes::from_static(b"ok")]); - let mut wire = BytesMut::new(); - wire.put_u32(forged); - wire.resize(4 + forged as usize, 0); - wire.extend_from_slice(&valid); - - let mut codec = LengthPrefixedCodec::new(); - match codec.decode(&mut wire) { - Err(TransportError::MessageTooLarge { .. }) => {} - other => panic!("expected MessageTooLarge, got {other:?}"), - } - - let got = codec.decode(&mut wire).expect("decode").expect("frame"); - assert_eq!(got, Bytes::from_static(b"ok")); - assert!(wire.is_empty(), "all wire bytes consumed"); -} - -#[test] -fn codec_encode_rejects_oversized_frame() { - let too_big = Bytes::from(vec![0u8; saikuro_transport::MAX_FRAME_SIZE + 1]); - let mut codec = LengthPrefixedCodec::new(); - let mut out = BytesMut::new(); - match codec.encode(too_big, &mut out) { - Err(TransportError::MessageTooLarge { .. }) => {} - other => panic!("expected MessageTooLarge, got {other:?}"), - } -} - -#[test] -fn framed_stream_roundtrips_multiple_frames() { - block_on(async { - let (client, server) = tokio::io::duplex(1024 * 1024); - let framed_client = FramedStream::new(client); - let (mut tx, _rx) = framed_client.split(); - let mut framed_server = FramedStream::new(server); - - let frames = vec![ - Bytes::from_static(b"a"), - Bytes::from_static(b"bb"), - Bytes::from(vec![0x42; 100_000]), - ]; - for frame in &frames { - tx.send(frame.clone()).await.expect("send"); - } - tx.close().await.expect("close"); - - for expected in &frames { - let got = framed_server.next().await.expect("stream").expect("frame"); - assert_eq!(&got, expected); - } - // Clean EOF after the sender closed. - assert!(framed_server.next().await.is_none()); - }) -} - -#[test] -fn framed_stream_truncated_frame_errors() { - block_on(async { - let (client, server) = tokio::io::duplex(4096); - // Write a length header promising 100 bytes, then only 3 bytes, and - // drop the write half: the reader must report a framing error, not - // silently return a short frame or hang. - let (_rx, mut tx) = tokio::io::split(client); - let mut framed_server = FramedStream::new(server); - - let mut partial = BytesMut::new(); - partial.put_u32(100); - partial.put_slice(b"abc"); - tx.write_all(&partial).await.expect("write"); - // Shut down the write half so the reader sees EOF after the partial - // frame (dropping the half alone does not signal EOF on a duplex). - tx.shutdown().await.expect("shutdown"); - drop(tx); - - match framed_server.next().await { - Some(Err(TransportError::FramingError(_))) => {} - other => panic!("expected FramingError, got {other:?}"), - } - // The stream is terminal after a framing error. - assert!(framed_server.next().await.is_none()); - }) -} - -#[test] -fn framed_stream_rejects_header_only_eof() { - block_on(async { - let (client, server) = tokio::io::duplex(4096); - let (_rx, mut tx) = tokio::io::split(client); - let mut framed_server = FramedStream::new(server); - - let mut header = BytesMut::new(); - header.put_u32(100); - tx.write_all(&header).await.expect("write"); - tx.shutdown().await.expect("shutdown"); - drop(tx); - - match framed_server.next().await { - Some(Err(TransportError::FramingError(_))) => {} - other => panic!("expected FramingError, got {other:?}"), - } - assert!(framed_server.next().await.is_none()); - }) -} - -#[test] -fn framed_stream_stays_terminal_after_oversized_frame_error() { - block_on(async { - let (client, server) = tokio::io::duplex(4096); - let (_rx, mut tx) = tokio::io::split(client); - let mut framed_server = FramedStream::new(server); - - // Forge an oversized length header. The byte stream is unaligned - // after it, so the reader must error once and stay terminal rather - // than resuming and misreading payload bytes as a header. - let mut wire = BytesMut::new(); - wire.put_u32(u32::MAX); - tx.write_all(&wire).await.expect("write"); - tx.shutdown().await.expect("shutdown"); - drop(tx); - - match framed_server.next().await { - Some(Err(TransportError::MessageTooLarge { .. })) => {} - other => panic!("expected MessageTooLarge, got {other:?}"), - } - assert!(framed_server.next().await.is_none()); - }) -} - -#[test] -fn framed_stream_supports_bidirectional_use() { - block_on(async { - let (client, server) = tokio::io::duplex(4096); - let (mut client_tx, mut client_rx) = FramedStream::new(client).split(); - let (mut server_tx, mut server_rx) = FramedStream::new(server).split(); - - client_tx.send(Bytes::from_static(b"ping")).await.unwrap(); - let got = server_rx.next().await.unwrap().unwrap(); - assert_eq!(got, Bytes::from_static(b"ping")); - - server_tx.send(Bytes::from_static(b"pong")).await.unwrap(); - let got = client_rx.next().await.unwrap().unwrap(); - assert_eq!(got, Bytes::from_static(b"pong")); - }) -} - -#[test] -fn framed_stream_tcp_roundtrip_concurrent() { - block_on(async { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().expect("local addr"); - - let server_task = saikuro_exec::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let mut framed = FramedStream::new(stream); - let mut frames = Vec::new(); - while let Some(frame) = framed.next().await { - frames.push(frame.expect("frame")); - } - frames - }); - - let client_task = saikuro_exec::spawn(async move { - let stream = tokio::net::TcpStream::connect(addr).await.expect("connect"); - let (mut tx, _rx) = FramedStream::new(stream).split(); - for i in 0..5 { - let payload = Bytes::from(vec![i as u8; 300_000]); - tx.send(payload.clone()).await.expect("send"); - } - tx.close().await.expect("close"); - }); - - let (client_res, server_res) = (client_task.await, server_task.await); - client_res.expect("client task"); - let frames = server_res.expect("server task"); - assert_eq!(frames.len(), 5, "expected 5 frames, got {}", frames.len()); - for (i, frame) in frames.iter().enumerate() { - assert_eq!(frame.len(), 300_000, "frame {i} wrong length"); - assert!( - frame.iter().all(|&b| b == i as u8), - "frame {i} content wrong" - ); - } - }) -} - -#[test] -fn framed_stream_tcp_raw_writer() { - // Server side uses FramedStream; client writes pre-encoded wire bytes - // directly, isolating the read path. - block_on(async { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().expect("local addr"); - - let server_task = saikuro_exec::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let mut framed = FramedStream::new(stream); - let mut frames = Vec::new(); - while let Some(frame) = framed.next().await { - frames.push(frame.expect("frame")); - } - frames - }); - - let client_task = saikuro_exec::spawn(async move { - let stream = tokio::net::TcpStream::connect(addr).await.expect("connect"); - let (_r, mut w) = tokio::io::split(stream); - let mut codec = LengthPrefixedCodec::new(); - let mut wire = BytesMut::new(); - for i in 0..5 { - let payload = Bytes::from(vec![i as u8; 300_000]); - codec.encode(payload, &mut wire).expect("encode"); - } - w.write_all(&wire).await.expect("write_all"); - w.shutdown().await.expect("shutdown"); - drop(w); - }); - - let (client_res, server_res) = (client_task.await, server_task.await); - client_res.expect("client task"); - let frames = server_res.expect("server task"); - assert_eq!(frames.len(), 5, "expected 5 frames, got {}", frames.len()); - for (i, frame) in frames.iter().enumerate() { - assert_eq!(frame.len(), 300_000, "frame {i} wrong length"); - assert!( - frame.iter().all(|&b| b == i as u8), - "frame {i} content wrong" - ); - } - }) -} diff --git a/Build/tests/saikuro-transport/transport_memory_stress.rs b/Build/tests/saikuro-transport/transport_memory_stress.rs index 5f9f2552..2efa25e4 100644 --- a/Build/tests/saikuro-transport/transport_memory_stress.rs +++ b/Build/tests/saikuro-transport/transport_memory_stress.rs @@ -9,16 +9,14 @@ use saikuro_exec::sync::Barrier; use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; use std::sync::Arc; -fn null_log() -> Arc { - Arc::from(Box::new(saikuro_event::NullSink) as Box) -} +use crate::common; // HIGH-VOLUME THROUGHPUT #[test] fn ten_thousand_frames_in_order() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(null_log()); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -46,7 +44,7 @@ fn ten_thousand_frames_in_order() { #[test] fn concurrent_bidirectional_stress() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(null_log()); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut a_tx, mut a_rx) = a.split(); let (mut b_tx, mut b_rx) = b.split(); @@ -83,7 +81,7 @@ fn concurrent_bidirectional_stress() { #[test] fn backpressure_sender_blocks_until_drain() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(null_log()); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -119,7 +117,7 @@ fn backpressure_sender_blocks_until_drain() { fn rapid_connect_disconnect_cycles() { saikuro_exec::block_on(async { for _ in 0..100 { - let (a, b) = MemoryTransport::connected_pair(null_log()); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -137,7 +135,7 @@ fn rapid_connect_disconnect_cycles() { #[test] fn max_size_frame_just_under_limit() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(null_log()); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -153,7 +151,7 @@ fn max_size_frame_just_under_limit() { #[test] fn zero_length_frames_dont_confuse_ordering() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(null_log()); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -173,7 +171,7 @@ fn zero_length_frames_dont_confuse_ordering() { #[test] fn many_concurrent_senders_single_receiver() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(null_log()); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender_base, _) = a.split(); let (_, mut receiver) = b.split(); @@ -183,7 +181,7 @@ fn many_concurrent_senders_single_receiver() { // Since TransportSender::send takes &mut self, each sender must be // used from one task. Create multiple transports for parallelism. for i in 0..n { - let (a_i, b_i) = MemoryTransport::connected_pair(null_log()); + let (a_i, b_i) = MemoryTransport::connected_pair(common::null_log()); let (mut tx_i, _) = a_i.split(); let (_, mut rx_i) = b_i.split(); handles.push(saikuro_exec::spawn(async move { @@ -209,7 +207,7 @@ fn many_concurrent_senders_single_receiver() { #[test] fn drop_receiver_while_sender_is_sending() { saikuro_exec::block_on(async { - let (a, b) = MemoryTransport::connected_pair(null_log()); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender, _) = a.split(); let (_, receiver) = b.split(); @@ -244,8 +242,8 @@ fn drop_receiver_while_sender_is_sending() { #[test] fn labels_do_not_cross_transports() { saikuro_exec::block_on(async { - let (a1, b1) = MemoryTransport::pair("sys-A", "sys-B", null_log()); - let (a2, b2) = MemoryTransport::pair("sys-C", "sys-D", null_log()); + let (a1, b1) = MemoryTransport::pair("sys-A", "sys-B", common::null_log()); + let (a2, b2) = MemoryTransport::pair("sys-C", "sys-D", common::null_log()); let (mut a1_tx, _) = a1.split(); let (_, mut b1_rx) = b1.split();