diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 0e66c8aa..00000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,11 +0,0 @@ -# Saikuro Cargo configuration -# -# 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 - -[target.wasm32-unknown-unknown] -runner = "wasm-bindgen-test-runner" - -# [build] -# rustflags = ["--cfg=web_sys_unstable_apis"] 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 f94b1a0f..fab2daaf 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" @@ -87,6 +78,15 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -95,9 +95,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "base64" -version = "0.22.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "bitflags" @@ -107,9 +107,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -129,15 +129,6 @@ dependencies = [ "hybrid-array", ] -[[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.2" @@ -155,6 +146,9 @@ name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "portable-atomic", +] [[package]] name = "cast" @@ -164,9 +158,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.56" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "shlex", @@ -178,6 +172,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" @@ -190,17 +195,13 @@ dependencies = [ ] [[package]] -name = "chrono" -version = "0.4.45" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", + "crypto-common 0.1.7", + "inout", ] [[package]] @@ -255,12 +256,6 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -288,6 +283,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" @@ -324,9 +325,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ "darling_core", "darling_macro", @@ -334,10 +335,11 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ + "fnv", "ident_case", "proc-macro2", "quote", @@ -347,9 +349,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", @@ -370,20 +372,55 @@ dependencies = [ "parking_lot_core 0.9.12", ] +[[package]] +name = "dashmap" +version = "7.0.0-rc2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a1e35a65fe0538a60167f0ada6e195ad5d477f6ddae273943596d4a1a5730b" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "equivalent", + "hashbrown 0.15.5", + "lock_api", + "parking_lot_core 0.9.12", +] + [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[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]] @@ -408,10 +445,230 @@ dependencies = [ ] [[package]] -name = "dyn-clone" -version = "1.0.20" +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "embassy-executor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f102d5e04befe3ea74b6f41a0e26218740124636eb2f59e1cc215b5839b96df2" +dependencies = [ + "critical-section", + "document-features", + "embassy-executor-macros", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "embassy-executor-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdddc3a04226828316bf31393b6903ee162238576b1584ee2669af215d55472" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-net" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71f0aa32082b7df00164f485322d6edab59122c9718b363b07ec23424c2c06a0" +dependencies = [ + "document-features", + "embassy-net-driver", + "embassy-sync", + "embassy-time", + "embedded-io-async 0.7.0", + "embedded-nal-async", + "heapless 0.8.0", + "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-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 0.8.0", +] + +[[package]] +name = "embassy-time" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "592b0c143ec626e821d4d90da51a2bd91d559d6c442b7c74a47d368c9e23d97a" +dependencies = [ + "cfg-if", + "critical-section", + "document-features", + "embassy-time-driver", + "embassy-time-queue-utils", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", + "js-sys", + "wasm-bindgen", + "wasm-timer", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" +dependencies = [ + "document-features", +] + +[[package]] +name = "embassy-time-queue-utils" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168297bf80aaf114b3c9ad589bf38b01b3009b9af7f97cd18086c5bbf96f5693" +dependencies = [ + "embassy-executor-timer-queue", + "heapless 0.9.3", +] + +[[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" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +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 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]] +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.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb5a1bd585135d302f8f6d7de329310938093da6271b37a6c94b8798795c0c6d" +dependencies = [ + "embedded-io-async 0.7.0", + "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 = "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" @@ -429,23 +686,11 @@ 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" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fluvio-wasm-timer" @@ -462,11 +707,17 @@ 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" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "fs2" @@ -587,13 +838,16 @@ 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]] @@ -603,18 +857,39 @@ 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", +] + +[[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 = "hashbrown" -version = "0.12.3" +name = "hash32" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] [[package]] name = "hashbrown" @@ -624,48 +899,64 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash", ] [[package]] name = "hashbrown" -version = "0.17.1" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" dependencies = [ - "foldhash", + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "spin 0.9.9", + "stable_deref_trait", ] [[package]] -name = "hashlink" -version = "0.12.0" +name = "heapless" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "hashbrown 0.17.1", + "hash32 0.3.1", + "serde", + "stable_deref_trait", ] [[package]] -name = "heck" -version = "0.5.0" +name = "heapless" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] [[package]] -name = "hex" -version = "0.4.3" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -679,36 +970,18 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] [[package]] -name = "iana-time-zone" -version = "0.1.65" +name = "id-arena" +version = "2.3.0" 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", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ident_case" @@ -716,17 +989,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" @@ -739,6 +1001,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" @@ -781,6 +1052,12 @@ 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" @@ -794,15 +1071,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] -name = "libsqlite3-sys" -version = "0.38.1" +name = "litrs" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" @@ -819,6 +1091,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" @@ -834,11 +1112,31 @@ 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" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" dependencies = [ "cc", "walkdir", @@ -851,10 +1149,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "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" @@ -864,12 +1177,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" @@ -959,24 +1266,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "pkg-config" -version = "0.3.33" +name = "portable-atomic" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +dependencies = [ + "critical-section", +] [[package]] -name = "powerfmt" -version = "0.2.0" +name = "portable-atomic-util" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "zerocopy", + "proc-macro2", + "syn", ] [[package]] @@ -999,20 +1313,15 @@ 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 = "rand" -version = "0.8.6" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha", - "rand_core 0.6.4", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" @@ -1020,28 +1329,24 @@ 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", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "rand_core" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] name = "rand_core" -version = "0.6.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.3.4", ] [[package]] @@ -1065,27 +1370,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]] @@ -1137,39 +1422,12 @@ dependencies = [ ] [[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.18", -] - -[[package]] -name = "rusqlite" -version = "0.40.1" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "bitflags 2.11.0", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", + "semver", ] [[package]] @@ -1186,19 +1444,17 @@ dependencies = [ "async-trait", "bytes", "clap", - "dashmap", + "dashmap 6.2.1", "futures", - "rmp-serde", + "portable-atomic-util", "saikuro-core", + "saikuro-event", "saikuro-exec", "saikuro-storage", "saikuro-transport", - "serde", "serde_json", "syn", - "thiserror 2.0.18", - "tracing", - "uuid", + "thiserror", ] [[package]] @@ -1210,11 +1466,13 @@ dependencies = [ "regex", "saikuro", "saikuro-core", + "saikuro-event", "saikuro-exec", "saikuro-runtime", "saikuro-transport", "serde_json", - "thiserror 2.0.18", + "spin 0.12.3", + "tokio", ] [[package]] @@ -1227,54 +1485,101 @@ dependencies = [ "saikuro-schema", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", ] [[package]] name = "saikuro-core" version = "0.1.0" dependencies = [ - "bytes", - "chrono", - "rmp-serde", - "rmpv", + "heapless 0.8.0", + "messagepack-serde", + "portable-atomic", + "saikuro-event", + "saikuro-random", "serde", "serde_bytes", "serde_json", - "serde_with", "strum", - "thiserror 2.0.18", + "thiserror", "uuid", ] +[[package]] +name = "saikuro-event" +version = "0.1.0" +dependencies = [ + "async-trait", + "embedded-io-async 0.7.0", + "getrandom 0.3.4", + "heapless 0.8.0", + "messagepack-serde", + "serde", + "serde_bytes", + "serde_json", + "spin 0.12.3", + "strum", + "thiserror", + "tracing", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "saikuro-exec" version = "0.1.0" dependencies = [ + "critical-section", + "embassy-executor", + "embassy-futures", + "embassy-net", + "embassy-sync", + "embassy-time", "fluvio-wasm-timer", "futures", + "portable-atomic", + "portable-atomic-util", "tokio", "tokio-util", "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 0.9.1", + "getrandom 0.3.4", + "portable-atomic", + "rand_core 0.9.5", + "saikuro-event", + "uuid", +] + [[package]] name = "saikuro-router" version = "0.1.0" dependencies = [ "async-trait", - "bytes", - "dashmap", - "futures", - "rmp-serde", + "portable-atomic", + "portable-atomic-util", "saikuro-core", + "saikuro-event", "saikuro-exec", "saikuro-schema", - "serde", - "thiserror 2.0.18", - "tracing", + "thiserror", "tracing-subscriber", - "uuid", ] [[package]] @@ -1285,37 +1590,38 @@ dependencies = [ "async-trait", "bytes", "clap", - "dashmap", + "embassy-executor", "futures", - "parking_lot 0.12.5", - "rmp-serde", + "portable-atomic", + "portable-atomic-util", "saikuro-core", + "saikuro-event", "saikuro-exec", + "saikuro-net", + "saikuro-random", "saikuro-router", "saikuro-schema", "saikuro-transport", "serde", "serde_json", - "serde_with", - "thiserror 2.0.18", - "tokio-tungstenite", + "spin 0.12.3", + "talc", "tracing", "tracing-subscriber", - "tungstenite 0.30.0", - "uuid", + "wasi 0.14.7+wasi-0.2.4", + "wasm-bindgen", ] [[package]] name = "saikuro-schema" version = "0.1.0" dependencies = [ - "dashmap", - "parking_lot 0.12.5", + "portable-atomic", + "portable-atomic-util", "saikuro-core", - "serde", - "serde_json", - "thiserror 2.0.18", - "tracing", + "saikuro-event", + "saikuro-exec", + "thiserror", ] [[package]] @@ -1324,44 +1630,54 @@ version = "0.1.0" dependencies = [ "async-trait", "bytes", - "dashmap", + "dashmap 7.0.0-rc2", + "embedded-storage-async", "futures", + "futures-executor", + "graphitesql", "js-sys", - "parking_lot 0.12.5", - "rmp-serde", - "rusqlite", "saikuro-core", + "saikuro-event", "saikuro-exec", + "sequential-storage", "serde", "serde_json", "sled", - "thiserror 2.0.18", + "spin 0.12.3", + "thiserror", "tokio", - "tracing", - "tracing-subscriber", + "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "wit-bindgen 0.46.0", ] [[package]] name = "saikuro-tests" version = "0.1.0" dependencies = [ + "async-trait", "bytes", + "embedded-storage-async", + "futures", + "futures-executor", "js-sys", "rmp-serde", "saikuro", "saikuro-codegen", "saikuro-core", + "saikuro-event", "saikuro-exec", + "saikuro-random", "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", @@ -1374,20 +1690,27 @@ version = "0.1.0" dependencies = [ "async-trait", "bytes", + "embassy-sync", + "embedded-io-async 0.7.0", + "embedded-websocket", "futures", - "getrandom 0.4.3", + "getrandom 0.3.4", "js-sys", "pin-project-lite", - "rmp-serde", + "portable-atomic", + "portable-atomic-util", + "rand_core 0.6.4", "saikuro-core", + "saikuro-event", "saikuro-exec", + "saikuro-net", + "saikuro-random", "send_wrapper", "serde", - "thiserror 2.0.18", + "thiserror", "tokio-tungstenite", - "tracing", - "tracing-subscriber", - "tungstenite 0.30.0", + "wasi 0.14.7+wasi-0.2.4", + "wasip1", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -1402,42 +1725,34 @@ dependencies = [ "winapi-util", ] -[[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" 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" @@ -1491,43 +1806,11 @@ 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", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -1556,9 +1839,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -1598,6 +1881,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 0.8.0", + "managed", +] + [[package]] name = "socket2" version = "0.6.3" @@ -1609,17 +1905,30 @@ dependencies = [ ] [[package]] -name = "sqlite-wasm-rs" -version = "0.5.5" +name = "spin" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", + "lock_api", +] + +[[package]] +name = "spin" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10" +dependencies = [ + "lock_api", + "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" @@ -1659,12 +1968,12 @@ dependencies = [ ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "talc" +version = "4.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "a3ae828aa394de34c7de08f522d1b86bd1c182c668d27da69caadda00590f26d" dependencies = [ - "thiserror-impl 1.0.69", + "lock_api", ] [[package]] @@ -1673,18 +1982,7 @@ 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]] @@ -1707,52 +2005,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" @@ -1783,14 +2035,14 @@ 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", "tokio", - "tungstenite 0.24.0", + "tungstenite", ] [[package]] @@ -1812,6 +2064,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", @@ -1880,24 +2133,6 @@ dependencies = [ "tracing-serde", ] -[[package]] -name = "tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.8.6", - "sha1 0.10.6", - "thiserror 1.0.69", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.30.0" @@ -1909,9 +2144,9 @@ dependencies = [ "http", "httparse", "log", - "rand 0.10.2", + "rand", "sha1 0.11.0", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1927,10 +2162,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "utf-8" -version = "0.7.6" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "utf8parse" @@ -1943,12 +2178,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" @@ -1956,18 +2185,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" 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" @@ -1984,6 +2213,30 @@ 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 0.57.1", +] + [[package]] name = "wasm-bindgen" version = "0.2.122" @@ -2078,6 +2331,55 @@ version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "684365b586a9a6256c1cc3544eee8680de48d6041142f581776ec7b139622ae9" +[[package]] +name = "wasm-encoder" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.99" @@ -2120,91 +2422,118 @@ 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 = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-implement" -version = "0.60.2" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-link", ] [[package]] -name = "windows-interface" -version = "0.59.3" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" dependencies = [ - "proc-macro2", - "quote", - "syn", + "bitflags 2.13.1", + "futures", + "once_cell", + "wit-bindgen-rust-macro", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags 2.13.1", +] [[package]] -name = "windows-result" -version = "0.4.1" +name = "wit-bindgen-core" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +checksum = "cabd629f94da277abc739c71353397046401518efb2c707669f805205f0b9890" dependencies = [ - "windows-link", + "anyhow", + "heck", + "wit-parser", ] [[package]] -name = "windows-strings" -version = "0.5.1" +name = "wit-bindgen-rust" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +checksum = "9a4232e841089fa5f3c4fc732a92e1c74e1a3958db3b12f1de5934da2027f1f4" dependencies = [ - "windows-link", + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] -name = "windows-sys" -version = "0.61.2" +name = "wit-bindgen-rust-macro" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "1e0d4698c2913d8d9c2b220d116409c3f51a7aa8d7765151b886918367179ee9" dependencies = [ - "windows-link", + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] -name = "zerocopy" -version = "0.8.40" +name = "wit-component" +version = "0.239.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" dependencies = [ - "zerocopy-derive", + "anyhow", + "bitflags 2.13.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "zerocopy-derive" -version = "0.8.40" +name = "wit-parser" +version = "0.239.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] diff --git a/Build/Cargo.toml b/Build/Cargo.toml index a408cee0..8a4ebe89 100644 --- a/Build/Cargo.toml +++ b/Build/Cargo.toml @@ -2,16 +2,19 @@ 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-random", "crates/saikuro-codegen", "adapters/c", "adapters/rust", - "tests", + "tests" ] [workspace.package] @@ -24,48 +27,124 @@ rust-version = "1.75" [workspace.dependencies] # Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -serde_bytes = "0.11" -rmp-serde = "1.3" -rmpv = { version = "1.3", features = ["with-serde"] } -bytes = "1.7" -futures = "0.3" +serde = { version = "1.0", default-features = false, features = [ + "alloc", + "derive", +] } +serde_json = { version = "1.0", default-features = false } +serde_bytes = { version = "0.11", default-features = false, features = [ + "alloc", +] } +messagepack-serde = { version = "0.2.4", default-features = false, features = [ + "alloc", +] } +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" -# UUID -uuid = { version = "1.23.2", features = ["v4", "serde", "js"] } +uuid = { version = "1.23", 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", + "critical-section", +] } +portable-atomic-util = { version = "0.2", default-features = false, features = ["alloc"] } + +rand_core = { version = "0.9", default-features = false } + +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", default-features = false, 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. -tracing = "0.1" -tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt", "json"] } +tracing = { version = "0.1", default-features = false } +tracing-subscriber = { version = "0.3", features = [ + "env-filter", + "fmt", + "json", +] } -# Error handling: both are wasm-safe -thiserror = "2.0" +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.8", 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" -# Concurrency: dashmap and parking_lot are wasm-safe for single-threaded WASM i think -dashmap = "6.1" +dashmap = "7.0.0-rc2" parking_lot = "0.12" +spin = { version = "0.12", default-features = false, features = [ + "mutex", + "spin_mutex", + "rwlock", + "portable-atomic", +] } + +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.8", default-features = false } +embassy-net-driver-channel = "0.4" +critical-section = { version = "1" } -# Time: chrono is wasm-safe when 'wasmbind' feature is enabled -chrono = { version = "0.4", features = ["serde", "wasmbind"] } +# 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 +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" +graphitesql = "0.1.6" +wit-bindgen = "0.46" +wasi = "0.14" +wasip1 = "1" + +# Futures +futures-executor = "0.3" + +# CLI +clap = { version = "4", features = ["derive", "env"] } # 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", 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 } -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 = { 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-event = { path = "crates/saikuro-event", default-features = false } +saikuro = { path = "adapters/rust", default-features = false } + +[profile.dev] + +[profile.release] diff --git a/Build/adapters/c/Cargo.toml b/Build/adapters/c/Cargo.toml index a9d8c2c2..62225b9f 100644 --- a/Build/adapters/c/Cargo.toml +++ b/Build/adapters/c/Cargo.toml @@ -15,22 +15,40 @@ crate-type = ["cdylib", "staticlib", "rlib"] [[bin]] name = "saikuro-c-schema" path = "src/cli/saikuro_c_schema.rs" +required-features = ["native"] [features] -default = ["saikuro/default"] -wasm = ["saikuro/wasm", "saikuro-exec/wasm-runtime"] +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 } +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 8dcb6536..9e753199 100644 --- a/Build/adapters/c/src/lib.rs +++ b/Build/adapters/c/src/lib.rs @@ -1,36 +1,227 @@ -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; +use alloc::ffi::CString; +#[cfg(not(feature = "std"))] +use alloc::format; +#[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); + } + + 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")] +fn spawn_future(fut: F) +where + F: Future + Send + 'static, +{ + 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 + 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. +// +// Every opaque C handle boxes exactly one Rust object; C callers release them +// with the matching `saikuro_*_free` function. + +mod handles { + use super::*; + + /// 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")] + 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")] +use handles::{client_ref, ChannelHandle, ClientHandle, StreamHandle}; + +/// 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; + +// 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")); @@ -41,14 +232,23 @@ 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() + } } } +#[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 +258,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 +304,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 +317,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 +340,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,83 +351,213 @@ fn int_saikuro(result: Result<(), saikuro::Error>, op: &str) -> c_int { } } -struct ClientHandle { - rt: Arc, - client: Option, +// 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), + } } -impl ClientHandle { - fn client(&self) -> &Client { - self.client.as_ref().expect("client already closed") +/// 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") +} - 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}"))?, - ); +#[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", + ) +} - let client = rt - .block_on(Client::connect(address)) - .map_err(|e| format!("failed to connect client: {e}"))?; +#[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") +} - Ok(Self { - rt, - client: Some(client), - }) +#[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() + } } +} - 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}"))?; +#[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() } - 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; +#[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() + } + } +} -struct ProviderHandle { - rt: saikuro_exec::Runtime, - provider: Option, +#[cfg(feature = "std")] +async fn client_inner_channel_send_json(channel: &SaikuroChannel, item: Value) -> c_int { + int_saikuro(channel.send(item).await, "channel send") } -struct StreamHandle { - rt: Arc, - stream: saikuro::SaikuroStream, +#[cfg(feature = "std")] +async fn client_inner_channel_close(channel: &SaikuroChannel) -> c_int { + int_saikuro(channel.close().await, "channel close") } -struct ChannelHandle { - rt: Arc, - channel: SaikuroChannel, +#[cfg(feature = "std")] +async fn client_inner_channel_abort(channel: &SaikuroChannel) -> c_int { + int_saikuro(channel.abort().await, "channel abort") } -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}"))?; +#[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, + } +} - Ok(Self { - rt, - provider: Some(Provider::new(namespace)), - }) +#[cfg(feature = "std")] +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 + #[no_mangle] pub extern "C" fn saikuro_string_dup(input: *const c_char) -> *mut c_char { match cstr_to_string(input, "input") { @@ -276,14 +569,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,222 +585,434 @@ 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(); + cb(ptr::null_mut(), user_data); + 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 { + drop(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_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 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; + } + }; + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(ptr::null_mut(), user_data); + return; + } + let (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(parsed) => parsed, + Err(e) => { + set_last_error(e); + cb(ptr::null_mut(), user_data); + return; + } + }; + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + 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); + }); } +/// # 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)); - if timeout_ms < 0 { - set_last_error("timeout_ms must be non-negative"); - return ptr::null_mut(); + 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(ptr::null_mut(), user_data); + 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 (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(parsed) => parsed, + Err(e) => { + set_last_error(e); + cb(ptr::null_mut(), user_data); + return; + } + }; + 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 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); + }); } +/// # 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; + } + }; + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(1, user_data); + return; + } + let (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(parsed) => parsed, + Err(e) => { + set_last_error(e); + cb(1, user_data); + return; + } + }; + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + 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); + }); } +/// # 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), - 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() + 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(ptr::null_mut(), user_data); + return; } + let raw = match cstr_to_string(calls_json, "calls_json") { + Ok(s) => s, + Err(e) => { + set_last_error(e); + cb(ptr::null_mut(), user_data); + return; + } + }; + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + 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); + }); } +/// # 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_resource_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; + } + }; + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(ptr::null_mut(), user_data); + return; + } + let (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(parsed) => parsed, Err(e) => { - set_last_error(format!("stream open failed: {e}")); - return ptr::null_mut(); + set_last_error(e); + cb(ptr::null_mut(), user_data); + return; } }; - Box::into_raw(Box::new(StreamHandle { rt, stream })) as *mut c_void + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + 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); + }); } -#[no_mangle] /// # 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( - stream: *mut c_void, - out_item_json: *mut *mut c_char, - out_done: *mut c_int, -) -> c_int { +/// `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(); - - 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 handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(1, user_data); + 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; - } - - 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 - } + 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(parsed) => parsed, + Err(e) => { + set_last_error(e); + cb(1, user_data); + return; + } + }; + 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) => { - unsafe { - *out_done = 1; - *out_item_json = ptr::null_mut(); - } - 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(e); + cb(1, user_data); + return; } - set_last_error(format!("stream receive failed: {e}")); - 1 } + }; + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + 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); + }); +} + +// 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_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 => { - unsafe { - *out_done = 1; - *out_item_json = ptr::null_mut(); - } - 0 + set_last_error("callback must not be null"); + return; } + }; + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(ptr::null_mut(), user_data); + return; } + let (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(parsed) => parsed, + Err(e) => { + set_last_error(e); + cb(ptr::null_mut(), user_data); + return; + } + }; + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + 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); + }); } +#[cfg(feature = "std")] #[no_mangle] pub extern "C" fn saikuro_stream_free(stream: *mut c_void) { if stream.is_null() { @@ -520,224 +1021,242 @@ pub extern "C" fn saikuro_stream_free(stream: *mut c_void) { let _ = unsafe { Box::from_raw(stream as *mut StreamHandle) }; } +/// # Safety +/// `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 extern "C" fn saikuro_client_channel_json( +pub unsafe extern "C" fn saikuro_stream_next_json_async( + stream: *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 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 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); + }); +} + +// 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_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; + } + }; + if handle.is_null() { + set_last_error(ERR_HANDLE_NULL); + cb(ptr::null_mut(), user_data); + return; + } + let (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(parsed) => parsed, Err(e) => { - set_last_error(format!("channel open failed: {e}")); - return ptr::null_mut(); + set_last_error(e); + cb(ptr::null_mut(), user_data); + return; } }; - Box::into_raw(Box::new(ChannelHandle { rt, channel })) as *mut c_void + + let handle_addr = handle as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + 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); + }); } +/// # 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 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(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_addr = channel as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + 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(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(); - - if channel.is_null() { - set_last_error("channel must not be null"); - return 1; - } - - 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 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"); + cb(1, user_data); + return; } + + let channel_addr = channel as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + 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); + }); } -#[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(); - } - } - - 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; - } - - 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(); - } - 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 - } + let cb = match cb { + Some(c) => c, None => { - unsafe { - *out_done = 1; - *out_item_json = ptr::null_mut(); - } - 0 + set_last_error("callback must not be null"); + return; } - } -} - -#[no_mangle] -pub extern "C" fn saikuro_channel_free(channel: *mut c_void) { + }; if channel.is_null() { + set_last_error("channel must not be null"); + cb(ptr::null_mut(), 1, user_data); 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") + let channel_addr = channel as usize; + let user_data_addr = user_data as usize; + spawn_future(async move { + 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); + }); } -#[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 +1270,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 +1323,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 +1454,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,58 +1491,436 @@ 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 + 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_free(handle: *mut c_void) { + if handle.is_null() { + return; } - #[cfg(not(target_arch = "wasm32"))] - match handle.rt.block_on(provider.serve(address)) { - Ok(()) => 0, + // 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(); +} + +// Synchronous blocking API +// 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] +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(format!("provider serve failed: {e}")); - 1 + 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_provider_close(handle: *mut c_void) -> c_int { +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(parsed) => parsed, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + block_on_future(client_inner_call_json(h.client(), target, args)) +} +#[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(); + } + let (target, args) = match cstr_to_string(target, "target") + .and_then(|t| c_json_array(args_json).map(|a| (t, a))) + { + Ok(parsed) => parsed, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + block_on_future(client_inner_call_json_timeout( + h.client(), + target, + args, + i64::from(timeout_ms), + )) +} + +#[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(parsed) => parsed, + Err(e) => { + set_last_error(e); + return 1; + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + block_on_future(client_inner_cast_json(h.client(), target, args)) +} - 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 +#[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 h = unsafe { &*(handle as *const ClientHandle) }; + block_on_future(client_inner_batch_json(h.client(), &raw)) } +#[cfg(all(feature = "std", feature = "native"))] #[no_mangle] -pub extern "C" fn saikuro_provider_free(handle: *mut c_void) { +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(parsed) => parsed, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + block_on_future(client_inner_stream_json(h.client(), target, args)) +} + +#[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(parsed) => parsed, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + block_on_future(client_inner_channel_json(h.client(), target, args)) +} + +#[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) }; + 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 { + 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) }; + 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 { + 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) }; + 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, + 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) }; + 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() { return; } + let _ = unsafe { Box::from_raw(channel as *mut ChannelHandle) }; +} - // Close first so registered handlers are cleaned up before the runtime drops. - unsafe { - let h = &mut *(handle as *mut ProviderHandle); - let _ = h.provider.take(); +#[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, + 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) }; + 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"))] +#[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(parsed) => parsed, + Err(e) => { + set_last_error(e); + return ptr::null_mut(); + } + }; + let h = unsafe { &*(handle as *const ClientHandle) }; + block_on_future(client_inner_resource_json(h.client(), target, args)) +} + +#[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(parsed) => parsed, + 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) }; + block_on_future(client_inner_log(h.client(), level, name, msg, fields)) +} - let _ = unsafe { Box::from_raw(handle as *mut ProviderHandle) }; +#[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_protocol.rs b/Build/adapters/c/tests/c_api_protocol.rs index 89d27f32..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() @@ -321,7 +387,8 @@ fn spawn_scripted_server_for_provider() -> (String, thread::JoinHandle(); + 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 f549276d..18cfd4b6 100644 --- a/Build/adapters/c/tests/c_api_runtime.rs +++ b/Build/adapters/c/tests/c_api_runtime.rs @@ -1,28 +1,31 @@ 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; 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::{FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility}, - value::Value, + schema::{ + FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, + TypeDescriptor, TypeMap, Visibility, + }, 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 { @@ -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()), } } @@ -76,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; @@ -128,7 +141,7 @@ impl RuntimeHarness { handle.accept_transport( transport, format!("c-test-peer-{peer_counter}"), - CapabilitySet::default(), + saikuro_core::CapabilitySet::default(), ); } Ok(None) => break, @@ -166,23 +179,40 @@ 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 _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); + let runtime = shared_runtime(); - 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: {}", @@ -191,20 +221,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: {}", @@ -213,7 +256,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, @@ -225,17 +271,29 @@ 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(); - 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(); @@ -244,7 +302,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..9e92eabd 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,15 +28,14 @@ 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")); + 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()); @@ -52,74 +52,121 @@ fn provider_register_rejects_null_callback() { #[test] fn batch_rejects_null_handle() { - // null handle error should trigger before JSON parsing. + let _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); 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()); + 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( + 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()); + 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( + 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 _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"); - 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..8fc2c56e 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,90 +29,169 @@ 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")); + 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]"); - 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()); + 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( + 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 _lock = common::LAST_ERROR_LOCK.lock().expect("lock poisoned"); 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")); } @@ -129,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 70249b20..65673045 100644 --- a/Build/adapters/c/tests/common/mod.rs +++ b/Build/adapters/c/tests/common/mod.rs @@ -1,7 +1,18 @@ -use std::ffi::{CStr, CString}; +#![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") } @@ -18,3 +29,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 024550d9..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, FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, - Visibility, + 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,116 +178,126 @@ 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)); - 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") + .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()) @@ -316,16 +325,16 @@ fn spawn_scripted_runtime_for_cpp_provider() -> (String, thread::JoinHandle (String, thread::JoinHandle { 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 3932835d..784e49bb 100644 --- a/Build/adapters/rust/Cargo.toml +++ b/Build/adapters/rust/Cargo.toml @@ -13,37 +13,72 @@ 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/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"] - -# 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"] +default = ["std", "native", "tcp", "unix", "ws", "storage", "inmemory"] +std = ["dep:dashmap"] +native = [ + "std", + "saikuro-core/native", + "saikuro-transport/native", + "saikuro-exec/native", + "saikuro-event/native", + "saikuro-event/std", + "storage", + "saikuro-storage/native", + "dep:anyhow", + "dep:clap", + "dep:syn", +] +wasm = [ + "saikuro-core/wasm", + "saikuro-transport/wasm", + "saikuro-transport/wasm-host", + "saikuro-exec/wasm", + "saikuro-event/wasm", +] +embedded = [ + "saikuro-core/embedded", + "saikuro-transport/embedded", + "saikuro-exec/embedded", + "saikuro-event/embedded", +] +no_std = [ + "saikuro-core/no_std", + "saikuro-transport/no_std", + "saikuro-exec/no_std", + "saikuro-event/no_std", +] +tcp = ["saikuro-transport/tcp"] +unix = ["saikuro-transport/unix"] +ws = ["saikuro-transport/ws"] +ws-wasi = ["saikuro-transport/ws-wasi"] +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" } -saikuro-storage = { path = "../../crates/saikuro-storage", default-features = false } +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-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_json = { workspace = true, features = ["alloc"] } +bytes = { workspace = true } +portable-atomic-util = { workspace = true, features = ["alloc"] } saikuro-exec = { workspace = true, default-features = false } -futures = "0.3" -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"] } +futures = { workspace = true, features = ["async-await"] } +async-trait = { 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 0c780dd7..25a7373b 100644 --- a/Build/adapters/rust/src/client.rs +++ b/Build/adapters/rust/src/client.rs @@ -4,26 +4,26 @@ //! invocation IDs as correlation keys. //! -use std::{ - sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, - }, - time::Duration, +use alloc::sync::Arc; +use alloc::{ + borrow::ToOwned, + boxed::Box, + string::{String, ToString}, + vec::Vec, }; +use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +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, LogLevel, LogRecord, LogSink, Value as CoreValue}; use saikuro_exec::{mpsc, oneshot, sync::Mutex}; -use tracing::{debug, error, warn}; use crate::{ error::{Error, Result}, @@ -33,10 +33,11 @@ use crate::{ }; /// Default capacity for the outbound frame channel and stream/channel buffers. -const CHANNEL_CAPACITY: usize = 256; +const CHANNEL_CAPACITY: saikuro_exec::ChannelCapacity = saikuro_exec::ChannelCapacity::MAX; /// Capacity for stream and channel pending item buffers. -const STREAM_CHANNEL_CAPACITY: usize = 128; +const STREAM_CHANNEL_CAPACITY: saikuro_exec::ChannelCapacity = + saikuro_exec::ChannelCapacity::DEFAULT; /// Options for [`Client`]. #[derive(Debug, Clone, Default)] @@ -115,7 +116,6 @@ impl SaikuroChannel { InvocationType::Channel, "", vec![], - None, Some(self.next_seq()), ); envelope.stream_control = Some(StreamControl::End); @@ -129,7 +129,6 @@ impl SaikuroChannel { InvocationType::Channel, "", vec![], - None, Some(self.next_seq()), ); envelope.stream_control = Some(StreamControl::Abort); @@ -143,7 +142,6 @@ impl SaikuroChannel { InvocationType::Channel, "", vec![value], - None, Some(self.next_seq()), ); self.send_channel_envelope(&envelope).await @@ -184,13 +182,14 @@ pub struct Client { /// Whether the client is still connected. connected: Arc, 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) } @@ -205,59 +204,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)) => { @@ -270,11 +278,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; } } @@ -295,6 +306,7 @@ impl Client { recv_task: Some(recv_task), connected, options, + log, }) } @@ -338,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(); @@ -370,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 } @@ -383,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); @@ -404,13 +416,13 @@ 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)) - .collect(); + .map(|(target, args)| make_envelope(InvocationType::Call, &target, args)) + .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 +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); @@ -469,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(); @@ -505,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 } @@ -526,44 +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. - warn!("client: unexpected frame during handshake phase, discarding"); } } @@ -591,17 +575,8 @@ 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( @@ -616,95 +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") - }); - if tx - .send(Err(Error::remote( - detail.code.to_string(), - detail.message, - None, - ))) - .await - .is_err() - { - warn!(id = %id, "stream receiver closed while sending error"); - } - 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); - } - } + 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") - }); - 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"); - } - 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() { - 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(); - } - } + 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(); } } } } - _ => { - debug!(id = %id, "received response for unknown invocation id"); - } + None => {} } } @@ -725,20 +675,14 @@ fn teardown_pending(pending: &DashMap) { } } // Helpers -fn make_envelope( - inv_type: InvocationType, - target: &str, - args: Vec, - capability: Option, -) -> Envelope { - make_envelope_with_id( - InvocationId::new(), +fn make_envelope(inv_type: InvocationType, target: &str, args: Vec) -> Result { + Ok(make_envelope_with_id( + InvocationId::new()?, inv_type, target, args, - capability, None, - ) + )) } fn make_envelope_with_id( @@ -746,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(); @@ -757,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 81a23d36..7d782505 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. +#[cfg(not(feature = "std"))] +use alloc::string::{String, ToString}; use thiserror::Error; /// 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)] @@ -31,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), @@ -42,6 +40,14 @@ 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, + + /// The configured entropy source could not generate an invocation ID. + #[error("entropy error: {0}")] + Entropy(#[from] saikuro_event::SaikuroError), } impl Error { @@ -64,9 +70,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 d90d5598..395d4479 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; @@ -12,16 +18,22 @@ 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; +#[cfg(feature = "std")] 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; -#[cfg(any(feature = "storage", feature = "wasm-storage"))] -pub use storage::{create_storage, create_transient_storage}; +#[cfg(all(not(target_arch = "wasm32"), feature = "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/provider.rs b/Build/adapters/rust/src/provider.rs index 335183c1..e628093a 100644 --- a/Build/adapters/rust/src/provider.rs +++ b/Build/adapters/rust/src/provider.rs @@ -1,16 +1,29 @@ //! Saikuro provider: register Rust functions and serve them to the runtime. //! -use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; +#[cfg(not(feature = "std"))] +use alloc::collections::BTreeMap as HashMap; +#[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::{ envelope::{Envelope, InvocationType, ResponseEnvelope}, - error::{ErrorCode, ErrorDetail}, invocation::InvocationId, schema::Schema, }; -use tracing::{debug, error, info, warn}; +use saikuro_event::{ErrorCode, ErrorDetail, LogLevel, LogRecord, LogSink}; use crate::{ error::{Error, Result}, @@ -24,10 +37,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)] @@ -48,7 +67,7 @@ struct HandlerEntry { pub struct Provider { namespace: String, handlers: HashMap, - extra_namespaces: HashMap, + log: Arc, } impl Provider { @@ -57,10 +76,16 @@ impl Provider { Self { 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 @@ -69,17 +94,7 @@ 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 F: Fn(HandlerArgs) -> Fut + Send + Sync + 'static, @@ -88,7 +103,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, @@ -99,8 +124,32 @@ 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 { + handler: boxed, + schema: options.schema, + }, + ); + } + + #[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(); + 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 { @@ -113,7 +162,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 { @@ -123,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) } @@ -136,30 +182,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"); + self.announce(&mut *transport).await?; + + { + 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; } }; @@ -167,31 +239,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; } } } @@ -202,67 +279,129 @@ impl Provider { // Announce - async fn announce(&self, transport: &mut dyn AdapterTransport) { - let schema = self.build_schema(); + async fn announce(&self, transport: &mut dyn AdapterTransport) -> Result<()> { + let schema = match self.build_schema() { + Ok(schema) => schema, + Err(e) => { + 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"); - return; + 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())); } }; - 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) => { - warn!(error = %e, "failed to encode announce envelope"); - return; + 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"); - return; + 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(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"); + 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(); @@ -288,12 +427,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; } @@ -303,42 +439,39 @@ 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_core::value::Value as CoreValue; + use saikuro_event::Value as CoreValue; let id = envelope.id; let items = match envelope.batch_items { @@ -368,47 +501,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, @@ -416,24 +539,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; } } } @@ -446,5 +580,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 610462e5..fc959c46 100644 --- a/Build/adapters/rust/src/schema.rs +++ b/Build/adapters/rust/src/schema.rs @@ -3,8 +3,14 @@ //! Used by [`Provider`](crate::Provider) to construct the schema announcement //! envelope that it sends to the runtime when it first connects. +#[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::{ ArgumentDescriptor, FunctionSchema as CoreFunctionSchema, NamespaceSchema as CoreNamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility, @@ -55,53 +61,79 @@ 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(); + /// + /// 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 + .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) + .map_err(|_| Error::SchemaCapacityExceeded)?; + } - CoreNamespaceSchema { - functions, + 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. +pub(crate) 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()); + schema + .namespaces + .insert(ns_name.clone(), ns.to_core()?) + .map_err(|_| Error::SchemaCapacityExceeded)?; + } + Ok(schema) +} + +#[cfg(test)] +mod tests { + use super::*; + use saikuro_core::schema::SCHEMA_NAMESPACES_CAPACITY; + + #[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)); } - schema } diff --git a/Build/adapters/rust/src/storage.rs b/Build/adapters/rust/src/storage.rs index 605e7084..8944219a 100644 --- a/Build/adapters/rust/src/storage.rs +++ b/Build/adapters/rust/src/storage.rs @@ -1,108 +1,241 @@ -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(), + } + } + + 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::Sled => { - return create_sled(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::Sqlite => { - return create_sqlite(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::WebStorage => { - return create_web_storage(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::IndexedDb => { - return create_indexeddb(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::Opfs => { - return create_opfs(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::FsAccess => { - return create_fs_access(config).await; + } + + 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, + } + } + + 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, + } + } + + 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, + } + } +} + +/// 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 */ } } - // InMemory (default): persistence-mode-based dispatch match config.persistence { - PersistenceMode::Transient => { - let storage = saikuro_storage::InMemoryStorage::with_config(config.clone()); - Ok(Box::new(storage)) - } - - 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)) - } - } - - 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(), - )) - } + 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" + .into(), + )), } } -// Platform helper factories - -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 +245,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 +263,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 +281,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/src/transport.rs b/Build/adapters/rust/src/transport.rs index 83a28cbd..d96ae34d 100644 --- a/Build/adapters/rust/src/transport.rs +++ b/Build/adapters/rust/src/transport.rs @@ -8,26 +8,21 @@ use saikuro_transport::DEFAULT_CHANNEL_CAPACITY; use crate::error::{Error, Result}; -/// A URL-style address string understood by the Saikuro adapter. -/// -/// Supported schemes: -/// - `tcp://host:port` -/// - `ws://host:port` (requires `ws` feature) -/// - `unix:///path/to/socket` (requires `unix` feature, Unix only) -/// - `wasm-host://channel-name` (WASM only, feature `wasm`) -/// - `wasm-host` (uses default channel "saikuro") -pub struct Address(pub String); - -impl> From for Address { - fn from(s: S) -> Self { - Self(s.into()) - } -} +#[cfg(not(feature = "std"))] +use alloc::{ + boxed::Box, + string::{String, ToString}, +}; /// 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 /// 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 +30,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 +57,35 @@ 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::shared::traits::{ + TransportConnector, TransportReceiver, TransportSender, }; + use saikuro_transport::tcp::{TcpConnector, TcpReceiver, TcpSender}; + use std::sync::Arc; pub struct TcpAdapter { sender: TcpSender, @@ -74,13 +94,12 @@ 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 log: Arc = Arc::new(saikuro_event::NullSink); + let transport = TcpConnector::new(addr, log) + .connect() + .await + .map_err(|e| Error::Transport(e.to_string()))?; let (sender, receiver) = transport.split(); Ok(Self { sender, receiver }) } @@ -96,17 +115,41 @@ 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}, }; + use std::sync::Arc; pub struct UnixAdapter { sender: UnixSender, @@ -115,7 +158,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 @@ -134,14 +178,15 @@ 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, }; + use std::sync::Arc; pub struct WsAdapter { sender: WebSocketSender, @@ -150,7 +195,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(); @@ -170,23 +216,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::shared::host::{WasmHostReceiver, WasmHostSender}; + 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"; 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 +265,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 +273,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 +316,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 +387,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 +405,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/adapters/rust/src/value.rs b/Build/adapters/rust/src/value.rs index 5e4d5886..3c44c4d8 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,24 +11,18 @@ /// 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) => { - tracing::warn!(error = %e, "core_to_json serialization failed"); - Value::Null - } + Err(_) => Value::Null, } } -/// 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 - } + Err(_) => saikuro_event::Value::Null, } } diff --git a/Build/adapters/rust/tests/integration.rs b/Build/adapters/rust/tests/integration.rs index 60035055..9902c49d 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 @@ -132,7 +133,7 @@ fn schema_build_basic() { }, ); // to_core should not panic - let _ = ns.to_core(); + let _ = ns.to_core().expect("schema conversion failed"); }) } @@ -148,7 +149,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 @@ -313,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_core::value::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"), @@ -352,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_core::value::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"), @@ -361,8 +360,7 @@ fn stream_roundtrip_with_simulated_runtime() { .await .expect("send item1"); - let item2 = - ResponseEnvelope::stream_item(env.id, 1, saikuro_core::value::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"), @@ -428,7 +426,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( @@ -549,13 +547,13 @@ 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, 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, @@ -590,11 +588,9 @@ fn envelope_roundtrip_msgpack_preserves_fields() { saikuro_exec::block_on(async { let original = Envelope::call( "math.add", - vec![ - saikuro_core::value::Value::Int(1), - saikuro_core::value::Value::Int(2), - ], - ); + vec![saikuro_event::Value::Int(1), saikuro_event::Value::Int(2)], + ) + .expect("entropy available"); let bytes = original.to_msgpack().expect("encode envelope"); let decoded = Envelope::from_msgpack(&bytes).expect("decode envelope"); @@ -744,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}" + ); }) } @@ -763,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}" + ); }) } @@ -778,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/adapters/rust/tests/schema_capacity.rs b/Build/adapters/rust/tests/schema_capacity.rs new file mode 100644 index 00000000..5ef18843 --- /dev/null +++ b/Build/adapters/rust/tests/schema_capacity.rs @@ -0,0 +1,13 @@ +use saikuro::schema::{FunctionSchema, NamespaceSchema}; +use saikuro::Error; +use saikuro_core::schema::SCHEMA_FUNCTIONS_CAPACITY; + +#[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)); +} 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/Cargo.toml b/Build/crates/saikuro-codegen/Cargo.toml index 4c9df9a4..b8208aca 100644 --- a/Build/crates/saikuro-codegen/Cargo.toml +++ b/Build/crates/saikuro-codegen/Cargo.toml @@ -8,16 +8,19 @@ 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] cli = ["dep:clap"] [dependencies] -saikuro-core = { workspace = true } +saikuro-core = { workspace = true, default-features = false } saikuro-schema = { workspace = true } serde = { workspace = true } @@ -25,6 +28,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-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..05bef934 --- /dev/null +++ b/Build/crates/saikuro-codegen/lib.rs @@ -0,0 +1,9 @@ +pub mod language; +pub mod shared; + +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 80% rename from Build/crates/saikuro-codegen/src/generator.rs rename to Build/crates/saikuro-codegen/shared/generator.rs index 8d21d1e9..e6049bea 100644 --- a/Build/crates/saikuro-codegen/src/generator.rs +++ b/Build/crates/saikuro-codegen/shared/generator.rs @@ -1,13 +1,9 @@ -//! 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, }; -use crate::error::Result; +use crate::shared::error::Result; /// A single generated source file. #[derive(Debug, Clone)] @@ -78,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(); @@ -98,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; @@ -121,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(), @@ -140,15 +124,10 @@ 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, - 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 { @@ -173,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..2e94de96 --- /dev/null +++ b/Build/crates/saikuro-codegen/shared/mod.rs @@ -0,0 +1,4 @@ +pub mod error; +pub mod generator; + +pub use generator::{to_camel_case, to_pascal_case}; 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, -}; diff --git a/Build/crates/saikuro-core/Cargo.toml b/Build/crates/saikuro-core/Cargo.toml index c79b2829..c1be30cc 100644 --- a/Build/crates/saikuro-core/Cargo.toml +++ b/Build/crates/saikuro-core/Cargo.toml @@ -8,15 +8,28 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "rpc", "msgpack"] +[features] +default = ["std", "native"] +std = [] +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" + [dependencies] serde = { workspace = true } -serde_json = { workspace = true } +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 } -thiserror = { workspace = true } -chrono = { workspace = true } -serde_with = { 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 } +messagepack-serde = { workspace = true } +portable-atomic = { 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/codec/msgpack.rs b/Build/crates/saikuro-core/codec/msgpack.rs new file mode 100644 index 00000000..c0466530 --- /dev/null +++ b/Build/crates/saikuro-core/codec/msgpack.rs @@ -0,0 +1,106 @@ +use alloc::vec::Vec; +use messagepack_serde::{ + messagepack_core::{encode::int::EncodeMinimizeInt, io::IoWrite, Encode}, + ser::NumEncoder, +}; +use saikuro_event::{DecodeError, EncodeError}; +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) + } +} + +/// Serialize a value to MessagePack bytes. +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/lib.rs b/Build/crates/saikuro-core/lib.rs new file mode 100644 index 00000000..217ad872 --- /dev/null +++ b/Build/crates/saikuro-core/lib.rs @@ -0,0 +1,47 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +#[macro_use] +extern crate alloc; + +mod protocol; +pub use protocol::*; + +mod value; +pub use value::*; + +mod codec; +pub use codec::*; + +// Engine selection guard +#[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 64% rename from Build/crates/saikuro-core/src/envelope.rs rename to Build/crates/saikuro-core/protocol/envelope.rs index e1c9abd7..75206c9a 100644 --- a/Build/crates/saikuro-core/src/envelope.rs +++ b/Build/crates/saikuro-core/protocol/envelope.rs @@ -1,21 +1,34 @@ -//! 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 `rmp-serde`) before transit; -//! the types here are the canonical in-memory representation. - -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; - -use crate::{ - capability::CapabilityToken, invocation::InvocationId, value::Value, PROTOCOL_VERSION, +use alloc::{string::String, vec::Vec}; +use serde::{ + ser::{SerializeMap, Serializer}, + Deserialize, Serialize, }; +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; + +/// 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. +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 -/// 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")] @@ -33,17 +46,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`](crate::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, } @@ -64,11 +68,9 @@ 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`]. + /// Protocol version: must equal [`PROTOCOL_VERSION`]. pub version: u32, /// What kind of invocation this is. @@ -76,24 +78,24 @@ 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, /// Optional key/value metadata bag (trace IDs, deadlines, …). - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub meta: BTreeMap, - - /// Capability token presented by the caller. Required when the target + #[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 /// function declares one or more `capabilities`. #[serde(skip_serializing_if = "Option::is_none")] pub capability: Option, @@ -118,13 +120,13 @@ 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, saikuro_event::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) } } }; @@ -135,68 +137,69 @@ 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: BTreeMap::new(), + meta: MetaMap::new(), capability: None, 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. - /// - /// `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 `.`). @@ -211,9 +214,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 { @@ -223,8 +223,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`]. @@ -233,14 +231,14 @@ 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, /// 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")] @@ -277,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/src/invocation.rs b/Build/crates/saikuro-core/protocol/invocation.rs similarity index 69% rename from Build/crates/saikuro-core/src/invocation.rs rename to Build/crates/saikuro-core/protocol/invocation.rs index ab53c224..538a7d46 100644 --- a/Build/crates/saikuro-core/src/invocation.rs +++ b/Build/crates/saikuro-core/protocol/invocation.rs @@ -1,20 +1,17 @@ -//! 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, +}; +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. /// /// 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); @@ -70,9 +67,11 @@ impl<'de> Deserialize<'de> for InvocationId { impl InvocationId { /// Generate a fresh, globally-unique invocation identifier. + /// + /// Returns an error when the configured entropy backend is unavailable. #[inline] - pub fn new() -> Self { - Self(Uuid::new_v4()) + pub fn new() -> Result { + saikuro_random::uuid_v4().map(Self) } /// Construct from an existing UUID. @@ -94,12 +93,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) @@ -124,34 +117,10 @@ 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 { Ok(Self(Uuid::parse_str(s)?)) } } - -#[cfg(test)] -mod tests { - use super::InvocationId; - - #[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"); - 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 decoded: InvocationId = - rmp_serde::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/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/protocol/registration.rs b/Build/crates/saikuro-core/protocol/registration.rs new file mode 100644 index 00000000..7e0e4b10 --- /dev/null +++ b/Build/crates/saikuro-core/protocol/registration.rs @@ -0,0 +1,30 @@ +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. + 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/src/schema.rs b/Build/crates/saikuro-core/protocol/schema.rs similarity index 78% rename from Build/crates/saikuro-core/src/schema.rs rename to Build/crates/saikuro-core/protocol/schema.rs index f113ed84..4c8386ed 100644 --- a/Build/crates/saikuro-core/src/schema.rs +++ b/Build/crates/saikuro-core/protocol/schema.rs @@ -1,23 +1,33 @@ -//! 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}; -use std::collections::{BTreeMap, HashMap}; use crate::capability::CapabilityToken; +use saikuro_event::Value; /// The protocol version this schema was compiled against. pub const SCHEMA_VERSION: u32 = 1; -// Primitive types +/// 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. -/// -/// Extended types (user-defined structs) are represented as `TypeRef`. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PrimitiveType { @@ -35,14 +45,14 @@ 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, } -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", @@ -64,7 +74,7 @@ impl std::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)] @@ -115,18 +125,18 @@ impl TypeDescriptor { } } -// Function schema +// Function schema /// Visibility of a function to external callers. #[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. + /// Callable by any namespace, including external callers. #[default] 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, } @@ -142,7 +152,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, @@ -180,9 +190,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. @@ -200,42 +208,36 @@ 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. 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 { /// 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, } -// 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`]. 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,14 +245,12 @@ 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()), } } /// 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/src/error.rs b/Build/crates/saikuro-core/src/error.rs deleted file mode 100644 index e10d0713..00000000 --- a/Build/crates/saikuro-core/src/error.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! Error types for the Saikuro system. -//! -//! Errors are modelled at two levels: -//! -//! 1. **[`SaikuroError`]** : the Rust `std::error::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 serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use thiserror::Error; - -use crate::value::Value; - -/// 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. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum ErrorCode { - // Schema errors - /// The requested namespace is not registered. - NamespaceNotFound, - /// The requested function does not exist within its namespace. - FunctionNotFound, - /// One or more arguments failed type/shape validation. - InvalidArguments, - /// The schema version in the envelope is incompatible with this runtime. - IncompatibleVersion, - /// A required field was missing from an envelope. - MalformedEnvelope, - - // Routing errors - /// No provider is registered for the target namespace. - NoProvider, - /// The provider for the target namespace is temporarily unavailable. - ProviderUnavailable, - /// A batch item's `target` resolved to a different namespace than allowed. - BatchRoutingConflict, - - // Capability errors - /// The caller did not present the required capability token. - CapabilityDenied, - /// The capability token presented was invalid or expired. - CapabilityInvalid, - - // Transport errors - /// The underlying transport connection was lost. - ConnectionLost, - /// A message exceeded the configured size limit. - MessageTooLarge, - /// The operation timed out waiting for a response. - Timeout, - /// The receive buffer overflowed due to backpressure violation. - BufferOverflow, - - // Provider errors - /// The provider's handler returned an explicit error. - ProviderError, - /// The provider panicked while handling the invocation. - ProviderPanic, - - // Stream / channel errors - /// A stream was already closed when an item was sent. - StreamClosed, - /// A channel was closed by the remote side. - ChannelClosed, - /// Out-of-order sequence number detected on an ordered stream. - OutOfOrder, - - // Catch-all - /// An error category not covered by the above codes. - Internal, -} - -impl std::fmt::Display for ErrorCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Delegate to the derived Debug output which matches the serde names. - write!(f, "{self:?}") - } -} - -/// The wire-level error payload carried inside a failed [`ResponseEnvelope`]. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ErrorDetail { - /// Machine-readable code for programmatic handling. - pub code: ErrorCode, - - /// Human-readable description, intended for log output and debugging. - pub message: String, - - /// Optional structured context (stack traces, field paths, …). - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub details: BTreeMap, -} - -impl ErrorDetail { - /// Construct a minimal error detail with a code and message. - pub fn new(code: ErrorCode, message: impl Into) -> Self { - Self { - code, - message: message.into(), - details: BTreeMap::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 - } -} - -impl std::fmt::Display for ErrorDetail { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "[{}] {}", self.code, self.message) - } -} - -/// 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), - - #[error("function not found: {0}")] - FunctionNotFound(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("malformed envelope: {0}")] - MalformedEnvelope(String), - - // Routing - #[error("no provider registered for namespace: {0}")] - NoProvider(String), - - #[error("provider unavailable for namespace: {0}")] - ProviderUnavailable(String), - - #[error("batch routing conflict: {0}")] - BatchRoutingConflict(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), - - #[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("buffer overflow on stream/channel")] - BufferOverflow, - - // Provider - #[error("provider returned error: {0}")] - ProviderError(String), - - #[error("provider panicked while handling invocation")] - ProviderPanic, - - // 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 }, - - // Serialisation - #[error("msgpack encode error: {0}")] - MsgpackEncode(#[from] rmp_serde::encode::Error), - - #[error("msgpack decode error: {0}")] - MsgpackDecode(#[from] rmp_serde::decode::Error), - - // I/O - #[error("I/O error: {0}")] - Io(#[from] std::io::Error), - - // Catch-all - #[error("internal error: {0}")] - Internal(String), -} - -impl From for ErrorDetail { - fn from(err: SaikuroError) -> Self { - let code = match &err { - SaikuroError::NamespaceNotFound(_) => ErrorCode::NamespaceNotFound, - SaikuroError::FunctionNotFound(_) => ErrorCode::FunctionNotFound, - SaikuroError::InvalidArguments { .. } => ErrorCode::InvalidArguments, - SaikuroError::IncompatibleVersion { .. } => ErrorCode::IncompatibleVersion, - SaikuroError::MalformedEnvelope(_) => ErrorCode::MalformedEnvelope, - SaikuroError::NoProvider(_) => ErrorCode::NoProvider, - SaikuroError::ProviderUnavailable(_) => ErrorCode::ProviderUnavailable, - SaikuroError::BatchRoutingConflict(_) => ErrorCode::BatchRoutingConflict, - SaikuroError::CapabilityDenied { .. } => ErrorCode::CapabilityDenied, - SaikuroError::CapabilityInvalid => ErrorCode::CapabilityInvalid, - SaikuroError::ConnectionLost(_) => ErrorCode::ConnectionLost, - SaikuroError::MessageTooLarge { .. } => ErrorCode::MessageTooLarge, - SaikuroError::Timeout { .. } => ErrorCode::Timeout, - SaikuroError::BufferOverflow => ErrorCode::BufferOverflow, - SaikuroError::ProviderError(_) => ErrorCode::ProviderError, - SaikuroError::ProviderPanic => ErrorCode::ProviderPanic, - SaikuroError::StreamClosed => ErrorCode::StreamClosed, - SaikuroError::ChannelClosed => ErrorCode::ChannelClosed, - SaikuroError::OutOfOrder { .. } => ErrorCode::OutOfOrder, - SaikuroError::MsgpackEncode(_) - | SaikuroError::MsgpackDecode(_) - | SaikuroError::Io(_) - | SaikuroError::Internal(_) => ErrorCode::Internal, - }; - - ErrorDetail::new(code, err.to_string()) - } -} - -/// Convenience alias for `Result`. -pub type Result = std::result::Result; diff --git a/Build/crates/saikuro-core/src/lib.rs b/Build/crates/saikuro-core/src/lib.rs deleted file mode 100644 index 766b407c..00000000 --- a/Build/crates/saikuro-core/src/lib.rs +++ /dev/null @@ -1,27 +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. - -pub mod capability; -pub mod envelope; -pub mod error; -pub mod invocation; -pub mod log; -pub mod resource; -pub mod schema; -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 log::{stderr_log_sink, LogLevel, LogRecord, LogSink}; -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/log.rs b/Build/crates/saikuro-core/src/log.rs deleted file mode 100644 index 0fb35e91..00000000 --- a/Build/crates/saikuro-core/src/log.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! 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 serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; - -use crate::value::Value; - -// 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 -/// 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"`). - pub ts: String, - - /// Severity level. - pub level: LogLevel, - - /// Logger name / origin (e.g. `"saikuro.transport"`, `"myapp.handler"`). - pub name: String, - - /// Human-readable message. - pub msg: String, - - /// Additional structured context fields. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub fields: BTreeMap, -} - -impl LogRecord { - /// Construct a minimal log record with no extra fields. - pub fn new( - ts: impl Into, - level: LogLevel, - name: impl Into, - msg: impl Into, - ) -> Self { - Self { - ts: ts.into(), - level, - name: name.into(), - msg: msg.into(), - fields: BTreeMap::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 - } -} - -/// Helper: extract a `Value::String` from a map by key. -fn take_string(map: &mut BTreeMap, key: &str) -> Option { - match map.remove(key) { - Some(Value::String(s)) => Some(s), - _ => None, - } -} - -impl TryFrom for LogRecord { - type Error = &'static str; - - fn try_from(value: Value) -> Result { - match value { - Value::Map(mut map) => { - let ts = take_string(&mut map, "ts").unwrap_or_default(); - let level = map - .remove("level") - .and_then(|v| match v { - Value::String(s) => LogLevel::try_from(s.as_str()).ok(), - _ => None, - }) - .unwrap_or(LogLevel::Info); - let name = take_string(&mut map, "name").unwrap_or_default(); - let msg = take_string(&mut map, "msg").unwrap_or_default(); - Ok(LogRecord { - ts, - level, - name, - msg, - fields: map, - }) - } - _ => Err("expected a Map"), - } - } -} - -impl std::fmt::Display for LogRecord { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - 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. -pub fn stderr_log_sink() -> LogSink { - Box::new(|record: LogRecord| { - if let Ok(json) = serde_json::to_string(&record) { - eprintln!("{}", json); - } - }) -} diff --git a/Build/crates/saikuro-core/src/capability.rs b/Build/crates/saikuro-core/value/capability.rs similarity index 63% rename from Build/crates/saikuro-core/src/capability.rs rename to Build/crates/saikuro-core/value/capability.rs index 0e531f14..1ade4c97 100644 --- a/Build/crates/saikuro-core/src/capability.rs +++ b/Build/crates/saikuro-core/value/capability.rs @@ -1,27 +1,22 @@ -//! 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}; -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 = "*"; -/// A single capability token : a namespaced, human-readable permission string. +/// 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: `"."`. /// 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); @@ -57,13 +52,9 @@ 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: HashSet, + tokens: TokenSet, } impl CapabilitySet { @@ -73,10 +64,16 @@ impl CapabilitySet { } /// Construct a set from an iterator of tokens. - pub fn from_tokens(iter: impl IntoIterator) -> Self { - Self { - tokens: iter.into_iter().collect(), + 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 +82,14 @@ 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 +98,8 @@ impl CapabilitySet { } /// Add a token to the set. - pub fn insert(&mut self, token: CapabilityToken) { - self.tokens.insert(token); + pub fn insert(&mut self, token: CapabilityToken) -> Result { + self.tokens.insert(token) } /// Return an iterator over all tokens in the set. @@ -119,9 +117,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/value/mod.rs b/Build/crates/saikuro-core/value/mod.rs new file mode 100644 index 00000000..2ac20a78 --- /dev/null +++ b/Build/crates/saikuro-core/value/mod.rs @@ -0,0 +1,5 @@ +pub mod capability; +pub mod resource; + +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 58% rename from Build/crates/saikuro-core/src/resource.rs rename to Build/crates/saikuro-core/value/resource.rs index ecf005ad..206cca1b 100644 --- a/Build/crates/saikuro-core/src/resource.rs +++ b/Build/crates/saikuro-core/value/resource.rs @@ -1,70 +1,30 @@ -//! 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}; -use std::collections::BTreeMap; -use std::fmt; -use crate::value::Value; +use saikuro_event::{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 @@ -104,18 +64,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`]. @@ -161,33 +126,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-event/Cargo.toml b/Build/crates/saikuro-event/Cargo.toml new file mode 100644 index 00000000..1fb6e49c --- /dev/null +++ b/Build/crates/saikuro-event/Cargo.toml @@ -0,0 +1,43 @@ +[package] +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", "error", "logging"] + +[features] +default = ["std", "native", "stderr", "null", "filter"] +std = [] +native = ["std"] +no_std = [] +wasm = ["dep:wasm-bindgen"] +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 = [] + +[lib] +path = "lib.rs" + +[dependencies] +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 } +async-trait = { 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/codec.rs b/Build/crates/saikuro-event/core_events/codec.rs new file mode 100644 index 00000000..9e961921 --- /dev/null +++ b/Build/crates/saikuro-event/core_events/codec.rs @@ -0,0 +1,6 @@ +/// 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-event/core_events/event.rs b/Build/crates/saikuro-event/core_events/event.rs new file mode 100644 index 00000000..4414a5e7 --- /dev/null +++ b/Build/crates/saikuro-event/core_events/event.rs @@ -0,0 +1,490 @@ +use alloc::string::{String, ToString}; +use core::fmt; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::core_events::io::{IoError, IoErrorKind}; +use crate::value::Value; + +/// 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`] and +/// [`LogRecord`]. +pub type ContextMap = heapless::FnvIndexMap; + +/// All error codes transmitted on the wire. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum ErrorCode { + // Schema errors + /// The requested namespace is not registered. + NamespaceNotFound, + /// The requested function does not exist within its namespace. + FunctionNotFound, + /// One or more arguments failed type/shape validation. + InvalidArguments, + /// The schema version in the envelope is incompatible with this runtime. + IncompatibleVersion, + /// A required field was missing from an envelope. + MalformedEnvelope, + + // Routing errors + /// No provider is registered for the target namespace. + NoProvider, + /// The provider for the target namespace is temporarily unavailable. + ProviderUnavailable, + /// A batch item's `target` resolved to a different namespace than allowed. + BatchRoutingConflict, + + // Capability errors + /// The caller did not present the required capability token. + CapabilityDenied, + /// The capability token presented was invalid or expired. + CapabilityInvalid, + + // Transport errors + /// The underlying transport connection was lost. + ConnectionLost, + /// A message exceeded the configured size limit. + MessageTooLarge, + /// The operation timed out waiting for a response. + Timeout, + /// The receive buffer overflowed due to backpressure violation. + BufferOverflow, + + // Provider errors + /// The provider's handler returned an explicit error. + ProviderError, + /// The provider panicked while handling the invocation. + ProviderPanic, + + // Stream / channel errors + /// A stream was already closed when an item was sent. + StreamClosed, + /// A channel was closed by the remote side. + ChannelClosed, + /// 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, + + // Capacity errors + /// A fixed-capacity collection reached its compile-time limit. + CapacityExceeded, + + // Catch-all + /// An error category not covered by the above codes. + Internal, +} + +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:?}") + } +} + +/// The wire-level error payload carried inside a failed [`ResponseEnvelope`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorDetail { + /// Machine-readable code for programmatic handling. + pub code: ErrorCode, + + /// Human-readable description, intended for log output and debugging. + pub message: String, + + /// Optional structured context (stack traces, field paths, …). + #[serde(default, skip_serializing_if = "ContextMap::is_empty")] + pub details: ContextMap, +} + +impl ErrorDetail { + /// Construct a minimal error detail with a code and message. + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + details: ContextMap::new(), + } + } + + /// 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, + ) -> 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 fmt::Display for ErrorDetail { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[{}] {}", self.code, self.message) + } +} + +/// 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}")] + NamespaceNotFound(String), + + #[error("function not found: {0}")] + FunctionNotFound(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("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), + + #[error("provider unavailable for namespace: {0}")] + ProviderUnavailable(String), + + #[error("batch routing conflict: {0}")] + BatchRoutingConflict(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), + + #[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("buffer overflow on stream/channel")] + BufferOverflow, + + // Provider + #[error("provider returned error: {0}")] + ProviderError(String), + + #[error("provider panicked while handling invocation")] + ProviderPanic, + + // 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 }, + + // 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::core_events::codec::EncodeError), + + #[error("msgpack decode error: {0}")] + MsgpackDecode(#[from] crate::core_events::codec::DecodeError), + + // I/O + #[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), +} + +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, + SaikuroError::IncompatibleVersion { .. } => ErrorCode::IncompatibleVersion, + SaikuroError::MalformedEnvelope(_) => ErrorCode::MalformedEnvelope, + SaikuroError::NoProvider(_) => ErrorCode::NoProvider, + SaikuroError::ProviderUnavailable(_) => ErrorCode::ProviderUnavailable, + SaikuroError::BatchRoutingConflict(_) => ErrorCode::BatchRoutingConflict, + SaikuroError::CapabilityDenied { .. } => ErrorCode::CapabilityDenied, + SaikuroError::CapabilityInvalid => ErrorCode::CapabilityInvalid, + SaikuroError::ConnectionLost(_) => ErrorCode::ConnectionLost, + SaikuroError::MessageTooLarge { .. } => ErrorCode::MessageTooLarge, + SaikuroError::Timeout { .. } => ErrorCode::Timeout, + SaikuroError::BufferOverflow => ErrorCode::BufferOverflow, + SaikuroError::ProviderError(_) => ErrorCode::ProviderError, + SaikuroError::ProviderPanic => ErrorCode::ProviderPanic, + 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 { + 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, + SaikuroError::EmptyBatch => ErrorCode::MalformedEnvelope, + SaikuroError::VisibilityDenied { .. } => ErrorCode::CapabilityDenied, + SaikuroError::ArgumentArity { .. } => ErrorCode::InvalidArguments, + SaikuroError::ArgumentType { .. } => ErrorCode::InvalidArguments, + SaikuroError::CapacityExceeded(_) | SaikuroError::Internal(_) => ErrorCode::Internal, + } + } +} + +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()) + } +} + +/// Convert a host `std::io::Error` into the unified error type. +#[cfg(feature = "std")] +impl From for SaikuroError { + fn from(err: std::io::Error) -> Self { + SaikuroError::Io(err.into()) + } +} + +/// 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-event/core_events/io.rs b/Build/crates/saikuro-event/core_events/io.rs new file mode 100644 index 00000000..e34f112e --- /dev/null +++ b/Build/crates/saikuro-event/core_events/io.rs @@ -0,0 +1,135 @@ +use alloc::string::String; +#[cfg(feature = "std")] +use alloc::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-event/core_events/mod.rs b/Build/crates/saikuro-event/core_events/mod.rs new file mode 100644 index 00000000..c8e3f0f7 --- /dev/null +++ b/Build/crates/saikuro-event/core_events/mod.rs @@ -0,0 +1,7 @@ +mod codec; +mod event; +mod io; + +pub use codec::*; +pub use event::*; +pub use io::*; diff --git a/Build/crates/saikuro-event/lib.rs b/Build/crates/saikuro-event/lib.rs new file mode 100644 index 00000000..7d79e131 --- /dev/null +++ b/Build/crates/saikuro-event/lib.rs @@ -0,0 +1,39 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] + +//! Unified error taxonomy, structured logging, and dynamically-typed value +//! types for Saikuro. +#[macro_use] +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"); + +/// 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 new file mode 100644 index 00000000..bfdb0a1e --- /dev/null +++ b/Build/crates/saikuro-event/log/embedded/mod.rs @@ -0,0 +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 new file mode 100644 index 00000000..a615d124 --- /dev/null +++ b/Build/crates/saikuro-event/log/embedded/serial.rs @@ -0,0 +1,58 @@ +#[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; + +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), + } + } +} + +#[async_trait(?Send)] +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-event/log/level.rs b/Build/crates/saikuro-event/log/level.rs new file mode 100644 index 00000000..13cf71d3 --- /dev/null +++ b/Build/crates/saikuro-event/log/level.rs @@ -0,0 +1,32 @@ +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-event/log/mod.rs b/Build/crates/saikuro-event/log/mod.rs new file mode 100644 index 00000000..e417e394 --- /dev/null +++ b/Build/crates/saikuro-event/log/mod.rs @@ -0,0 +1,61 @@ +/// 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")] +pub mod ring; + +pub use level::*; +pub use record::*; +#[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") + ) +))] +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"); + +#[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-event/log/native/mod.rs b/Build/crates/saikuro-event/log/native/mod.rs new file mode 100644 index 00000000..a190a0cd --- /dev/null +++ b/Build/crates/saikuro-event/log/native/mod.rs @@ -0,0 +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/native/stderr.rs b/Build/crates/saikuro-event/log/native/stderr.rs new file mode 100644 index 00000000..a66af9a2 --- /dev/null +++ b/Build/crates/saikuro-event/log/native/stderr.rs @@ -0,0 +1,22 @@ +use async_trait::async_trait; +use serde_json; + +use crate::record::LogRecord; +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) { + std::eprintln!("{}", json); + } + } +} + +/// Construct a [`StderrSink`]. +pub fn stderr_log_sink() -> StderrSink { + StderrSink +} diff --git a/Build/crates/saikuro-event/log/native/tracing.rs b/Build/crates/saikuro-event/log/native/tracing.rs new file mode 100644 index 00000000..5d463789 --- /dev/null +++ b/Build/crates/saikuro-event/log/native/tracing.rs @@ -0,0 +1,28 @@ +use async_trait::async_trait; + +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; + +#[async_trait] +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-event/log/record.rs b/Build/crates/saikuro-event/log/record.rs new file mode 100644 index 00000000..a3c9718d --- /dev/null +++ b/Build/crates/saikuro-event/log/record.rs @@ -0,0 +1,149 @@ +use alloc::string::String; +use core::fmt; +use core::str::FromStr; +use serde::{Deserialize, Serialize}; + +use crate::level::LogLevel; +use crate::value::{Value, ValueMap}; +use crate::ContextMap; +use crate::SaikuroError; + +/// A structured log record forwarded from an adapter to the runtime log sink. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogRecord { + /// ISO-8601 timestamp string (e.g. `"2026-01-01T00:00:00.000Z"`). + pub ts: String, + + /// Severity level. + pub level: LogLevel, + + /// Logger name / origin (e.g. `"saikuro.transport"`, `"myapp.handler"`). + pub name: String, + + /// Human-readable message. + pub msg: String, + + /// Additional structured context fields. + #[serde(default, skip_serializing_if = "ContextMap::is_empty")] + pub fields: ContextMap, +} + +impl LogRecord { + /// Construct a minimal log record with no extra fields. + pub fn new( + ts: impl Into, + level: LogLevel, + name: impl Into, + msg: impl Into, + ) -> Self { + Self { + ts: ts.into(), + level, + name: name.into(), + msg: msg.into(), + fields: ContextMap::new(), + } + } + + /// 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 + /// capacity fields. + pub fn with_context( + mut self, + key: impl Into, + value: impl Into, + ) -> Result { + let key = key.into(); + self.fields.insert(key.clone(), value.into()).map_err(|_| { + SaikuroError::CapacityExceeded(format!("log field bag full at key '{key}'")) + })?; + Ok(self) + } +} + +/// 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, + } +} + +impl TryFrom for LogRecord { + type Error = &'static str; + + fn try_from(value: Value) -> Result { + match value { + Value::Map(mut map) => { + let ts = take_string(&mut map, "ts").unwrap_or_default(); + let level = map + .remove("level") + .and_then(|v| match v { + Value::String(s) => LogLevel::from_str(s.as_str()).ok(), + _ => None, + }) + .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 = ContextMap::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, + }) + } + _ => Err("expected a Map"), + } + } +} + +impl fmt::Display for LogRecord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "[{}] {} {} : {}", + self.ts, self.level, self.name, self.msg + ) + } +} diff --git a/Build/crates/saikuro-event/log/ring.rs b/Build/crates/saikuro-event/log/ring.rs new file mode 100644 index 00000000..6d57310c --- /dev/null +++ b/Build/crates/saikuro-event/log/ring.rs @@ -0,0 +1,57 @@ +#[cfg(not(feature = "std"))] +use alloc::boxed::Box; + +use alloc::vec::Vec; +use async_trait::async_trait; +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() + } +} + +#[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(); + if buf.len() >= self.capacity { + buf.remove(0); + } + buf.push(record.clone()); + } +} diff --git a/Build/crates/saikuro-event/log/sink.rs b/Build/crates/saikuro-event/log/sink.rs new file mode 100644 index 00000000..51506aaf --- /dev/null +++ b/Build/crates/saikuro-event/log/sink.rs @@ -0,0 +1,79 @@ +#[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. +#[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); +} + +/// A sink that discards every record. +/// +/// 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) {} +} + +/// 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 } + } +} + +#[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 { + self.inner.emit(record).await; + } + } +} diff --git a/Build/crates/saikuro-event/log/wasm/console.rs b/Build/crates/saikuro-event/log/wasm/console.rs new file mode 100644 index 00000000..345ee5da --- /dev/null +++ b/Build/crates/saikuro-event/log/wasm/console.rs @@ -0,0 +1,39 @@ +#[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")] + { + if let Ok(json) = serde_json::to_string(record) { + web_sys::console::log_1(&JsValue::from_str(&json)); + } + } + #[cfg(not(feature = "console"))] + { + let _ = record; + } + } +} + +/// Construct a [`ConsoleSink`]. +pub fn console_log_sink() -> ConsoleSink { + ConsoleSink +} diff --git a/Build/crates/saikuro-event/log/wasm/mod.rs b/Build/crates/saikuro-event/log/wasm/mod.rs new file mode 100644 index 00000000..93f9aa1e --- /dev/null +++ b/Build/crates/saikuro-event/log/wasm/mod.rs @@ -0,0 +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 new file mode 100644 index 00000000..7138fb2b --- /dev/null +++ b/Build/crates/saikuro-event/value/mod.rs @@ -0,0 +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-core/src/value.rs b/Build/crates/saikuro-event/value/value.rs similarity index 52% rename from Build/crates/saikuro-core/src/value.rs rename to Build/crates/saikuro-event/value/value.rs index 67420ea3..d7f6534e 100644 --- a/Build/crates/saikuro-core/src/value.rs +++ b/Build/crates/saikuro-event/value/value.rs @@ -1,21 +1,15 @@ -//! 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, 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}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +/// Maximum number of entries a [`Value::Map`] can hold. +pub const VALUE_MAP_CAPACITY: usize = 64; + +/// Fixed-capacity map backing [`Value::Map`]. +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, PartialEq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(untagged)] pub enum Value { /// Explicit absence of a value. @@ -38,26 +32,49 @@ 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. `BTreeMap` is used for deterministic - /// serialisation order, which makes content-addressed hashing predictable. - Map(BTreeMap), + /// String-keyed mapping of values. + 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`]. +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() + .all(|(key, value)| b.get(key).is_some_and(|other| other == value)) + } + _ => false, + } + } } impl Value { @@ -138,7 +155,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 +244,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 { @@ -241,77 +252,3 @@ impl> From> for Value { } } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::schema::{ - FunctionSchema, 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 schema = Schema { - version: 1, - namespaces, - types: HashMap::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"); - - 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 = rmp_serde::to_vec_named(&original).expect("serialize"); - let decoded: Value = rmp_serde::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 = rmp_serde::to_vec_named(&original).expect("serialize"); - let decoded: Value = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert!( - matches!(decoded, Value::Bytes(_)), - "Expected Bytes, got: {decoded:?}" - ); - } -} diff --git a/Build/crates/saikuro-exec/Cargo.toml b/Build/crates/saikuro-exec/Cargo.toml index 41d333e6..db22fd83 100644 --- a/Build/crates/saikuro-exec/Cargo.toml +++ b/Build/crates/saikuro-exec/Cargo.toml @@ -7,16 +7,66 @@ authors.workspace = true license.workspace = true repository.workspace = true +[lib] +path = "lib.rs" + [features] -default = ["tokio-runtime"] -tokio-runtime = ["tokio/full", "tokio-util"] -wasm-runtime = ["wasm-bindgen-futures", "fluvio-wasm-timer"] -embassy-runtime = [] +default = ["std", "native"] +std = [] +native = ["std", "dep:tokio", "tokio/full", "dep:tokio-util", "futures/std"] +no_std = [ + "dep:embassy-executor", + "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/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/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-8"] [dependencies] -tokio = { version = "1.52.3", default-features = false, features = ["macros"] } -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 } \ No newline at end of file + +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 = [ + "medium-ip", + "proto-ipv4", + "tcp", + "udp", +] } +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 new file mode 100644 index 00000000..790932ec --- /dev/null +++ b/Build/crates/saikuro-exec/base/exec.rs @@ -0,0 +1,378 @@ +#![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; +use core::future::Future; +#[cfg(any(feature = "wasm", feature = "no_std"))] +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; +#[cfg(target_has_atomic = "ptr")] +use futures::stream::FuturesUnordered; +use futures::stream::StreamExt; + +use crate::shared::JoinError; + +#[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 { + value: Option, + closed: bool, + wakers: MultiWakerRegistration<8>, +} + +type JoinResultSlot = CriticalSectionMutex>>; + +#[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; + } + } +} + +#[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(()); + }); +} + +#[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 {} +} + +/// 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 + } + + pub fn new_multi_thread() -> Self { + Runtime + } + + pub fn new_current_thread() -> Self { + Runtime + } + + 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: () } + } + + 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 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, + } + } +} + +/// 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 new file mode 100644 index 00000000..d091cc48 --- /dev/null +++ b/Build/crates/saikuro-exec/base/mod.rs @@ -0,0 +1,62 @@ +#[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; +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", feature = "embedded"))] +pub(crate) mod exec; diff --git a/Build/crates/saikuro-exec/base/mpsc.rs b/Build/crates/saikuro-exec/base/mpsc.rs new file mode 100644 index 00000000..0cc2d126 --- /dev/null +++ b/Build/crates/saikuro-exec/base/mpsc.rs @@ -0,0 +1,222 @@ +// mpsc + +use super::*; +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>, +} + +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/saikuro-exec/base/oneshot.rs b/Build/crates/saikuro-exec/base/oneshot.rs new file mode 100644 index 00000000..35827d3d --- /dev/null +++ b/Build/crates/saikuro-exec/base/oneshot.rs @@ -0,0 +1,135 @@ +// 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.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 { + 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); + return Err(value); + } + State::Closed => { + data.channel = State::Closed; + return Err(value); + } + } + 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 + } + } + }) + } +} + +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 { + 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 new file mode 100644 index 00000000..b6e79109 --- /dev/null +++ b/Build/crates/saikuro-exec/base/sync.rs @@ -0,0 +1,158 @@ +// 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/saikuro-exec/base/watch.rs b/Build/crates/saikuro-exec/base/watch.rs new file mode 100644 index 00000000..f0d1089f --- /dev/null +++ b/Build/crates/saikuro-exec/base/watch.rs @@ -0,0 +1,131 @@ +// 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/saikuro-exec/embedded/exec.rs b/Build/crates/saikuro-exec/embedded/exec.rs new file mode 100644 index 00000000..2c74dcb6 --- /dev/null +++ b/Build/crates/saikuro-exec/embedded/exec.rs @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..3ec5bebf --- /dev/null +++ b/Build/crates/saikuro-exec/embedded/mod.rs @@ -0,0 +1,3 @@ +pub mod exec; + +pub use exec::*; diff --git a/Build/crates/saikuro-exec/lib.rs b/Build/crates/saikuro-exec/lib.rs new file mode 100644 index 00000000..a504a98e --- /dev/null +++ b/Build/crates/saikuro-exec/lib.rs @@ -0,0 +1,95 @@ +//! Saikuro execution and concurrency facade. +#![cfg_attr(not(feature = "std"), no_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::JoinError; +pub use shared::{ChannelCapacity, InvalidChannelCapacity}; + +#[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 = "no_std")] +mod no_std; +#[cfg(feature = "no_std")] +pub use no_std::*; + +#[cfg(feature = "embedded")] +mod embedded; +#[cfg(feature = "embedded")] +pub use embedded::*; + +#[cfg(not(feature = "native"))] +pub use futures as _futures; +#[cfg(feature = "native")] +pub use tokio as _tokio; + +#[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/saikuro-exec/native/exec.rs b/Build/crates/saikuro-exec/native/exec.rs new file mode 100644 index 00000000..5794e58c --- /dev/null +++ b/Build/crates/saikuro-exec/native/exec.rs @@ -0,0 +1,166 @@ +use std::future::Future; +use std::pin::Pin; +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(mut self) -> Runtime { + Runtime { + inner: self + .inner + .build() + .expect("saikuro-exec: failed to build tokio runtime"), + } + } +} + +/// 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, + F::Output: Send + 'static, +{ + JoinHandle { + inner: tokio::spawn(fut), + } +} + +/// 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(); + } + + 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/saikuro-exec/native/mod.rs b/Build/crates/saikuro-exec/native/mod.rs new file mode 100644 index 00000000..68a01ee3 --- /dev/null +++ b/Build/crates/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/saikuro-exec/native/mpsc.rs b/Build/crates/saikuro-exec/native/mpsc.rs new file mode 100644 index 00000000..5b4d0cd5 --- /dev/null +++ b/Build/crates/saikuro-exec/native/mpsc.rs @@ -0,0 +1,50 @@ +use crate::shared::mpsc::{SendError, TrySendError}; +use crate::ChannelCapacity; + +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.0)) + } + + pub fn try_send(&self, value: T) -> Result<(), TrySendError> { + self.inner.try_send(value).map_err(|e| match e { + 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) + } + }) + } + + 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/saikuro-exec/native/oneshot.rs b/Build/crates/saikuro-exec/native/oneshot.rs new file mode 100644 index 00000000..5f4e714d --- /dev/null +++ b/Build/crates/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 { + 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 new file mode 100644 index 00000000..1f5d9481 --- /dev/null +++ b/Build/crates/saikuro-exec/native/sync.rs @@ -0,0 +1,104 @@ +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/saikuro-exec/native/watch.rs b/Build/crates/saikuro-exec/native/watch.rs new file mode 100644 index 00000000..8b333512 --- /dev/null +++ b/Build/crates/saikuro-exec/native/watch.rs @@ -0,0 +1,78 @@ +use core::future::Future; +use core::marker::PhantomData; +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.0)) + } +} + +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()).clone() + } + + 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> { + inner: + Pin> + Send + 'a>>, + _marker: PhantomData, +} + +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(); + 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-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 new file mode 100644 index 00000000..5ac2250a --- /dev/null +++ b/Build/crates/saikuro-exec/shared/mod.rs @@ -0,0 +1,221 @@ +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 +#[allow(dead_code)] +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 {} +} + +#[allow(dead_code)] +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 {} +} + +#[allow(dead_code)] +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/saikuro-exec/src/embassy_backend.rs b/Build/crates/saikuro-exec/src/embassy_backend.rs deleted file mode 100644 index 711bda1c..00000000 --- a/Build/crates/saikuro-exec/src/embassy_backend.rs +++ /dev/null @@ -1,2 +0,0 @@ -// Embassy backend placeholder. Implement when I am ready to add an embedded async runtime. -compile_error!("embassy-runtime backend is not implemented yet"); diff --git a/Build/crates/saikuro-exec/src/lib.rs b/Build/crates/saikuro-exec/src/lib.rs deleted file mode 100644 index 9e41dd62..00000000 --- a/Build/crates/saikuro-exec/src/lib.rs +++ /dev/null @@ -1,55 +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(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`." -); - -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::*; - -#[macro_export] -macro_rules! select { - ($($tt:tt)*) => { - $crate::select_impl! { $($tt)* } - }; -} - -#[doc(hidden)] -#[macro_export] -macro_rules! select_impl { - ($($tt:tt)*) => { - $crate::_tokio::select! { $($tt)* } - }; -} 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 82a8b564..00000000 --- a/Build/crates/saikuro-exec/src/tokio_backend.rs +++ /dev/null @@ -1,81 +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 { - pub use tokio::sync::mpsc::{channel, Receiver, Sender}; -} - -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 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 c91a34d2..00000000 --- a/Build/crates/saikuro-exec/src/wasm_backend.rs +++ /dev/null @@ -1,435 +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, - } - - pub fn channel(buffer: usize) -> (Sender, Receiver) { - let (tx, rx) = inner::channel(buffer); - ( - 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/saikuro-exec/wasm/exec.rs b/Build/crates/saikuro-exec/wasm/exec.rs new file mode 100644 index 00000000..f40d7edf --- /dev/null +++ b/Build/crates/saikuro-exec/wasm/exec.rs @@ -0,0 +1 @@ +pub use crate::base::exec::*; diff --git a/Build/crates/saikuro-exec/wasm/mod.rs b/Build/crates/saikuro-exec/wasm/mod.rs new file mode 100644 index 00000000..3ec5bebf --- /dev/null +++ b/Build/crates/saikuro-exec/wasm/mod.rs @@ -0,0 +1,3 @@ +pub mod exec; + +pub use exec::*; diff --git a/Build/crates/saikuro-net/Cargo.toml b/Build/crates/saikuro-net/Cargo.toml new file mode 100644 index 00000000..70653c3c --- /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 = [] +embedded = [ + "dep:embassy-net", + "embassy-net/medium-ip", + "embassy-net/proto-ipv4", + "embassy-net/tcp", + "embassy-net/udp", + "dep:embedded-io-async", +] +wasm = [] +embassy-test = ["embedded", "embassy-time/std", "embassy-time/generic-queue-8"] + +[dependencies] +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/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..becbaba3 --- /dev/null +++ b/Build/crates/saikuro-net/embedded/mod.rs @@ -0,0 +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/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..a8d65905 --- /dev/null +++ b/Build/crates/saikuro-net/lib.rs @@ -0,0 +1,38 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] + +//! Networking and IO facade for Saikuro. + +// 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"); + +#[cfg(feature = "native")] +mod native; +#[cfg(feature = "native")] +pub use native::{io, net}; + +#[cfg(feature = "embedded")] +mod embedded; +#[cfg(feature = "embedded")] +pub use embedded::{io, net}; diff --git a/Build/crates/saikuro-net/native/io.rs b/Build/crates/saikuro-net/native/io.rs new file mode 100644 index 00000000..75124c9d --- /dev/null +++ b/Build/crates/saikuro-net/native/io.rs @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..d9b52946 --- /dev/null +++ b/Build/crates/saikuro-net/native/mod.rs @@ -0,0 +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-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-random/Cargo.toml b/Build/crates/saikuro-random/Cargo.toml new file mode 100644 index 00000000..f73dc69b --- /dev/null +++ b/Build/crates/saikuro-random/Cargo.toml @@ -0,0 +1,31 @@ +[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"] + +[lib] +path = "lib.rs" + +[features] +default = ["std", "native"] +std = ["rand_core/std"] +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 } +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 new file mode 100644 index 00000000..5509b9e9 --- /dev/null +++ b/Build/crates/saikuro-random/base/mod.rs @@ -0,0 +1,33 @@ +#[cfg(feature = "no_std")] +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")] +pub struct WasiEntropy; + +#[cfg(feature = "no_std")] +impl EntropySource for WasiEntropy { + 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<(), SaikuroError> { + 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<(), SaikuroError> { + if crate::shared::is_seeded() { + return Ok(()); + } + init(&WasiEntropy) +} diff --git a/Build/crates/saikuro-random/embedded/mod.rs b/Build/crates/saikuro-random/embedded/mod.rs new file mode 100644 index 00000000..ca540f60 --- /dev/null +++ b/Build/crates/saikuro-random/embedded/mod.rs @@ -0,0 +1,20 @@ +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<(), SaikuroError> { + 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<(), SaikuroError> { + 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 new file mode 100644 index 00000000..589c1c8d --- /dev/null +++ b/Build/crates/saikuro-random/lib.rs @@ -0,0 +1,53 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] + +//! Randomness and entropy facade for Saikuro. + +#[macro_use] +extern crate alloc; + +#[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::*; + +#[cfg(any(feature = "wasm", feature = "embedded", feature = "no_std"))] +mod base; +#[cfg(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-random/native/mod.rs b/Build/crates/saikuro-random/native/mod.rs new file mode 100644 index 00000000..13a89b4f --- /dev/null +++ b/Build/crates/saikuro-random/native/mod.rs @@ -0,0 +1,28 @@ +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<(), SaikuroError> { + getrandom::fill(dest).map_err(SaikuroError::from) + } +} + +/// Seed the process-wide DRBG from the OS entropy source. +pub fn init_default() -> Result<(), SaikuroError> { + 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<(), 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 new file mode 100644 index 00000000..3dabfde3 --- /dev/null +++ b/Build/crates/saikuro-random/shared/mod.rs @@ -0,0 +1,305 @@ +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}; +use saikuro_event::SaikuroError; + +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<(), 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")) + })?; + // chacha20 seeks by byte offset, not by block index. + let pos = index + .checked_mul(BLOCK_LEN as u64) + .ok_or(SaikuroError::Entropy("DRBG keystream exhausted".into()))?; + cipher + .try_seek(pos) + .map_err(|_| SaikuroError::Entropy("DRBG keystream exhausted".into()))?; + 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(SaikuroError::Entropy(format!( + "DRBG seed must be at least {SEED_LEN} 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]); + Ok(Self { + key, + nonce, + counter: 0, + }) + } + + /// Fill `dest` with the next bytes of the keystream. + 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(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)?; + 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<(), SaikuroError> { + // 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<(), SaikuroError> { + if seed.len() < SEED_LEN { + return Err(SaikuroError::Entropy(format!( + "DRBG seed must be at least {SEED_LEN} bytes" + ))); + } + 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() { + 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<(), SaikuroError> { + 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. +#[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( + "DRBG used before being seeded".into(), + )); + } + } + 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<(), SaikuroError> { + // 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(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-random/wasm/mod.rs b/Build/crates/saikuro-random/wasm/mod.rs new file mode 100644 index 00000000..7c1bac0d --- /dev/null +++ b/Build/crates/saikuro-random/wasm/mod.rs @@ -0,0 +1,27 @@ +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<(), 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<(), SaikuroError> { + 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<(), SaikuroError> { + if crate::shared::is_seeded() { + return Ok(()); + } + init(&JsEntropy) +} diff --git a/Build/crates/saikuro-router/Cargo.toml b/Build/crates/saikuro-router/Cargo.toml index e3e50de0..bcfd72aa 100644 --- a/Build/crates/saikuro-router/Cargo.toml +++ b/Build/crates/saikuro-router/Cargo.toml @@ -8,20 +8,53 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "router", "rpc"] +[lib] +path = "lib.rs" + +[features] +default = ["std", "native"] +std = [] +native = [ + "std", + "saikuro-core/native", + "saikuro-exec/native", + "saikuro-schema/native", + "saikuro-event/native", + "saikuro-event/tracing", +] +no_std = [ + "saikuro-core/no_std", + "saikuro-exec/no_std", + "saikuro-schema/no_std", + "saikuro-event/no_std", + "saikuro-event/null", +] +wasm = [ + "saikuro-core/wasm", + "saikuro-exec/wasm", + "saikuro-schema/wasm", + "saikuro-event/wasm", + "saikuro-event/console", +] +embedded = [ + "saikuro-core/embedded", + "saikuro-exec/embedded", + "saikuro-schema/embedded", + "saikuro-event/embedded", + "saikuro-event/null", +] + [dependencies] -saikuro-core = { workspace = true } -saikuro-schema = { workspace = true } +saikuro-core = { path = "../saikuro-core", default-features = false } +saikuro-schema = { workspace = true, default-features = false } +saikuro-exec = { workspace = true, default-features = false } +saikuro-event = { 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 } -uuid = { workspace = true } +portable-atomic = { workspace = true } +portable-atomic-util = { workspace = true } [dev-dependencies] +saikuro-exec = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/Build/crates/saikuro-router/lib.rs b/Build/crates/saikuro-router/lib.rs new file mode 100644 index 00000000..73fb44b9 --- /dev/null +++ b/Build/crates/saikuro-router/lib.rs @@ -0,0 +1,40 @@ +#![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}; +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; + +// 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..51bfbb0f --- /dev/null +++ b/Build/crates/saikuro-router/provider/mod.rs @@ -0,0 +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 new file mode 100644 index 00000000..b09da6a1 --- /dev/null +++ b/Build/crates/saikuro-router/provider/provider.rs @@ -0,0 +1,234 @@ +#[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}; + +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 +/// An abstract provider that can receive invocations. +#[async_trait] +pub trait Provider: Send + Sync + 'static { + /// The unique identifier for this provider connection. + fn id(&self) -> &str; + + /// The namespaces this provider handles. + fn namespaces(&self) -> &[String]; + + /// Send an invocation envelope to this provider. + /// + /// For `Call` invocations the caller attaches a `response_tx` oneshot + /// sender; the provider must eventually call `response_tx.send(...)` to + /// complete the call. + async fn send_invocation( + &self, + envelope: Envelope, + response_tx: Option, + ) -> Result<()>; + + /// Returns `true` if this provider is still alive and can accept work. + fn is_alive(&self) -> bool; +} + +// ProviderHandle + +/// 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, +} + +/// A cheap, cloneable handle to a connected provider. +#[derive(Clone)] +pub struct ProviderHandle { + id: String, + registration_token: RegistrationToken, + namespaces: Vec, + sender: mpsc::Sender, +} + +impl ProviderHandle { + /// Build a provider handle with a freshly generated registration token. + pub fn new( + 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] +impl Provider for ProviderHandle { + fn id(&self) -> &str { + &self.id + } + + fn namespaces(&self) -> &[String] { + &self.namespaces + } + + async fn send_invocation( + &self, + envelope: Envelope, + response_tx: Option, + ) -> Result<()> { + self.sender + .send(ProviderWorkItem { + envelope, + response_tx, + }) + .await + .map_err(|_| SaikuroError::ProviderUnavailable(self.id.clone())) + } + + fn is_alive(&self) -> bool { + !self.sender.is_closed() + } +} + +// ProviderRegistry +/// Thread-safe registry mapping namespace names to provider handles. +#[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 + by_namespace: BTreeMap, + /// provider identity -> list of namespaces (for cleanup on disconnect) + by_provider: BTreeMap<(String, RegistrationToken), Vec>, +} + +impl ProviderRegistry { + /// Create an empty provider registry. + pub fn new() -> Self { + Self::default() + } + + /// Register a provider handle for the given namespaces. + 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().await; + + // A re-registering provider that dropped a namespace must release its route. + let dropped: Vec = state + .by_provider + .get(&provider_key) + .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 && h.registration_token() == registration_token) + .unwrap_or(false) + { + state.by_namespace.remove(ns); + } + } + + 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); + } + } + _ => {} + } + } + state.by_provider.insert(provider_key, namespaces); + } + + /// Remove all namespaces owned by one specific provider registration. + 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 { + if state + .by_namespace + .get(&ns) + .map(|h| h.id() == provider_id && h.registration_token() == registration_token) + .unwrap_or(false) + { + state.by_namespace.remove(&ns); + } + } + } + } + + /// Look up the provider for a namespace. + 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 async fn has_live_provider(&self, namespace: &str) -> bool { + self.inner + .read() + .await + .by_namespace + .get(namespace) + .map(|h| h.is_alive()) + .unwrap_or(false) + } +} diff --git a/Build/crates/saikuro-router/router/mod.rs b/Build/crates/saikuro-router/router/mod.rs new file mode 100644 index 00000000..4d0905ca --- /dev/null +++ b/Build/crates/saikuro-router/router/mod.rs @@ -0,0 +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 new file mode 100644 index 00000000..6fc0900c --- /dev/null +++ b/Build/crates/saikuro-router/router/router.rs @@ -0,0 +1,522 @@ +//! Invocation router +#[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, + ResponseEnvelope, +}; +use saikuro_event::{ErrorDetail, LogLevel, LogRecord, LogSink, Result, SaikuroError}; +use saikuro_exec::{mpsc, oneshot, timeout, ChannelCapacity}; + +use crate::{ + provider::{Provider, ProviderRegistry}, + stream_state::{ChannelState, DeliveryOutcome, StreamState, StreamStateStore}, + DefaultRouterSink, +}; + +// Config + +/// Configuration for the invocation router. +#[derive(Debug, Clone)] +pub struct RouterConfig { + /// Timeout for call invocations (the router will return a Timeout error if + /// the provider doesn't respond within this window). + pub call_timeout: Duration, + + /// Capacity of per-stream item channels. + pub stream_channel_capacity: ChannelCapacity, + + /// Capacity of per-channel inbound/outbound item channels. + pub channel_capacity: ChannelCapacity, +} + +impl Default for RouterConfig { + fn default() -> Self { + Self { + call_timeout: Duration::from_secs(30), + stream_channel_capacity: ChannelCapacity::DEFAULT, + channel_capacity: ChannelCapacity::DEFAULT, + } + } +} + +// Router + +/// The central dispatch hub. +/// +/// `InvocationRouter` is cheap to clone : all state is `Arc`-wrapped inside +/// the registries it references. +pub struct InvocationRouter { + providers: ProviderRegistry, + streams: StreamStateStore, + config: RouterConfig, + /// Sink for `Log`-type envelopes. Wrapped in `Arc` so `Clone` works. + 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 { + /// 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()) + } + + /// 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: S2, + ) -> InvocationRouter { + InvocationRouter { + providers, + streams: StreamStateStore::new(), + config, + log_sink: Arc::new(sink), + } + } + + // State store access + /// Return a reference to the router's stream/channel state store. + pub fn streams(&self) -> &StreamStateStore { + &self.streams + } + + /// 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, + InvocationType::Cast => self.dispatch_cast(envelope).await, + InvocationType::Stream => self.dispatch_stream_open(envelope).await, + InvocationType::Channel => self.dispatch_channel_open(envelope).await, + InvocationType::Batch => self.dispatch_batch(envelope).await, + InvocationType::Resource => { + // Resource handles are provider-specific; route the same way + // as a call and let the provider interpret the args. + self.dispatch_call(envelope).await + } + 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 + // so we don't panic but we do warn. + 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; + + let provider = match self.resolve_namespace(&envelope.target).await { + Ok(p) => p, + Err(e) => return error_response(id, e.into()), + }; + + let (resp_tx, resp_rx) = oneshot::channel(); + + if let Err(e) = provider.send_invocation(envelope, Some(resp_tx)).await { + return error_response(id, e.into()); + } + + match timeout(self.config.call_timeout, resp_rx).await { + Ok(Ok(response)) => response, + Ok(Err(_)) => { + 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(_) => { + 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 { + millis: self.config.call_timeout.as_millis() as u64, + } + .into(), + ) + } + } + } + + // Cast + async fn dispatch_cast(&self, envelope: Envelope) -> ResponseEnvelope { + let id = envelope.id; + + let provider = match self.resolve_namespace(&envelope.target).await { + Ok(p) => p, + Err(e) => return error_response(id, e.into()), + }; + + // Fire-and-forget: we don't wait for any response. + if let Err(e) = provider.send_invocation(envelope, None).await { + 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. + } + + ResponseEnvelope::ok_empty(id) + } + + // Stream + async fn dispatch_stream_open(&self, envelope: Envelope) -> ResponseEnvelope { + let id = envelope.id; + + 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).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).await; + return error_response(id, e.into()); + } + + 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; + + // If a channel with this id already exists, treat as data frame + if let Some(channel) = self.streams.get_channel(&id).await { + // Map the Envelope to a ResponseEnvelope for channel data delivery + let resp = ResponseEnvelope { + id, + ok: true, + result: envelope.args.first().cloned(), + error: None, + seq: envelope.seq, + stream_control: envelope.stream_control, + }; + match channel.deliver(resp, true).await { + DeliveryOutcome::Terminal => { + self.streams.remove_channel_if(&id, &channel).await; + return ResponseEnvelope::ok_empty(id); + } + DeliveryOutcome::Closed => { + self.streams.remove_channel_if(&id, &channel).await; + return error_response(id, SaikuroError::ChannelClosed.into()); + } + DeliveryOutcome::OutOfOrder => { + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!("out-of-order channel item dropped (id={})", id), + )) + .await; + } + DeliveryOutcome::Delivered => {} + } + // For non-terminal frames, do not return a response (one-way) + return ResponseEnvelope { + id, + ok: true, + result: None, + error: None, + seq: None, + stream_control: None, + }; + } + + // Otherwise, open a new channel as before + let provider = match self.resolve_namespace(&envelope.target).await { + Ok(p) => p, + Err(e) => return error_response(id, e.into()), + }; + + let (inbound_tx, inbound_rx) = mpsc::channel(self.config.channel_capacity); + 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; + + if let Err(e) = provider.send_invocation(envelope, None).await { + self.streams.remove_channel(&id).await; + return error_response(id, e.into()); + } + + 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 { + Some(items) => items, + None => { + return error_response( + id, + SaikuroError::MalformedEnvelope("batch has no items".into()).into(), + ); + } + }; + + let mut results = Vec::with_capacity(items.len()); + for item in items { + 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_event::Value::Null) + } else { + saikuro_event::Value::Null + }); + } + + ResponseEnvelope::ok(id, saikuro_event::Value::Array(results)) + } + + // Log + async fn dispatch_log(&self, envelope: Envelope) -> ResponseEnvelope { + let id = envelope.id; + + // args[0] is the LogRecord as a Value::Map. + let record = match envelope.args.into_iter().next() { + Some(v) => match LogRecord::try_from(v) { + Ok(r) => Some(r), + Err(e) => { + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!( + "failed to parse LogRecord from log envelope (id={}): {e}", + id + ), + )) + .await; + None + } + }, + None => None, + }; + + match record { + Some(r) => { + self.log_sink.emit(&r).await; + } + None => { + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!( + "log envelope has no valid LogRecord in args[0]; dropping (id={})", + id + ), + )) + .await; + } + } + + ResponseEnvelope::ok_empty(id) + } + + // Stream item routing + async fn route_channel_item(&self, response: ResponseEnvelope, inbound: bool) -> Result<()> { + let id = response.id; + let state = self + .streams + .get_channel(&id) + .await + .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(SaikuroError::ChannelClosed) + } + DeliveryOutcome::OutOfOrder => { + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!("out-of-order channel item dropped (id={})", id), + )) + .await; + Ok(()) + } + DeliveryOutcome::Terminal => { + self.streams.remove_channel_if(&id, &state).await; + Ok(()) + } + DeliveryOutcome::Delivered => Ok(()), + } + } + + /// 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 + } + + /// Route an outbound channel item (provider -> client direction) to the + /// appropriate open channel's outbound queue. + pub async fn route_channel_outbound(&self, response: ResponseEnvelope) -> Result<()> { + self.route_channel_item(response, false).await + } + + /// Route an inbound stream item to the appropriate open stream. + pub async fn route_stream_item(&self, response: ResponseEnvelope) -> Result<()> { + let id = response.id; + let state = self + .streams + .get_stream(&id) + .await + .ok_or_else(|| SaikuroError::StreamNotFound(id.to_string()))?; + + match state.deliver(response).await { + DeliveryOutcome::Closed => { + self.streams.remove_stream_if(&id, &state).await; + Err(SaikuroError::StreamClosed) + } + DeliveryOutcome::OutOfOrder => { + self.log_sink + .emit(&LogRecord::new( + "", + LogLevel::Warn, + "saikuro.router", + format!("out-of-order stream item dropped (id={})", id), + )) + .await; + Ok(()) + } + DeliveryOutcome::Terminal => { + self.streams.remove_stream_if(&id, &state).await; + Ok(()) + } + DeliveryOutcome::Delivered => Ok(()), + } + } + + // Helpers + async fn resolve_namespace(&self, target: &str) -> Result { + let ns = + namespace_of(target).ok_or_else(|| SaikuroError::MalformedTarget(target.to_owned()))?; + + let handle = self + .providers + .get(ns) + .await + .ok_or_else(|| SaikuroError::NoProvider(ns.to_owned()))?; + + if !handle.is_alive() { + return Err(SaikuroError::ProviderUnavailable(handle.id().to_owned())); + } + + Ok(handle) + } +} + +// Helpers +fn namespace_of(target: &str) -> Option<&str> { + saikuro_core::envelope::split_target(target).map(|(ns, _)| ns) +} + +fn error_response(id: InvocationId, detail: ErrorDetail) -> ResponseEnvelope { + ResponseEnvelope::err(id, detail) +} + +fn default_sink() -> DefaultRouterSink { + #[cfg(feature = "native")] + { + saikuro_event::TracingSink + } + #[cfg(feature = "wasm")] + { + saikuro_event::ConsoleSink + } + #[cfg(any(feature = "no_std", feature = "embedded"))] + { + saikuro_event::NullSink + } +} diff --git a/Build/crates/saikuro-router/src/error.rs b/Build/crates/saikuro-router/src/error.rs deleted file mode 100644 index 7e125028..00000000 --- a/Build/crates/saikuro-router/src/error.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Router error type. - -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 = std::result::Result; diff --git a/Build/crates/saikuro-router/src/lib.rs b/Build/crates/saikuro-router/src/lib.rs deleted file mode 100644 index d0c30017..00000000 --- a/Build/crates/saikuro-router/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Saikuro Router -//! -//! This crate owns the invocation router and provider registry. It maps -//! namespace names to provider handles and dispatches incoming envelopes. - -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::{tracing_log_sink, InvocationRouter, RouterConfig}; -pub use stream_state::{ChannelState, StreamState, StreamStateStore}; diff --git a/Build/crates/saikuro-router/src/provider.rs b/Build/crates/saikuro-router/src/provider.rs deleted file mode 100644 index af703617..00000000 --- a/Build/crates/saikuro-router/src/provider.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! 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 async_trait::async_trait; -use dashmap::DashMap; -use saikuro_core::{envelope::Envelope, ResponseEnvelope}; -use saikuro_exec::{mpsc, oneshot}; -use std::sync::Arc; -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. - fn id(&self) -> &str; - - /// The namespaces this provider handles. - fn namespaces(&self) -> &[String]; - - /// Send an invocation envelope to this provider. - /// - /// For `Call` invocations the caller attaches a `response_tx` oneshot - /// sender; the provider must eventually call `response_tx.send(...)` to - /// complete the call. - async fn send_invocation( - &self, - envelope: Envelope, - response_tx: Option, - ) -> Result<()>; - - /// Returns `true` if this provider is still alive and can accept work. - fn is_alive(&self) -> bool; -} - -// ProviderHandle - -/// Work item sent through the provider's dispatch channel. -pub struct ProviderWorkItem { - pub envelope: Envelope, - pub response_tx: Option, -} - -/// 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, - namespaces: Vec, - sender: mpsc::Sender, -} - -impl ProviderHandle { - pub fn new( - id: impl Into, - namespaces: Vec, - sender: mpsc::Sender, - ) -> Self { - Self { - id: id.into(), - namespaces, - sender, - } - } -} - -#[async_trait] -impl Provider for ProviderHandle { - fn id(&self) -> &str { - &self.id - } - - fn namespaces(&self) -> &[String] { - &self.namespaces - } - - async fn send_invocation( - &self, - envelope: Envelope, - response_tx: Option, - ) -> Result<()> { - self.sender - .send(ProviderWorkItem { - envelope, - response_tx, - }) - .await - .map_err(|_| RouterError::ProviderUnavailable(self.id.clone())) - } - - fn is_alive(&self) -> bool { - !self.sender.is_closed() - } -} - -// ProviderRegistry - -/// Thread-safe registry mapping namespace names to provider handles. -#[derive(Clone, Default)] -pub struct ProviderRegistry { - /// namespace -> provider handle - by_namespace: Arc>, - /// provider_id -> list of namespaces (for cleanup on disconnect) - by_provider: Arc>>, -} - -impl ProviderRegistry { - pub fn new() -> Self { - Self::default() - } - - /// 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. - pub fn register(&self, handle: ProviderHandle) { - 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"); - } - self.by_namespace.insert(ns.clone(), handle.clone()); - } - - self.by_provider.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) { - for ns in namespaces { - self.by_namespace.remove(&ns); - debug!(namespace = %ns, provider = %provider_id, "deregistered namespace provider"); - } - } - } - - /// Look up the provider for a namespace. - pub fn get(&self, namespace: &str) -> Option { - self.by_namespace.get(namespace).map(|r| r.clone()) - } - - /// Return `true` if a live provider exists for the namespace. - pub fn has_live_provider(&self, namespace: &str) -> bool { - self.by_namespace - .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 deleted file mode 100644 index cf2ad86c..00000000 --- a/Build/crates/saikuro-router/src/router.rs +++ /dev/null @@ -1,538 +0,0 @@ -//! 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 saikuro_core::{ - envelope::{Envelope, InvocationType, StreamControl}, - error::{ErrorDetail, SaikuroError}, - invocation::InvocationId, - log::{LogLevel, LogRecord, LogSink}, - ResponseEnvelope, -}; -use saikuro_exec::{mpsc, oneshot, timeout}; -use std::{sync::Arc, time::Duration}; -use tracing::{debug, instrument, warn}; - -use crate::{ - error::{Result, RouterError}, - provider::{Provider, ProviderRegistry}, - stream_state::{ChannelState, StreamState, StreamStateStore}, -}; - -// Config - -/// Configuration for the invocation router. -#[derive(Debug, Clone)] -pub struct RouterConfig { - /// Timeout for call invocations (the router will return a Timeout error if - /// the provider doesn't respond within this window). - pub call_timeout: Duration, - - /// Capacity of per-stream item channels. - pub stream_channel_capacity: usize, - - /// Capacity of per-channel inbound/outbound item channels. - pub channel_capacity: usize, -} - -impl Default for RouterConfig { - fn default() -> Self { - Self { - call_timeout: Duration::from_secs(30), - stream_channel_capacity: 128, - channel_capacity: 128, - } - } -} - -// 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 { - providers: ProviderRegistry, - streams: StreamStateStore, - config: RouterConfig, - /// Sink for `Log`-type envelopes. Wrapped in `Arc` so `Clone` works. - log_sink: Arc, -} - -impl InvocationRouter { - pub fn new(providers: ProviderRegistry, config: RouterConfig) -> Self { - Self::with_log_sink(providers, config, tracing_log_sink()) - } - - /// Create a router with a custom log sink. - pub fn with_log_sink(providers: ProviderRegistry, config: RouterConfig, sink: LogSink) -> Self { - Self { - providers, - streams: StreamStateStore::new(), - config, - log_sink: Arc::new(sink), - } - } - - /// 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. - /// - /// 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, - ))] - pub async fn dispatch(&self, envelope: Envelope) -> ResponseEnvelope { - match envelope.invocation_type { - InvocationType::Call => self.dispatch_call(envelope).await, - InvocationType::Cast => self.dispatch_cast(envelope).await, - InvocationType::Stream => self.dispatch_stream_open(envelope).await, - InvocationType::Channel => self.dispatch_channel_open(envelope).await, - InvocationType::Batch => self.dispatch_batch(envelope).await, - InvocationType::Resource => { - // Resource handles are provider-specific; route the same way - // as a call and let the provider interpret the args. - self.dispatch_call(envelope).await - } - InvocationType::Log => self.dispatch_log(envelope), - InvocationType::Announce => { - // 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"); - ResponseEnvelope::ok_empty(envelope.id) - } - } - } - - // Call - - async fn dispatch_call(&self, envelope: Envelope) -> ResponseEnvelope { - let id = envelope.id; - - let provider = match self.resolve_namespace(&envelope.target) { - Ok(p) => p, - Err(e) => return error_response(id, e.into()), - }; - - let (resp_tx, resp_rx) = oneshot::channel(); - - if let Err(e) = provider.send_invocation(envelope, Some(resp_tx)).await { - return error_response(id, e.into()); - } - - match timeout(self.config.call_timeout, resp_rx).await { - Ok(Ok(response)) => response, - Ok(Err(_)) => { - warn!(%id, "provider dropped response sender without replying"); - error_response( - id, - SaikuroError::ProviderUnavailable("response channel dropped".into()).into(), - ) - } - Err(_) => { - warn!(%id, timeout_ms = self.config.call_timeout.as_millis(), "call timed out"); - error_response( - id, - SaikuroError::Timeout { - millis: self.config.call_timeout.as_millis() as u64, - } - .into(), - ) - } - } - } - - // Cast - - async fn dispatch_cast(&self, envelope: Envelope) -> ResponseEnvelope { - let id = envelope.id; - - let provider = match self.resolve_namespace(&envelope.target) { - Ok(p) => p, - Err(e) => return error_response(id, e.into()), - }; - - // 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}"); - // Still return ok_empty : the caller opted out of responses. - } - - ResponseEnvelope::ok_empty(id) - } - - // 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; - - let provider = match self.resolve_namespace(&envelope.target) { - 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); - - // 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); - return error_response(id, e.into()); - } - - debug!(%id, "stream opened"); - ResponseEnvelope::ok_empty(id) - } - - // Channel - - async fn dispatch_channel_open(&self, envelope: Envelope) -> ResponseEnvelope { - 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) { - // Map the Envelope to a ResponseEnvelope for channel data delivery - let resp = ResponseEnvelope { - id, - ok: true, - result: envelope.args.first().cloned(), - error: None, - 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); - } - // For non-terminal frames, do not return a response (one-way) - return ResponseEnvelope { - id, - ok: true, - result: None, - error: None, - seq: None, - stream_control: None, - }; - } - - // Otherwise, open a new channel as before - let provider = match self.resolve_namespace(&envelope.target) { - Ok(p) => p, - Err(e) => return error_response(id, e.into()), - }; - - let (inbound_tx, inbound_rx) = mpsc::channel(self.config.channel_capacity); - 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); - - if let Err(e) = provider.send_invocation(envelope, None).await { - self.streams.remove_channel(&id); - return error_response(id, e.into()); - } - - debug!(%id, "channel opened"); - ResponseEnvelope::ok_empty(id) - } - - // Batch - - async fn dispatch_batch(&self, envelope: Envelope) -> ResponseEnvelope { - let id = envelope.id; - let items = match envelope.batch_items { - Some(items) => items, - None => { - return error_response( - id, - SaikuroError::MalformedEnvelope("batch has no items".into()).into(), - ); - } - }; - - let mut results = Vec::with_capacity(items.len()); - for item in items { - 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) - } else { - saikuro_core::value::Value::Null - }); - } - - ResponseEnvelope::ok(id, saikuro_core::value::Value::Array(results)) - } - - // 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. - fn dispatch_log(&self, envelope: Envelope) -> ResponseEnvelope { - 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) { - Ok(r) => Some(r), - Err(e) => { - warn!(%id, error = %e, "failed to parse LogRecord from log envelope"); - None - } - }); - - match record { - Some(r) => { - (self.log_sink)(r); - } - None => { - warn!(%id, "log envelope has no valid LogRecord in args[0]; dropping"); - } - } - - ResponseEnvelope::ok_empty(id) - } - - // 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, - pick_tx: impl FnOnce(&ChannelState) -> &mpsc::Sender, - advance_seq: impl FnOnce(&ChannelState, u64) -> 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(()); - } - } - - 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 - } - - /// 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, - |s| s.outbound_tx(), - |s, seq| s.advance_outbound(seq), - ) - .await - } - - /// Route an inbound stream item to the appropriate open stream. - pub async fn route_stream_item(&self, response: ResponseEnvelope) -> Result<()> { - let id = response.id; - let state = self - .streams - .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(()); - } - } - - // 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 - - 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) - .ok_or_else(|| RouterError::NoProvider(ns.to_owned()))?; - - if !handle.is_alive() { - return Err(RouterError::ProviderUnavailable(handle.id().to_owned())); - } - - Ok(handle) - } -} - -// Helpers - -fn namespace_of(target: &str) -> Option<&str> { - saikuro_core::envelope::split_target(target).map(|(ns, _)| ns) -} - -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()) - } -} diff --git a/Build/crates/saikuro-router/src/stream_state.rs b/Build/crates/saikuro-router/src/stream_state.rs deleted file mode 100644 index 8aba16f5..00000000 --- a/Build/crates/saikuro-router/src/stream_state.rs +++ /dev/null @@ -1,228 +0,0 @@ -//! 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 dashmap::DashMap; -use saikuro_core::invocation::InvocationId; -use saikuro_core::ResponseEnvelope; -use saikuro_exec::mpsc; -use std::sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, -}; - -/// 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; -} - -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() - } -} - -// 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. - 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), - 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 - } -} - -// 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. - inbound_tx: mpsc::Sender, - /// Channel to deliver outbound messages back to the client. - outbound_tx: mpsc::Sender, -} - -impl ChannelState { - pub fn new( - inbound_tx: mpsc::Sender, - outbound_tx: mpsc::Sender, - ) -> Arc { - Arc::new(Self { - inbound_seq: AtomicU64::new(0), - outbound_seq: AtomicU64::new(0), - closed: AtomicBool::new(false), - 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 fn mark_closed(&self) { - self.closed.store(true, Ordering::Release); - } - - pub fn is_closed(&self) -> bool { - self.closed.load(Ordering::Acquire) - } - - pub fn inbound_tx(&self) -> &mpsc::Sender { - &self.inbound_tx - } - - pub fn outbound_tx(&self) -> &mpsc::Sender { - &self.outbound_tx - } -} - -// Store - -/// Thread-safe store for all open stream and channel states. -#[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>>, -} - -impl StreamStateStore { - pub fn new() -> Self { - 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, - state: Arc, - receiver: mpsc::Receiver, - ) { - self.streams.insert(id, state); - self.stream_receivers.insert(id, receiver); - } - - pub fn get_stream(&self, id: &InvocationId) -> Option> { - self.streams.get(id).map(|r| r.clone()) - } - - pub fn remove_stream(&self, id: &InvocationId) -> Option> { - self.stream_receivers.remove(id); - self.streams.remove(id).map(|(_, v)| v) - } - - /// 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 take_stream_receiver( - &self, - id: &InvocationId, - ) -> Option> { - self.stream_receivers.remove(id).map(|(_, v)| v) - } - - // Channel - - pub fn insert_channel( - &self, - id: InvocationId, - state: Arc, - 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); - } - - pub fn get_channel(&self, id: &InvocationId) -> Option> { - self.channels.get(id).map(|r| r.clone()) - } - - 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) - } - - /// Take the inbound receiver (client -> provider) for a channel. - pub fn take_channel_inbound_receiver( - &self, - id: &InvocationId, - ) -> Option> { - self.channel_inbound_receivers.remove(id).map(|(_, v)| v) - } - - /// Take the outbound receiver (provider -> client) for a channel. - pub fn take_channel_outbound_receiver( - &self, - id: &InvocationId, - ) -> Option> { - self.channel_outbound_receivers.remove(id).map(|(_, v)| v) - } -} 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..93a90359 --- /dev/null +++ b/Build/crates/saikuro-router/stream_state/mod.rs @@ -0,0 +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 new file mode 100644 index 00000000..1b30256a --- /dev/null +++ b/Build/crates/saikuro-router/stream_state/stream_state.rs @@ -0,0 +1,310 @@ +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::{ + mpsc, + sync::{Mutex, RwLock}, +}; + +/// 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, +} + +#[derive(Default)] +struct Lifecycle { + inbound_seq: u64, + outbound_seq: u64, + closed: bool, +} + +/// Lifecycle state for an open server-to-client stream. +pub struct StreamState { + lifecycle: Mutex, + item_tx: mpsc::Sender, +} + +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()), + item_tx, + }) + } + + /// 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; + 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 + } +} + +/// Lifecycle state for an open bidirectional channel. +pub struct ChannelState { + lifecycle: Mutex, + inbound_tx: mpsc::Sender, + outbound_tx: mpsc::Sender, +} + +impl ChannelState { + /// Build a new bidirectional channel state with inbound/outbound item sinks. + pub fn new( + inbound_tx: mpsc::Sender, + outbound_tx: mpsc::Sender, + ) -> Arc { + Arc::new(Self { + lifecycle: Mutex::new(Lifecycle::default()), + inbound_tx, + outbound_tx, + }) + } + + /// 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 { + (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 + } +} + +async fn deliver_locked( + closed: &mut bool, + expected_seq: u64, + tx: &mpsc::Sender, + response: ResponseEnvelope, +) -> DeliveryOutcome { + if *closed { + return DeliveryOutcome::Closed; + } + if let Some(seq) = response.seq { + if seq.checked_add(1).is_none() || seq != expected_seq { + return DeliveryOutcome::OutOfOrder; + } + } + 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; + } + if terminal { + *closed = true; + DeliveryOutcome::Terminal + } else { + DeliveryOutcome::Delivered + } +} + +/// Thread-safe store for all open stream and channel states. +#[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>, +} + +struct ChannelEntry { + state: Arc, + inbound_receiver: Option>, + outbound_receiver: Option>, +} + +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, + state: Arc, + receiver: mpsc::Receiver, + ) { + self.streams.write().await.insert( + id, + StreamEntry { + state, + receiver: Some(receiver), + }, + ); + } + + /// Look up the stream state for an invocation id. + pub async fn get_stream(&self, id: &InvocationId) -> Option> { + self.streams + .read() + .await + .get(id) + .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() + .await + .remove(id) + .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 + .get(id) + .is_some_and(|entry| Arc::ptr_eq(&entry.state, state)) + { + streams.remove(id); + true + } else { + false + } + } + + /// 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, + state: Arc, + inbound_rx: mpsc::Receiver, + outbound_rx: mpsc::Receiver, + ) { + self.channels.write().await.insert( + id, + ChannelEntry { + state, + inbound_receiver: Some(inbound_rx), + outbound_receiver: Some(outbound_rx), + }, + ); + } + + /// 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() + .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().await; + if channels + .get(id) + .is_some_and(|entry| Arc::ptr_eq(&entry.state, state)) + { + channels.remove(id); + true + } else { + false + } + } + + /// 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 e08737b5..75a2e8cb 100644 --- a/Build/crates/saikuro-runtime/Cargo.toml +++ b/Build/crates/saikuro-runtime/Cargo.toml @@ -8,44 +8,122 @@ 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" -required-features = ["native-transport"] +path = "main.rs" +required-features = ["native"] + +[[bin]] +name = "saikuro-runtime-embedded" +path = "bin/embedded.rs" +required-features = ["embedded", "tcp"] + +[[bin]] +name = "saikuro-runtime-wasi" +path = "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/ws-transport", "dep:tokio-tungstenite", "dep:tungstenite"] -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", + "saikuro-event/tracing", + "dep:anyhow", + "dep:clap", + "dep:tracing", + "dep:tracing-subscriber", +] +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"] +ws-wasi = ["saikuro-transport/ws-wasi"] +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 = { workspace = true } -saikuro-schema = { workspace = true } -saikuro-transport = { workspace = true } -saikuro-router = { workspace = true } -saikuro-exec = { workspace = true, default-features = false } - -serde = { workspace = true } -serde_json = { workspace = true } -rmp-serde = { workspace = true } -bytes = { workspace = true } +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 } + +serde = { workspace = true } +serde_json = { workspace = true, features = ["alloc"] } +bytes = { workspace = true, default-features = false, features = ["extra-platforms"] } async-trait = { workspace = true } -futures = { workspace = true } -thiserror = { workspace = true } -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 -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] +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 } +saikuro-net = { path = "../saikuro-net", default-features = false, optional = true } +wasm-bindgen = { workspace = true, optional = true } +talc = { 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/bin/embedded.rs b/Build/crates/saikuro-runtime/bin/embedded.rs new file mode 100644 index 00000000..537f1ed3 --- /dev/null +++ b/Build/crates/saikuro-runtime/bin/embedded.rs @@ -0,0 +1,27 @@ +#![cfg(feature = "embedded")] + +extern crate alloc; + +use saikuro_exec::start_runner; +use saikuro_runtime::embedded::run; + +/// 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(spawner: embassy_executor::Spawner) { + start_runner(spawner); + run(board::stack(), board::endpoint()).await; +} diff --git a/Build/crates/saikuro-runtime/bin/wasi.rs b/Build/crates/saikuro-runtime/bin/wasi.rs new file mode 100644 index 00000000..886912d5 --- /dev/null +++ b/Build/crates/saikuro-runtime/bin/wasi.rs @@ -0,0 +1,54 @@ +#![cfg(feature = "no_std")] +#![no_std] +#![no_main] + +#[macro_use] +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 { + #[cfg(all(not(feature = "std"), not(feature = "embedded")))] + saikuro_runtime::init_heap(); + + 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(); + + 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/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/native/mod.rs b/Build/crates/saikuro-runtime/native/mod.rs new file mode 100644 index 00000000..c9ab8fcb --- /dev/null +++ b/Build/crates/saikuro-runtime/native/mod.rs @@ -0,0 +1,267 @@ +use std::net::IpAddr; +use std::sync::Arc; + +use crate::config::RuntimeMode; +use crate::SaikuroRuntime; +use anyhow::{Context, Result}; +use clap::Parser; +use saikuro_event::LogSink; +use saikuro_exec::{signal, timeout, watch}; +use tracing::{error, info, warn}; + +// CLI +#[derive(Debug, Parser)] +#[command( + name = "saikuro-runtime", + about = "Saikuro runtime server: routes invocations between adapters", + version +)] +struct Args { + /// Path to a schema JSON file to load at startup. + #[arg(long, value_name = "PATH")] + schema: Option, + + /// Port to listen for raw TCP connections. + #[arg(long, value_name = "PORT", default_value = "7700")] + tcp_port: u16, + + /// Port to listen for WebSocket connections. + #[arg(long, value_name = "PORT", default_value = "7701")] + ws_port: u16, + + /// Path to a Unix domain socket to create and listen on. + #[arg(long, value_name = "PATH")] + unix: Option, + + /// Bind address for TCP and WebSocket listeners. + #[arg(long, value_name = "ADDR", default_value = "127.0.0.1")] + bind: IpAddr, + + /// Runtime mode. + #[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" + )] + log_level: String, + + /// Emit logs as newline-delimited JSON instead of human-readable text. + #[arg(long)] + json_logs: bool, + + /// Disable the TCP listener. + #[arg(long)] + no_tcp: bool, + + /// Disable the WebSocket listener. + #[arg(long)] + no_ws: bool, +} + +#[derive(Debug, Clone, clap::ValueEnum)] +enum CliMode { + Development, + Production, +} + +impl From for RuntimeMode { + fn from(m: CliMode) -> Self { + match m { + CliMode::Development => RuntimeMode::Development, + CliMode::Production => RuntimeMode::Production, + } + } +} + +// Main + +/// 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()) +} + +async fn async_main() -> Result<()> { + let args = Args::parse(); + + 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, + "saikuro-runtime starting" + ); + + // Build the runtime. + let mut builder = SaikuroRuntime::builder() + .mode(args.mode.into()) + .json_logs(args.json_logs) + .log_sink(log); + + // Load a baked-in schema from disk (native only). + if let Some(schema_path) = &args.schema { + let raw = std::fs::read(schema_path) + .with_context(|| format!("reading schema file {}", schema_path.display()))?; + 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().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. + #[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); + 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(); + let rx = shutdown_rx.clone(); + serve_tasks.push(spawn(async move { + rt.serve(vec![listener], rx).await; + })); + } + Err(e) => { + error!(addr = %addr, error = %e, "failed to bind TCP listener"); + return Err(anyhow::anyhow!("TCP bind failed: {e}")); + } + } + } + + // 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); + 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(); + let rx = shutdown_rx.clone(); + serve_tasks.push(spawn(async move { + rt.serve(vec![listener], rx).await; + })); + } + Err(e) => { + error!(addr = %addr, error = %e, "failed to bind WebSocket listener"); + return Err(anyhow::anyhow!("WebSocket bind failed: {e}")); + } + } + } + + // 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; + 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(); + let rx = shutdown_rx.clone(); + 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"); + return Err(anyhow::anyhow!("Unix socket bind failed: {e}")); + } + } + } + + if serve_tasks.is_empty() { + warn!("no listeners are active; all transports were disabled"); + } + + // Wait for Ctrl-C or SIGTERM. + wait_for_shutdown_signal().await; + info!("shutdown signal received; stopping listeners"); + + let _ = shutdown_tx.send(true); + runtime.shutdown().await; + + // Allow the listener tasks to exit cleanly. + for task in serve_tasks { + let _ = timeout(std::time::Duration::from_secs(5), task).await; + } + + info!("saikuro-runtime stopped"); + Ok(()) +} + +/// Wait for Ctrl-C (SIGINT) or SIGTERM. +async fn wait_for_shutdown_signal() { + let ctrl_c = async { + if let Err(e) = signal::ctrl_c().await { + error!("failed to install Ctrl-C handler: {e}"); + } + }; + + #[cfg(unix)] + let terminate = async { + match signal::unix::signal(signal::unix::SignalKind::terminate()) { + Ok(mut sig) => { + sig.recv().await; + } + Err(e) => { + error!("failed to install SIGTERM handler: {e}"); + } + } + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + saikuro_exec::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} + +// Logging initialisation + +fn init_logging(level: &str, json: bool) { + use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + + let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info")); + + if json { + tracing_subscriber::registry() + .with(filter) + .with(fmt::layer().json()) + .init(); + } else { + tracing_subscriber::registry() + .with(filter) + .with(fmt::layer()) + .init(); + } +} diff --git a/Build/crates/saikuro-runtime/src/config.rs b/Build/crates/saikuro-runtime/shared/config.rs similarity index 60% rename from Build/crates/saikuro-runtime/src/config.rs rename to Build/crates/saikuro-runtime/shared/config.rs index f017c06e..08753678 100644 --- a/Build/crates/saikuro-runtime/src/config.rs +++ b/Build/crates/saikuro-runtime/shared/config.rs @@ -1,15 +1,12 @@ -//! Runtime configuration. - -use serde::{Deserialize, Serialize}; -use serde_with::serde_as; -use std::time::Duration; +use core::time::Duration; +use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; +use saikuro_exec::ChannelCapacity; use saikuro_router::router::RouterConfig; 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. @@ -21,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, @@ -34,12 +31,21 @@ 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)] 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 { @@ -62,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, } } } @@ -95,6 +102,41 @@ 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) +} + +/// (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/shared/connection.rs b/Build/crates/saikuro-runtime/shared/connection.rs new file mode 100644 index 00000000..d3b18654 --- /dev/null +++ b/Build/crates/saikuro-runtime/shared/connection.rs @@ -0,0 +1,652 @@ +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; +use saikuro_core::{ + capability::CapabilitySet, + envelope::{Envelope, InvocationType}, + invocation::InvocationId, + schema::Schema, + RegistrationToken, ResponseEnvelope, +}; +use saikuro_event::{ErrorDetail, LogLevel, LogRecord, LogSink, Value}; +use saikuro_exec::{mpsc, oneshot, spawn}; +use saikuro_router::{ + provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, + router::InvocationRouter, +}; +use saikuro_schema::{ + capability_engine::{CapabilityEngine, CapabilityOutcome}, + registry::SchemaRegistry, + validator::InvocationValidator, +}; +use serde::Serialize; +use spin::Mutex; + +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>>>; + +/// Encode a serializable value as MessagePack `Bytes`. +fn encode_bytes(value: &T) -> Result { + saikuro_core::msgpack::to_vec(value) + .map(Bytes::from) + .map_err(|e| e.to_string()) +} + +/// A handler for a single connected peer. +/// +/// Generic over the transport halves so it works with every backend and +/// compiles cleanly on wasm32 targets. +pub struct ConnectionHandler +where + S: RuntimeSender + 'static, + R: RuntimeReceiver + 'static, +{ + pub peer_id: String, + /// Identity of this connection's provider registration. + pub registration_token: RegistrationToken, + pub sender: S, + pub receiver: R, + pub validator: InvocationValidator, + pub capability_engine: CapabilityEngine, + pub router: InvocationRouter, + pub peer_capabilities: CapabilitySet, + pub max_message_size: usize, + /// Schema registry shared with the runtime; used to merge announced schemas. + pub schema_registry: SchemaRegistry, + /// 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 +where + S: RuntimeSender + 'static, + R: RuntimeReceiver + 'static, +{ + /// Build a handler in **sandbox mode**. + pub fn sandboxed(mut self) -> Self { + self.capability_engine = CapabilityEngine::sandboxed(); + self + } + + /// Return `true` if this handler is operating in sandbox mode. + pub fn is_sandboxed(&self) -> bool { + self.capability_engine.is_sandboxed() + } +} + +impl ConnectionHandler +where + S: RuntimeSender + 'static, + R: RuntimeReceiver + 'static, +{ + /// Run the receive loop until the connection is closed or an unrecoverable + /// error occurs. + pub async fn run(mut self) { + { + 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. + 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`. + let (forward_tx, mut forward_rx) = + mpsc::channel::(saikuro_exec::ChannelCapacity::MAX); + + loop { + saikuro_exec::select! { + // Outbound frame from the ForwardTask (call forwarded to this + // peer acting as a provider). + frame_opt = forward_rx.recv() => { + match frame_opt { + Some(frame) => { + if let Err(e) = self.sender.send(frame).await { + 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 => { + 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; + } + } + } + + // Inbound frame from the peer (either a new request OR a + // response to a previously forwarded call). + incoming = self.receiver.recv().fuse() => { + match incoming { + Ok(Some(frame)) => { + if !self.handle_incoming(frame, &pending, &forward_tx).await { + break; + } + } + Ok(None) => { + 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) => { + 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; + } + } + } + } + } + + // Clean up: deregister any provider the peer announced. + self.provider_registry + .deregister(&self.peer_id, self.registration_token) + .await; + self.schema_registry + .deregister_provider(&self.peer_id, self.registration_token) + .await; + + { + 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. + /// + /// Returns `(response, Option)`. The second element is + /// `Some` only when sandbox mode is active and the frame was a successful + /// `Announce`: in that case the caller must push the schema back to the + /// peer. + async fn handle_frame( + &self, + frame: Bytes, + pending: &PendingCalls, + forward_tx: &mpsc::Sender, + ) -> Option<(ResponseEnvelope, Option)> { + // 1. Decode the MessagePack envelope. + 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; + { + 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 { + InvocationType::Announce => { + 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().await + } else { + None + }; + return Some((response, sandbox_schema)); + } + InvocationType::Log => { + // Let the router's log sink handle it: no validation needed. + return Some((self.router.dispatch(envelope).await, None)); + } + _ => {} + } + + // 3. Validate the envelope against the schema. + let validation = match self.validator.validate(&envelope).await { + Ok(report) => report, + Err(e) => { + return Some(( + ResponseEnvelope::err(id, ErrorDetail::new(e.error_code(), e.to_string())), + None, + )); + } + }; + + // 4. Capability check. + match self + .capability_engine + .check_ref(&self.peer_capabilities, &validation.function_ref) + { + CapabilityOutcome::Granted => {} + CapabilityOutcome::Denied { missing } => { + return Some(( + ResponseEnvelope::err( + id, + ErrorDetail::new( + saikuro_event::ErrorCode::CapabilityDenied, + format!("caller lacks '{}' to invoke '{}'", missing, envelope.target), + ), + ), + None, + )); + } + } + + // 5. Route to provider. + Some((self.router.dispatch(envelope).await, None)) + } + + /// Decode a MessagePack frame into an [`Envelope`], or return an error + /// response on failure. + async fn decode_envelope( + &self, + frame: &[u8], + ) -> Result>> { + match saikuro_core::msgpack::from_slice(frame) { + Ok(env) => Ok(env), + Err(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) => { + 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); + } + }; + Err(Some(Box::new(ResponseEnvelope::err( + id, + ErrorDetail::new( + saikuro_event::ErrorCode::MalformedEnvelope, + format!("msgpack decode error: {e}"), + ), + )))) + } + } + } + + /// Process one incoming frame. Returns `false` when the loop should break. + async fn handle_incoming( + &mut self, + frame: Bytes, + pending: &PendingCalls, + forward_tx: &mpsc::Sender, + ) -> bool { + if frame.len() > self.max_message_size { + let err = ErrorDetail::new( + saikuro_event::ErrorCode::MessageTooLarge, + format!( + "frame {} bytes exceeds limit {} bytes", + frame.len(), + self.max_message_size + ), + ); + let id = match InvocationId::new() { + Ok(id) => 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; + } + }; + let response = ResponseEnvelope::err(id, err); + let _ = self.send_response(response).await; + return true; + } + + // Try to decode as ResponseEnvelope first. + if let Ok(resp) = saikuro_core::msgpack::from_slice::(&frame) { + if let Some(sender) = pending.lock().remove(&resp.id) { + let _ = sender.send(resp); + return true; + } + } + + 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 { + let mut record = + LogRecord::now(LogLevel::Error, "saikuro.runtime.connection", "send error"); + record.set_context("peer", self.peer_id.clone()); + record.set_context("error", e.to_string()); + self.log.emit(&record).await; + return false; + } + + if let Some(filtered) = sandbox_schema { + if let Err(e) = self.push_sandbox_schema(filtered).await { + 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", e.to_string()); + self.log.emit(&record).await; + return false; + } + } + + true + } + + /// Handle a schema-announcement envelope. + async fn handle_announce( + &self, + envelope: Envelope, + pending: &PendingCalls, + forward_tx: &mpsc::Sender, + ) -> ResponseEnvelope { + let id = envelope.id; + + let schema: Option = envelope.args.into_iter().next().and_then(|v| { + let bytes = encode_bytes(&v).ok()?; + saikuro_core::msgpack::from_slice(&bytes).ok() + }); + + match schema { + Some(s) => { + 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) + .await + { + Ok(()) => { + { + 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. + self.register_wire_provider(namespaces, pending, forward_tx) + .await; + + ResponseEnvelope::ok_empty(id) + } + Err(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( + saikuro_event::ErrorCode::Internal, + format!("schema merge error: {e}"), + ), + ) + } + } + } + None => { + 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( + saikuro_event::ErrorCode::MalformedEnvelope, + "announce envelope must carry a Schema in args[0]".to_owned(), + ), + ) + } + } + } + + /// Create and register a [`ProviderHandle`] that forwards invocations to + /// the connected peer over the wire. + async fn register_wire_provider( + &self, + namespaces: Vec, + pending: &PendingCalls, + forward_tx: &mpsc::Sender, + ) { + 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).await; + + 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) => { + 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", e.to_string()); + log.emit(&record).await; + if let Some(tx) = item.response_tx { + let _ = tx.send(ResponseEnvelope::err( + item.envelope.id, + ErrorDetail::new( + saikuro_event::ErrorCode::Internal, + format!("encode error: {e}"), + ), + )); + } + continue; + } + }; + + // Register before sending so an incoming response can find the + // 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.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() { + 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; + } + } + 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; + }); + } + + /// Build a capability-filtered schema snapshot for a sandboxed peer. + /// + /// Only namespaces and functions visible to `peer_capabilities` (and not + /// `Internal` or `Private`) are included. + async fn build_filtered_schema(&self) -> Option { + let full = match self.schema_registry.snapshot().await { + Ok(schema) => schema, + Err(e) => { + 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; + } + }; + let mut filtered = Schema::new(); + // Copy types: they are passive descriptors and always included. + filtered.types = full.types.clone(); + + 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, + ); + if accessible.is_empty() { + continue; + } + 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(); + } + + Some(filtered) + } + + /// Encode `filtered_schema` as a `Value` and push it as an unsolicited + /// `Announce` frame to the peer. The peer uses this to discover what it + /// is allowed to call. + async fn push_sandbox_schema(&mut self, filtered: Schema) -> Result<(), String> { + let schema_value: Value = { + let bytes = + encode_bytes(&filtered).map_err(|e| format!("sandbox schema encode error: {e}"))?; + saikuro_core::msgpack::from_slice::(&bytes) + .map_err(|e| format!("sandbox schema value decode error: {e}"))? + }; + 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}"))?; + { + 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()) + } + + async fn send_response(&mut self, response: ResponseEnvelope) -> Result<(), String> { + let frame = encode_bytes(&response).map_err(|e| format!("response encode error: {e}"))?; + self.sender.send(frame).await.map_err(|e| e.to_string()) + } +} diff --git a/Build/crates/saikuro-runtime/src/handle.rs b/Build/crates/saikuro-runtime/shared/handle.rs similarity index 55% rename from Build/crates/saikuro-runtime/src/handle.rs rename to Build/crates/saikuro-runtime/shared/handle.rs index bd4d48be..edb7dcce 100644 --- a/Build/crates/saikuro-runtime/src/handle.rs +++ b/Build/crates/saikuro-runtime/shared/handle.rs @@ -1,18 +1,15 @@ -//! [`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, 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, ResponseEnvelope, + 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}, @@ -23,10 +20,12 @@ use saikuro_schema::{ registry::{NamespaceRegistration, SchemaRegistry}, validator::InvocationValidator, }; -use saikuro_transport::traits::Transport; -use tracing::{debug, info}; +use spin::RwLock; -use crate::{config::RuntimeConfig, connection::ConnectionHandler, error::Result}; +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`]. /// @@ -38,44 +37,73 @@ pub struct RuntimeHandle { pub(crate) capability_engine: CapabilityEngine, pub(crate) config: RuntimeConfig, pub(crate) shutdown: Arc>, + pub(crate) log: Arc, } 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 + } + + /// Register or merge a schema under an existing provider registration. + pub async fn register_schema_with_token( + &self, + schema: Schema, + provider_id: impl Into, + registration_token: RegistrationToken, + ) -> Result<()> { self.schema_registry - .merge_schema(schema, provider_id) - .map_err(Into::into) + .merge_schema_with_token(schema, provider_id, registration_token) + .await } /// 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 } /// 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 async fn deregister_provider_schema( + &self, + provider_id: &str, + registration_token: RegistrationToken, + ) { + self.schema_registry + .deregister_provider(provider_id, registration_token) + .await; } /// Export a snapshot of the current schema state. - pub fn schema_snapshot(&self) -> Schema { - self.schema_registry.snapshot() + pub async fn schema_snapshot(&self) -> Result { + self.schema_registry.snapshot().await } // 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 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 async fn deregister_provider( + &self, + provider_id: &str, + registration_token: RegistrationToken, + ) { + self.provider_registry + .deregister(provider_id, registration_token) + .await; + self.schema_registry + .deregister_provider(provider_id, registration_token) + .await; } // Dispatch @@ -93,12 +121,12 @@ 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( envelope.id, - saikuro_core::error::ErrorDetail::new(e.error_code(), e.to_string()), + saikuro_event::ErrorDetail::new(e.error_code(), e.to_string()), ); } }; @@ -111,8 +139,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), ), ); @@ -130,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, @@ -140,6 +168,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()), @@ -149,10 +178,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 @@ -163,24 +203,40 @@ 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, - ) where + ) -> RegistrationToken + where F: Fn(Envelope) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, + Fut: core::future::Future + Send + 'static, { let provider_id = provider_id.into(); - let (work_tx, mut work_rx) = mpsc::channel::(256); + let registration_token = RegistrationToken::new(); + let (work_tx, mut work_rx) = + mpsc::channel::(saikuro_exec::ChannelCapacity::MAX); - let handle = ProviderHandle::new(provider_id.clone(), namespaces.clone(), work_tx); - self.provider_registry.register(handle); + let handle = ProviderHandle::with_registration_token( + provider_id.clone(), + registration_token, + namespaces.clone(), + work_tx, + ); + self.provider_registry.register(handle).await; 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 { @@ -193,6 +249,8 @@ impl RuntimeHandle { }); } }); + + registration_token } // Helpers diff --git a/Build/crates/saikuro-runtime/shared/mod.rs b/Build/crates/saikuro-runtime/shared/mod.rs new file mode 100644 index 00000000..64eaf784 --- /dev/null +++ b/Build/crates/saikuro-runtime/shared/mod.rs @@ -0,0 +1,9 @@ +pub mod config; +pub mod connection; +pub mod handle; +pub mod runtime; +pub mod transport_adapter; + +pub use config::RuntimeConfig; +pub use handle::RuntimeHandle; +pub use runtime::SaikuroRuntime; diff --git a/Build/crates/saikuro-runtime/shared/runtime.rs b/Build/crates/saikuro-runtime/shared/runtime.rs new file mode 100644 index 00000000..66734aeb --- /dev/null +++ b/Build/crates/saikuro-runtime/shared/runtime.rs @@ -0,0 +1,290 @@ +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 crate::transport_adapter::RuntimeListener; +use crate::{config::RuntimeConfig, handle::RuntimeHandle}; + +/// 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); + +/// 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 + +/// 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), + } + } + + pub fn config(mut self, config: RuntimeConfig) -> Self { + self.config = config; + self + } + + pub fn mode(mut self, mode: crate::config::RuntimeMode) -> Self { + self.config.mode = mode; + 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 + } + + /// 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, self.log).await + } +} + +/// The central Saikuro runtime instance. +/// +/// Create one with `SaikuroRuntime::builder().build()` then use the returned +/// [`RuntimeHandle`] to interact with it from async tasks. +pub struct SaikuroRuntime { + config: RuntimeConfig, + schema_registry: SchemaRegistry, + provider_registry: ProviderRegistry, + capability_engine: CapabilityEngine, + shutdown: Arc>, + log: Arc, +} + +impl SaikuroRuntime { + pub fn builder() -> RuntimeBuilder { + RuntimeBuilder::new() + } + + async fn from_config(config: RuntimeConfig, log: Arc) -> Self { + let schema_bytes = config.schema_bytes; + let schema_registry = SchemaRegistry::new(); + + let runtime = Self { + config, + schema_registry, + 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 + // 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").await { + 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) => { + 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; + } + } + } + + if runtime.config.mode == crate::config::RuntimeMode::Production { + runtime.schema_registry.freeze().await; + } + + runtime + } + + /// Return a shared reference to the schema registry. + pub fn schema_registry(&self) -> &SchemaRegistry { + &self.schema_registry + } + + /// Return a shared reference to the provider registry. + pub fn provider_registry(&self) -> &ProviderRegistry { + &self.provider_registry + } + + /// Return a shared reference to the capability engine. + pub fn capability_engine(&self) -> &CapabilityEngine { + &self.capability_engine + } + + /// Build an [`InvocationValidator`] configured for this runtime. + pub fn validator(&self) -> InvocationValidator { + InvocationValidator::new(self.schema_registry.clone()) + } + + /// Produce a cheap [`RuntimeHandle`] that can be cloned and shared across + /// tasks. + pub fn handle(&self) -> RuntimeHandle { + RuntimeHandle { + schema_registry: self.schema_registry.clone(), + provider_registry: self.provider_registry.clone(), + capability_engine: self.capability_engine.clone(), + config: self.config.clone(), + shutdown: self.shutdown.clone(), + log: self.log.clone(), + } + } + + /// Signal a graceful shutdown. + pub async fn shutdown(&self) { + *self.shutdown.write() = true; + let record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime", + "saikuro runtime shutting down", + ); + self.log.emit(&record).await; + } + + 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(); + let log = self.log.clone(); + tasks.push(spawn(async move { + loop { + saikuro_exec::select! { + result = listener.accept() => { + match result { + Ok(Some(transport)) => { + let id = next_peer_id(); + 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) => { + let record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime", + "listener closed", + ); + log.emit(&record).await; + break; + } + Err(e) => { + 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() { + let record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime", + "listener shutting down", + ); + log.emit(&record).await; + 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; + } + + let record = LogRecord::now( + LogLevel::Info, + "saikuro.runtime", + "saikuro runtime listener set stopped", + ); + self.log.emit(&record).await; + } +} 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/nonnative.rs b/Build/crates/saikuro-runtime/shared/transport_adapter/nonnative.rs new file mode 100644 index 00000000..47784db7 --- /dev/null +++ b/Build/crates/saikuro-runtime/shared/transport_adapter/nonnative.rs @@ -0,0 +1,183 @@ +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, +}; + +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<()>; + } +} + +pub use nosend_runtime_traits::*; + +// Blanket impls forwarding the `Transport*` family to the runtime traits. +#[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 + } +} + +#[async_trait(?Send)] +impl RuntimeReceiver for T { + async fn recv(&mut self) -> Result> { + T::recv(self).await + } +} + +#[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) + } +} + +#[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. +pub struct LocalRuntimeSender(S); + +#[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 + } +} + +pub struct LocalRuntimeReceiver(R); + +#[async_trait(?Send)] +impl RuntimeReceiver for LocalRuntimeReceiver { + async fn recv(&mut self) -> Result> { + self.0.recv().await + } +} + +pub struct LocalRuntimeTransport(T); + +#[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() + } +} + +pub struct LocalRuntimeListener(L); + +impl LocalRuntimeListener { + /// Wrap a `LocalTransportListener` so it satisfies [`RuntimeListener`]. + pub fn new(listener: L) -> Self { + Self(listener) + } +} + +#[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 + } +} + +pub struct HostPipeListener { + channel: String, + _marker: core::marker::PhantomData F>, +} + +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, + } + } +} + +#[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/connection.rs b/Build/crates/saikuro-runtime/src/connection.rs deleted file mode 100644 index 5da4759f..00000000 --- a/Build/crates/saikuro-runtime/src/connection.rs +++ /dev/null @@ -1,523 +0,0 @@ -//! 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 bytes::Bytes; -use dashmap::DashMap; -use futures::future::FutureExt; -use saikuro_core::{ - capability::CapabilitySet, - envelope::{Envelope, InvocationType}, - error::ErrorDetail, - invocation::InvocationId, - schema::Schema, - value::Value, - ResponseEnvelope, -}; -use saikuro_exec::{mpsc, oneshot, spawn}; -use saikuro_router::{ - provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, - router::InvocationRouter, -}; -use saikuro_schema::{ - capability_engine::{CapabilityEngine, CapabilityOutcome}, - registry::SchemaRegistry, - validator::InvocationValidator, -}; -use saikuro_transport::traits::{TransportReceiver, TransportSender}; -use serde::Serialize; -use std::sync::Arc; -use tracing::{debug, error, info, instrument, warn}; - -// Pending call map - -/// Tracks in-flight `Call` invocations forwarded to a wire-connected provider. -/// Maps `InvocationId -> oneshot::Sender`. -type PendingCalls = Arc>>; - -/// Encode a serializable value as MessagePack `Bytes`. -fn encode_bytes(value: &T) -> Result { - rmp_serde::to_vec_named(value) - .map(Bytes::from) - .map_err(|e| e.to_string()) -} - -/// A handler for a single connected peer. -/// -/// Generic over the transport halves so it works with every backend and -/// compiles cleanly on wasm32 targets. -pub struct ConnectionHandler -where - S: TransportSender, - R: TransportReceiver, -{ - pub peer_id: String, - pub sender: S, - pub receiver: R, - pub validator: InvocationValidator, - pub capability_engine: CapabilityEngine, - pub router: InvocationRouter, - pub peer_capabilities: CapabilitySet, - pub max_message_size: usize, - /// Schema registry shared with the runtime; used to merge announced schemas. - pub schema_registry: SchemaRegistry, - /// Provider registry shared with the runtime; used to register/deregister - /// wire-forwarding provider handles when the peer announces its schema. - pub provider_registry: ProviderRegistry, -} - -impl ConnectionHandler -where - S: TransportSender, - R: TransportReceiver, -{ - /// 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 - } - - /// Return `true` if this handler is operating in sandbox mode. - pub fn is_sandboxed(&self) -> bool { - self.capability_engine.is_sandboxed() - } -} - -impl ConnectionHandler -where - S: TransportSender, - R: TransportReceiver, -{ - /// 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()); - - // 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); - - loop { - saikuro_exec::select! { - // Outbound frame from the ForwardTask (call forwarded to this - // peer acting as a provider). - frame_opt = forward_rx.recv() => { - 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}"); - break; - } - } - None => { - info!(peer = %self.peer_id, "forward channel closed"); - break; - } - } - } - - // Inbound frame from the peer (either a new request OR a - // response to a previously forwarded call). - incoming = self.receiver.recv().fuse() => { - match incoming { - Ok(Some(frame)) => { - if !self.handle_incoming(frame, &pending, &forward_tx).await { - break; - } - } - Ok(None) => { - info!(peer = %self.peer_id, "connection closed by peer"); - break; - } - Err(e) => { - error!(peer = %self.peer_id, "recv error: {e}"); - break; - } - } - } - } - } - - // Clean up: deregister any provider the peer announced. - self.provider_registry.deregister(&self.peer_id); - self.schema_registry.deregister_provider(&self.peer_id); - - info!(peer = %self.peer_id, "connection handler exiting"); - } - - /// Decode, validate, check capabilities, and route a single frame. - /// - /// Returns `(response, Option)`. The second element is - /// `Some` only when sandbox mode is active and the frame was a successful - /// `Announce`: in that case the caller must push the schema back to the - /// peer. - async fn handle_frame( - &self, - frame: Bytes, - pending: &PendingCalls, - forward_tx: &mpsc::Sender, - ) -> (ResponseEnvelope, Option) { - // 1. Decode the MessagePack envelope. - let envelope = match self.decode_envelope(&frame) { - Ok(e) => e, - Err(resp) => return (*resp, None), - }; - - let id = envelope.id; - debug!(peer = %self.peer_id, %id, target = %envelope.target, "received envelope"); - - // 2. Handle system envelopes before schema validation. - match envelope.invocation_type { - InvocationType::Announce => { - let response = self.handle_announce(envelope, pending, forward_tx); - // 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()) - } else { - None - }; - return (response, sandbox_schema); - } - InvocationType::Log => { - // Let the router's log sink handle it: no validation needed. - return (self.router.dispatch(envelope).await, None); - } - _ => {} - } - - // 3. Validate the envelope against the schema. - let validation = match self.validator.validate(&envelope) { - Ok(report) => report, - Err(e) => { - return ( - ResponseEnvelope::err(id, ErrorDetail::new(e.error_code(), e.to_string())), - None, - ); - } - }; - - // 4. Capability check. - match self - .capability_engine - .check_ref(&self.peer_capabilities, &validation.function_ref) - { - CapabilityOutcome::Granted => {} - CapabilityOutcome::Denied { missing } => { - return ( - ResponseEnvelope::err( - id, - ErrorDetail::new( - saikuro_core::error::ErrorCode::CapabilityDenied, - format!("caller lacks '{}' to invoke '{}'", missing, envelope.target), - ), - ), - None, - ); - } - } - - // 5. Route to provider. - (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> { - match rmp_serde::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(), - ErrorDetail::new( - saikuro_core::error::ErrorCode::MalformedEnvelope, - format!("msgpack decode error: {e}"), - ), - ))) - } - } - } - - /// Process one incoming frame. Returns `false` when the loop should break. - async fn handle_incoming( - &mut self, - frame: Bytes, - pending: &PendingCalls, - forward_tx: &mpsc::Sender, - ) -> bool { - if frame.len() > self.max_message_size { - let err = ErrorDetail::new( - saikuro_core::error::ErrorCode::MessageTooLarge, - format!( - "frame {} bytes exceeds limit {} bytes", - frame.len(), - self.max_message_size - ), - ); - let response = ResponseEnvelope::err(InvocationId::new(), err); - let _ = self.send_response(response).await; - return true; - } - - // 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) = rmp_serde::from_slice::(&frame) { - if let Some((_, sender)) = pending.remove(&resp.id) { - let _ = sender.send(resp); - return true; - } - } - - let (response, sandbox_schema) = self.handle_frame(frame, pending, forward_tx).await; - - if let Err(e) = self.send_response(response).await { - error!(peer = %self.peer_id, "send error: {e}"); - 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}"); - return false; - } - } - - 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. - fn handle_announce( - &self, - envelope: Envelope, - pending: &PendingCalls, - forward_tx: &mpsc::Sender, - ) -> ResponseEnvelope { - let id = envelope.id; - - let schema: Option = envelope.args.into_iter().next().and_then(|v| { - let bytes = encode_bytes(&v).ok()?; - rmp_serde::from_slice(&bytes).ok() - }); - - match schema { - Some(s) => { - let ns_count = s.namespaces.len(); - let namespaces: Vec = s.namespaces.keys().cloned().collect(); - - match self.schema_registry.merge_schema(s, &self.peer_id) { - Ok(()) => { - info!( - peer = %self.peer_id, - namespaces = ns_count, - "schema announced and merged" - ); - - // Register a wire-forwarding provider handle so the - // router can dispatch calls to this peer. - self.register_wire_provider(namespaces, pending, forward_tx); - - ResponseEnvelope::ok_empty(id) - } - Err(e) => { - warn!(peer = %self.peer_id, "schema merge failed: {e}"); - ResponseEnvelope::err( - id, - ErrorDetail::new( - saikuro_core::error::ErrorCode::Internal, - format!("schema merge error: {e}"), - ), - ) - } - } - } - None => { - warn!(peer = %self.peer_id, "announce envelope has no valid Schema in args[0]"); - ResponseEnvelope::err( - id, - ErrorDetail::new( - saikuro_core::error::ErrorCode::MalformedEnvelope, - "announce envelope must carry a Schema in args[0]".to_owned(), - ), - ) - } - } - } - - /// 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, - 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); - self.provider_registry.register(handle); - - let pending_clone = pending.clone(); - let forward_tx_clone = forward_tx.clone(); - let peer_id = self.peer_id.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}"); - if let Some(tx) = item.response_tx { - let _ = tx.send(ResponseEnvelope::err( - item.envelope.id, - ErrorDetail::new( - saikuro_core::error::ErrorCode::Internal, - format!("encode error: {e}"), - ), - )); - } - continue; - } - }; - - // Register before sending so an incoming response can find the - // 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); - } - - // 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); - break; - } - } - debug!(peer = %peer_id, "wire-forward task exiting"); - }); - } - - /// Build a capability-filtered schema snapshot for a sandboxed peer. - /// - /// 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(); - let mut filtered = Schema::new(); - // Copy types: they are passive descriptors and always included. - filtered.types = full.types.clone(); - - for (ns_name, ns_schema) in &full.namespaces { - let accessible = self.capability_engine.filter_accessible_functions( - ns_schema.functions.iter().map(|(n, s)| (n.as_str(), s)), - &self.peer_capabilities, - ); - 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(), - }, - ); - } - - filtered - } - - /// Encode `filtered_schema` as a `Value` and push it as an unsolicited - /// `Announce` frame to the peer. The peer uses this to discover what it - /// is allowed to call. - async fn push_sandbox_schema(&mut self, filtered: Schema) -> Result<(), String> { - let schema_value: Value = { - let bytes = - encode_bytes(&filtered).map_err(|e| format!("sandbox schema encode error: {e}"))?; - rmp_serde::from_slice::(&bytes) - .map_err(|e| format!("sandbox schema value decode error: {e}"))? - }; - let announce = Envelope::announce(schema_value); - 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"); - self.sender.send(frame).await.map_err(|e| e.to_string()) - } - - async fn send_response(&mut self, response: ResponseEnvelope) -> Result<(), String> { - let frame = encode_bytes(&response).map_err(|e| format!("response encode error: {e}"))?; - self.sender.send(frame).await.map_err(|e| e.to_string()) - } -} diff --git a/Build/crates/saikuro-runtime/src/error.rs b/Build/crates/saikuro-runtime/src/error.rs deleted file mode 100644 index 840e24c5..00000000 --- a/Build/crates/saikuro-runtime/src/error.rs +++ /dev/null @@ -1,53 +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), -} - -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/lib.rs b/Build/crates/saikuro-runtime/src/lib.rs deleted file mode 100644 index b5ac2680..00000000 --- a/Build/crates/saikuro-runtime/src/lib.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Saikuro Runtime -//! -//! This is the top-level orchestrator that wires together every component: - -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; - -// A small number of doc-test-only inline tests live here -#[cfg(test)] -mod tests { - use crate::runtime::SaikuroRuntime; - use saikuro_core::schema::{ - 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 ns = NamespaceSchema { - functions, - doc: None, - }; - - let mut schema = Schema::new(); - schema.namespaces.insert("health".to_owned(), ns); - - 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/src/main.rs b/Build/crates/saikuro-runtime/src/main.rs deleted file mode 100644 index f339604e..00000000 --- a/Build/crates/saikuro-runtime/src/main.rs +++ /dev/null @@ -1,378 +0,0 @@ -//! Saikuro Runtime Server -//! -//! 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. -//! -//! # 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}, - path::PathBuf, - sync::atomic::{AtomicU64, Ordering}, - 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 tracing::{error, info, warn}; - -// CLI - -#[derive(Debug, Parser)] -#[command( - name = "saikuro-runtime", - about = "Saikuro runtime server: routes invocations between adapters", - version -)] -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, - - /// 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, - - /// 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" - )] - log_level: String, - - /// Emit logs as newline-delimited JSON instead of human-readable text. - #[arg(long)] - json_logs: bool, - - /// Disable the TCP listener. - #[arg(long)] - no_tcp: bool, - - /// Disable the WebSocket listener. - #[arg(long)] - no_ws: bool, -} - -#[derive(Debug, Clone, clap::ValueEnum)] -enum CliMode { - Development, - Production, -} - -impl From for RuntimeMode { - fn from(m: CliMode) -> Self { - match m { - CliMode::Development => RuntimeMode::Development, - CliMode::Production => RuntimeMode::Production, - } - } -} - -// Main - -fn main() -> Result<()> { - saikuro_exec::block_on(async_main()) -} - -async fn async_main() -> Result<()> { - let args = Args::parse(); - - init_logging(&args.log_level, args.json_logs); - - info!( - version = env!("CARGO_PKG_VERSION"), - mode = ?args.mode, - "saikuro-runtime starting" - ); - - // 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(); - - // Load schema from disk if requested. - if let Some(schema_path) = &args.schema { - let raw = std::fs::read_to_string(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")?; - info!(path = %schema_path.display(), "loaded static schema"); - } - - // Set up graceful shutdown channel. - let (shutdown_tx, shutdown_rx) = watch::channel(false); - - // Spawn transport listeners. - let mut listener_tasks = Vec::new(); - - // TCP listener. - if !args.no_tcp { - let addr = SocketAddr::new(args.bind, args.tcp_port); - match TcpTransportListener::bind(addr).await { - Ok(mut listener) => { - info!(addr = %listener.local_addr(), "TCP listener ready"); - let h = handle.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; - })); - } - Err(e) => { - error!(addr = %addr, error = %e, "failed to bind TCP listener"); - return Err(anyhow::anyhow!("TCP bind failed: {e}")); - } - } - } - - // WebSocket listener. - #[cfg(feature = "ws-transport")] - 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) => { - info!(addr = %listener.local_addr(), "WebSocket listener ready"); - let h = handle.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; - })); - } - Err(e) => { - error!(addr = %addr, error = %e, "failed to bind WebSocket listener"); - return Err(anyhow::anyhow!("WebSocket bind failed: {e}")); - } - } - } - - // Unix domain socket listener (Unix-only). - #[cfg(all(feature = "native-transport", target_family = "unix"))] - if let Some(unix_path) = &args.unix { - use saikuro_transport::unix::UnixTransportListener; - match UnixTransportListener::bind(unix_path).await { - Ok(mut listener) => { - info!(path = %unix_path.display(), "Unix socket listener ready"); - let h = handle.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; - })); - } - Err(e) => { - error!(path = %unix_path.display(), error = %e, "failed to bind Unix listener"); - return Err(anyhow::anyhow!("Unix socket bind failed: {e}")); - } - } - } - - if listener_tasks.is_empty() { - warn!("no listeners are active; all transports were disabled"); - } - - // Wait for Ctrl-C or SIGTERM. - 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 { - let _ = timeout(std::time::Duration::from_secs(5), task).await; - } - - info!("saikuro-runtime stopped"); - 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 { - if let Err(e) = signal::ctrl_c().await { - error!("failed to install Ctrl-C handler: {e}"); - } - }; - - #[cfg(unix)] - let terminate = async { - match signal::unix::signal(signal::unix::SignalKind::terminate()) { - Ok(mut sig) => { - sig.recv().await; - } - Err(e) => { - error!("failed to install SIGTERM handler: {e}"); - } - } - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - saikuro_exec::select! { - _ = ctrl_c => {}, - _ = terminate => {}, - } -} - -// Logging initialisation - -fn init_logging(level: &str, json: bool) { - use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; - - let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info")); - - if json { - tracing_subscriber::registry() - .with(filter) - .with(fmt::layer().json()) - .init(); - } else { - tracing_subscriber::registry() - .with(filter) - .with(fmt::layer()) - .init(); - } -} diff --git a/Build/crates/saikuro-runtime/src/runtime.rs b/Build/crates/saikuro-runtime/src/runtime.rs deleted file mode 100644 index 3de986df..00000000 --- a/Build/crates/saikuro-runtime/src/runtime.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! The main Saikuro runtime and its builder. - -use std::sync::Arc; - -use parking_lot::RwLock; -use saikuro_router::provider::ProviderRegistry; -use saikuro_schema::{ - capability_engine::CapabilityEngine, registry::SchemaRegistry, validator::InvocationValidator, -}; -use tracing::info; - -use crate::{ - config::{RuntimeConfig, RuntimeMode}, - handle::RuntimeHandle, -}; - -// Builder - -/// Fluent builder for [`SaikuroRuntime`]. -pub struct RuntimeBuilder { - config: RuntimeConfig, -} - -impl RuntimeBuilder { - fn new() -> Self { - Self { - config: RuntimeConfig::default(), - } - } - - pub fn config(mut self, config: RuntimeConfig) -> Self { - self.config = config; - self - } - - pub fn mode(mut self, mode: RuntimeMode) -> Self { - self.config.mode = mode; - self - } - - pub fn call_timeout(mut self, timeout: std::time::Duration) -> Self { - self.config.call_timeout = timeout; - 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. - 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 -/// [`RuntimeHandle`] to interact with it from async tasks. -pub struct SaikuroRuntime { - config: RuntimeConfig, - schema_registry: SchemaRegistry, - provider_registry: ProviderRegistry, - capability_engine: CapabilityEngine, - shutdown: Arc>, -} - -impl SaikuroRuntime { - pub fn builder() -> RuntimeBuilder { - RuntimeBuilder::new() - } - - fn from_config(config: RuntimeConfig) -> Self { - let schema_registry = SchemaRegistry::new(); - - if config.mode == RuntimeMode::Production { - schema_registry.freeze(); - } - - Self { - config, - schema_registry, - provider_registry: ProviderRegistry::new(), - capability_engine: CapabilityEngine::new(), - shutdown: Arc::new(RwLock::new(false)), - } - } - - /// Return a shared reference to the schema registry. - pub fn schema_registry(&self) -> &SchemaRegistry { - &self.schema_registry - } - - /// Return a shared reference to the provider registry. - pub fn provider_registry(&self) -> &ProviderRegistry { - &self.provider_registry - } - - /// Return a shared reference to the capability engine. - pub fn capability_engine(&self) -> &CapabilityEngine { - &self.capability_engine - } - - /// Build an [`InvocationValidator`] configured for this runtime. - pub fn validator(&self) -> InvocationValidator { - InvocationValidator::new(self.schema_registry.clone()) - } - - /// Produce a cheap [`RuntimeHandle`] that can be cloned and shared across - /// tasks. - pub fn handle(&self) -> RuntimeHandle { - RuntimeHandle { - schema_registry: self.schema_registry.clone(), - provider_registry: self.provider_registry.clone(), - capability_engine: self.capability_engine.clone(), - config: self.config.clone(), - shutdown: self.shutdown.clone(), - } - } - - /// Signal a graceful shutdown. - pub fn shutdown(&self) { - *self.shutdown.write() = true; - info!("saikuro runtime shutting down"); - } - - pub fn is_shutdown(&self) -> bool { - *self.shutdown.read() - } -} 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/Cargo.toml b/Build/crates/saikuro-schema/Cargo.toml index cbaeadd7..347c8f67 100644 --- a/Build/crates/saikuro-schema/Cargo.toml +++ b/Build/crates/saikuro-schema/Cargo.toml @@ -8,11 +8,22 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "schema", "validation"] +[lib] +path = "lib.rs" + +[features] +default = ["std", "native"] +std = [] +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 = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } +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 } -dashmap = { workspace = true } -parking_lot = { 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 73% rename from Build/crates/saikuro-schema/src/capability_engine.rs rename to Build/crates/saikuro-schema/capability/engine.rs index cad70fda..568207f4 100644 --- a/Build/crates/saikuro-schema/src/capability_engine.rs +++ b/Build/crates/saikuro-schema/capability/engine.rs @@ -1,24 +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; @@ -28,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. @@ -64,7 +51,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. @@ -75,7 +62,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"), }; @@ -83,10 +69,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(), }; @@ -105,7 +87,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..b5ccf259 --- /dev/null +++ b/Build/crates/saikuro-schema/capability/mod.rs @@ -0,0 +1,3 @@ +/// 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 new file mode 100644 index 00000000..c534b407 --- /dev/null +++ b/Build/crates/saikuro-schema/lib.rs @@ -0,0 +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; + +/// 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 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" +)))] +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..88a5b29c --- /dev/null +++ b/Build/crates/saikuro-schema/registry/mod.rs @@ -0,0 +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 new file mode 100644 index 00000000..8de5b46d --- /dev/null +++ b/Build/crates/saikuro-schema/registry/registry.rs @@ -0,0 +1,302 @@ +#[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, +}; +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 { + /// Development: providers can register/update schemas at runtime. + Development, + /// Production: the schema is frozen at startup; updates are rejected. + Production, +} + +// Registration descriptor +/// All information a provider submits when it registers a namespace. +#[derive(Debug, Clone)] +pub struct NamespaceRegistration { + /// The namespace name (e.g. `"math"`, `"events"`). + pub namespace: String, + /// The schema for this namespace. + 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 +/// All registry state, guarded as a unit by [`SchemaRegistry`]'s lock. +struct Schemata { + /// Per-namespace schemas and their owning provider ID. + namespaces: BTreeMap, + /// Shared type library merged from all registered schemas. + types: BTreeMap, + /// Mode controlling whether dynamic updates are allowed. + mode: RegistryMode, +} + +#[derive(Debug, Clone)] +struct NamespaceEntry { + schema: NamespaceSchema, + provider_id: String, + registration_token: RegistrationToken, +} + +/// The live schema registry. +#[derive(Clone)] +pub struct SchemaRegistry { + inner: Arc>, +} + +impl SchemaRegistry { + /// Create a new registry in development mode. + pub fn new() -> Self { + Self { + 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 mut schemata = Schemata { + namespaces: BTreeMap::new(), + 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, + }, + ); + } + for (type_name, type_def) in (*schema.types).into_iter() { + schemata.types.insert(type_name, type_def); + } + Self { + inner: Arc::new(RwLock::new(schemata)), + } + } + + /// Register (or replace) a namespace. + /// In production mode this returns an error rather than mutating state. + pub async fn register(&self, registration: NamespaceRegistration) -> Result<(), SaikuroError> { + let mut schemata = self.inner.write().await; + if schemata.mode == RegistryMode::Production { + 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(SaikuroError::SchemaCapacity); + } + schemata.namespaces.insert( + ns, + NamespaceEntry { + schema: registration.schema, + provider_id: registration.provider_id, + registration_token: registration.registration_token, + }, + ); + Ok(()) + } + + /// Merge an entire [`Schema`] document into the registry. + pub async fn merge_schema( + &self, + schema: Schema, + provider_id: impl Into, + ) -> Result<(), SaikuroError> { + self.merge_schema_with_token(schema, provider_id, RegistrationToken::new()) + .await + } + + /// Merge a schema document under an existing provider registration. + pub async fn merge_schema_with_token( + &self, + schema: Schema, + provider_id: impl Into, + registration_token: RegistrationToken, + ) -> Result<(), SaikuroError> { + 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().await; + + if schemata.mode == RegistryMode::Production { + let ns = schema.namespaces.keys().next().cloned().unwrap_or_default(); + return Err(SaikuroError::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(SaikuroError::SchemaCapacity); + } + + // Merge types first (functions may reference them). + for (name, typedef) in (*schema.types).into_iter() { + schemata.types.insert(name, typedef); + } + for (ns_name, ns_schema) in (*schema.namespaces).into_iter() { + let ns = ns_name.clone(); + schemata.namespaces.insert( + ns, + NamespaceEntry { + schema: ns_schema, + provider_id: provider_id.clone(), + registration_token, + }, + ); + } + Ok(()) + } + + /// Remove namespaces owned by one specific provider registration. + 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| { + entry.provider_id != provider_id || entry.registration_token != registration_token + }); + } + + /// Look up the schema for a single function. + /// `target` must be in `"namespace.function"` format. + 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(|| SaikuroError::NamespaceNotFound(ns_name.to_owned()))?; + + let fn_schema = entry + .schema + .functions + .get(fn_name) + .ok_or_else(|| SaikuroError::FunctionNotFound(target.to_owned()))? + .clone(); + + Ok(FunctionRef { + namespace: ns_name.to_owned(), + function: fn_name.to_owned(), + schema: fn_schema, + provider_id: entry.provider_id.clone(), + }) + } + + /// Return the provider ID for the given namespace. + pub async fn provider_for_namespace(&self, namespace: &str) -> Option { + self.inner + .read() + .await + .namespaces + .get(namespace) + .map(|e| e.provider_id.clone()) + } + + /// Return `true` if the given namespace is registered. + 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 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 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(|_| SaikuroError::SchemaCapacity)?; + } + for (name, type_def) in schemata.types.iter() { + schema + .types + .insert(name.clone(), type_def.clone()) + .map_err(|_| SaikuroError::SchemaCapacity)?; + } + Ok(schema) + } + + /// Freeze the registry, preventing any further schema changes. + pub async fn freeze(&self) { + self.inner.write().await.mode = RegistryMode::Production; + } + + /// Return the current operating mode. + pub async fn mode(&self) -> RegistryMode { + self.inner.read().await.mode + } +} + +impl Default for SchemaRegistry { + fn default() -> Self { + Self::new() + } +} + +/// 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, +} + +/// Split a `"namespace.function"` target into its two components. +fn split_target(target: &str) -> Result<(&str, &str), SaikuroError> { + saikuro_core::split_target(target) + .ok_or_else(|| SaikuroError::MalformedTarget(target.to_owned())) +} diff --git a/Build/crates/saikuro-schema/src/lib.rs b/Build/crates/saikuro-schema/src/lib.rs deleted file mode 100644 index b2b6630d..00000000 --- a/Build/crates/saikuro-schema/src/lib.rs +++ /dev/null @@ -1,13 +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?". - -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/src/registry.rs b/Build/crates/saikuro-schema/src/registry.rs deleted file mode 100644 index 20e08cd1..00000000 --- a/Build/crates/saikuro-schema/src/registry.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! 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. - -use dashmap::DashMap; -use parking_lot::RwLock; -use saikuro_core::schema::{FunctionSchema, NamespaceSchema, Schema}; -use std::sync::Arc; -use tracing::{debug, info, warn}; - -use crate::validator::ValidationError; - -// Modes - -/// Whether the registry accepts dynamic schema updates. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RegistryMode { - /// Development: providers can register/update schemas at runtime. - Development, - /// Production: the schema is frozen at startup; updates are rejected. - Production, -} - -// Registration descriptor - -/// All information a provider submits when it registers a namespace. -#[derive(Debug, Clone)] -pub struct NamespaceRegistration { - /// The namespace name (e.g. `"math"`, `"events"`). - pub namespace: String, - /// The schema for this namespace. - pub schema: NamespaceSchema, - /// Opaque identifier for the provider connection (used for routing). - pub provider_id: String, -} - -// 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 { - /// Per-namespace schemas and their owning provider ID. - namespaces: Arc>, - /// Shared type library merged from all registered schemas. - types: Arc>, - /// Mode controlling whether dynamic updates are allowed. - mode: Arc>, -} - -#[derive(Debug, Clone)] -struct NamespaceEntry { - schema: NamespaceSchema, - provider_id: String, -} - -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)), - } - } - - /// 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(); - for (ns_name, ns_schema) in schema.namespaces { - registry.namespaces.insert( - ns_name.clone(), - NamespaceEntry { - schema: ns_schema, - provider_id: "frozen".to_owned(), - }, - ); - } - for (type_name, type_def) in schema.types { - registry.types.insert(type_name, type_def); - } - *registry.mode.write() = RegistryMode::Production; - info!( - "schema registry frozen with {} namespace(s)", - registry.namespaces.len() - ); - registry - } - - /// 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 { - return Err(RegistryError::FrozenSchema(registration.namespace)); - } - - let ns = registration.namespace.clone(); - if self.namespaces.contains_key(&ns) { - warn!(namespace = %ns, "overwriting existing namespace schema"); - } else { - debug!(namespace = %ns, provider = %registration.provider_id, "registering namespace"); - } - - self.namespaces.insert( - ns, - NamespaceEntry { - schema: registration.schema, - provider_id: registration.provider_id, - }, - ); - Ok(()) - } - - /// 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, - provider_id: impl Into, - ) -> Result<(), RegistryError> { - let provider_id = provider_id.into(); - // Merge types first (functions may reference them). - for (name, typedef) in schema.types { - self.types.insert(name, typedef); - } - for (ns_name, ns_schema) in schema.namespaces { - self.register(NamespaceRegistration { - namespace: ns_name, - schema: ns_schema, - provider_id: provider_id.clone(), - })?; - } - Ok(()) - } - - /// Remove all namespaces owned by `provider_id`. - /// - /// Called when a provider disconnects. - pub fn deregister_provider(&self, provider_id: &str) { - self.namespaces.retain(|_ns, entry| { - let keep = entry.provider_id != provider_id; - 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)?; - - let entry = self - .namespaces - .get(ns_name) - .ok_or_else(|| RegistryError::NamespaceNotFound(ns_name.to_owned()))?; - - let fn_schema = entry - .schema - .functions - .get(fn_name) - .ok_or_else(|| RegistryError::FunctionNotFound(target.to_owned()))? - .clone(); - - Ok(FunctionRef { - namespace: ns_name.to_owned(), - function: fn_name.to_owned(), - schema: fn_schema, - provider_id: entry.provider_id.clone(), - }) - } - - /// Return the provider ID for the given namespace. - pub fn provider_for_namespace(&self, namespace: &str) -> Option { - self.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) - } - - /// Return all registered namespace names. - pub fn namespace_names(&self) -> Vec { - self.namespaces.iter().map(|e| e.key().clone()).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() { - schema - .namespaces - .insert(entry.key().clone(), entry.value().schema.clone()); - } - for entry in self.types.iter() { - schema - .types - .insert(entry.key().clone(), entry.value().clone()); - } - schema - } - - /// Freeze the registry, preventing any further schema changes. - pub fn freeze(&self) { - *self.mode.write() = RegistryMode::Production; - info!("schema registry frozen"); - } - - /// Return the current operating mode. - pub fn mode(&self) -> RegistryMode { - *self.mode.read() - } -} - -impl Default for SchemaRegistry { - fn default() -> Self { - Self::new() - } -} - -// Resolved reference - -/// A fully-resolved reference to a function schema plus its owning provider. -#[derive(Debug, Clone)] -pub struct FunctionRef { - pub namespace: String, - pub function: String, - pub schema: FunctionSchema, - 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), -} - -// Helpers - -/// Split a `"namespace.function"` target into its two components. -fn split_target(target: &str) -> Result<(&str, &str), RegistryError> { - saikuro_core::split_target(target) - .ok_or_else(|| RegistryError::MalformedTarget(target.to_owned())) -} diff --git a/Build/crates/saikuro-schema/validator/mod.rs b/Build/crates/saikuro-schema/validator/mod.rs new file mode 100644 index 00000000..520c2dd4 --- /dev/null +++ b/Build/crates/saikuro-schema/validator/mod.rs @@ -0,0 +1,3 @@ +#[allow(clippy::module_inception)] +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 55% rename from Build/crates/saikuro-schema/src/validator.rs rename to Build/crates/saikuro-schema/validator/validator.rs index 6a21f940..dc206534 100644 --- a/Build/crates/saikuro-schema/src/validator.rs +++ b/Build/crates/saikuro-schema/validator/validator.rs @@ -1,118 +1,26 @@ -//! 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, + string::{String, ToString}, +}; 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}; - -// 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)) - } -} +use saikuro_event::{SaikuroError, Value}; -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, - }, - 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}; -// 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, @@ -122,6 +30,7 @@ pub struct InvocationValidator { } impl InvocationValidator { + /// Build a validator that enforces `Public` visibility only. pub fn new(registry: SchemaRegistry) -> Self { Self { registry, @@ -138,25 +47,20 @@ impl InvocationValidator { } /// Validate a single envelope. - /// - /// For [`InvocationType::Batch`] each item is validated recursively. - 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 { + return Err(SaikuroError::IncompatibleVersion { expected: PROTOCOL_VERSION, received: envelope.version, }); } - // 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::Batch => self.validate_batch(envelope).await, InvocationType::Log | InvocationType::Announce => Ok(ValidationReport { function_ref: crate::registry::FunctionRef { namespace: String::new(), @@ -174,21 +78,18 @@ impl InvocationValidator { provider_id: String::new(), }, }), - _ => self.validate_single(envelope), + _ => self.validate_single(envelope).await, } } // 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. + 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 ))); @@ -197,8 +98,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), _ => {} } } @@ -207,10 +108,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)?; @@ -224,25 +124,24 @@ 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()) + 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) - .map_err(|source| ValidationError::BatchItem { + Box::pin(self.validate(item)).await.map_err(|source| { + SaikuroError::BatchItemFailed { index, - source: Box::new(source), - })?; + reason: source.to_string(), + } + })?; } - // 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)?; + let first_ref = self.registry.lookup_function(&items[0].target).await?; Ok(ValidationReport { function_ref: first_ref, @@ -250,22 +149,17 @@ impl InvocationValidator { } // Helpers - - fn check_visibility( - &self, - target: &str, - visibility: &Visibility, - ) -> Result<(), ValidationError> { + fn check_visibility(&self, target: &str, visibility: &Visibility) -> 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:?}"), }), } } @@ -275,7 +169,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() @@ -283,14 +177,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(), }); @@ -313,9 +207,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, @@ -323,8 +214,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(), @@ -338,8 +229,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 @@ -374,7 +263,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(), )) } @@ -388,7 +277,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 => { @@ -407,7 +296,7 @@ impl InvocationValidator { if ok { Ok(()) } else { - Err(ValidationError::ArgumentType { + Err(SaikuroError::ArgumentType { name: name.to_owned(), position, expected: prim.to_string(), @@ -416,23 +305,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-storage/Cargo.toml b/Build/crates/saikuro-storage/Cargo.toml index 231fa726..f5ceb2ff 100644 --- a/Build/crates/saikuro-storage/Cargo.toml +++ b/Build/crates/saikuro-storage/Cargo.toml @@ -8,56 +8,93 @@ license.workspace = true repository.workspace = true keywords = ["ipc", "cross-language", "saikuro", "storage", "key-value"] +[lib] +path = "lib.rs" + [features] -default = ["native-storage"] -native-storage = [ - "inmemory", - "local-storage", - "session-storage", - "saikuro-exec/tokio-runtime", +default = ["std", "native", "inmemory"] +std = ["saikuro-event/std"] + +# Engine features. +native = [ + "std", + "dep:graphitesql", + "graphitesql/std", + "graphitesql/fts5", + "dep:tokio", + "saikuro-core/std", + "saikuro-exec/native", + "saikuro-event/native", + "saikuro-event/std", +] +no_std = [ + "sqlite", + "saikuro-core/no_std", + "saikuro-exec/no_std", + "saikuro-event/no_std", ] -inmemory = ["dashmap", "parking_lot"] -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"] -wasm-storage = [ - "inmemory", - "local-storage", - "session-storage", - "saikuro-exec/wasm-runtime", +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"] [dependencies] -saikuro-core = { workspace = true } -saikuro-exec = { workspace = true, default-features = false } +saikuro-core = { path = "../saikuro-core", default-features = false } +saikuro-event = { path = "../saikuro-event", default-features = false } serde = { workspace = true } -serde_json = { workspace = true } -rmp-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 } +async-trait = { 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 } +tokio = { workspace = true, optional = true, features = ["sync", "rt", "rt-multi-thread"] } +sled = { 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 = { 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", @@ -89,4 +126,6 @@ web-sys = { version = "0.3.99", optional = true, features = [ ] } [dev-dependencies] -tracing-subscriber = { workspace = true } +saikuro-exec = { workspace = true, features = ["native"] } +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 73% rename from Build/crates/saikuro-storage/src/inmemory.rs rename to Build/crates/saikuro-storage/common/inmemory.rs index 6f28cca5..653ec48e 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 std::sync::Arc; -use tracing::debug; +use saikuro_event::{LogLevel, LogRecord, LogSink}; -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; @@ -25,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 != super::config::CleanupPolicy::Never { - debug!("in-memory backend does not enforce cleanup (TTL/Age/LRU); configured cleanup settings are ignored"); + if config.cleanup != crate::config::CleanupPolicy::Never { + 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. @@ -77,7 +101,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 +126,6 @@ impl Default for InMemoryStorage { } } -#[async_trait] impl KeyValueBackend for InMemoryStorage { fn config(&self) -> &StorageConfig { &self.config @@ -115,7 +138,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 +151,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 +179,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 +197,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 +221,6 @@ 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..87297e8d --- /dev/null +++ b/Build/crates/saikuro-storage/common/sqlite/mod.rs @@ -0,0 +1,209 @@ +#[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..2b130c1a --- /dev/null +++ b/Build/crates/saikuro-storage/embedded/flash.rs @@ -0,0 +1,450 @@ +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..295cc1ce --- /dev/null +++ b/Build/crates/saikuro-storage/lib.rs @@ -0,0 +1,216 @@ +#![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 common; +#[cfg(feature = "embedded")] +pub mod embedded; +#[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] +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 + } + } + }; +} + +#[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::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(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; + +#[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..1db59ee1 100644 --- a/Build/crates/saikuro-storage/src/fs.rs +++ b/Build/crates/saikuro-storage/native/fs.rs @@ -3,13 +3,11 @@ 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 +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 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 +49,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 +86,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 +120,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 +143,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 +171,6 @@ 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] +#[async_trait(?Send)] 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,6 @@ 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..cdbd1377 100644 --- a/Build/crates/saikuro-storage/src/sled.rs +++ b/Build/crates/saikuro-storage/native/sled.rs @@ -1,15 +1,12 @@ -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 +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 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 +31,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 +43,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 +73,6 @@ impl SledStorage { } } -#[async_trait] impl KeyValueBackend for SledStorage { fn config(&self) -> &StorageConfig { &self.config @@ -92,9 +85,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 +99,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 +117,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 +132,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 +146,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 +186,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 +197,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 +209,15 @@ 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 55% rename from Build/crates/saikuro-storage/src/config.rs rename to Build/crates/saikuro-storage/shared/config.rs index 3337c377..018fb862 100644 --- a/Build/crates/saikuro-storage/src/config.rs +++ b/Build/crates/saikuro-storage/shared/config.rs @@ -1,16 +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. -/// -/// 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. @@ -28,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, } @@ -86,6 +77,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 +102,7 @@ impl Default for StorageConfig { namespace_prefix: None, auto_create_namespaces: true, sync_on_write: false, + #[cfg(feature = "std")] storage_path: None, } } @@ -140,6 +133,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 @@ -157,3 +151,99 @@ impl StorageConfig { self } } + +/// Bounded-size limits +#[cfg(feature = "flash")] +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")] +#[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")] +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/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..bcef80d3 --- /dev/null +++ b/Build/crates/saikuro-storage/shared/traits/file.rs @@ -0,0 +1,34 @@ +use alloc::boxed::Box; +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. +#[async_trait(?Send)] +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/shared/util.rs b/Build/crates/saikuro-storage/shared/util.rs new file mode 100644 index 00000000..b5508181 --- /dev/null +++ b/Build/crates/saikuro-storage/shared/util.rs @@ -0,0 +1,58 @@ +use alloc::borrow::ToOwned; +use alloc::string::String; +use alloc::vec::Vec; +use bytes::Bytes; + +use super::config::StorageConfig; + +pub(crate) const NAMESPACE_SEPARATOR: char = ':'; + +#[doc(hidden)] +pub fn encode_bytes(val: &Bytes) -> String { + val.iter().map(|&b| b as char).collect() +} + +#[doc(hidden)] +pub fn decode_bytes(s: &str) -> Bytes { + let vec: Vec = s.chars().map(|c| c as u8).collect(); + Bytes::from(vec) +} + +#[doc(hidden)] +pub fn make_key(namespace: &str, key: &str) -> String { + format!( + "{namespace}{SEPARATOR}{key}", + SEPARATOR = NAMESPACE_SEPARATOR + ) +} + +#[doc(hidden)] +pub fn key_prefix(namespace: &str) -> String { + format!("{namespace}{SEPARATOR}", SEPARATOR = NAMESPACE_SEPARATOR) +} + +#[doc(hidden)] +pub fn apply_prefix(config: &StorageConfig, namespace: &str) -> String { + match &config.namespace_prefix { + Some(prefix) => format!( + "{prefix}{SEPARATOR}{namespace}", + SEPARATOR = NAMESPACE_SEPARATOR + ), + None => namespace.to_owned(), + } +} + +#[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); + if stored.starts_with(&prefix_str) { + stored[prefix_str.len()..].to_owned() + } else { + stored.to_owned() + } + } + None => stored.to_owned(), + } +} diff --git a/Build/crates/saikuro-storage/src/error.rs b/Build/crates/saikuro-storage/src/error.rs deleted file mode 100644 index 3a6c7853..00000000 --- a/Build/crates/saikuro-storage/src/error.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Error types for the storage backend abstraction. - -use std::io; -use thiserror::Error; - -pub type Result = std::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), - - #[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: rmp_serde::encode::Error) -> Self { - StorageError::Serialization(e.to_string()) - } -} - -impl From for StorageError { - fn from(e: rmp_serde::decode::Error) -> Self { - StorageError::Deserialization(e.to_string()) - } -} diff --git a/Build/crates/saikuro-storage/src/lib.rs b/Build/crates/saikuro-storage/src/lib.rs deleted file mode 100644 index 8fc523f3..00000000 --- a/Build/crates/saikuro-storage/src/lib.rs +++ /dev/null @@ -1,211 +0,0 @@ -//! 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). - -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; - -/// 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 async_trait::async_trait; - use bytes::Bytes; - use $crate::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::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() - } - } - - #[async_trait] - impl KeyValueBackend 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 - } - } - - #[async_trait] - impl StorageBackend for $name { - fn supports_files(&self) -> bool { - false - } - } - }; -} - -pub use config::{BackendKind, CleanupPolicy, PersistenceMode, StorageConfig}; -pub use error::{Result, StorageError}; -pub use traits::{FileBackend, KeyValueBackend, KeyValueBackendExt, 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; 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 6de6eb06..00000000 --- a/Build/crates/saikuro-storage/src/traits.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Storage backend traits and utilities. - -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] -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] -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] -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] -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 = rmp_serde::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 = rmp_serde::to_vec_named(value) - .map_err(|e| super::error::StorageError::serialization(e.to_string()))?; - self.put(namespace, key, Bytes::from(bytes)).await - } -} - -impl KeyValueBackendExt for B {} diff --git a/Build/crates/saikuro-storage/src/util.rs b/Build/crates/saikuro-storage/src/util.rs deleted file mode 100644 index a3bc44b5..00000000 --- a/Build/crates/saikuro-storage/src/util.rs +++ /dev/null @@ -1,170 +0,0 @@ -// 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 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)] - -use bytes::Bytes; - -use super::config::StorageConfig; - -pub(crate) const NAMESPACE_SEPARATOR: char = ':'; - -pub(crate) fn encode_bytes(val: &Bytes) -> String { - val.iter().map(|&b| b as char).collect() -} - -pub(crate) 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 { - format!( - "{namespace}{SEPARATOR}{key}", - SEPARATOR = NAMESPACE_SEPARATOR - ) -} - -pub(crate) fn key_prefix(namespace: &str) -> String { - format!("{namespace}{SEPARATOR}", SEPARATOR = NAMESPACE_SEPARATOR) -} - -pub(crate) fn apply_prefix(config: &StorageConfig, namespace: &str) -> String { - match &config.namespace_prefix { - Some(prefix) => format!( - "{prefix}{SEPARATOR}{namespace}", - SEPARATOR = NAMESPACE_SEPARATOR - ), - None => namespace.to_owned(), - } -} - -pub(crate) fn strip_prefix(config: &StorageConfig, stored: &str) -> String { - match &config.namespace_prefix { - Some(prefix) => { - let prefix_str = format!("{prefix}{SEPARATOR}", SEPARATOR = NAMESPACE_SEPARATOR); - if stored.starts_with(&prefix_str) { - stored[prefix_str.len()..].to_owned() - } else { - stored.to_owned() - } - } - 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/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..3c407149 --- /dev/null +++ b/Build/crates/saikuro-storage/wasi/preview1.rs @@ -0,0 +1,438 @@ +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; +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() + } +} + +#[async_trait(?Send)] +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..ad66b3ca --- /dev/null +++ b/Build/crates/saikuro-storage/wasi/preview2.rs @@ -0,0 +1,297 @@ +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, +}; +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() + } +} + +#[async_trait(?Send)] +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 84% rename from Build/crates/saikuro-storage/src/fs_access.rs rename to Build/crates/saikuro-storage/wasm/fs_access.rs index 7efb7e37..abf2fa9e 100644 --- a/Build/crates/saikuro-storage/src/fs_access.rs +++ b/Build/crates/saikuro-storage/wasm/fs_access.rs @@ -1,13 +1,9 @@ #![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; @@ -17,57 +13,43 @@ use web_sys::{ FileSystemHandleKind, FileSystemRemoveOptions, }; -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}; 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 { - 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:?}" )) }) @@ -82,7 +64,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()) } @@ -95,7 +77,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()) } @@ -118,13 +100,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]; @@ -136,19 +118,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(()) } @@ -158,7 +140,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; @@ -167,27 +149,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(()) } @@ -196,7 +178,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(()) } @@ -205,7 +187,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(()) } @@ -218,10 +200,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() @@ -233,7 +215,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(); @@ -327,7 +309,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(); @@ -337,7 +319,7 @@ impl FsAccessStorage { current = val.into(); } Err(_) => { - return Err(StorageError::key_not_found(dirs.join("/"))); + return Err(SaikuroError::key_not_found(dirs.join("/"))); } } } @@ -346,7 +328,6 @@ impl FsAccessStorage { } } -#[async_trait] impl KeyValueBackend for FsAccessStorage { fn config(&self) -> &StorageConfig { &self.config @@ -429,14 +410,14 @@ impl KeyValueBackend for FsAccessStorage { } } -#[async_trait] +#[async_trait(?Send)] 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 } @@ -489,7 +470,7 @@ impl FileBackend 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(); @@ -497,14 +478,14 @@ impl FileBackend 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, @@ -514,16 +495,11 @@ impl FileBackend for FsAccessStorage { } } -#[async_trait] impl StorageBackend 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/wasm/indexeddb.rs similarity index 80% rename from Build/crates/saikuro-storage/src/indexeddb.rs rename to Build/crates/saikuro-storage/wasm/indexeddb.rs index 1ef41511..1b38de0d 100644 --- a/Build/crates/saikuro-storage/src/indexeddb.rs +++ b/Build/crates/saikuro-storage/wasm/indexeddb.rs @@ -1,17 +1,6 @@ -//! 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 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::{ @@ -19,11 +8,9 @@ use web_sys::{ IdbVersionChangeEvent, }; -use super::{ - config::StorageConfig, - error::{Result, StorageError}, - traits::{KeyValueBackend, StorageBackend}, -}; +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"; @@ -33,24 +20,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 +31,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 +56,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`. @@ -106,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 @@ -141,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()) } @@ -161,40 +130,40 @@ 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 { +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 { +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 { +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)) } -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(), } - .map_err(|_| StorageError::internal("IndexedDB getAllKeys request failed"))?; + .map_err(|_| SaikuroError::internal("IndexedDB getAllKeys request failed"))?; Ok(idb_await(&request)) } @@ -206,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 @@ -246,7 +215,6 @@ impl Default for IndexedDbStorage { } } -#[async_trait] impl KeyValueBackend for IndexedDbStorage { fn config(&self) -> &StorageConfig { &self.config @@ -257,7 +225,7 @@ impl KeyValueBackend 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:?}" ))), } @@ -281,7 +249,7 @@ impl KeyValueBackend 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(()) } @@ -290,7 +258,7 @@ impl KeyValueBackend 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(()) } @@ -301,7 +269,7 @@ impl KeyValueBackend 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 @@ -319,7 +287,7 @@ impl KeyValueBackend 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 @@ -345,9 +313,9 @@ impl KeyValueBackend 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(()) } @@ -357,7 +325,6 @@ impl KeyValueBackend for IndexedDbStorage { } } -#[async_trait] 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 86% rename from Build/crates/saikuro-storage/src/opfs.rs rename to Build/crates/saikuro-storage/wasm/opfs.rs index b31cd3e5..c510d946 100644 --- a/Build/crates/saikuro-storage/src/opfs.rs +++ b/Build/crates/saikuro-storage/wasm/opfs.rs @@ -1,11 +1,7 @@ -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::{ @@ -13,28 +9,14 @@ use web_sys::{ FileSystemGetFileOptions, FileSystemHandle, FileSystemHandleKind, FileSystemRemoveOptions, }; -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}; 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! { @@ -42,18 +24,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:?}" )) })?; @@ -78,7 +60,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()) } @@ -90,7 +72,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()) } @@ -114,13 +96,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(()) } @@ -185,7 +167,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(()) } @@ -198,10 +180,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() @@ -213,7 +195,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(); @@ -301,7 +283,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(); @@ -311,7 +293,7 @@ impl OpfsStorage { current = val.into(); } Err(_) => { - return Err(StorageError::key_not_found(dirs.join("/"))); + return Err(SaikuroError::key_not_found(dirs.join("/"))); } } } @@ -326,7 +308,6 @@ impl Default for OpfsStorage { } } -#[async_trait] impl KeyValueBackend for OpfsStorage { fn config(&self) -> &StorageConfig { &self.config @@ -411,14 +392,14 @@ impl KeyValueBackend for OpfsStorage { } } -#[async_trait] +#[async_trait(?Send)] 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 } @@ -471,7 +452,7 @@ impl FileBackend 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(); @@ -479,14 +460,14 @@ impl FileBackend 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, @@ -496,16 +477,11 @@ impl FileBackend for OpfsStorage { } } -#[async_trait] impl StorageBackend 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/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/crates/saikuro-transport/Cargo.toml b/Build/crates/saikuro-transport/Cargo.toml index 54bc32ab..684e52a2 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 @@ -8,49 +8,78 @@ license.workspace = true repository.workspace = true 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) -# wasm-host-transport: BroadcastChannel transport (wasm32 only) -# -# The in-memory transport is always compiled; it has zero OS dependencies. -[features] -default = ["native-transport"] -native-transport = ["saikuro-exec/tokio-runtime"] -ws-transport = [] -wasm-runtime = ["saikuro-exec/wasm-runtime"] +[lib] +path = "lib.rs" -# native-ws is only available on non-wasm32 (where tokio-tungstenite exists) -native-ws = ["ws-transport", "saikuro-exec/tokio-runtime", "tokio-tungstenite", "tungstenite"] +[features] +default = ["std", "native", "tcp", "ws"] +std = [] +native = [ + "std", + "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", "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", + "dep:send_wrapper", + "dep:wasm-bindgen-futures", +] +embedded = [ + "saikuro-core/embedded", + "saikuro-net/embedded", + "saikuro-exec/embedded", + "saikuro-event/embedded", + "dep:embedded-io-async", + "dep:embassy-sync", +] +tcp = [] +unix = [] +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 = ["saikuro-exec/no_std", "dep:embedded-io-async"] +wasi-host = ["no_std", "saikuro-exec/no_std", "wasi-tcp"] +wasi-preview2 = ["dep:wasi"] +wasi-preview1 = ["dep:wasip1"] [dependencies] -saikuro-core = { workspace = true } - -serde = { workspace = true } -bytes = { workspace = true } -rmp-serde = { workspace = true } +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, features = ["extra-platforms"] } 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 } +portable-atomic-util = { workspace = true } +portable-atomic = { workspace = true } +thiserror = { workspace = true } +embedded-io-async = { workspace = true, optional = true } +embassy-sync = { workspace = true, optional = true } +tokio-tungstenite = { workspace = true, optional = true } +embedded-websocket = { workspace = true, optional = true } +rand_core_06 = { workspace = true, optional = true } +getrandom = { 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 } -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" -wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", 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", @@ -61,5 +90,8 @@ web-sys = { version = "0.3", features = [ "BinaryType", ] } +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/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/io_transport.rs b/Build/crates/saikuro-transport/embedded/io_transport.rs new file mode 100644 index 00000000..82dbf1b4 --- /dev/null +++ b/Build/crates/saikuro-transport/embedded/io_transport.rs @@ -0,0 +1,83 @@ +use alloc::boxed::Box; +use async_trait::async_trait; +use bytes::Bytes; +use embedded_io_async::{Read, Write}; + +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. +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, +} + +impl EmbeddedIoTransport { + /// Construct a transport from `reader` and `writer`. + /// + /// 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 { + size: max_frame_size, + limit: crate::MAX_FRAME_SIZE, + }); + } + Ok(Self { + reader, + writer, + max_frame_size, + }) + } + + /// Split into independent sender and receiver halves. + pub fn split(self) -> (EmbeddedIoSender, EmbeddedIoReceiver) { + ( + EmbeddedIoSender { + writer: self.writer, + max_frame_size: self.max_frame_size, + }, + EmbeddedIoReceiver { + reader: self.reader, + }, + ) + } +} + +#[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 { + size: frame.len(), + limit: self.max_frame_size, + }); + } + write_frame(&mut self.writer, &frame).await + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} + +#[async_trait(?Send)] +impl LocalTransportReceiver for EmbeddedIoReceiver { + async fn recv(&mut self) -> Result> { + read_frame(&mut self.reader).await + } +} diff --git a/Build/crates/saikuro-transport/embedded/mod.rs b/Build/crates/saikuro-transport/embedded/mod.rs new file mode 100644 index 00000000..ace73044 --- /dev/null +++ b/Build/crates/saikuro-transport/embedded/mod.rs @@ -0,0 +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/embedded/tcp.rs b/Build/crates/saikuro-transport/embedded/tcp.rs new file mode 100644 index 00000000..a1a0c4ef --- /dev/null +++ b/Build/crates/saikuro-transport/embedded/tcp.rs @@ -0,0 +1,177 @@ +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 saikuro_event::{LogLevel, LogRecord}; + +use saikuro_net::net::tcp::TcpSocket; +use saikuro_net::net::{IpEndpoint, IpListenEndpoint, Stack}; + +use crate::shared::error::{Result, TransportError}; +use crate::shared::framing::{read_frame, write_frame}; +use crate::shared::traits::{ + Transport, TransportConnector, TransportListener, TransportReceiver, TransportSender, +}; + +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, +} + +impl TcpTransport { + /// Wrap an already-connected embassy-net [`TcpSocket`]. + pub fn new(socket: TcpSocket<'static>) -> Self { + Self { + socket: Arc::new(AsyncMutex::new(socket)), + } + } +} + +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(), + }, + TcpReceiver { socket }, + ) + } + + fn description(&self) -> &str { + "tcp" + } +} + +/// Sending half of an embedded TCP transport. +pub struct TcpSender { + socket: SharedSocket, +} + +#[async_trait(?Send)] +impl TransportSender for TcpSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + let mut socket = self.socket.lock().await; + write_frame(&mut *socket, &frame).await + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} + +/// Receiving half of an embedded TCP transport. +pub struct TcpReceiver { + socket: SharedSocket, +} + +#[async_trait(?Send)] +impl TransportReceiver for TcpReceiver { + async fn recv(&mut self) -> Result> { + let mut socket = self.socket.lock().await; + read_frame(&mut *socket).await + } +} + +/// 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(?Send)] +impl TransportConnector for TcpConnector { + type Output = TcpTransport; + + async fn connect(&self) -> Result { + 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); + socket + .connect(self.remote) + .await + .map_err(|e| TransportError::ConnectionRefused(alloc::format!("{:?}", e)))?; + Ok(TcpTransport::new(socket)) + } +} + +/// Accepts incoming TCP connections over embassy-net. +/// +/// 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, +} + +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(?Send)] +impl TransportListener for TcpTransportListener { + type Output = TcpTransport; + + async fn accept(&mut self) -> Result> { + 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(alloc::format!("{:?}", e)))?; + Ok(Some(TcpTransport::new(socket))) + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/Build/crates/saikuro-transport/lib.rs b/Build/crates/saikuro-transport/lib.rs new file mode 100644 index 00000000..d69debec --- /dev/null +++ b/Build/crates/saikuro-transport/lib.rs @@ -0,0 +1,165 @@ +//! Pluggable, backend-agnostic transports for Saikuro. + +#![cfg_attr(not(feature = "std"), no_std)] +#![allow(async_fn_in_trait)] + +#[macro_use] +extern crate alloc; + +#[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") +))] +compile_error!( + "saikuro-transport: enable exactly one engine feature (native, no_std, wasm, embedded), not more" +); + +#[cfg(all(feature = "no_std", feature = "std"))] +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(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::{ + HostPipeFactory, HostPipeRecv, HostPipeSend, Role, WasmHostConnector, WasmHostListener, + WasmHostTransport, +}; +pub use shared::memory::MemoryTransport; +pub use shared::selector::{self, TransportConfig, TransportKind, TransportSelector}; +pub use shared::traits::{ + LocalTransport, LocalTransportConnector, LocalTransportListener, LocalTransportReceiver, + LocalTransportSender, Transport, TransportConnector, TransportListener, TransportReceiver, + TransportSender, +}; + +/// 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; + +/// Implements [`TransportSender`] for a native transport's sending half. +#[macro_export] +macro_rules! impl_native_sender { + ($ty:ty, $addr:ident, $desc:literal) => { + #[async_trait::async_trait] + impl $crate::shared::traits::TransportSender for $ty { + async fn send(&mut self, frame: ::bytes::Bytes) -> $crate::shared::error::Result<()> { + 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<()> { + 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 + } + } + }; +} + +/// 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> { + match $crate::shared::framing::read_frame(&mut self.inner).await { + Ok(bytes) => { + match &bytes { + 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) + } + Err(e) => Err(e), + } + } + } + }; +} diff --git a/Build/crates/saikuro-transport/native/framed.rs b/Build/crates/saikuro-transport/native/framed.rs new file mode 100644 index 00000000..7e4a3479 --- /dev/null +++ b/Build/crates/saikuro-transport/native/framed.rs @@ -0,0 +1,29 @@ +//! Native (tokio) adapter for the shared framing core. +use saikuro_net::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +use crate::shared::error::TransportError; +use crate::shared::framing::{AsyncByteRead, AsyncByteWrite}; + +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 AsyncByteWrite for W { + async fn write(&mut self, buf: &[u8]) -> Result { + AsyncWriteExt::write(self, buf) + .await + .map_err(|e| TransportError::ConnectionLost(e.to_string())) + } + + async fn flush(&mut self) -> Result<(), TransportError> { + AsyncWriteExt::flush(self) + .await + .map_err(|e| TransportError::ConnectionLost(e.to_string())) + } +} + +pub use crate::shared::framing::{read_frame, write_frame}; diff --git a/Build/crates/saikuro-transport/native/mod.rs b/Build/crates/saikuro-transport/native/mod.rs new file mode 100644 index 00000000..78debb67 --- /dev/null +++ b/Build/crates/saikuro-transport/native/mod.rs @@ -0,0 +1,18 @@ +#[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; + +#[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/src/tcp.rs b/Build/crates/saikuro-transport/native/tcp.rs similarity index 50% rename from Build/crates/saikuro-transport/src/tcp.rs rename to Build/crates/saikuro-transport/native/tcp.rs index 9950ed07..19550c00 100644 --- a/Build/crates/saikuro-transport/src/tcp.rs +++ b/Build/crates/saikuro-transport/native/tcp.rs @@ -1,45 +1,37 @@ -//! 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}; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; use async_trait::async_trait; -use bytes::Bytes; -use futures::StreamExt; -use saikuro_exec::net::{TcpListener, TcpStream}; -use saikuro_exec::tokio_util::codec::Framed; +#[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::{ +use crate::shared::{ error::Result, - framing::LengthPrefixedCodec, 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: Framed, + 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 { - framed: Framed::new(stream, LengthPrefixedCodec::new()), + stream, peer_addr, + log, }) } } @@ -49,16 +41,19 @@ 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(); + let log = self.log; ( TcpSender { - inner: sink, + inner: write, peer_addr: peer, + log: log.clone(), }, TcpReceiver { - inner: stream, + inner: read, peer_addr: peer, + log, }, ) } @@ -69,31 +64,31 @@ impl Transport for TcpTransport { } // Sender / Receiver - pub struct TcpSender { - inner: futures::stream::SplitSink, Bytes>, + inner: WriteHalf, peer_addr: SocketAddr, + log: Arc, } impl_native_sender!(TcpSender, peer_addr, "tcp"); pub struct TcpReceiver { - inner: futures::stream::SplitStream>, + inner: ReadHalf, peer_addr: SocketAddr, + log: Arc, } impl_native_receiver!(TcpReceiver, peer_addr, "tcp"); -// Connector - /// 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 } } } @@ -102,27 +97,38 @@ 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()) } } -// Listener - /// Accepts incoming TCP connections. 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. @@ -138,15 +144,27 @@ impl TransportListener for TcpTransportListener { async fn accept(&mut self) -> Result> { match self.inner.accept().await { Ok((stream, peer)) => { - debug!(%peer, "tcp accepted connection"); - Ok(Some(TcpTransport::new(stream)?)) + 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/src/unix.rs b/Build/crates/saikuro-transport/native/unix.rs similarity index 50% rename from Build/crates/saikuro-transport/src/unix.rs rename to Build/crates/saikuro-transport/native/unix.rs index 1f2e4fe1..5ecfe5df 100644 --- a/Build/crates/saikuro-transport/src/unix.rs +++ b/Build/crates/saikuro-transport/native/unix.rs @@ -1,40 +1,30 @@ -//! 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}; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; use async_trait::async_trait; -use bytes::Bytes; -use futures::StreamExt; -use saikuro_exec::net::{UnixListener, UnixStream}; -use saikuro_exec::tokio_util::codec::Framed; +#[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::{ +use crate::shared::{ error::Result, - framing::LengthPrefixedCodec, traits::{Transport, TransportConnector, TransportListener}, }; -// Transport - /// A Unix domain socket transport connection. pub struct UnixTransport { - framed: Framed, + stream: UnixStream, path: PathBuf, + log: Arc, } impl UnixTransport { /// Wrap an already-connected [`UnixStream`]. - pub fn new(stream: UnixStream, path: PathBuf) -> Self { - Self { - framed: Framed::new(stream, LengthPrefixedCodec::new()), - path, - } + pub fn new(stream: UnixStream, path: PathBuf, log: Arc) -> Self { + Self { stream, path, log } } } @@ -43,16 +33,19 @@ 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(); + let log = self.log; ( UnixSender { - inner: sink, + inner: write, path: path.clone(), + log: log.clone(), }, UnixReceiver { - inner: stream, + inner: read, path, + log, }, ) } @@ -63,32 +56,33 @@ impl Transport for UnixTransport { } // Sender / Receiver - pub struct UnixSender { - inner: futures::stream::SplitSink, Bytes>, + inner: WriteHalf, path: PathBuf, + log: Arc, } impl_native_sender!(UnixSender, path, "unix"); pub struct UnixReceiver { - inner: futures::stream::SplitStream>, + inner: ReadHalf, path: PathBuf, + log: Arc, } impl_native_receiver!(UnixReceiver, path, "unix"); -// Connector - /// 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, } } } @@ -98,33 +92,48 @@ 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(), + )) } } -// Listener - /// Accepts incoming Unix domain socket connections. 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 { @@ -146,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 new file mode 100644 index 00000000..0540de46 --- /dev/null +++ b/Build/crates/saikuro-transport/native/websocket.rs @@ -0,0 +1,270 @@ +#[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 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, + 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, + log: Arc, + ) -> Result { + let url = url.into(); + 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, + log, + }) + } + + /// Construct from an already-upgraded WebSocket stream (server-side accept path). + pub fn from_stream( + ws: WebSocketStream>, + url: String, + log: Arc, + ) -> Self { + Self { + inner: ws, + url, + log, + } + } +} + +impl Transport for WebSocketTransport { + type Sender = WebSocketSender; + type Receiver = WebSocketReceiver; + + 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, + }, + ) + } + + 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, + log: Arc, +} + +impl WsTransportListener { + /// Bind a TCP listener on the given address for WebSocket upgrades. + pub async fn bind(addr: SocketAddr, log: Arc) -> Result { + let inner = TcpListener::bind(addr).await?; + let local_addr = inner.local_addr()?; + 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, + }) + } + + /// 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) => { + 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) => { + 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}" + ))) + } + } + } + + async fn close(&mut self) -> Result<()> { + 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(()) + } +} + +/// Sending half of a native WebSocket transport. +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<()> { + 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 + .map_err(|e| TransportError::SendFailed(e.to_string())) + } + + async fn close(&mut self) -> Result<()> { + 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 + .map_err(|e| TransportError::SendFailed(e.to_string())) + } +} + +/// Receiving half of a native WebSocket transport. +pub struct WebSocketReceiver { + inner: futures::stream::SplitStream>>, + url: String, + log: Arc, +} + +#[async_trait] +impl TransportReceiver for WebSocketReceiver { + async fn recv(&mut self) -> Result> { + loop { + match self.inner.next().await { + Some(Ok(Message::Binary(data))) => { + 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(_))) => { + 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)) => { + 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)) => { + 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/shared/error.rs similarity index 71% rename from Build/crates/saikuro-transport/src/error.rs rename to Build/crates/saikuro-transport/shared/error.rs index a421132c..613679e7 100644 --- a/Build/crates/saikuro-transport/src/error.rs +++ b/Build/crates/saikuro-transport/shared/error.rs @@ -1,5 +1,4 @@ -//! Transport error type. - +use alloc::string::String; use thiserror::Error; #[derive(Debug, Error)] @@ -25,17 +24,12 @@ pub enum TransportError { #[error("transport not supported on this platform")] NotSupported, + #[cfg(feature = "std")] #[error("I/O error: {0}")] Io(#[from] std::io::Error), - #[error("msgpack encode error: {0}")] - MsgpackEncode(#[from] rmp_serde::encode::Error), - - #[error("msgpack decode error: {0}")] - MsgpackDecode(#[from] rmp_serde::decode::Error), - #[error("channel closed")] ChannelClosed, } -pub type Result = std::result::Result; +pub type Result = core::result::Result; diff --git a/Build/crates/saikuro-transport/shared/framing.rs b/Build/crates/saikuro-transport/shared/framing.rs new file mode 100644 index 00000000..71b18302 --- /dev/null +++ b/Build/crates/saikuro-transport/shared/framing.rs @@ -0,0 +1,109 @@ +//! 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::shared::error::{Result, TransportError}; +use crate::MAX_FRAME_SIZE; + +/// Number of big-endian length bytes that prefix every frame. +pub(crate) const HEADER_LEN: usize = 4; + +fn message_too_large(size: usize) -> TransportError { + TransportError::MessageTooLarge { + size, + limit: MAX_FRAME_SIZE, + } +} + +/// 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; +} + +/// 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<()>; +} + +/// 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(), + )); + } + filled += n; + } + Ok(()) +} + +/// 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) +} + +/// 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())) +} + +/// 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 new file mode 100644 index 00000000..3a0225f3 --- /dev/null +++ b/Build/crates/saikuro-transport/shared/host.rs @@ -0,0 +1,254 @@ +use alloc::boxed::Box; +use alloc::string::String; +use alloc::vec::Vec; +use async_trait::async_trait; +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, +} + +/// 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, +} + +/// Receiving half of a [`WasmHostTransport`]. +pub struct WasmHostReceiver { + pipe: R, +} + +/// 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, + } + } +} + +/// 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, + } + } +} + +// 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 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 new file mode 100644 index 00000000..b3440fb5 --- /dev/null +++ b/Build/crates/saikuro-transport/shared/memory.rs @@ -0,0 +1,220 @@ +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 crate::shared::{ + error::{Result, TransportError}, + traits::{Transport, TransportReceiver, TransportSender}, +}; + +use crate::DEFAULT_CHANNEL_CAPACITY; + +/// An in-memory transport backed by a pair of MPSC channels. +/// +/// Construct a connected pair with [`MemoryTransport::pair`]. +pub struct MemoryTransport { + sender: mpsc::Sender, + receiver: mpsc::Receiver, + label: String, + log: Arc, +} + +impl MemoryTransport { + /// Create a connected pair of in-memory transports. + /// + /// The two returned transports can be split and handed to separate tasks; + /// 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, + log: Arc, + ) -> (Self, Self) { + let (a_tx, b_rx) = mpsc::channel(DEFAULT_CHANNEL_CAPACITY); + let (b_tx, a_rx) = mpsc::channel(DEFAULT_CHANNEL_CAPACITY); + + let transport_a = Self { + 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(log: Arc) -> (Self, Self) { + Self::pair("client", "server", log) + } +} + +impl Transport for MemoryTransport { + type Sender = MemorySender; + type Receiver = MemoryReceiver; + + fn split(self) -> (Self::Sender, Self::Receiver) { + ( + MemorySender { + inner: self.sender, + label: self.label.clone(), + log: self.log.clone(), + }, + MemoryReceiver { + inner: self.receiver, + label: self.label, + log: self.log, + }, + ) + } + + fn description(&self) -> &str { + "in-memory" + } +} + +/// Sending half of a [`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")] +mod send_impls { + use super::*; + + #[async_trait] + impl TransportSender for MemorySender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + 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 '{}'", + self.label + )) + }) + } + + async fn close(&mut self) -> Result<()> { + 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(()) + } + } + + #[async_trait] + impl TransportReceiver for MemoryReceiver { + async fn recv(&mut self) -> Result> { + let result = self.inner.recv().await; + match &result { + 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) + } + } +} + +#[cfg(not(feature = "native"))] +mod nosend_impls { + use super::*; + + #[async_trait(?Send)] + impl TransportSender for MemorySender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + 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 '{}'", + self.label + )) + }) + } + + async fn close(&mut self) -> Result<()> { + 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(()) + } + } + + #[async_trait(?Send)] + impl TransportReceiver for MemoryReceiver { + async fn recv(&mut self) -> Result> { + let result = self.inner.recv().await; + match &result { + 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/shared/mod.rs b/Build/crates/saikuro-transport/shared/mod.rs new file mode 100644 index 00000000..e5ea6ab1 --- /dev/null +++ b/Build/crates/saikuro-transport/shared/mod.rs @@ -0,0 +1,6 @@ +pub mod error; +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/shared/selector.rs similarity index 81% rename from Build/crates/saikuro-transport/src/selector.rs rename to Build/crates/saikuro-transport/shared/selector.rs index 210305dc..63e04b98 100644 --- a/Build/crates/saikuro-transport/src/selector.rs +++ b/Build/crates/saikuro-transport/shared/selector.rs @@ -1,20 +1,5 @@ -//! 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}; /// The set of transport backends Saikuro knows about. diff --git a/Build/crates/saikuro-transport/shared/traits.rs b/Build/crates/saikuro-transport/shared/traits.rs new file mode 100644 index 00000000..a126445b --- /dev/null +++ b/Build/crates/saikuro-transport/shared/traits.rs @@ -0,0 +1,224 @@ +use alloc::boxed::Box; +use async_trait::async_trait; +use bytes::Bytes; + +use crate::shared::error::Result; + +// 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::*; + + /// 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; + } + + /// 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. + /// + /// 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<()>; + } +} + +#[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<()>; + } +} + +#[cfg(feature = "native")] +pub use send_traits::*; + +#[cfg(not(feature = "native"))] +pub use nosend_traits::*; diff --git a/Build/crates/saikuro-transport/src/framing.rs b/Build/crates/saikuro-transport/src/framing.rs deleted file mode 100644 index 4a8c9931..00000000 --- a/Build/crates/saikuro-transport/src/framing.rs +++ /dev/null @@ -1,96 +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: - -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. -#[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, -} - -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> { - // 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(|_| TransportError::MessageTooLarge { - size: frame_len as usize, - limit: MAX_FRAME_SIZE, - })?; - - if frame_len > MAX_FRAME_SIZE { - return Err(TransportError::MessageTooLarge { - size: frame_len, - limit: MAX_FRAME_SIZE, - }); - } - - // 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)) - } -} - -impl Encoder for LengthPrefixedCodec { - type Error = TransportError; - - 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, - }); - } - - dst.reserve(4 + len); - dst.put_u32( - u32::try_from(len).map_err(|_| TransportError::MessageTooLarge { - size: len, - limit: MAX_FRAME_SIZE, - })?, - ); - dst.put(item); - Ok(()) - } -} diff --git a/Build/crates/saikuro-transport/src/lib.rs b/Build/crates/saikuro-transport/src/lib.rs deleted file mode 100644 index 48572f6e..00000000 --- a/Build/crates/saikuro-transport/src/lib.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! 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`] | `ws-transport` | native + wasm32 | -//! | [`wasm_host`] | always on (wasm32) | wasm32 only | - -pub mod error; -#[cfg(feature = "native-transport")] -pub mod framing; -pub mod memory; -pub mod selector; -pub mod traits; - -#[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(feature = "ws-transport")] -pub mod websocket; - -#[cfg(target_arch = "wasm32")] -pub mod wasm_host; - -pub use error::TransportError; -pub use memory::MemoryTransport; -pub use selector::{TransportConfig, TransportKind, TransportSelector}; -pub use traits::{Transport, TransportReceiver, TransportSender}; - -#[cfg(all(feature = "native-transport", not(target_arch = "wasm32")))] -pub use tcp::TcpTransport; - -#[cfg(all( - feature = "native-transport", - not(target_arch = "wasm32"), - target_family = "unix" -))] -pub use unix::UnixTransport; - -#[cfg(feature = "ws-transport")] -pub use websocket::WebSocketTransport; - -#[cfg(all(feature = "ws-transport", not(target_arch = "wasm32")))] -pub use websocket::WsTransportListener; - -#[cfg(target_arch = "wasm32")] -pub use wasm_host::WasmHostTransport; - -/// 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). -#[macro_export] -macro_rules! impl_native_sender { - ($sender:ty, $addr_field:ident, $transport: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")); - 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")); - futures::SinkExt::close(&mut self.inner).await - } - } - }; -} - -/// Implements [`TransportReceiver`] for a receiver type whose `inner` field -/// implements `Stream>`. -#[macro_export] -macro_rules! impl_native_receiver { - ($receiver:ty, $addr_field:ident, $transport:literal) => { - #[async_trait::async_trait] - impl $crate::traits::TransportReceiver for $receiver { - async fn recv(&mut self) -> $crate::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")); - Ok(Some(bytes)) - } - Some(Err(e)) => Err($crate::error::TransportError::from(e)), - None => { - tracing::debug!($addr_field = ?self.$addr_field, concat!($transport, " connection closed by peer")); - Ok(None) - } - } - } - } - }; -} - -/// 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: usize = 256; diff --git a/Build/crates/saikuro-transport/src/memory.rs b/Build/crates/saikuro-transport/src/memory.rs deleted file mode 100644 index 25528280..00000000 --- a/Build/crates/saikuro-transport/src/memory.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! 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 async_trait::async_trait; -use bytes::Bytes; -use saikuro_exec::mpsc; -use tracing::trace; - -use crate::{ - error::{Result, TransportError}, - traits::{Transport, TransportReceiver, TransportSender}, -}; - -use crate::DEFAULT_CHANNEL_CAPACITY; - -/// An in-memory transport backed by a pair of MPSC channels. -/// -/// Construct a connected pair with [`MemoryTransport::pair`]. -pub struct MemoryTransport { - sender: mpsc::Sender, - receiver: mpsc::Receiver, - label: String, -} - -impl MemoryTransport { - /// Create a connected pair of in-memory transports. - /// - /// The two returned transports can be split and handed to separate tasks; - /// 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) { - let (a_tx, b_rx) = mpsc::channel(DEFAULT_CHANNEL_CAPACITY); - let (b_tx, a_rx) = mpsc::channel(DEFAULT_CHANNEL_CAPACITY); - - let transport_a = Self { - sender: a_tx, - receiver: a_rx, - label: label_a.into(), - }; - let transport_b = Self { - sender: b_tx, - receiver: b_rx, - label: label_b.into(), - }; - - (transport_a, transport_b) - } - - /// Create a pair with the default labels `"client"` and `"server"`. - pub fn connected_pair() -> (Self, Self) { - Self::pair("client", "server") - } -} - -impl Transport for MemoryTransport { - type Sender = MemorySender; - type Receiver = MemoryReceiver; - - fn split(self) -> (Self::Sender, Self::Receiver) { - ( - MemorySender { - inner: self.sender, - label: self.label.clone(), - }, - MemoryReceiver { - inner: self.receiver, - label: self.label, - }, - ) - } - - fn description(&self) -> &str { - "in-memory" - } -} - -/// Sending half of a [`MemoryTransport`]. -pub struct MemorySender { - inner: mpsc::Sender, - 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"), - } - Ok(result) - } -} diff --git a/Build/crates/saikuro-transport/src/traits.rs b/Build/crates/saikuro-transport/src/traits.rs deleted file mode 100644 index a803a66e..00000000 --- a/Build/crates/saikuro-transport/src/traits.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! 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 async_trait::async_trait; -use bytes::Bytes; - -use crate::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; -} - -/// 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. -/// -/// 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. - /// - /// 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<()>; -} 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 873755cf..00000000 --- a/Build/crates/saikuro-transport/src/wasm_host.rs +++ /dev/null @@ -1,383 +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::<()>(1); - - // 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::(32); - - 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), - } - } -} diff --git a/Build/crates/saikuro-transport/wasi/host.rs b/Build/crates/saikuro-transport/wasi/host.rs new file mode 100644 index 00000000..a32c11bc --- /dev/null +++ b/Build/crates/saikuro-transport/wasi/host.rs @@ -0,0 +1,103 @@ +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::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; +/// 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); + +#[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? { + Some(bytes) => Ok(Some(bytes.to_vec())), + None => Ok(None), + } + } +} + +#[async_trait(?Send)] +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/wasi/mod.rs b/Build/crates/saikuro-transport/wasi/mod.rs new file mode 100644 index 00000000..38ff0a5d --- /dev/null +++ b/Build/crates/saikuro-transport/wasi/mod.rs @@ -0,0 +1,15 @@ +#[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 = "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/preview1.rs b/Build/crates/saikuro-transport/wasi/preview1.rs new file mode 100644 index 00000000..de59f1c1 --- /dev/null +++ b/Build/crates/saikuro-transport/wasi/preview1.rs @@ -0,0 +1,217 @@ +#[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}; + +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 `Arc` dropping closes it. +pub 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 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], + } +} + +/// 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}")))?; + + 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 = 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}" + ))); + } + Ok(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 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}" + ))); + } + Ok(Arc::new(Connection { fd })) + } +} diff --git a/Build/crates/saikuro-transport/wasi/preview2.rs b/Build/crates/saikuro-transport/wasi/preview2.rs new file mode 100644 index 00000000..e2c438fd --- /dev/null +++ b/Build/crates/saikuro-transport/wasi/preview2.rs @@ -0,0 +1,110 @@ +#[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; +use wasi::sockets::network::{ + ErrorCode, IpAddressFamily, IpSocketAddress, Ipv4SocketAddress, Network, +}; +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, parse_ipv4, WasiConn}; + +/// 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 socket. +pub struct Listener { + socket: TcpSocket, +} + +/// Map a preview2 socket error code into a transport error. +fn to_err(code: ErrorCode) -> TransportError { + TransportError::ConnectionRefused(format!("{code:?}")) +} + +/// 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:?}"))) + } +} + +/// 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 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, ipv4_socket_addr(octets, port)) + .map_err(to_err)?; + let (input, output) = socket.finish_connect().map_err(to_err)?; + Ok(Arc::new(Connection { input, output })) +} + +/// Bind and listen on `port` on all interfaces. +pub fn listen(port: u16) -> Result { + 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)?; + 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 socket. + pub fn accept(&self) -> Result> { + let (_new_socket, input, output) = self.socket.accept().map_err(to_err)?; + Ok(Arc::new(Connection { input, output })) + } +} diff --git a/Build/crates/saikuro-transport/wasi/tcp.rs b/Build/crates/saikuro-transport/wasi/tcp.rs new file mode 100644 index 00000000..1012a5dc --- /dev/null +++ b/Build/crates/saikuro-transport/wasi/tcp.rs @@ -0,0 +1,202 @@ +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; + +use crate::shared::error::{Result, TransportError}; +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!( + "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-preview1")] +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); + +/// 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 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; + + fn split(self) -> (Self::Sender, Self::Receiver) { + ( + WasiTcpSender { + conn: self.conn.clone(), + }, + WasiTcpReceiver { conn: self.conn }, + ) + } + + fn description(&self) -> &str { + "wasi-tcp" + } +} + +/// Sending half of a [`WasiTcpTransport`]. +pub struct WasiTcpSender { + conn: Arc, +} + +#[async_trait(?Send)] +impl LocalTransportSender for WasiTcpSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + write_frame(&mut WasiWriter(self.conn.as_ref()), &frame).await + } + + async fn close(&mut self) -> Result<()> { + Ok(()) + } +} + +/// Receiving half of a [`WasiTcpTransport`]. +pub struct WasiTcpReceiver { + conn: Arc, +} + +#[async_trait(?Send)] +impl LocalTransportReceiver for WasiTcpReceiver { + async fn recv(&mut self) -> Result> { + read_frame(&mut WasiReader(self.conn.as_ref())).await + } +} + +/// 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() } + } +} + +#[async_trait(?Send)] +impl LocalTransportConnector for WasiTcpConnector { + type Output = WasiTcpTransport; + + async fn connect(&self) -> Result { + 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: 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)?, + }) + } +} + +#[async_trait(?Send)] +impl LocalTransportListener for WasiTcpListener { + type Output = WasiTcpTransport; + + async fn accept(&mut self) -> Result> { + let conn = self.inner.accept()?; + Ok(Some(WasiTcpTransport::new(conn, 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/wasi/websocket.rs b/Build/crates/saikuro-transport/wasi/websocket.rs new file mode 100644 index 00000000..45bf16bb --- /dev/null +++ b/Build/crates/saikuro-transport/wasi/websocket.rs @@ -0,0 +1,241 @@ +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; +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 new file mode 100644 index 00000000..37d12f44 --- /dev/null +++ b/Build/crates/saikuro-transport/wasm/host_browser.rs @@ -0,0 +1,254 @@ +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; +use js_sys::{ArrayBuffer, Reflect, Uint8Array}; +use send_wrapper::SendWrapper; +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; + +#[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).await, + Role::Accept => open_accept(channel).await, + } + } +} + +#[async_trait(?Send)] +impl HostPipeSend for BroadcastChannelSend { + async fn send(&mut self, frame: &[u8]) -> Result<()> { + send_buffer(&self.channel, frame) + } +} + +#[async_trait(?Send)] +impl HostPipeRecv for BroadcastChannelRecv { + async fn recv(&mut self) -> Result>> { + match self.rx.recv().await { + Some(bytes) => Ok(Some(bytes.to_vec())), + None => 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: SendWrapper> = SendWrapper::new(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(Ok(())) => { + let send = BroadcastChannelSend { + channel: SendWrapper::new(private.clone()), + }; + let recv = BroadcastChannelRecv { + channel: SendWrapper::new(private), + rx: data_rx, + _handler: handler, + }; + Ok((send, recv)) + } + Ok(Err(_)) => 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, mut conn_rx) = mpsc::channel::( + saikuro_exec::ChannelCapacity::try_from(32).expect("32 is a valid channel capacity"), + ); + 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())); + + 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/wasm/mod.rs b/Build/crates/saikuro-transport/wasm/mod.rs new file mode 100644 index 00000000..df4f0e59 --- /dev/null +++ b/Build/crates/saikuro-transport/wasm/mod.rs @@ -0,0 +1,10 @@ +#[cfg(all(feature = "ws", feature = "std"))] +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/crates/saikuro-transport/wasm/websocket.rs b/Build/crates/saikuro-transport/wasm/websocket.rs new file mode 100644 index 00000000..23aab24f --- /dev/null +++ b/Build/crates/saikuro-transport/wasm/websocket.rs @@ -0,0 +1,207 @@ +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 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(); + + 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(())) => 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(?Send)] +impl TransportSender for WebSocketSender { + async fn send(&mut self, frame: Bytes) -> Result<()> { + use js_sys::{ArrayBuffer, Uint8Array}; + use wasm_bindgen::JsValue; + + 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<()> { + 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(?Send)] +impl TransportReceiver for WebSocketReceiver { + async fn recv(&mut self) -> Result> { + match self.rx.recv().await { + Some(Ok(opt)) => Ok(opt), + Some(Err(e)) => Err(e), + None => Ok(None), + } + } +} 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/scripts/check_adapter_matrix.py b/Build/scripts/check_adapter_matrix.py new file mode 100755 index 00000000..0d67ac67 --- /dev/null +++ b/Build/scripts/check_adapter_matrix.py @@ -0,0 +1,312 @@ +#!/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 (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", + [ + "--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()) diff --git a/Build/scripts/check_matrix.py b/Build/scripts/check_matrix.py new file mode 100755 index 00000000..5bd748e0 --- /dev/null +++ b/Build/scripts/check_matrix.py @@ -0,0 +1,320 @@ +#!/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) (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", + [ + "--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 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: + 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" + ) + 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: + 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()) diff --git a/Build/tests/Cargo.toml b/Build/tests/Cargo.toml index 761283d2..832493e7 100644 --- a/Build/tests/Cargo.toml +++ b/Build/tests/Cargo.toml @@ -7,37 +7,49 @@ authors.workspace = true license.workspace = true publish = false +[lib] +path = "lib.rs" + [features] -wasm-runtime = ["saikuro-transport/wasm-runtime"] +flash = ["saikuro-storage/embedded", "dep:embedded-storage-async", "dep:futures-executor"] +embedded-io = [] [dependencies] saikuro-core = { workspace = true } saikuro-schema = { workspace = true } saikuro-router = { workspace = true } saikuro-codegen = { 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 } -# Native-only: enable TCP/Unix transport backends [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -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"] } +saikuro-random = { workspace = true } +tokio = { workspace = true, features = ["net", "io-util", "rt", "macros"] } -# WASM-only: BroadcastChannel-based host transport, no native-transport [target.'cfg(target_arch = "wasm32")'.dependencies] -saikuro-transport = { workspace = true, features = ["wasm-runtime"] } -saikuro-runtime = { workspace = true, default-features = false } -wasm-bindgen = "0.2" -wasm-bindgen-test = "0.3" -wasm-bindgen-futures = "0.4" -js-sys = "0.3" -web-sys = { version = "0.3", features = [ +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 } +wasm-bindgen-futures = { workspace = true } +js-sys = { workspace = true } +web-sys = { workspace = true, features = [ "BroadcastChannel", "MessageEvent", ] } diff --git a/Build/tests/common/mod.rs b/Build/tests/common/mod.rs new file mode 100644 index 00000000..36d62f89 --- /dev/null +++ b/Build/tests/common/mod.rs @@ -0,0 +1,152 @@ +use bytes::Bytes; +use saikuro_core::{ + capability::CapabilitySet, + envelope::Envelope, + schema::{ + FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, + TypeDescriptor, TypeMap, Visibility, + }, + RegistrationToken, ResponseEnvelope, +}; +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::SchemaRegistry, validator::InvocationValidator, +}; +use saikuro_transport::shared::memory::{MemoryReceiver, MemorySender}; +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 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) +} + +pub fn simple_schema(namespace: &str, function: &str) -> Schema { + 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: Box::new(namespaces), + types: Box::new(TypeMap::new()), + } +} + +pub 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") +} + +pub fn make_announce_envelope(schema: &Schema) -> Envelope { + Envelope::announce(schema_to_value(schema)).expect("entropy available") +} + +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"); +} + +/// 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, + envelope: Envelope, +) -> ResponseEnvelope { + let log = null_log(); + let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler", log.clone()); + let (mut test_sender, mut test_receiver) = test_transport.split(); + + 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"); + drop(test_sender); + + handler.run().await; + + let resp_frame = test_receiver + .recv() + .await + .expect("recv response") + .expect("frame must be present"); + ResponseEnvelope::from_msgpack(&resp_frame).expect("decode response") +} diff --git a/Build/tests/lib.rs b/Build/tests/lib.rs new file mode 100644 index 00000000..8d32d9c0 --- /dev/null +++ b/Build/tests/lib.rs @@ -0,0 +1,68 @@ +mod common; + +#[path = "saikuro-codegen"] +mod codegen_tests { + mod c_cpp_codegen; + mod codegen_output; +} + +#[path = "saikuro-core"] +mod core_tests { + mod cross_language_wire; + mod envelope_roundtrip; + mod error_propagation; + mod invocation; + mod resource; + mod value; +} + +#[path = "saikuro-exec"] +mod exec_tests { + mod exec_channels; + mod exec_concurrency; + mod exec_select; +} + +#[path = "saikuro-router"] +mod router_tests { + 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 runtime_tests { + mod config_capacity; + mod schema_registration; +} + +#[path = "saikuro-schema"] +mod schema_tests { + mod capability_enforcement; + mod registry; + mod schema_validation; +} + +#[path = "saikuro-storage"] +mod storage_tests { + #[cfg(feature = "flash")] + mod flash; + mod inmemory; + mod util; +} + +#[path = "saikuro-transport"] +mod transport_tests { + #[cfg(feature = "embedded-io")] + mod embedded_io; + mod transport_compliance; + mod transport_memory_stress; + #[cfg(target_arch = "wasm32")] + mod transport_wasm_host; +} diff --git a/Build/crates/saikuro-codegen/tests/c_cpp_codegen.rs b/Build/tests/saikuro-codegen/c_cpp_codegen.rs similarity index 72% rename from Build/crates/saikuro-codegen/tests/c_cpp_codegen.rs rename to Build/tests/saikuro-codegen/c_cpp_codegen.rs index 5786cffd..37728b74 100644 --- a/Build/crates/saikuro-codegen/tests/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, }; @@ -9,23 +8,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/tests/tests/codegen_output.rs b/Build/tests/saikuro-codegen/codegen_output.rs similarity index 79% rename from Build/tests/tests/codegen_output.rs rename to Build/tests/saikuro-codegen/codegen_output.rs index 5d893c17..ae021de7 100644 --- a/Build/tests/tests/codegen_output.rs +++ b/Build/tests/saikuro-codegen/codegen_output.rs @@ -1,14 +1,14 @@ //! 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, 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 +31,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 +62,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 +261,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 +271,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 +426,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 +436,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 +471,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 +529,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 +881,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 +891,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/cross_language_wire.rs b/Build/tests/saikuro-core/cross_language_wire.rs similarity index 80% rename from Build/tests/tests/cross_language_wire.rs rename to Build/tests/saikuro-core/cross_language_wire.rs index 5f14b2c8..8e27adb6 100644 --- a/Build/tests/tests/cross_language_wire.rs +++ b/Build/tests/saikuro-core/cross_language_wire.rs @@ -1,20 +1,19 @@ //! Cross-language wire-protocol integration tests +use crate::common; use bytes::Bytes; use saikuro_core::{ capability::CapabilitySet, envelope::{Envelope, InvocationType}, - error::ErrorCode, - schema::{FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility}, - value::Value, + schema::{ + ArgumentDescriptor, FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, + PrimitiveType, Schema, TypeDescriptor, TypeMap, Visibility, + }, InvocationId, ResponseEnvelope, PROTOCOL_VERSION, }; -use saikuro_runtime::runtime::SaikuroRuntime; -use saikuro_transport::{ - memory::MemoryTransport, - traits::{Transport, TransportReceiver, TransportSender}, -}; -use std::collections::HashMap; +use saikuro_event::{ErrorCode, Value}; +use saikuro_runtime::SaikuroRuntime; +use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; // Shared helpers @@ -40,7 +39,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}"), @@ -50,30 +48,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()), } } @@ -82,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. @@ -100,8 +96,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, @@ -119,32 +117,36 @@ 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"); // 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 @@ -167,28 +169,32 @@ 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"); // 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"); @@ -213,28 +219,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(), @@ -280,7 +292,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(); }) } @@ -294,7 +306,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. @@ -302,8 +314,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(), @@ -378,7 +393,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)) @@ -397,8 +413,8 @@ fn n_rust_adapter_provider_serves_simulated_client() { ); drop(client_tx); - serve_task.abort(); - bridge.abort(); + let _ = serve_task.abort(); + let _ = bridge.abort(); }) } @@ -407,7 +423,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. @@ -415,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)); + let announce = + Envelope::announce(common::schema_to_value(&schema)).expect("entropy available"); provider_tx .send(encode_envelope(&announce)) .await @@ -449,7 +466,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); @@ -469,23 +486,27 @@ 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"); let ext_schema = make_schema_with_args("ext", "echo", 1); - let announce = Envelope::announce(schema_to_value(&ext_schema)); + let announce = + Envelope::announce(common::schema_to_value(&ext_schema)).expect("entropy available"); ext_tx .send(encode_envelope(&announce)) .await @@ -513,7 +534,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)) @@ -526,7 +547,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)) @@ -539,7 +561,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(); }) } @@ -549,29 +571,32 @@ 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"); // 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 { @@ -613,12 +638,12 @@ 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"); - 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"); @@ -645,7 +670,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"); @@ -677,14 +702,15 @@ 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 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(common::schema_to_value(&schema)).expect("entropy available"); prov_tx .send(encode_envelope(&announce)) .await @@ -713,7 +739,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)) @@ -728,7 +754,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(); }) } @@ -738,18 +764,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); @@ -759,7 +788,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"); @@ -782,26 +812,30 @@ 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"); - 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"); @@ -822,7 +856,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 @@ -830,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)); + let announce = + Envelope::announce(common::schema_to_value(&schema)).expect("entropy available"); prov_tx .send(encode_envelope(&announce)) .await @@ -854,7 +889,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"); @@ -871,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)); + let announce2 = + Envelope::announce(common::schema_to_value(&schema2)).expect("entropy available"); prov2_tx .send(encode_envelope(&announce2)) .await @@ -893,7 +929,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 @@ -903,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(); }) } @@ -914,26 +950,30 @@ 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"); // 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"); @@ -956,25 +996,28 @@ 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"); // 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/saikuro-core/envelope_roundtrip.rs similarity index 66% rename from Build/tests/tests/envelope_roundtrip.rs rename to Build/tests/saikuro-core/envelope_roundtrip.rs index 5df9fd1f..57658b6f 100644 --- a/Build/tests/tests/envelope_roundtrip.rs +++ b/Build/tests/saikuro-core/envelope_roundtrip.rs @@ -3,12 +3,9 @@ use saikuro_core::{ capability::CapabilityToken, envelope::{Envelope, InvocationType, ResponseEnvelope, StreamControl}, - error::{ErrorCode, ErrorDetail}, - invocation::InvocationId, - value::Value, - PROTOCOL_VERSION, + InvocationId, PROTOCOL_VERSION, }; -use std::collections::BTreeMap; +use saikuro_event::{ErrorCode, ErrorDetail, Value, ValueMap}; // Helpers @@ -26,7 +23,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); @@ -41,7 +39,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())); @@ -49,14 +48,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()); @@ -64,7 +64,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!( @@ -75,20 +75,43 @@ 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())); - 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)); } +#[test] +fn envelope_meta_serializes_canonically_regardless_of_insertion_order() { + 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![]).expect("entropy available"); + 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)]); - 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]); @@ -102,7 +125,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); @@ -113,7 +136,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); @@ -124,7 +147,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); @@ -147,14 +170,14 @@ 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)) }, ]; 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/saikuro-core/error_propagation.rs similarity index 91% rename from Build/tests/tests/error_propagation.rs rename to Build/tests/saikuro-core/error_propagation.rs index 959a740e..8da1bab0 100644 --- a/Build/tests/tests/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,8 +152,10 @@ 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_detail("line", Value::Int(42)); + .with_context("field", Value::String("arg_a".into())) + .unwrap() + .with_context("line", Value::Int(42)) + .unwrap(); assert_eq!(detail.details["field"], Value::String("arg_a".into())); assert_eq!(detail.details["line"], Value::Int(42)); @@ -175,9 +173,10 @@ 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())); + .with_context("arg", Value::String("x".into())) + .unwrap(); let resp = ResponseEnvelope::err(id, detail.clone()); let bytes = resp.to_msgpack().expect("serialize"); @@ -217,7 +216,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"); @@ -242,10 +241,12 @@ 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); + registry.register(handle).await; // Provider always returns an error. saikuro_exec::spawn(async move { @@ -258,7 +259,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/saikuro-core/invocation.rs b/Build/tests/saikuro-core/invocation.rs new file mode 100644 index 00000000..3da7a269 --- /dev/null +++ b/Build/tests/saikuro-core/invocation.rs @@ -0,0 +1,27 @@ +use saikuro_core::msgpack; +use saikuro_core::InvocationId; + +#[test] +fn msgpack_roundtrip_uses_binary_uuid() { + 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, + // 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); +} + +#[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/tests/saikuro-core/resource.rs b/Build/tests/saikuro-core/resource.rs new file mode 100644 index 00000000..0496f5d0 --- /dev/null +++ b/Build/tests/saikuro-core/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/tests/saikuro-core/value.rs b/Build/tests/saikuro-core/value.rs new file mode 100644 index 00000000..b199f6f8 --- /dev/null +++ b/Build/tests/saikuro-core/value.rs @@ -0,0 +1,117 @@ +use saikuro_core::msgpack; +use saikuro_core::schema::{ + FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, + TypeDescriptor, TypeMap, Visibility, +}; +use saikuro_event::{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"); + + // `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. +#[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_eq!( + decoded, original, + "bytes payload changed across the round-trip" + ); + assert_eq!(bytes[0], 0xC4, "Value::Bytes must encode as msgpack bin8"); +} + +#[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); +} + +#[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/tests/tests/exec_channels.rs b/Build/tests/saikuro-exec/exec_channels.rs similarity index 83% rename from Build/tests/tests/exec_channels.rs rename to Build/tests/saikuro-exec/exec_channels.rs index 2bc20138..d39d8a68 100644 --- a/Build/tests/tests/exec_channels.rs +++ b/Build/tests/saikuro-exec/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(); @@ -265,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); }) } @@ -276,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); }) } @@ -292,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(); }) } @@ -301,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"); }) } @@ -311,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); }) } @@ -333,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/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 81% rename from Build/tests/tests/exec_select.rs rename to Build/tests/saikuro-exec/exec_select.rs index b957c403..f87b5eb0 100644 --- a/Build/tests/tests/exec_select.rs +++ b/Build/tests/saikuro-exec/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/announce_dispatch.rs b/Build/tests/saikuro-router/announce_dispatch.rs similarity index 84% rename from Build/tests/tests/announce_dispatch.rs rename to Build/tests/saikuro-router/announce_dispatch.rs index 273997fe..f527dc2d 100644 --- a/Build/tests/tests/announce_dispatch.rs +++ b/Build/tests/saikuro-router/announce_dispatch.rs @@ -2,28 +2,16 @@ 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}, - router::{InvocationRouter, RouterConfig}, -}; -use saikuro_runtime::connection::ConnectionHandler; -use saikuro_schema::{ - capability_engine::CapabilityEngine, - registry::{RegistryMode, SchemaRegistry}, - validator::InvocationValidator, -}; -use saikuro_transport::{ - memory::MemoryTransport, - traits::{Transport, TransportReceiver, TransportSender}, -}; +use saikuro_router::provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}; +use saikuro_schema::registry::{RegistryMode, SchemaRegistry}; +use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; -mod common; +use crate::common; use common::{make_announce_envelope, round_trip_via_handler, simple_schema}; @@ -38,26 +26,17 @@ async fn round_trip_while_alive( provider_registry: ProviderRegistry, envelope: Envelope, ) -> ResponseEnvelope { - let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler"); - let (handler_sender, handler_receiver) = handler_transport.split(); + let log = common::null_log(); + let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler", log.clone()); 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(), - 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()); @@ -91,7 +70,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" ); @@ -145,8 +124,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"); @@ -159,13 +138,13 @@ 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 ); // Namespace must not have been registered. assert!( - !registry.has_namespace("frozen"), + !registry.has_namespace("frozen").await, "namespace must not appear after a rejected announce" ); }) @@ -183,7 +162,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(), @@ -199,7 +178,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 ); @@ -216,7 +195,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(), @@ -232,7 +211,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 ); @@ -248,10 +227,12 @@ 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); + providers.register(handle).await; let schema = simple_schema("intercept_test", "fn"); let env = make_announce_envelope(&schema); @@ -263,7 +244,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/tests/batch_dispatch.rs b/Build/tests/saikuro-router/batch_dispatch.rs similarity index 81% rename from Build/tests/tests/batch_dispatch.rs rename to Build/tests/saikuro-router/batch_dispatch.rs index a6aaf8a2..d57853c3 100644 --- a/Build/tests/tests/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,14 +13,16 @@ use saikuro_router::{ // Helpers -fn register_echo_provider(registry: &ProviderRegistry, namespace: &str, response: Value) { - let (work_tx, work_rx) = mpsc::channel::(64); +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"), + ); let handle = ProviderHandle::new( format!("{namespace}-provider"), vec![namespace.to_owned()], work_tx, ); - registry.register(handle); + registry.register(handle).await; // Spawn a background responder. saikuro_exec::spawn({ @@ -43,12 +44,13 @@ 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); - 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]); @@ -68,15 +70,15 @@ 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); 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 +102,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 @@ -116,17 +118,17 @@ 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); 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); @@ -150,16 +152,16 @@ 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); 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,9 +184,11 @@ 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); + registry.register(handle).await; saikuro_exec::spawn(async move { while let Some(item) = work_rx.recv().await { @@ -199,10 +203,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/saikuro-router/call_dispatch.rs similarity index 79% rename from Build/tests/tests/call_dispatch.rs rename to Build/tests/saikuro-router/call_dispatch.rs index 1ff2999e..155e98c4 100644 --- a/Build/tests/tests/call_dispatch.rs +++ b/Build/tests/saikuro-router/call_dispatch.rs @@ -1,6 +1,8 @@ //! Call and cast dispatch integration tests -use saikuro_core::{envelope::Envelope, error::ErrorCode, value::Value, ResponseEnvelope}; +use crate::common; +use saikuro_core::{envelope::Envelope, ResponseEnvelope}; +use saikuro_event::{ErrorCode, Value}; use saikuro_exec::mpsc; use saikuro_router::{ provider::{ProviderHandle, ProviderRegistry, ProviderWorkItem}, @@ -10,22 +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. -fn make_echo_provider(namespace: &str) -> (ProviderRegistry, mpsc::Receiver) { - let (work_tx, work_rx) = mpsc::channel::(64); - let handle = ProviderHandle::new( - format!("{namespace}-provider"), - vec![namespace.to_owned()], - work_tx, - ); - let registry = ProviderRegistry::new(); - registry.register(handle); - (registry, work_rx) -} - /// Spawn a background task that answers every work item with the given value. fn spawn_responder( mut work_rx: mpsc::Receiver, @@ -64,11 +50,12 @@ fn spawn_silent_responder( #[test] fn call_returns_provider_response() { saikuro_exec::block_on(async { - let (registry, work_rx) = make_echo_provider("math"); + let (registry, work_rx) = common::make_provider("math").await; 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"); @@ -79,13 +66,14 @@ 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"); + 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() {} }); 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 +87,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,16 +99,17 @@ 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); + registry.register(handle).await; // Drop the receiver: the provider is "gone". 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); @@ -136,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"); + let (registry, work_rx) = common::make_provider("slow").await; let _silent = spawn_silent_responder(work_rx); let config = RouterConfig { @@ -145,7 +134,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); @@ -157,13 +146,13 @@ 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) = common::make_provider("counter").await; let _responder = spawn_responder(work_rx, Value::Bool(true)); 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); } @@ -173,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"); + let (registry, work_rx) = common::make_provider("parallel").await; let _responder = spawn_responder(work_rx, Value::Int(0)); let router = InvocationRouter::with_providers(registry); @@ -182,7 +171,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,12 +190,14 @@ 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(); 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 ); @@ -222,7 +213,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/saikuro-router/channel_dispatch.rs similarity index 77% rename from Build/tests/tests/channel_dispatch.rs rename to Build/tests/saikuro-router/channel_dispatch.rs index 40b1b49a..6fa2f9d3 100644 --- a/Build/tests/tests/channel_dispatch.rs +++ b/Build/tests/saikuro-router/channel_dispatch.rs @@ -1,17 +1,19 @@ //! Channel dispatch Tests +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; +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 { @@ -51,12 +53,13 @@ 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() {} }); 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 +74,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); @@ -83,10 +86,10 @@ 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![]); + 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() {} }); @@ -98,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. @@ -110,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()))); }) @@ -119,10 +124,10 @@ 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![]); + 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() {} }); @@ -133,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. @@ -144,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))); }) @@ -153,17 +160,20 @@ 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![]); + 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() {} }); 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); @@ -180,16 +190,19 @@ 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![]); + 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() {} }); 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; @@ -207,16 +220,20 @@ 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 = 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() {} }); 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; @@ -235,7 +252,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 +265,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!( @@ -261,21 +278,21 @@ 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() {} }); - 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; 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)); @@ -300,20 +317,21 @@ 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()], work_tx, ); let registry = ProviderRegistry::new(); - registry.register(handle); + registry.register(handle).await; // Drop the receiver: provider is now 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); @@ -329,10 +347,11 @@ 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 = 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() {} }); @@ -341,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. @@ -355,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)); @@ -374,8 +395,56 @@ 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)); }) } + +#[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 + ); + let recv_fut = outbound_rx.recv(); + pin_mut!(recv_fut); + assert!( + matches!(poll!(recv_fut.as_mut()), Poll::Pending), + "post-terminal frame was not delivered" + ); + }) +} diff --git a/Build/tests/tests/log_dispatch.rs b/Build/tests/saikuro-router/log_dispatch.rs similarity index 77% rename from Build/tests/tests/log_dispatch.rs rename to Build/tests/saikuro-router/log_dispatch.rs index 11e13f69..6d85b61e 100644 --- a/Build/tests/tests/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) } @@ -35,7 +46,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(), @@ -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 @@ -57,13 +68,19 @@ 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); + 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; @@ -72,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" ); }) @@ -137,7 +156,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(), @@ -151,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" ); }) } @@ -169,7 +188,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(), @@ -182,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" ); }) } @@ -192,10 +211,12 @@ 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); + registry.register(handle).await; // Spawn an auto-responder. saikuro_exec::spawn(async move { @@ -211,9 +232,13 @@ 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![]); + 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/saikuro-router/provider_registry.rs b/Build/tests/saikuro-router/provider_registry.rs new file mode 100644 index 00000000..c943600a --- /dev/null +++ b/Build/tests/saikuro-router/provider_registry.rs @@ -0,0 +1,113 @@ +use saikuro_core::RegistrationToken; +use saikuro_router::provider::{Provider, ProviderHandle, ProviderRegistry, ProviderWorkItem}; + +fn handle(id: &str, namespaces: &[&str]) -> ProviderHandle { + 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() { + 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, + )) + .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") + .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() { + saikuro_exec::block_on(async { + let registry = ProviderRegistry::new(); + let registration_token = RegistrationToken::new(); + + 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"])) + .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() { + saikuro_exec::block_on(async { + let registry = ProviderRegistry::new(); + let registration_token = RegistrationToken::new(); + + 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").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/tests/resource_dispatch.rs b/Build/tests/saikuro-router/resource_dispatch.rs similarity index 87% rename from Build/tests/tests/resource_dispatch.rs rename to Build/tests/saikuro-router/resource_dispatch.rs index 5d0e3ab5..64b035bc 100644 --- a/Build/tests/tests/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}, @@ -14,23 +13,10 @@ use saikuro_router::{ }; use saikuro_schema::registry::SchemaRegistry; -mod common; +use crate::common; // Helpers -/// 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 handle = ProviderHandle::new( - format!("{namespace}-provider"), - vec![namespace.to_owned()], - work_tx, - ); - let registry = ProviderRegistry::new(); - registry.register(handle); - (registry, work_rx) -} - /// Spawn a background task that answers every work item with `result_value`. fn spawn_responder( mut work_rx: mpsc::Receiver, @@ -56,7 +42,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); @@ -73,11 +60,12 @@ 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"); + let (registry, work_rx) = common::make_provider("files").await; 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!( @@ -99,11 +87,12 @@ 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"); + let (registry, work_rx) = common::make_provider("storage").await; 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 +120,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 +139,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); + registry.register(handle).await; 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"); @@ -214,13 +204,13 @@ 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) = common::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![]); + 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 +236,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"); @@ -267,11 +257,11 @@ 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) = common::make_provider("corr").await; 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; @@ -291,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"); + let (registry, work_rx) = common::make_provider("bulk").await; let _responder = spawn_responder(work_rx, result_value); let router = InvocationRouter::with_providers(registry); @@ -300,7 +290,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 })); } @@ -332,10 +322,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_event::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/saikuro-router/sandbox_dispatch.rs similarity index 72% rename from Build/tests/tests/sandbox_dispatch.rs rename to Build/tests/saikuro-router/sandbox_dispatch.rs index 1fc855d5..39de259e 100644 --- a/Build/tests/tests/sandbox_dispatch.rs +++ b/Build/tests/saikuro-router/sandbox_dispatch.rs @@ -4,96 +4,91 @@ use bytes::Bytes; use saikuro_core::{ capability::{CapabilitySet, CapabilityToken}, envelope::{Envelope, InvocationType}, - schema::{FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility}, - value::Value, + schema::{ + FunctionMap, FunctionSchema, NamespaceMap, NamespaceSchema, PrimitiveType, Schema, + TypeDescriptor, TypeMap, Visibility, + }, 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, -}; -use saikuro_transport::{ - memory::MemoryTransport, - traits::{Transport, TransportReceiver, TransportSender}, -}; -use std::collections::HashMap; +use saikuro_router::provider::ProviderRegistry; +use saikuro_schema::registry::SchemaRegistry; +use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; -// Helpers +use crate::common; + +// 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()), } } -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)) -} - /// Send `envelope` through a `ConnectionHandler` (optionally sandboxed) and /// collect all frames the handler pushes back. /// @@ -105,31 +100,22 @@ async fn run_and_collect( sandbox: bool, envelope: Envelope, ) -> Vec { - let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler"); - let (handler_sender, handler_receiver) = handler_transport.split(); + let log = common::null_log(); + let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler", log.clone()); 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(), - 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"); @@ -144,7 +130,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. @@ -153,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; @@ -180,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); @@ -208,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); @@ -233,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); @@ -258,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; @@ -284,9 +270,9 @@ 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")]); + 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); @@ -310,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; @@ -334,13 +320,14 @@ 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. 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(), @@ -361,7 +348,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/tests/stream_dispatch.rs b/Build/tests/saikuro-router/stream_dispatch.rs similarity index 66% rename from Build/tests/tests/stream_dispatch.rs rename to Build/tests/saikuro-router/stream_dispatch.rs index 7debea2e..19ec7a78 100644 --- a/Build/tests/tests/stream_dispatch.rs +++ b/Build/tests/saikuro-router/stream_dispatch.rs @@ -1,29 +1,32 @@ //! Stream dispatch tests. +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}; +use std::task::Poll; -mod common; +use crate::common; -// Tests +// Tests #[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() {} }); + 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"); @@ -35,16 +38,16 @@ 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); // 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() {} }); + saikuro_exec::spawn(async move { while work_rx.recv().await.is_some() {} }); let open_resp = router.dispatch(open_env).await; assert!(open_resp.ok); @@ -59,13 +62,13 @@ 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![]); + 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; @@ -87,7 +90,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 +103,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); @@ -112,14 +115,14 @@ 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() {} }); + 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; @@ -149,12 +152,12 @@ 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() {} }); + 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; @@ -172,12 +175,12 @@ 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() {} }); + 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 +202,44 @@ 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 + ); + let recv_fut = rx.recv(); + pin_mut!(recv_fut); + assert!( + matches!(poll!(recv_fut.as_mut()), Poll::Pending), + "post-terminal frame was not delivered" + ); + }) +} diff --git a/Build/tests/saikuro-runtime/config_capacity.rs b/Build/tests/saikuro-runtime/config_capacity.rs new file mode 100644 index 00000000..303ccbb6 --- /dev/null +++ b/Build/tests/saikuro-runtime/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/tests/saikuro-runtime/schema_registration.rs b/Build/tests/saikuro-runtime/schema_registration.rs new file mode 100644 index 00000000..f1cb7184 --- /dev/null +++ b/Build/tests/saikuro-runtime/schema_registration.rs @@ -0,0 +1,136 @@ +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. +#[test] +fn schema_registration_roundtrip() { + 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 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") + .await + .expect("merge 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"); + }); +} + +#[test] +fn stale_same_id_cleanup_preserves_new_provider_and_schema() { + 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, + )) + .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).await; + + 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 { + 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/tests/tests/capability_enforcement.rs b/Build/tests/saikuro-schema/capability_enforcement.rs similarity index 94% rename from Build/tests/tests/capability_enforcement.rs rename to Build/tests/saikuro-schema/capability_enforcement.rs index e399e4fa..1a841d64 100644 --- a/Build/tests/tests/capability_enforcement.rs +++ b/Build/tests/saikuro-schema/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/saikuro-schema/registry.rs b/Build/tests/saikuro-schema/registry.rs new file mode 100644 index 00000000..46e23018 --- /dev/null +++ b/Build/tests/saikuro-schema/registry.rs @@ -0,0 +1,70 @@ +use saikuro_core::schema::{PrimitiveType, Schema, TypeDefinition, TypeDescriptor}; +use saikuro_core::RegistrationToken; +use saikuro_event::SaikuroError; +use saikuro_schema::registry::SchemaRegistry; + +#[test] +fn frozen_registry_rejects_type_only_merge() { + 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").await, + Err(SaikuroError::FrozenSchema(_)) + )); + assert!(registry + .snapshot() + .await + .expect("snapshot") + .types + .is_empty()); + }); +} + +#[test] +fn stale_same_id_deregistration_preserves_new_schema() { + 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) + .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").await); + }); +} + +fn empty_namespace() -> saikuro_core::schema::NamespaceSchema { + saikuro_core::schema::NamespaceSchema { + functions: Box::default(), + doc: None, + } +} diff --git a/Build/tests/saikuro-schema/schema_validation.rs b/Build/tests/saikuro-schema/schema_validation.rs new file mode 100644 index 00000000..36647675 --- /dev/null +++ b/Build/tests/saikuro-schema/schema_validation.rs @@ -0,0 +1,319 @@ +//! Schema registry and invocation validator tests + +use saikuro_core::{ + envelope::{Envelope, InvocationType}, + schema::{ + ArgumentDescriptor, FunctionMap, FunctionSchema, NamespaceSchema, PrimitiveType, + TypeDescriptor, Visibility, + }, +}; +use saikuro_event::{ErrorCode, SaikuroError, Value}; +use saikuro_schema::{ + registry::{NamespaceRegistration, SchemaRegistry}, + validator::InvocationValidator, +}; + +// Helpers + +fn two_arg_fn(vis: Visibility) -> FunctionSchema { + FunctionSchema { + args: vec![ + ArgumentDescriptor { + name: "a".into(), + r#type: TypeDescriptor::primitive(PrimitiveType::I64), + optional: false, + default: None, + doc: None, + }, + ArgumentDescriptor { + name: "b".into(), + r#type: TypeDescriptor::primitive(PrimitiveType::I64), + optional: false, + default: None, + doc: None, + }, + ], + returns: TypeDescriptor::primitive(PrimitiveType::I64), + visibility: vis, + capabilities: vec![], + idempotent: true, + doc: Some("add two integers".into()), + } +} + +fn unit_fn() -> FunctionSchema { + FunctionSchema { + args: vec![], + returns: TypeDescriptor::primitive(PrimitiveType::Unit), + visibility: Visibility::Public, + capabilities: vec![], + idempotent: false, + doc: None, + } +} + +async fn make_registry_with_math() -> SchemaRegistry { + let registry = SchemaRegistry::new(); + 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: Box::new(functions), + doc: None, + }, + provider_id: "provider-1".into(), + registration_token: saikuro_core::RegistrationToken::new(), + }) + .await + .unwrap(); + + registry +} + +#[test] +fn lookup_existing_function() { + 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() { + 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() { + 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() { + 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() { + 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).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).await.unwrap_err(); + assert!(matches!(err, SaikuroError::ArgumentArity { .. })); + }); +} + +#[test] +fn wrong_type_fails_validation() { + 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).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() { + 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).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() { + 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).await.unwrap_err(); + assert!( + matches!(err, SaikuroError::VisibilityDenied { .. }), + "expected VisibilityDenied for private fn, got {err:?}" + ); + }); +} + +#[test] +fn batch_with_no_items_fails() { + 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 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() { + 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 err = validator.validate(&env).await.unwrap_err(); + assert!(matches!(err, SaikuroError::EmptyBatch)); + }); +} + +#[test] +fn malformed_target_without_dot_fails() { + 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).await.unwrap_err(); + assert!( + matches!(err, SaikuroError::MalformedEnvelope(_)), + "expected MalformedEnvelope, got {err:?}" + ); + }); +} + +#[test] +fn optional_argument_may_be_omitted() { + 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).await.is_ok(), + "one-arg call to two-arg fn (second optional) should pass" + ); + }); +} diff --git a/Build/tests/saikuro-storage/flash.rs b/Build/tests/saikuro-storage/flash.rs new file mode 100644 index 00000000..65cbb1ed --- /dev/null +++ b/Build/tests/saikuro-storage/flash.rs @@ -0,0 +1,506 @@ +//! Integration tests for the flash-backed key-value store. +//! +//! 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`.) + +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, + } + + 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, + } + } + + /// 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; + } + } + } + + impl NorFlash for RcFlash { + const WRITE_SIZE: usize = WRITE_SIZE; + const ERASE_SIZE: usize = ERASE_SIZE; + + type Error = core::convert::Infallible; + + 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(()) + } + + 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(()) + } + + 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(()) + } + } +} + +use mock::{RcFlash, ERASE_SIZE, SECTORS}; + +fn flash_config() -> FlashConfig { + FlashConfig::new(0, 512, SECTORS, 32, 128, ERASE_SIZE).expect("valid flash config") +} + +fn config(auto_create: bool) -> StorageConfig { + StorageConfig { + auto_create_namespaces: auto_create, + ..Default::default() + } +} + +#[test] +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 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_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")) + ); + }); +} + +#[test] +fn get_put_roundtrip() { + block_on(async { + 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", "key").await.unwrap(), + Some(Bytes::from_static(b"value")) + ); + assert_eq!(store.get("ns", "missing").await.unwrap(), None); + }); +} + +#[test] +fn rejects_value_exceeding_max_value_len() { + block_on(async { + 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 rejects_key_exceeding_max_key_len() { + block_on(async { + 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 rejects_namespace_exceeding_255_bytes() { + block_on(async { + 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 accepts_item_at_exact_limits() { + block_on(async { + 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 auto_create_creates_namespace_implicitly() { + block_on(async { + 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())); + }); +} + +#[test] +fn auto_create_disabled_returns_not_found() { + block_on(async { + 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 prefix_isolation() { + block_on(async { + 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 namespaces_are_independent() { + block_on(async { + 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 durability_survives_reboot() { + block_on(async { + 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")) + ); + }); +} + +#[test] +fn mount_tolerates_tail_corruption() { + block_on(async { + 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(); + 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 compaction_rolls_over_without_data_loss() { + block_on(async { + 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 quota_exceeded_when_region_full_then_recoverable() { + block_on(async { + 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)); + }); +} + +#[test] +fn delete_is_idempotent() { + block_on(async { + 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); + }); +} + +#[test] +fn list_keys_returns_only_live() { + block_on(async { + 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()]); + }); +} + +#[test] +fn list_namespaces_includes_all() { + block_on(async { + 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 namespace_marker_records_existence() { + block_on(async { + 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 tombstone_distinguishes_present_from_absent() { + block_on(async { + 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 clear_namespace_keeps_namespace() { + block_on(async { + 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())); + }); +} + +#[test] +fn delete_namespace_removes_all() { + block_on(async { + 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())); + }); +} + +#[test] +fn deleting_unknown_namespace_is_ok() { + block_on(async { + let store = FlashKvStore::new(RcFlash::new(), config(true), flash_config()); + store.delete_namespace("ghost").await.unwrap(); + }); +} diff --git a/Build/crates/saikuro-storage/tests/inmemory.rs b/Build/tests/saikuro-storage/inmemory.rs similarity index 87% rename from Build/crates/saikuro-storage/tests/inmemory.rs rename to Build/tests/saikuro-storage/inmemory.rs index 58b6c5a3..2f9a3006 100644 --- a/Build/crates/saikuro-storage/tests/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] @@ -17,9 +19,11 @@ fn new_creates_empty_store() { #[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(), common::null_log()).await; + assert_eq!(s.config(), &cfg); + }) } // put / get / exists @@ -68,7 +72,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, common::null_log()).await; let r = s.exists("nonexistent", "k").await; assert!(r.is_err()); }) @@ -255,7 +259,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, common::null_log()).await; let r = s.put("manual", "k", Bytes::from("v")).await; assert!(r.is_err()); }) @@ -264,10 +268,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() + }, + common::null_log(), + ) + .await; let r = s.get("nowhere", "k").await; assert!(r.is_err()); }) @@ -278,8 +286,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"), + common::null_log(), + ) + .await; + let b = InMemoryStorage::with_config( + StorageConfig::default().with_prefix("tenant_b"), + common::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 +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")); + 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-storage/util.rs b/Build/tests/saikuro-storage/util.rs new file mode 100644 index 00000000..754981cb --- /dev/null +++ b/Build/tests/saikuro-storage/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); +} diff --git a/Build/tests/saikuro-transport/embedded_io.rs b/Build/tests/saikuro-transport/embedded_io.rs new file mode 100644 index 00000000..6f45c1f3 --- /dev/null +++ b/Build/tests/saikuro-transport/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" + )); + }); +} diff --git a/Build/tests/tests/transport_compliance.rs b/Build/tests/saikuro-transport/transport_compliance.rs similarity index 94% rename from Build/tests/tests/transport_compliance.rs rename to Build/tests/saikuro-transport/transport_compliance.rs index 6df46f35..4505f4eb 100644 --- a/Build/tests/tests/transport_compliance.rs +++ b/Build/tests/saikuro-transport/transport_compliance.rs @@ -10,10 +10,11 @@ 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; +use crate::common; + // COMPLIANCE TEST SUITE /// Run the full compliance suite against a transport pair factory. @@ -210,10 +211,14 @@ fn many_sequential_transports_correct(pair: (MemoryTransport, MemoryTransport)) #[test] fn memory_transport_compliance() { - run_transport_compliance(MemoryTransport::connected_pair); + let log = common::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 = common::null_log(); + run_transport_compliance(move || { + MemoryTransport::pair("compliance-a", "compliance-b", log.clone()) + }); } diff --git a/Build/tests/tests/transport_memory_stress.rs b/Build/tests/saikuro-transport/transport_memory_stress.rs similarity index 82% rename from Build/tests/tests/transport_memory_stress.rs rename to Build/tests/saikuro-transport/transport_memory_stress.rs index 763fc405..2efa25e4 100644 --- a/Build/tests/tests/transport_memory_stress.rs +++ b/Build/tests/saikuro-transport/transport_memory_stress.rs @@ -6,18 +6,17 @@ use bytes::Bytes; use saikuro_exec::sync::Barrier; -use saikuro_transport::{ - memory::MemoryTransport, - traits::{Transport, TransportReceiver, TransportSender}, -}; +use saikuro_transport::{MemoryTransport, Transport, TransportReceiver, TransportSender}; use std::sync::Arc; +use crate::common; + // 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(common::null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -45,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(); + 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(); @@ -82,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(); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -118,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(); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -136,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(); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -152,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(); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender, _) = a.split(); let (_, mut receiver) = b.split(); @@ -172,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(); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender_base, _) = a.split(); let (_, mut receiver) = b.split(); @@ -182,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(); + 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 { @@ -208,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(); + let (a, b) = MemoryTransport::connected_pair(common::null_log()); let (mut sender, _) = a.split(); let (_, receiver) = b.split(); @@ -219,13 +218,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(); }) } @@ -235,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"); - let (a2, b2) = MemoryTransport::pair("sys-C", "sys-D"); + 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(); 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 diff --git a/Build/tests/src/lib.rs b/Build/tests/src/lib.rs deleted file mode 100644 index 12274288..00000000 --- a/Build/tests/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -// Placeholder so the crate compiles diff --git a/Build/tests/tests/common/mod.rs b/Build/tests/tests/common/mod.rs deleted file mode 100644 index ccfe58a3..00000000 --- a/Build/tests/tests/common/mod.rs +++ /dev/null @@ -1,117 +0,0 @@ -use bytes::Bytes; -use saikuro_core::{ - capability::CapabilitySet, - envelope::Envelope, - schema::{FunctionSchema, NamespaceSchema, PrimitiveType, Schema, TypeDescriptor, Visibility}, - value::Value, - ResponseEnvelope, -}; -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::SchemaRegistry, validator::InvocationValidator, -}; -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); - let handle = ProviderHandle::new( - format!("{namespace}-provider"), - vec![namespace.to_owned()], - work_tx, - ); - let registry = ProviderRegistry::new(); - registry.register(handle); - (registry, work_rx) -} - -pub fn simple_schema(namespace: &str, function: &str) -> 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, - }, - ); - Schema { - version: 1, - namespaces, - types: HashMap::new(), - } -} - -pub 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") -} - -pub fn make_announce_envelope(schema: &Schema) -> Envelope { - Envelope::announce(schema_to_value(schema)) -} - -pub fn register_namespace(registry: &SchemaRegistry, namespace: &str, function: &str) { - registry - .merge_schema(simple_schema(namespace, function), "test-provider") - .expect("merge_schema must succeed"); -} - -pub async fn round_trip_via_handler( - schema_registry: SchemaRegistry, - provider_registry: ProviderRegistry, - envelope: Envelope, -) -> ResponseEnvelope { - let (test_transport, handler_transport) = MemoryTransport::pair("test", "handler"); - 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(), - sender: handler_sender, - receiver: handler_receiver, - validator, - capability_engine, - router, - peer_capabilities: CapabilitySet::empty(), - max_message_size: 4 * 1024 * 1024, - schema_registry, - provider_registry, - }; - - let frame = Bytes::from(envelope.to_msgpack().expect("encode envelope")); - test_sender.send(frame).await.expect("send frame"); - drop(test_sender); - - handler.run().await; - - let resp_frame = test_receiver - .recv() - .await - .expect("recv response") - .expect("frame must be present"); - ResponseEnvelope::from_msgpack(&resp_frame).expect("decode response") -} diff --git a/Build/tests/tests/schema_validation.rs b/Build/tests/tests/schema_validation.rs deleted file mode 100644 index 7fcefce1..00000000 --- a/Build/tests/tests/schema_validation.rs +++ /dev/null @@ -1,283 +0,0 @@ -//! Schema registry and invocation validator tests - -use saikuro_core::{ - envelope::{Envelope, InvocationType}, - error::ErrorCode, - schema::{ - ArgumentDescriptor, FunctionSchema, NamespaceSchema, PrimitiveType, TypeDescriptor, - Visibility, - }, - value::Value, -}; -use saikuro_schema::{ - registry::{NamespaceRegistration, SchemaRegistry}, - validator::{InvocationValidator, ValidationError}, -}; -use std::collections::HashMap; - -// Helpers - -fn two_arg_fn(vis: Visibility) -> FunctionSchema { - FunctionSchema { - args: vec![ - ArgumentDescriptor { - name: "a".into(), - r#type: TypeDescriptor::primitive(PrimitiveType::I64), - optional: false, - default: None, - doc: None, - }, - ArgumentDescriptor { - name: "b".into(), - r#type: TypeDescriptor::primitive(PrimitiveType::I64), - optional: false, - default: None, - doc: None, - }, - ], - returns: TypeDescriptor::primitive(PrimitiveType::I64), - visibility: vis, - capabilities: vec![], - idempotent: true, - doc: Some("add two integers".into()), - } -} - -fn unit_fn() -> FunctionSchema { - FunctionSchema { - args: vec![], - returns: TypeDescriptor::primitive(PrimitiveType::Unit), - visibility: Visibility::Public, - capabilities: vec![], - idempotent: false, - doc: None, - } -} - -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 - }); - - registry - .register(NamespaceRegistration { - namespace: "math".into(), - schema: NamespaceSchema { - functions, - doc: None, - }, - provider_id: "provider-1".into(), - }) - .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); -} - -#[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}" - ); -} - -#[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()); -} - -#[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)]); - assert!(validator.validate(&env).is_ok()); -} - -#[test] -fn wrong_arity_fails_validation() { - let registry = make_registry_with_math(); - let validator = InvocationValidator::new(registry); - - // too few args - let env_few = Envelope::call("math.add", vec![Value::Int(1)]); - let err = validator.validate(&env_few).unwrap_err(); - assert!(matches!(err, ValidationError::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)], - ); - let err = validator.validate(&env_many).unwrap_err(); - assert!(matches!(err, ValidationError::ArgumentArity { .. })); -} - -#[test] -fn wrong_type_fails_validation() { - let registry = make_registry_with_math(); - let validator = InvocationValidator::new(registry); - - // "hello" is not an integer - let env = Envelope::call( - "math.add", - vec![Value::String("hello".into()), Value::Int(2)], - ); - let err = validator.validate(&env).unwrap_err(); - assert!( - matches!(err, ValidationError::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); - - let env = Envelope::call("math.internal_op", vec![]); - let err = validator.validate(&env).unwrap_err(); - assert!( - matches!(err, ValidationError::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); - - let env = Envelope::call("math.secret", vec![]); - let err = validator.validate(&env).unwrap_err(); - assert!( - matches!(err, ValidationError::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); - - let mut env = Envelope::call("", vec![]); - env.invocation_type = InvocationType::Batch; - env.target = String::new(); - env.batch_items = None; - - let err = validator.validate(&env).unwrap_err(); - assert!( - matches!(err, ValidationError::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); - - let mut env = Envelope::call("", vec![]); - env.invocation_type = InvocationType::Batch; - env.target = String::new(); - env.batch_items = Some(vec![]); - - let err = validator.validate(&env).unwrap_err(); - assert!(matches!(err, ValidationError::EmptyBatch)); -} - -#[test] -fn malformed_target_without_dot_fails() { - let registry = make_registry_with_math(); - let validator = InvocationValidator::new(registry); - - let env = Envelope::call("nofunctionpart", vec![]); - let err = validator.validate(&env).unwrap_err(); - assert!( - matches!(err, ValidationError::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 = 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, - }, - ); - registry - .register(NamespaceRegistration { - namespace: "greet".into(), - schema: NamespaceSchema { - functions, - doc: None, - }, - provider_id: "p".into(), - }) - .unwrap(); - - let validator = InvocationValidator::new(registry); - // Providing only the required argument should pass. - let env = Envelope::call("greet.greet", vec![Value::String("Alice".into())]); - assert!( - validator.validate(&env).is_ok(), - "one-arg call to two-arg fn (second optional) should pass" - ); -} 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"