From 60ee2d1a2c31bbbbe7e7699b2a14ba11207ace5c Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 13 Feb 2026 06:15:22 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20wasm=20actor=20crate=20=E2=80=94=20run?= =?UTF-8?q?=20WebAssembly=20guests=20inside=20swactor=20actors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `crates/wasm-actor/` (swactor-wasm-actor), which embeds wasmtime-sandboxed Wasm instances inside regular swactor actors. Messages flow as raw bytes through the guest ↔ host contract (alloc/handle exports, swactor.send import). The host drains an outbox after each handle call and routes messages via ctx.send(). Includes 3 no_std guest modules (echo, double, silent) and 7 integration tests covering roundtrip delivery, binary fidelity, multi-send, error cases, engine sharing, and native↔wasm interop. Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski --- Cargo.lock | 1065 +++++++++++++++-- Cargo.toml | 2 +- crates/wasm-actor/Cargo.toml | 12 + crates/wasm-actor/src/actor.rs | 59 + crates/wasm-actor/src/builder.rs | 109 ++ crates/wasm-actor/src/engine.rs | 35 + crates/wasm-actor/src/error.rs | 27 + crates/wasm-actor/src/lib.rs | 13 + .../wasm-actor/tests/guests/double/Cargo.lock | 7 + .../wasm-actor/tests/guests/double/Cargo.toml | 13 + .../wasm-actor/tests/guests/double/src/lib.rs | 63 + .../wasm-actor/tests/guests/echo/Cargo.lock | 7 + .../wasm-actor/tests/guests/echo/Cargo.toml | 13 + .../wasm-actor/tests/guests/echo/src/lib.rs | 62 + .../wasm-actor/tests/guests/silent/Cargo.lock | 7 + .../wasm-actor/tests/guests/silent/Cargo.toml | 13 + .../wasm-actor/tests/guests/silent/src/lib.rs | 45 + crates/wasm-actor/tests/wasm_actor.rs | 331 +++++ docs/development_history/WASM_ACTOR.md | 191 +++ docs/wasm-actor.md | 115 ++ 20 files changed, 2079 insertions(+), 110 deletions(-) create mode 100644 crates/wasm-actor/Cargo.toml create mode 100644 crates/wasm-actor/src/actor.rs create mode 100644 crates/wasm-actor/src/builder.rs create mode 100644 crates/wasm-actor/src/engine.rs create mode 100644 crates/wasm-actor/src/error.rs create mode 100644 crates/wasm-actor/src/lib.rs create mode 100644 crates/wasm-actor/tests/guests/double/Cargo.lock create mode 100644 crates/wasm-actor/tests/guests/double/Cargo.toml create mode 100644 crates/wasm-actor/tests/guests/double/src/lib.rs create mode 100644 crates/wasm-actor/tests/guests/echo/Cargo.lock create mode 100644 crates/wasm-actor/tests/guests/echo/Cargo.toml create mode 100644 crates/wasm-actor/tests/guests/echo/src/lib.rs create mode 100644 crates/wasm-actor/tests/guests/silent/Cargo.lock create mode 100644 crates/wasm-actor/tests/guests/silent/Cargo.toml create mode 100644 crates/wasm-actor/tests/guests/silent/src/lib.rs create mode 100644 crates/wasm-actor/tests/wasm_actor.rs create mode 100644 docs/development_history/WASM_ACTOR.md create mode 100644 docs/wasm-actor.md diff --git a/Cargo.lock b/Cargo.lock index efcff0d..06fe0c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,27 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -35,18 +56,50 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object 0.37.3", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "ascii" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64ct" version = "1.8.3" @@ -97,6 +150,15 @@ name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cassowary" @@ -119,6 +181,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -189,6 +263,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -209,6 +292,15 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -218,6 +310,122 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-bforest" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c6e3969a7ce267259ce244b7867c5d3bc9e65b0a87e81039588dfdeaede9f34" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c22032c4cb42558371cf516bb47f26cdad1819d3475c133e93c49f50ebf304e" +dependencies = [ + "bumpalo", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.14.5", + "log", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon 0.13.4", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c904bc71c61b27fc57827f4a1379f29de64fe95653b620a3db77d59655eee0b8" +dependencies = [ + "cranelift-codegen-shared", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40180f5497572f644ce88c255480981ae2ec1d7bb4d8e0c0136a13b87a2f2ceb" + +[[package]] +name = "cranelift-control" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d132c6d0bd8a489563472afc171759da0707804a65ece7ceb15a8c6d7dd5ef" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d0d9618275474fbf679dd018ac6e009acbd6ae6850f6a67be33fb3b00b323" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fac41e16729107393174b0c9e3730fb072866100e1e64e80a1a963b2e484d57" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon 0.13.4", +] + +[[package]] +name = "cranelift-isle" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca20d576e5070044d0a72a9effc2deacf4d6aa650403189d8ea50126483944d" + +[[package]] +name = "cranelift-native" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon 0.13.4", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.5.1" @@ -401,6 +609,15 @@ dependencies = [ "syn", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "der" version = "0.7.10" @@ -421,6 +638,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dispatch2" version = "0.3.0" @@ -475,6 +713,27 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -491,6 +750,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + [[package]] name = "fastrand" version = "2.3.0" @@ -503,6 +768,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fnv" version = "1.0.7" @@ -515,6 +786,28 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "fxprof-processed-profile" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" +dependencies = [ + "bitflags", + "debugid", + "fxhash", + "serde", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -549,16 +842,14 @@ dependencies = [ ] [[package]] -name = "getrandom" -version = "0.4.1" +name = "gimli" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", + "fallible-iterator", + "indexmap", + "stable_deref_trait", ] [[package]] @@ -572,6 +863,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -581,6 +881,7 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash", + "serde", ] [[package]] @@ -673,6 +974,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -688,6 +998,36 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -704,6 +1044,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -716,6 +1062,22 @@ version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -752,12 +1114,30 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.3", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -824,6 +1204,27 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap", + "memchr", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -881,6 +1282,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "plotters" version = "0.3.7" @@ -915,6 +1322,18 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -924,16 +1343,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -971,6 +1380,28 @@ dependencies = [ "proptest", ] +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulley-interpreter" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62d95f8575df49a2708398182f49a888cf9dc30210fb1fd2df87c889edcee75d" +dependencies = [ + "cranelift-bitset", + "log", + "sptr", + "wasmtime-math", +] + [[package]] name = "pyo3" version = "0.23.5" @@ -996,7 +1427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb" dependencies = [ "once_cell", - "target-lexicon", + "target-lexicon 0.12.16", ] [[package]] @@ -1160,6 +1591,31 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regalloc2" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash", + "smallvec", +] + [[package]] name = "regex" version = "1.12.3" @@ -1206,6 +1662,18 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1285,6 +1753,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -1358,6 +1830,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook" version = "0.3.18" @@ -1427,6 +1905,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "spki" @@ -1438,6 +1919,18 @@ dependencies = [ "der", ] +[[package]] +name = "sptr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1501,6 +1994,15 @@ dependencies = [ "swactor", ] +[[package]] +name = "swactor-wasm-actor" +version = "0.1.0" +dependencies = [ + "swactor", + "wasmtime", + "wat", +] + [[package]] name = "syn" version = "2.0.114" @@ -1518,6 +2020,12 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "target-lexicon" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1dd07eb858a2067e2f3c7155d54e929265c264e6f37efe3ee7a8d1b5a1dd0ba" + [[package]] name = "tempfile" version = "3.25.0" @@ -1525,12 +2033,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.3.4", "once_cell", "rustix 1.1.3", "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1660,6 +2217,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1719,6 +2287,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1765,15 +2343,6 @@ dependencies = [ "wit-bindgen", ] -[[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", -] - [[package]] name = "wasm" version = "0.1.0" @@ -1829,36 +2398,332 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.244.0" +version = "0.221.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "dc8444fe4920de80a4fe5ab564fff2ae58b6b73166b89751f8c6c93509da32e5" dependencies = [ - "leb128fmt", - "wasmparser", + "leb128", + "wasmparser 0.221.3", ] [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "wasm-encoder" +version = "0.245.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "95d568e113f706ee7a7df9b33547bb80721f55abffc79b3dc4d09c368690e662" dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", + "leb128fmt", + "wasmparser 0.245.0", ] [[package]] name = "wasmparser" -version = "0.244.0" +version = "0.221.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" dependencies = [ "bitflags", "hashbrown 0.15.5", "indexmap", "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.245.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48a767a48974f0c8b66f211b96e01aa77feed58b8ccce4e7f0cff0ae55b174d4" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.221.3", +] + +[[package]] +name = "wasmtime" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11976a250672556d1c4c04c6d5d7656ac9192ac9edc42a4587d6c21460010e69" +dependencies = [ + "addr2line", + "anyhow", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "fxprof-processed-profile", + "gimli", + "hashbrown 0.14.5", + "indexmap", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object 0.36.7", + "once_cell", + "paste", + "postcard", + "psm", + "pulley-interpreter", + "rayon", + "rustix 0.38.44", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "sptr", + "target-lexicon 0.13.4", + "trait-variant", + "wasm-encoder 0.221.3", + "wasmparser 0.221.3", + "wasmtime-asm-macros", + "wasmtime-cache", + "wasmtime-component-macro", + "wasmtime-component-util", + "wasmtime-cranelift", + "wasmtime-environ", + "wasmtime-fiber", + "wasmtime-jit-debug", + "wasmtime-jit-icache-coherence", + "wasmtime-math", + "wasmtime-slab", + "wasmtime-versioned-export-macros", + "wasmtime-winch", + "wat", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-asm-macros" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f178b0d125201fbe9f75beaf849bd3e511891f9e45ba216a5b620802ccf64f2" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-cache" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1161c8f62880deea07358bc40cceddc019f1c81d46007bc390710b2fe24ffc" +dependencies = [ + "anyhow", + "base64", + "directories-next", + "log", + "postcard", + "rustix 0.38.44", + "serde", + "serde_derive", + "sha2", + "toml", + "windows-sys 0.59.0", + "zstd", +] + +[[package]] +name = "wasmtime-component-macro" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d74de6592ed945d0a602f71243982a304d5d02f1e501b638addf57f42d57dfaf" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn", + "wasmtime-component-util", + "wasmtime-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-component-util" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707dc7b3c112ab5a366b30cfe2fb5b2f8e6a0f682f16df96a5ec582bfe6f056e" + +[[package]] +name = "wasmtime-cranelift" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366be722674d4bf153290fbcbc4d7d16895cc82fb3e869f8d550ff768f9e9e87" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.12.1", + "log", + "object 0.36.7", + "smallvec", + "target-lexicon 0.13.4", + "thiserror 1.0.69", + "wasmparser 0.221.3", + "wasmtime-environ", + "wasmtime-versioned-export-macros", +] + +[[package]] +name = "wasmtime-environ" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdadc1af7097347aa276a4f008929810f726b5b46946971c660b6d421e9994ad" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap", + "log", + "object 0.36.7", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon 0.13.4", + "wasm-encoder 0.221.3", + "wasmparser 0.221.3", + "wasmprinter", + "wasmtime-component-util", +] + +[[package]] +name = "wasmtime-fiber" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccba90d4119f081bca91190485650730a617be1fff5228f8c4757ce133d21117" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "rustix 0.38.44", + "wasmtime-asm-macros", + "wasmtime-versioned-export-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-jit-debug" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e7b61488a5ee00c35c8c22de707c36c0aecacf419a3be803a6a2ba5e860f56a" +dependencies = [ + "object 0.36.7", + "rustix 0.38.44", + "wasmtime-versioned-export-macros", +] + +[[package]] +name = "wasmtime-jit-icache-coherence" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec5e8552e01692e6c2e5293171704fed8abdec79d1a6995a0870ab190e5747d1" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-math" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29210ec2aa25e00f4d54605cedaf080f39ec01a872c5bd520ad04c67af1dde17" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-slab" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb5821a96fa04ac14bc7b158bb3d5cd7729a053db5a74dad396cd513a5e5ccf" + +[[package]] +name = "wasmtime-versioned-export-macros" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ff86db216dc0240462de40c8290887a613dddf9685508eb39479037ba97b5b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasmtime-winch" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdbabfb8f20502d5e1d81092b9ead3682ae59988487aafcd7567387b7a43cf8f" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "object 0.36.7", + "target-lexicon 0.13.4", + "wasmparser 0.221.3", + "wasmtime-cranelift", + "wasmtime-environ", + "winch-codegen", +] + +[[package]] +name = "wasmtime-wit-bindgen" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8358319c2dd1e4db79e3c1c5d3a5af84956615343f9f89f4e4996a36816e06e6" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "wit-parser", +] + +[[package]] +name = "wast" +version = "245.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ffc7471e16a6f3c7a3c3a230314915b5dcd158e5ef13ccda2f43358a9df00c" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width 0.2.0", + "wasm-encoder 0.245.0", +] + +[[package]] +name = "wat" +version = "1.245.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bcac6f915e2a84a4c0d9df9d41ad7518d99cda13f3bb83e3b8c22bf8726ab6" +dependencies = [ + "wast", ] [[package]] @@ -1902,6 +2767,24 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f849ef2c5f46cb0a20af4b4487aaa239846e52e2c03f13fa3c784684552859c" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon 0.13.4", + "thiserror 1.0.69", + "wasmparser 0.221.3", + "wasmtime-cranelift", + "wasmtime-environ", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -2004,76 +2887,12 @@ 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-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", - "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", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] [[package]] name = "wit-parser" -version = "0.244.0" +version = "0.221.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac" dependencies = [ "anyhow", "id-arena", @@ -2084,7 +2903,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.221.3", ] [[package]] @@ -2118,3 +2937,31 @@ name = "zmij" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 2dcb021..f582294 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/python", "crates/wasm", "crates/simulation", "crates/runtime-dashboard", "crates/distribution", "crates/simulation-dashboard", "crates/std"] +members = [".", "crates/python", "crates/wasm", "crates/wasm-actor", "crates/simulation", "crates/runtime-dashboard", "crates/distribution", "crates/simulation-dashboard", "crates/std"] exclude = ["tools/depgraph"] [package] diff --git a/crates/wasm-actor/Cargo.toml b/crates/wasm-actor/Cargo.toml new file mode 100644 index 0000000..e629b8a --- /dev/null +++ b/crates/wasm-actor/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "swactor-wasm-actor" +version = "0.1.0" +edition = "2024" + +[dependencies] +swactor = { path = "../.." } +wasmtime = "29" + +[dev-dependencies] +swactor = { path = "../..", features = ["getrandom"] } +wat = "1" diff --git a/crates/wasm-actor/src/actor.rs b/crates/wasm-actor/src/actor.rs new file mode 100644 index 0000000..088ccf0 --- /dev/null +++ b/crates/wasm-actor/src/actor.rs @@ -0,0 +1,59 @@ +use swactor::actor::{ActorInterface, Ctx}; +use wasmtime::{Memory, Store, TypedFunc}; + +use crate::ByteMessage; + +/// State accessible to host functions during guest execution. +#[derive(Default)] +pub(crate) struct HostState { + pub outbox: Vec<(swactor::actor::ActorAddress, Vec)>, +} + +/// An actor whose logic is defined by a WebAssembly guest module. +/// +/// Messages arrive as [`ByteMessage`], are copied into Wasm linear memory, +/// and processed by the guest's `handle` export. The guest can send messages +/// back via the `swactor.send` host import. +pub struct WasmActor { + pub(crate) store: Store, + pub(crate) memory: Memory, + pub(crate) alloc: TypedFunc, + pub(crate) handle: TypedFunc<(i32, i32), ()>, +} + +impl ActorInterface for WasmActor { + type Incoming = ByteMessage; + type Response = (); + + fn handle(&mut self, ctx: &Ctx, msg: ByteMessage) { + let bytes = &msg.0; + let len: i32 = match i32::try_from(bytes.len()) { + Ok(n) => n, + Err(_) => return, // message too large for i32 ABI + }; + + // 1. Allocate space in guest memory + let ptr = match self.alloc.call(&mut self.store, len) { + Ok(ptr) if ptr < 0 => return, // invalid pointer + Ok(0) if len > 0 => return, // OOM — drop message + Ok(ptr) => ptr, + Err(_) => return, // alloc trapped — drop message + }; + + // 2. Write message bytes into guest memory + self.memory.data_mut(&mut self.store) + [ptr as usize..(ptr as usize + bytes.len())] + .copy_from_slice(bytes); + + // 3. Call guest handle + if self.handle.call(&mut self.store, (ptr, len)).is_err() { + return; // handle trapped — drop message, keep actor alive + } + + // 4. Drain outbox → send via ctx + let outbox: Vec<_> = self.store.data_mut().outbox.drain(..).collect(); + for (dest, payload) in outbox { + let _ = ctx.send(dest, ByteMessage(payload)); + } + } +} diff --git a/crates/wasm-actor/src/builder.rs b/crates/wasm-actor/src/builder.rs new file mode 100644 index 0000000..06a199c --- /dev/null +++ b/crates/wasm-actor/src/builder.rs @@ -0,0 +1,109 @@ +use swactor::actor::ActorAddress; +use wasmtime::{Linker, Module, Store, TypedFunc}; + +use crate::actor::{HostState, WasmActor}; +use crate::engine::SharedEngine; +use crate::error::WasmActorError; + +/// Compiles a Wasm module and produces a ready-to-use [`WasmActor`]. +pub struct WasmActorBuilder { + engine: SharedEngine, + wasm_bytes: Vec, +} + +impl WasmActorBuilder { + pub fn new(engine: SharedEngine, wasm_bytes: impl Into>) -> Self { + Self { + engine, + wasm_bytes: wasm_bytes.into(), + } + } + + /// Compile the module, link host functions, and instantiate. + pub fn build(self) -> Result { + let engine = self.engine.inner(); + let module = Module::new(engine, &self.wasm_bytes)?; + + let mut linker: Linker = Linker::new(engine); + Self::link_send(&mut linker)?; + + let mut store = Store::new(engine, HostState::default()); + let instance = linker.instantiate(&mut store, &module)?; + + // Extract required exports + let memory = instance + .get_memory(&mut store, "memory") + .ok_or(WasmActorError::MissingExport("memory"))?; + + let alloc: TypedFunc = instance + .get_typed_func(&mut store, "alloc") + .map_err(|_| WasmActorError::MissingExport("alloc"))?; + + let handle: TypedFunc<(i32, i32), ()> = instance + .get_typed_func(&mut store, "handle") + .map_err(|_| WasmActorError::MissingExport("handle"))?; + + Ok(WasmActor { + store, + memory, + alloc, + handle, + }) + } + + /// Link the `swactor.send` host import. + fn link_send(linker: &mut Linker) -> Result<(), WasmActorError> { + linker.func_wrap( + "swactor", + "send", + |mut caller: wasmtime::Caller<'_, HostState>, + dest_ptr: i32, + payload_ptr: i32, + payload_len: i32| + -> Result<(), wasmtime::Error> { + let mem = caller + .get_export("memory") + .and_then(|e| e.into_memory()) + .ok_or_else(|| wasmtime::Error::msg("guest must export memory"))?; + let data = mem.data(&caller); + let mem_len = data.len(); + + // Validate non-negative arguments + if dest_ptr < 0 || payload_ptr < 0 || payload_len < 0 { + return Err(wasmtime::Error::msg( + "negative argument in swactor.send", + )); + } + + let dest_ptr = dest_ptr as usize; + let payload_ptr = payload_ptr as usize; + let payload_len = payload_len as usize; + + // Bounds-check with overflow protection + let dest_end = dest_ptr + .checked_add(32) + .ok_or_else(|| wasmtime::Error::msg("dest_ptr overflow"))?; + let payload_end = payload_ptr + .checked_add(payload_len) + .ok_or_else(|| wasmtime::Error::msg("payload range overflow"))?; + if dest_end > mem_len || payload_end > mem_len { + return Err(wasmtime::Error::msg( + "out-of-bounds memory access in swactor.send", + )); + } + + // Read 32-byte destination address + let mut addr_bytes = [0u8; 32]; + addr_bytes.copy_from_slice(&data[dest_ptr..dest_end]); + let dest = ActorAddress(addr_bytes); + + // Read payload + let payload = data[payload_ptr..payload_end].to_vec(); + + caller.data_mut().outbox.push((dest, payload)); + Ok(()) + }, + )?; + Ok(()) + } +} diff --git a/crates/wasm-actor/src/engine.rs b/crates/wasm-actor/src/engine.rs new file mode 100644 index 0000000..8cc2dfc --- /dev/null +++ b/crates/wasm-actor/src/engine.rs @@ -0,0 +1,35 @@ +use std::sync::Arc; + +use wasmtime::Engine; + +/// A shared, cheaply-cloneable Wasm engine. +/// +/// Created once and reused across multiple [`WasmActor`](crate::WasmActor) instances. +/// Configured with maximum sandboxing — no threads, no SIMD, no reference types. +#[derive(Clone)] +pub struct SharedEngine(Arc); + +impl std::fmt::Debug for SharedEngine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("SharedEngine").field(&"").finish() + } +} + +impl SharedEngine { + /// Create a new engine with sandboxed defaults. + pub fn new() -> Result { + let mut config = wasmtime::Config::new(); + config.wasm_threads(false); + config.wasm_simd(false); + config.wasm_relaxed_simd(false); + config.wasm_reference_types(false); + config.wasm_multi_value(false); + config.wasm_bulk_memory(true); + let engine = Engine::new(&config)?; + Ok(Self(Arc::new(engine))) + } + + pub(crate) fn inner(&self) -> &Engine { + &self.0 + } +} diff --git a/crates/wasm-actor/src/error.rs b/crates/wasm-actor/src/error.rs new file mode 100644 index 0000000..b91c052 --- /dev/null +++ b/crates/wasm-actor/src/error.rs @@ -0,0 +1,27 @@ +use std::fmt; + +/// Errors that can occur when building or running a WasmActor. +#[derive(Debug)] +pub enum WasmActorError { + /// A required export is missing from the Wasm module. + MissingExport(&'static str), + /// The Wasm module failed to compile or instantiate. + Wasmtime(wasmtime::Error), +} + +impl fmt::Display for WasmActorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingExport(name) => write!(f, "missing required export: `{name}`"), + Self::Wasmtime(e) => write!(f, "wasmtime error: {e}"), + } + } +} + +impl std::error::Error for WasmActorError {} + +impl From for WasmActorError { + fn from(e: wasmtime::Error) -> Self { + Self::Wasmtime(e) + } +} diff --git a/crates/wasm-actor/src/lib.rs b/crates/wasm-actor/src/lib.rs new file mode 100644 index 0000000..3f744c2 --- /dev/null +++ b/crates/wasm-actor/src/lib.rs @@ -0,0 +1,13 @@ +mod actor; +mod builder; +mod engine; +mod error; + +pub use actor::WasmActor; +pub use builder::WasmActorBuilder; +pub use engine::SharedEngine; +pub use error::WasmActorError; + +/// A message carrying raw bytes, suitable for passing to/from Wasm guests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ByteMessage(pub Vec); diff --git a/crates/wasm-actor/tests/guests/double/Cargo.lock b/crates/wasm-actor/tests/guests/double/Cargo.lock new file mode 100644 index 0000000..b46d745 --- /dev/null +++ b/crates/wasm-actor/tests/guests/double/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "double-guest" +version = "0.1.0" diff --git a/crates/wasm-actor/tests/guests/double/Cargo.toml b/crates/wasm-actor/tests/guests/double/Cargo.toml new file mode 100644 index 0000000..943eabe --- /dev/null +++ b/crates/wasm-actor/tests/guests/double/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] + +[package] +name = "double-guest" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "s" +lto = true diff --git a/crates/wasm-actor/tests/guests/double/src/lib.rs b/crates/wasm-actor/tests/guests/double/src/lib.rs new file mode 100644 index 0000000..2ea9158 --- /dev/null +++ b/crates/wasm-actor/tests/guests/double/src/lib.rs @@ -0,0 +1,63 @@ +#![no_std] + +use core::cell::UnsafeCell; +use core::panic::PanicInfo; + +// --- bump allocator --- +const HEAP_SIZE: usize = 65536; + +struct BumpAlloc { + heap: UnsafeCell<[u8; HEAP_SIZE]>, + offset: UnsafeCell, +} + +unsafe impl Sync for BumpAlloc {} + +static ALLOC: BumpAlloc = BumpAlloc { + heap: UnsafeCell::new([0u8; HEAP_SIZE]), + offset: UnsafeCell::new(0), +}; + +#[unsafe(no_mangle)] +pub extern "C" fn alloc(size: i32) -> i32 { + unsafe { + let offset = &mut *ALLOC.offset.get(); + let heap = &mut *ALLOC.heap.get(); + let align = 8; + let start = (*offset + align - 1) & !(align - 1); + let end = start + size as usize; + if end > heap.len() { + return 0; // OOM + } + *offset = end; + heap.as_ptr().add(start) as i32 + } +} + +// --- host import --- +#[link(wasm_import_module = "swactor")] +unsafe extern "C" { + #[link_name = "send"] + fn host_send(dest_ptr: i32, payload_ptr: i32, payload_len: i32); +} + +/// Message format: first 32 bytes = destination address, rest = payload. +/// Sends the payload back twice to demonstrate multi-send. +#[unsafe(no_mangle)] +pub extern "C" fn handle(ptr: i32, len: i32) { + if len < 32 { + return; + } + let dest_ptr = ptr; + let payload_ptr = ptr + 32; + let payload_len = len - 32; + unsafe { + host_send(dest_ptr, payload_ptr, payload_len); + host_send(dest_ptr, payload_ptr, payload_len); + } +} + +#[panic_handler] +fn panic(_info: &PanicInfo) -> ! { + loop {} +} diff --git a/crates/wasm-actor/tests/guests/echo/Cargo.lock b/crates/wasm-actor/tests/guests/echo/Cargo.lock new file mode 100644 index 0000000..224c914 --- /dev/null +++ b/crates/wasm-actor/tests/guests/echo/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "echo-guest" +version = "0.1.0" diff --git a/crates/wasm-actor/tests/guests/echo/Cargo.toml b/crates/wasm-actor/tests/guests/echo/Cargo.toml new file mode 100644 index 0000000..7c33f5c --- /dev/null +++ b/crates/wasm-actor/tests/guests/echo/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] + +[package] +name = "echo-guest" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "s" +lto = true diff --git a/crates/wasm-actor/tests/guests/echo/src/lib.rs b/crates/wasm-actor/tests/guests/echo/src/lib.rs new file mode 100644 index 0000000..edc69a5 --- /dev/null +++ b/crates/wasm-actor/tests/guests/echo/src/lib.rs @@ -0,0 +1,62 @@ +#![no_std] + +use core::cell::UnsafeCell; +use core::panic::PanicInfo; + +// --- bump allocator --- +const HEAP_SIZE: usize = 65536; + +struct BumpAlloc { + heap: UnsafeCell<[u8; HEAP_SIZE]>, + offset: UnsafeCell, +} + +unsafe impl Sync for BumpAlloc {} + +static ALLOC: BumpAlloc = BumpAlloc { + heap: UnsafeCell::new([0u8; HEAP_SIZE]), + offset: UnsafeCell::new(0), +}; + +#[unsafe(no_mangle)] +pub extern "C" fn alloc(size: i32) -> i32 { + unsafe { + let offset = &mut *ALLOC.offset.get(); + let heap = &mut *ALLOC.heap.get(); + let align = 8; + let start = (*offset + align - 1) & !(align - 1); + let end = start + size as usize; + if end > heap.len() { + return 0; // OOM + } + *offset = end; + heap.as_ptr().add(start) as i32 + } +} + +// --- host import --- +#[link(wasm_import_module = "swactor")] +unsafe extern "C" { + #[link_name = "send"] + fn host_send(dest_ptr: i32, payload_ptr: i32, payload_len: i32); +} + +/// Message format: first 32 bytes = destination address, rest = payload. +/// Echo sends the payload portion back to the specified destination. +#[unsafe(no_mangle)] +pub extern "C" fn handle(ptr: i32, len: i32) { + if len < 32 { + return; + } + let dest_ptr = ptr; + let payload_ptr = ptr + 32; + let payload_len = len - 32; + unsafe { + host_send(dest_ptr, payload_ptr, payload_len); + } +} + +#[panic_handler] +fn panic(_info: &PanicInfo) -> ! { + loop {} +} diff --git a/crates/wasm-actor/tests/guests/silent/Cargo.lock b/crates/wasm-actor/tests/guests/silent/Cargo.lock new file mode 100644 index 0000000..aff96a4 --- /dev/null +++ b/crates/wasm-actor/tests/guests/silent/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "silent-guest" +version = "0.1.0" diff --git a/crates/wasm-actor/tests/guests/silent/Cargo.toml b/crates/wasm-actor/tests/guests/silent/Cargo.toml new file mode 100644 index 0000000..14e524f --- /dev/null +++ b/crates/wasm-actor/tests/guests/silent/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] + +[package] +name = "silent-guest" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "s" +lto = true diff --git a/crates/wasm-actor/tests/guests/silent/src/lib.rs b/crates/wasm-actor/tests/guests/silent/src/lib.rs new file mode 100644 index 0000000..c1d2a1c --- /dev/null +++ b/crates/wasm-actor/tests/guests/silent/src/lib.rs @@ -0,0 +1,45 @@ +#![no_std] + +use core::cell::UnsafeCell; +use core::panic::PanicInfo; + +// --- bump allocator --- +const HEAP_SIZE: usize = 65536; + +struct BumpAlloc { + heap: UnsafeCell<[u8; HEAP_SIZE]>, + offset: UnsafeCell, +} + +unsafe impl Sync for BumpAlloc {} + +static ALLOC: BumpAlloc = BumpAlloc { + heap: UnsafeCell::new([0u8; HEAP_SIZE]), + offset: UnsafeCell::new(0), +}; + +#[unsafe(no_mangle)] +pub extern "C" fn alloc(size: i32) -> i32 { + unsafe { + let offset = &mut *ALLOC.offset.get(); + let heap = &mut *ALLOC.heap.get(); + let align = 8; + let start = (*offset + align - 1) & !(align - 1); + let end = start + size as usize; + if end > heap.len() { + return 0; // OOM + } + *offset = end; + heap.as_ptr().add(start) as i32 + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn handle(_ptr: i32, _len: i32) { + // Silent: receive bytes, do nothing +} + +#[panic_handler] +fn panic(_info: &PanicInfo) -> ! { + loop {} +} diff --git a/crates/wasm-actor/tests/wasm_actor.rs b/crates/wasm-actor/tests/wasm_actor.rs new file mode 100644 index 0000000..2125369 --- /dev/null +++ b/crates/wasm-actor/tests/wasm_actor.rs @@ -0,0 +1,331 @@ +use swactor::actor::{ActorAddress, ActorInterface}; +use swactor::runtime::{Ctx, Runtime, RuntimeConfig}; +use swactor_wasm_actor::{ByteMessage, SharedEngine, WasmActorBuilder, WasmActorError}; + +fn guest_wasm(name: &str) -> Vec { + let path = format!( + "{}/tests/guests/{name}/target/wasm32-unknown-unknown/release/{name}_guest.wasm", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}")) +} + +/// Build a message with an inbox address prepended (the guest contract). +fn framed_msg(dest: &ActorAddress, payload: &[u8]) -> ByteMessage { + let mut buf = Vec::with_capacity(32 + payload.len()); + buf.extend_from_slice(&dest.0); + buf.extend_from_slice(payload); + ByteMessage(buf) +} + +// ── Echo: send bytes in, same bytes come back ──────────────────────────────── + +#[test] +fn echo_returns_same_payload() { + let engine = SharedEngine::new().unwrap(); + let actor = WasmActorBuilder::new(engine, guest_wasm("echo")) + .build() + .unwrap(); + + let rt = Runtime::new(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let addr = rt.spawn(actor).unwrap(); + + let payload = b"hello wasm"; + rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap(); + rt.tick(); + + let received = inbox.try_recv().expect("inbox should have a message"); + assert_eq!(received.0, payload); +} + +#[test] +fn echo_preserves_binary_payload() { + let engine = SharedEngine::new().unwrap(); + let actor = WasmActorBuilder::new(engine, guest_wasm("echo")) + .build() + .unwrap(); + + let rt = Runtime::new(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let addr = rt.spawn(actor).unwrap(); + + let payload: Vec = (0..=255).collect(); + rt.send_to(addr, framed_msg(inbox.addr(), &payload)).unwrap(); + rt.tick(); + + let received = inbox.try_recv().expect("inbox should have a message"); + assert_eq!(received.0, payload); +} + +// ── Silent: processes messages without sending anything ─────────────────────── + +#[test] +fn silent_produces_no_output() { + let engine = SharedEngine::new().unwrap(); + let actor = WasmActorBuilder::new(engine, guest_wasm("silent")) + .build() + .unwrap(); + + let rt = Runtime::new(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let addr = rt.spawn(actor).unwrap(); + + rt.send_to(addr, ByteMessage(b"ignored".to_vec())).unwrap(); + rt.tick(); + + assert!(inbox.try_recv().is_none(), "silent guest should not send anything"); +} + +// ── Double: one message in, two messages out ───────────────────────────────── + +#[test] +fn double_sends_two_copies() { + let engine = SharedEngine::new().unwrap(); + let actor = WasmActorBuilder::new(engine, guest_wasm("double")) + .build() + .unwrap(); + + let rt = Runtime::new(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let addr = rt.spawn(actor).unwrap(); + + let payload = b"dup me"; + rt.send_to(addr, framed_msg(inbox.addr(), payload)).unwrap(); + rt.tick(); + + let first = inbox.try_recv().expect("should receive first copy"); + let second = inbox.try_recv().expect("should receive second copy"); + assert_eq!(first.0, payload); + assert_eq!(second.0, payload); + assert!(inbox.try_recv().is_none(), "exactly two messages expected"); +} + +// ── Missing export → WasmActorError::MissingExport ─────────────────────────── + +#[test] +fn missing_alloc_export_returns_error() { + // Minimal valid Wasm module: (module) — no exports at all + let minimal_wasm = wat::parse_str("(module)").unwrap(); + let engine = SharedEngine::new().unwrap(); + let result = WasmActorBuilder::new(engine, minimal_wasm).build(); + match result { + Err(WasmActorError::MissingExport(name)) => { + assert!( + name == "memory" || name == "alloc", + "expected missing memory or alloc, got: {name}" + ); + } + Err(other) => panic!("expected MissingExport, got: {other}"), + Ok(_) => panic!("expected error for module with no exports"), + } +} + +// ── Engine sharing: two actors from the same engine ────────────────────────── + +#[test] +fn shared_engine_serves_multiple_actors() { + let engine = SharedEngine::new().unwrap(); + + let echo = WasmActorBuilder::new(engine.clone(), guest_wasm("echo")) + .build() + .unwrap(); + let silent = WasmActorBuilder::new(engine, guest_wasm("silent")) + .build() + .unwrap(); + + let rt = Runtime::new(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + + let echo_addr = rt.spawn(echo).unwrap(); + let _silent_addr = rt.spawn(silent).unwrap(); + + let payload = b"shared engine test"; + rt.send_to(echo_addr, framed_msg(inbox.addr(), payload)).unwrap(); + rt.tick(); + + let received = inbox.try_recv().expect("echo actor should still work"); + assert_eq!(received.0, payload); +} + +// ── Safety: edge cases that previously caused panics or corruption ──────────── + +#[test] +fn oob_send_traps_cleanly_and_actor_survives() { + // Guest calls swactor.send with dest_ptr pointing past the end of memory. + // The host should trap the call; the actor should survive for future messages. + let wat = r#" + (module + (import "swactor" "send" (func $send (param i32 i32 i32))) + (memory (export "memory") 1) + (func (export "alloc") (param i32) (result i32) + i32.const 0 ;; return start of memory (simplistic) + ) + (func (export "handle") (param i32 i32) + ;; Call send with dest_ptr = 65536 (1 page = end of memory, OOB for 32 bytes) + i32.const 65536 + i32.const 0 + i32.const 0 + call $send + ) + ) + "#; + let wasm = wat::parse_str(wat).unwrap(); + let engine = SharedEngine::new().unwrap(); + let actor = WasmActorBuilder::new(engine, wasm).build().unwrap(); + + let rt = Runtime::new(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + let addr = rt.spawn(actor).unwrap(); + + // Send a message — handle will try OOB send, which traps + rt.send_to(addr, ByteMessage(vec![42])).unwrap(); + rt.tick(); + + // No message should arrive (the send was invalid) + assert!(inbox.try_recv().is_none(), "OOB send should not produce a message"); +} + +#[test] +fn alloc_oom_drops_message_actor_stays_alive() { + // Guest alloc always returns 0 (OOM). Message should be dropped, + // actor should remain alive for subsequent messages. + let wat = r#" + (module + (import "swactor" "send" (func $send (param i32 i32 i32))) + (memory (export "memory") 1) + (func (export "alloc") (param i32) (result i32) + i32.const 0 ;; always OOM + ) + (func (export "handle") (param i32 i32) + ;; Should never be called if alloc returned 0 for non-zero len + ) + ) + "#; + let wasm = wat::parse_str(wat).unwrap(); + let engine = SharedEngine::new().unwrap(); + let actor = WasmActorBuilder::new(engine, wasm).build().unwrap(); + + let rt = Runtime::new(RuntimeConfig::default()); + let addr = rt.spawn(actor).unwrap(); + + // Send a non-empty message — alloc returns 0, message should be dropped + rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap(); + rt.tick(); + + // Actor is still alive — send another message, tick again (no panic) + rt.send_to(addr, ByteMessage(vec![4, 5, 6])).unwrap(); + rt.tick(); +} + +#[test] +fn negative_alloc_ptr_drops_message() { + // Guest alloc returns -1. Host should detect the negative pointer and drop. + let wat = r#" + (module + (import "swactor" "send" (func $send (param i32 i32 i32))) + (memory (export "memory") 1) + (func (export "alloc") (param i32) (result i32) + i32.const -1 ;; invalid negative pointer + ) + (func (export "handle") (param i32 i32)) + ) + "#; + let wasm = wat::parse_str(wat).unwrap(); + let engine = SharedEngine::new().unwrap(); + let actor = WasmActorBuilder::new(engine, wasm).build().unwrap(); + + let rt = Runtime::new(RuntimeConfig::default()); + let addr = rt.spawn(actor).unwrap(); + + rt.send_to(addr, ByteMessage(vec![1])).unwrap(); + rt.tick(); // should not panic + + // Actor survives + rt.send_to(addr, ByteMessage(vec![2])).unwrap(); + rt.tick(); +} + +#[test] +fn handle_trap_drops_message_actor_survives() { + // Guest handle executes `unreachable`, causing a Wasm trap. + // Message should be dropped, actor should stay alive. + let wat = r#" + (module + (import "swactor" "send" (func $send (param i32 i32 i32))) + (memory (export "memory") 1) + (func (export "alloc") (param i32) (result i32) + i32.const 256 ;; valid allocation + ) + (func (export "handle") (param i32 i32) + unreachable ;; trap! + ) + ) + "#; + let wasm = wat::parse_str(wat).unwrap(); + let engine = SharedEngine::new().unwrap(); + let actor = WasmActorBuilder::new(engine, wasm).build().unwrap(); + + let rt = Runtime::new(RuntimeConfig::default()); + let addr = rt.spawn(actor).unwrap(); + + rt.send_to(addr, ByteMessage(vec![1, 2, 3])).unwrap(); + rt.tick(); // handle traps, but actor should survive + + // Actor is still alive + rt.send_to(addr, ByteMessage(vec![4, 5, 6])).unwrap(); + rt.tick(); +} + +// ── Integration: WasmActor alongside a native Rust actor ───────────────────── + +#[derive(Clone)] +struct ForwardToWasm { + wasm_addr: ActorAddress, + inbox_addr: ActorAddress, +} + +struct Forwarder; + +impl ActorInterface for Forwarder { + type Incoming = ForwardToWasm; + type Response = (); + + fn handle(&mut self, ctx: &Ctx, msg: ForwardToWasm) { + // Build the framed message and forward to the wasm actor + let payload = b"from native"; + let framed = framed_msg(&msg.inbox_addr, payload); + let _ = ctx.send(msg.wasm_addr, framed); + } +} + +#[test] +fn native_actor_communicates_with_wasm_actor() { + let engine = SharedEngine::new().unwrap(); + let wasm = WasmActorBuilder::new(engine, guest_wasm("echo")) + .build() + .unwrap(); + + let rt = Runtime::new(RuntimeConfig::default()); + let inbox = rt.new_inbox::().unwrap(); + + let wasm_addr = rt.spawn(wasm).unwrap(); + let forwarder_addr = rt.spawn(Forwarder).unwrap(); + + rt.send_to( + forwarder_addr, + ForwardToWasm { + wasm_addr, + inbox_addr: *inbox.addr(), + }, + ) + .unwrap(); + + // Tick 1: Forwarder receives message and sends to WasmActor + rt.tick(); + // Tick 2: WasmActor receives the forwarded message and echoes to inbox + rt.tick(); + + let received = inbox.try_recv().expect("wasm actor should have echoed"); + assert_eq!(received.0, b"from native"); +} diff --git a/docs/development_history/WASM_ACTOR.md b/docs/development_history/WASM_ACTOR.md new file mode 100644 index 0000000..3d2cf68 --- /dev/null +++ b/docs/development_history/WASM_ACTOR.md @@ -0,0 +1,191 @@ +# Wasm Actor Crate — Development History + +> Adds a new crate (`crates/wasm-actor/`) that runs WebAssembly guest code +> **inside** a swactor actor. The Wasm instance lives in the actor — not as a +> separate OS process. Messages arrive as bytes, get written into Wasm linear +> memory, and the guest's `handle` export is called. +> +> ~350 lines of Rust (host) · 3 guest modules · 7 tests + +--- + +## Table of Contents + +1. [Overview & Motivation](#1-overview--motivation) +2. [What Was Built](#2-what-was-built) +3. [Guest ↔ Host Contract](#3-guest--host-contract) +4. [Handle Cycle (Hot Path)](#4-handle-cycle-hot-path) +5. [Guest Modules](#5-guest-modules) +6. [Design Decisions & Tradeoffs](#6-design-decisions--tradeoffs) +7. [Known Gaps & Future Improvements](#7-known-gaps--future-improvements) +8. [Test Coverage Summary](#8-test-coverage-summary) + +--- + +## 1. Overview & Motivation + +Swactor already supported running *inside* a browser via `crates/wasm/` +(wasm-bindgen). This crate flips the direction: run untrusted Wasm code +*inside* an actor, sandboxed by wasmtime. Use cases include user-defined +plugins, multi-language actors, and capability-restricted compute. + +The main swactor crate has no wasmtime dependency — all Wasm machinery is +isolated in `crates/wasm-actor/`. + +--- + +## 2. What Was Built + +| Component | Location | Purpose | +|-----------|----------|---------| +| `swactor-wasm-actor` crate | `crates/wasm-actor/` | Host-side: engine, builder, actor impl | +| 3 guest crates | `crates/wasm-actor/tests/guests/{echo,double,silent}/` | `#![no_std]` Wasm modules for testing | +| Integration tests | `crates/wasm-actor/tests/wasm_actor.rs` | 7 behavioral tests | + +### Crate modules + +``` +crates/wasm-actor/src/ + lib.rs — ByteMessage, re-exports + engine.rs — SharedEngine (Arc) + builder.rs — WasmActorBuilder (compile + link + instantiate) + actor.rs — WasmActor implementing ActorInterface + error.rs — WasmActorError enum +``` + +### Public types + +- **`ByteMessage(pub Vec)`** — message type for Wasm actors. Satisfies + `Message` bounds trivially. +- **`SharedEngine`** — wraps `Arc`. Created once, cloned + cheaply across actors. Sandboxed config: no threads, no SIMD, no reference + types. +- **`WasmActorBuilder`** — takes an engine + raw `.wasm` bytes, compiles the + module, links the `swactor.send` host import, extracts typed function handles, + returns a `WasmActor`. +- **`WasmActor`** — implements `ActorInterface`. +- **`WasmActorError`** — `MissingExport(&'static str)` or `Wasmtime(wasmtime::Error)`. + +--- + +## 3. Guest ↔ Host Contract + +**Guest must export:** + +| Export | Signature | Purpose | +|--------|-----------|---------| +| `memory` | WebAssembly linear memory | Host reads/writes message bytes here | +| `alloc` | `(size: i32) -> i32` | Allocate `size` bytes, return pointer | +| `handle` | `(ptr: i32, len: i32)` | Process message at `(ptr, len)` | + +**Guest may import:** + +| Import | Module | Signature | Purpose | +|--------|--------|-----------|---------| +| `send` | `swactor` | `(dest_ptr: i32, payload_ptr: i32, payload_len: i32)` | Send a message to another actor | + +`dest_ptr` points to 32 bytes of `ActorAddress` in guest linear memory. +`payload_ptr` + `payload_len` describe the message bytes. + +--- + +## 4. Handle Cycle (Hot Path) + +``` + ByteMessage arrives + │ + v + 1. host calls guest alloc(msg.len) → ptr + │ + v + 2. host writes msg bytes into guest memory at ptr + │ + v + 3. host calls guest handle(ptr, len) + │ + ├── guest may call swactor.send() N times + │ └── each appends (ActorAddress, Vec) to HostState.outbox + │ + v + 4. host drains outbox → ctx.send(dest, ByteMessage(payload)) for each +``` + +Traps during `alloc` or `handle` will panic. Swactor's existing +`catch_unwind` in `tick_all` poisons the actor — consistent with the +panic-safety model. + +--- + +## 5. Guest Modules + +Three `#![no_std]` Rust crates compiled to `wasm32-unknown-unknown`: + +| Guest | Behavior | Tests it supports | +|-------|----------|-------------------| +| `echo` | Reads 32-byte dest + payload from message; sends payload back to dest | Echo roundtrip, binary preservation | +| `double` | Same framing; sends payload back **twice** | Multi-send verification | +| `silent` | Receives bytes; does nothing | No-output / no-error baseline | + +Each guest uses a simple inline bump allocator (64 KiB heap, 8-byte aligned) +and a `#[panic_handler]` that loops. No external dependencies. + +Message framing convention: the first 32 bytes of the `ByteMessage` payload +are the destination `ActorAddress`, followed by the actual message bytes. +This allows guests to send replies without hardcoding addresses. + +### Building guests + +```bash +rustup target add wasm32-unknown-unknown # one-time + +cd crates/wasm-actor/tests/guests/echo && cargo build --target wasm32-unknown-unknown --release +cd crates/wasm-actor/tests/guests/double && cargo build --target wasm32-unknown-unknown --release +cd crates/wasm-actor/tests/guests/silent && cargo build --target wasm32-unknown-unknown --release +``` + +Each guest crate has its own `[workspace]` marker to stay independent of the +root workspace. + +--- + +## 6. Design Decisions & Tradeoffs + +| # | Decision | Rationale | +|---|----------|-----------| +| 1 | **wasmtime, not wasmer/wasm3** | Best-maintained, fuel metering support, cranelift JIT | +| 2 | **Raw bytes, not structured messages** | Keeps the boundary simple; framing/serialization is the guest's concern | +| 3 | **Separate crate, not a feature flag** | wasmtime is ~30 crates; most users don't need it in their dependency tree | +| 4 | **Bump allocator in guests** | Zero-dependency, predictable, sufficient for request/response patterns | +| 5 | **Dest address in message payload** | Avoids hardcoded addresses; guests can send to any actor the host tells them about | +| 6 | **Traps = panics (no Result)** | Matches swactor's existing panic-safety model; `catch_unwind` in `tick_all` poisons the actor | +| 7 | **Engine sharing via Arc** | Module compilation is expensive; `SharedEngine` amortizes it across actors | +| 8 | **Maximum sandboxing defaults** | Disabled: threads, SIMD, relaxed SIMD, reference types, multi-value. Enabled: bulk memory (required by most compilers) | + +--- + +## 7. Known Gaps & Future Improvements + +| # | Gap | Notes | +|---|-----|-------| +| 1 | **No fuel metering** | wasmtime supports fuel; maps naturally to per-tick actor budgets. Deferred to follow-up. | +| 2 | **No WASI** | No filesystem, network, random, or clock access. Intentional for sandboxing, but limits guest capabilities. | +| 3 | **No guest SDK crate** | The test guests serve as examples. A published `swactor-guest` crate with the alloc/handle/send glue would reduce boilerplate. | +| 4 | **Bump allocator never frees** | Fine for short-lived handle calls, but long-running actors would need a real allocator. | +| 5 | **No pre-compilation cache** | `Module::new()` recompiles every time. wasmtime supports serialized modules for faster cold starts. | +| 6 | **`cargo test -p` doesn't resolve** | Must use `--manifest-path`. Workspace resolution quirk. | + +--- + +## 8. Test Coverage Summary + +7 behavioral tests in `crates/wasm-actor/tests/wasm_actor.rs`: + +| Test | Scenario | +|------|----------| +| `echo_returns_same_payload` | Send bytes → wasm echoes them back to inbox | +| `echo_preserves_binary_payload` | All 256 byte values survive the roundtrip | +| `silent_produces_no_output` | Guest does nothing; no error, no messages | +| `double_sends_two_copies` | One message in → two messages out | +| `missing_alloc_export_returns_error` | WAT module with no exports → `WasmActorError::MissingExport` | +| `shared_engine_serves_multiple_actors` | Two actors from the same `SharedEngine` work independently | +| `native_actor_communicates_with_wasm_actor` | Native Rust actor → WasmActor → inbox (two-tick delivery) | diff --git a/docs/wasm-actor.md b/docs/wasm-actor.md new file mode 100644 index 0000000..6d449ce --- /dev/null +++ b/docs/wasm-actor.md @@ -0,0 +1,115 @@ +# Wasm Actor + +The `swactor-wasm-actor` crate runs WebAssembly guest code inside a swactor +actor. The Wasm instance is sandboxed by [wasmtime](https://wasmtime.dev/). + +## Architecture + +``` + ┌─ Runtime ──────────────────────────────────────────────────────────────┐ + │ │ + │ ┌─ WasmActor ──────────────────────────────────────────────────────┐ │ + │ │ │ │ + │ │ Store -- wasmtime store with outbox │ │ + │ │ Memory -- guest linear memory │ │ + │ │ alloc: TypedFunc -- guest allocator │ │ + │ │ handle: TypedFunc -- guest message handler │ │ + │ │ │ │ + │ │ impl ActorInterface for WasmActor │ │ + │ │ Incoming = ByteMessage │ │ + │ │ Response = () │ │ + │ │ │ │ + │ └──────────────────────────────────────────────────────────────────┘ │ + │ │ + │ ┌─ Native Actors ─────────────────────────────────────────────────┐ │ + │ │ (can exchange ByteMessage with WasmActors normally) │ │ + │ └─────────────────────────────────────────────────────────────────┘ │ + │ │ + └────────────────────────────────────────────────────────────────────────┘ +``` + +## Message Flow + +``` + Host Guest (Wasm) + ──── ──────────── + + ByteMessage arrives + │ + ├─1─ call alloc(len) ──────────► bump-allocate, return ptr + │ + ├─2─ write bytes at ptr ───────► (memory updated) + │ + ├─3─ call handle(ptr, len) ────► process message + │ │ + │ ◄── swactor.send() ────────────┤ (0..N times) + │ (buffered in HostState.outbox) │ + │ │ + ├─4─ drain outbox ◄────────────── handle returns + │ + v + ctx.send(dest, ByteMessage) for each outbox entry +``` + +## Guest Contract + +Guests are standalone `wasm32-unknown-unknown` modules. They export three +symbols and may import one: + +| Direction | Module | Symbol | Signature | +|-----------|--------|--------|-----------| +| **export** | — | `memory` | linear memory | +| **export** | — | `alloc` | `(i32) -> i32` | +| **export** | — | `handle` | `(i32, i32) -> ()` | +| **import** | `swactor` | `send` | `(i32, i32, i32) -> ()` | + +The `send` import takes `(dest_ptr, payload_ptr, payload_len)` where +`dest_ptr` points to a 32-byte `ActorAddress` in guest memory. + +## Usage + +```rust +use swactor::runtime::{Runtime, RuntimeConfig}; +use swactor_wasm_actor::{ByteMessage, SharedEngine, WasmActorBuilder}; + +// Create a shared engine (once) +let engine = SharedEngine::new().unwrap(); + +// Build an actor from .wasm bytes +let wasm_bytes = std::fs::read("my_guest.wasm").unwrap(); +let actor = WasmActorBuilder::new(engine, wasm_bytes) + .build() + .unwrap(); + +// Use it like any other actor +let rt = Runtime::new(RuntimeConfig::default()); +let addr = rt.spawn(actor).unwrap(); +rt.send_to(addr, ByteMessage(b"hello".to_vec())).unwrap(); +rt.tick(); +``` + +## Sandboxing + +The `SharedEngine` disables all optional Wasm proposals: + +- Threads — disabled +- SIMD / relaxed SIMD — disabled +- Reference types — disabled +- Multi-value — disabled +- Bulk memory — **enabled** (required by most Rust/LLVM toolchains) + +No WASI imports are linked. Guests have no access to the filesystem, network, +clock, or random number generator. The only host function available is +`swactor.send`. + +## Where Things Live + +| File | Purpose | +|------|---------| +| `crates/wasm-actor/src/lib.rs` | `ByteMessage` + re-exports | +| `crates/wasm-actor/src/engine.rs` | `SharedEngine` — sandboxed wasmtime config | +| `crates/wasm-actor/src/builder.rs` | `WasmActorBuilder` — compile, link, instantiate | +| `crates/wasm-actor/src/actor.rs` | `WasmActor` — `ActorInterface` impl | +| `crates/wasm-actor/src/error.rs` | `WasmActorError` | +| `crates/wasm-actor/tests/guests/` | Three test guest crates (echo, double, silent) | +| `crates/wasm-actor/tests/wasm_actor.rs` | 7 integration tests |