diff --git a/Cargo.lock b/Cargo.lock index c8706702..eeea0f35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,16 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ - "gimli", + "gimli 0.32.3", +] + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli 0.33.0", ] [[package]] @@ -716,11 +725,11 @@ version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ - "addr2line", + "addr2line 0.25.1", "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "libc", "miniz_oxide 0.8.9", - "object", + "object 0.37.3", "rustc-demangle", "windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -935,6 +944,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytecount" @@ -1333,6 +1345,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -1521,6 +1542,15 @@ dependencies = [ "libm", ] +[[package]] +name = "cpp_demangle" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +dependencies = [ + "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "cpu-time" version = "1.0.0" @@ -1555,6 +1585,152 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658e8623bea025d142fb11c299b8c6572ffe9fd663b52887c4dc124735cc130b" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4b12b8c55c31c3d8598c2de9a657c3b978f905f6886c43e02216e0b15fe45a3" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "894b984f82c5a36f05fdd4973fbf4d37d69cd5cdf8c16e8ffe3af4a9cdfe193c" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1e4c34abe94193ada09f821f474be87ac8a1fbbacd87058571ef04c1bf4009" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63826cef0bf70e61d56cc930aa4ee423638f66dc3cfc10d683e73138a6d177b8" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli 0.33.0", + "hashbrown 0.17.1", + "libm", + "log", + "postcard", + "pulley-interpreter", + "regalloc2", + "rustc-hash 2.1.3", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec 1.16.1", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26de7849112293681a19f7525c689e1d73dc6eb2687355170fb7458c4da8d353" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df315a8b1cfd6e620971e3d4249bd2c4528167717e8f48a6317a87c55891bcb8" + +[[package]] +name = "cranelift-control" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3fff7610fb17f228f16598e1a044db79a12806ece7496ff2c69cf1a27a79a5" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6158b1ac381792ad11e670abbcd15db8b1737e84bfa8a7f2bd7eb41388a92530" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099361a0919067086dcde624a281ce719834cebf8544d6c24646f5e924107234" +dependencies = [ + "cranelift-codegen", + "hashbrown 0.17.1", + "log", + "smallvec 1.16.1", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2f30ea3fd5a11f7d3473976d3b62b5b3f2b62fa943c85e0331d88bf505082c" + +[[package]] +name = "cranelift-native" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397508a8d817dc74019603ac5a4c4e40b4df627e5aed4f2f8088345d4c01d401" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4944ef05513e1c1a85a945cf8f29d95ddea8bcbb19e0bfd4e71063450967420" + [[package]] name = "crc" version = "3.4.0" @@ -2288,6 +2464,18 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +[[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 = "emojis" version = "0.9.0" @@ -2939,7 +3127,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "windows-result 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -3029,6 +3217,18 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1 (registry+https://github.com/rust-lang/crates.io-index)", + "indexmap 2.14.2", + "stable_deref_trait", +] + [[package]] name = "gix" version = "0.79.0" @@ -3897,6 +4097,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "foldhash 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "serde", + "serde_core", ] [[package]] @@ -4885,6 +5087,12 @@ dependencies = [ "goblin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lettre" version = "0.11.23" @@ -4914,9 +5122,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libdbus-sys" @@ -5118,6 +5326,12 @@ dependencies = [ "libc", ] +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "macroific" version = "2.0.0" @@ -6398,6 +6612,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix", +] + [[package]] name = "memmap2" version = "0.9.11" @@ -6542,7 +6765,7 @@ dependencies = [ "bitflags 2.13.2", "combine", "libc", - "mach2", + "mach2 0.4.3", "nix 0.30.1", "sysctl", "thiserror 2.0.20", @@ -7034,6 +7257,18 @@ dependencies = [ "memchr 2.8.3", ] +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "crc32fast", + "hashbrown 0.17.1", + "indexmap 2.14.2", + "memchr 2.8.3", +] + [[package]] name = "octos-agent" version = "2.0.3-rc.13" @@ -7636,9 +7871,9 @@ dependencies = [ [[package]] name = "octosense-app-contract" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec46b54da6c411fce07e863af8ad36bb8394319deea861219e2815ad94e04f91" +checksum = "5c3e8bb885e67a1290abdf6d3a71bf59b2707fa7e67c9b833eae6843355e86e5" dependencies = [ "blake3", "serde", @@ -7649,7 +7884,7 @@ dependencies = [ [[package]] name = "octosense-app-hub" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=b1109a397ae45349546d50889a1706f6c58fb923#b1109a397ae45349546d50889a1706f6c58fb923" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a" dependencies = [ "base64 0.22.1", "blake3", @@ -7668,7 +7903,7 @@ dependencies = [ [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=b1109a397ae45349546d50889a1706f6c58fb923#b1109a397ae45349546d50889a1706f6c58fb923" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -7696,7 +7931,7 @@ dependencies = [ [[package]] name = "octosense-app-policy" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=b1109a397ae45349546d50889a1706f6c58fb923#b1109a397ae45349546d50889a1706f6c58fb923" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a" dependencies = [ "makepad-widgets", "octosense-app-contract", @@ -7718,7 +7953,7 @@ dependencies = [ [[package]] name = "octosense-appstore" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=b1109a397ae45349546d50889a1706f6c58fb923#b1109a397ae45349546d50889a1706f6c58fb923" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=2e5768964f15916cdb66cc6824f7671dafc6445a#2e5768964f15916cdb66cc6824f7671dafc6445a" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -7740,6 +7975,13 @@ dependencies = [ "serde_json", ] +[[package]] +name = "octosense-guest" +version = "0.1.0" +dependencies = [ + "serde_json", +] + [[package]] name = "octosense-home" version = "0.1.0" @@ -7949,6 +8191,7 @@ dependencies = [ "octosense-news-service", "octosense-oauth-service", "octosense-reference", + "octosense-wasm-host", "rinx", "serde", "serde_json", @@ -7979,6 +8222,18 @@ dependencies = [ "url", ] +[[package]] +name = "octosense-wasm-host" +version = "0.1.0" +dependencies = [ + "libc", + "serde_json", + "sha2 0.10.9", + "wasmlab-functions", + "wasmtime", + "wat", +] + [[package]] name = "ohos-sys" version = "0.2.2" @@ -8475,6 +8730,18 @@ dependencies = [ "portable-atomic", ] +[[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 = "postscript" version = "0.14.1" @@ -8603,6 +8870,29 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "pulley-interpreter" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b66b7448cb92d709f2924de98b5ba5b5ce4ea08e6d6b9c3b403fcd88d122ca2" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185b9b4cd91f85110f48f8c0314757dfb2bb48c40f5d91055ea666cbbcbf317b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -8967,6 +9257,21 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "regalloc2" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash 2.1.3", + "serde", + "smallvec 1.16.1", +] + [[package]] name = "regex" version = "1.13.1" @@ -10224,6 +10529,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -10920,6 +11229,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -10953,6 +11268,15 @@ dependencies = [ "new_debug_unreachable", ] +[[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" @@ -12084,6 +12408,26 @@ dependencies = [ "unicode-ident 1.0.24", ] +[[package]] +name = "wasm-encoder" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f" +dependencies = [ + "leb128fmt", + "wasmparser 0.258.3", +] + +[[package]] +name = "wasm-encoder" +version = "0.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" +dependencies = [ + "leb128fmt", + "wasmparser 0.261.0", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -12128,6 +12472,244 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmlab-functions" +version = "0.1.0" +dependencies = [ + "octosense-guest", + "pulldown-cmark 0.13.4", + "serde", + "similar", + "strsim", +] + +[[package]] +name = "wasmparser" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indexmap 2.14.2", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" +dependencies = [ + "bitflags 2.13.2", + "indexmap 2.14.2", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a457ba8670a3db3d357c1aeac3ed4fe378a76054a221789f0df0ea507e292b" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.258.3", +] + +[[package]] +name = "wasmtime" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1801b9c9f91e16e352ed77656824e7c4556a7538dbfc1a50b37243f0e12ea045" +dependencies = [ + "addr2line 0.26.1", + "async-trait", + "bitflags 2.13.2", + "bumpalo", + "cc", + "futures", + "libc", + "log", + "mach2 0.6.0", + "memfd", + "object 0.40.0", + "once_cell 1.21.4", + "postcard", + "pulley-interpreter", + "rayon", + "rustix", + "serde", + "serde_derive", + "smallvec 1.16.1", + "target-lexicon", + "wasmparser 0.258.3", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f397969b8daf208e45120036be74956e99364ce916d5b5d67f9e9f0fe40e5477" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli 0.33.0", + "hashbrown 0.17.1", + "indexmap 2.14.2", + "log", + "object 0.40.0", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec 1.16.1", + "target-lexicon", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fe14bd5ea6444a09f6baa3865559c93c91158930e7fe6d127429a9372f6165" + +[[package]] +name = "wasmtime-internal-core" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2053fd1d90080e502ca4ea39d86c3528587a40220b9572a874c8b742c219a00c" +dependencies = [ + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e96d6c55401e68e40e3f74078ec96f92fe9b8a7e90e885fb87265e69eea559" +dependencies = [ + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli 0.33.0", + "itertools 0.14.0", + "log", + "object 0.40.0", + "pulley-interpreter", + "smallvec 1.16.1", + "target-lexicon", + "thiserror 2.0.20", + "wasmparser 0.258.3", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b913875bab1b78fada489e55d5e0a10b53286fb4bb09f5657212f549c9af36c" +dependencies = [ + "cc", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0356a1a09dee1239904e09e962456d9f6edfa5da61c5e3e1b0e4669710f48fba" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dead9a2d7b5d835698a2e1a32006e7d0b5711f4697b24bab1564bb2488e3d7a" +dependencies = [ + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69ffac8fc893de2958b263d9d7274cf4c531ba9ff582558348d4e28d97b8a0f2" +dependencies = [ + "cranelift-codegen", + "log", + "object 0.40.0", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a20f0dcb6c6a006363d3f85b5c633675730487b0dc23726b2d40495872407fb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wast" +version = "261.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr 2.8.3", + "unicode-width", + "wasm-encoder 0.261.0", +] + +[[package]] +name = "wat" +version = "1.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f" +dependencies = [ + "wast", +] + [[package]] name = "wayland-backend" version = "0.3.12" diff --git a/Cargo.toml b/Cargo.toml index 1bec3169..51d51847 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ members = [ "crates/l0-chat", "crates/oauth-service", "crates/markdown-editor", + "crates/wasm-host", "apps/reference", "apps/news/host-service", "apps/mail/host-service", @@ -49,6 +50,7 @@ exclude = [ "apps/appcard/tools", "apps/appcard/vendor", "apps/appcard/app/splash-native", + "apps/wasmlab/guest", ] default-members = ["desktop"] @@ -71,7 +73,7 @@ octosense-reference = { path = "apps/reference" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2" } octosense-appcard = { path = "apps/appcard/module" } -octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "b1109a397ae45349546d50889a1706f6c58fb923" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" } makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } @@ -106,17 +108,17 @@ octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepa octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "33dea2f1f3ad3f1346a219aa8cf6e91b31361e23" } # --- App Hub's host service registry the Mail and llm services register # into; the same revision as App Hub's app crate (a native app, above). -octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "b1109a397ae45349546d50889a1706f6c58fb923" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" } # The app policy (resolution, isolate settings, the Splash adapter) and the # Hub client the Card runner resolves an app's policy with; the shell's # glance tiles run cards under it (crates/shell/src/glance_card.rs). -octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "b1109a397ae45349546d50889a1706f6c58fb923" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "b1109a397ae45349546d50889a1706f6c58fb923" } +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "2e5768964f15916cdb66cc6824f7671dafc6445a" } # The app contract (ADR 0005), from crates.io by version: App Hub, the shell # and Rinx all depend on `octosense-app-contract = "1"`, so Cargo links ONE # 1.x for the whole build (Cargo.lock pins the exact version OctoSense ships) # and App Hub and Rinx no longer need the same App Hub commit. -octosense-app-contract = "1.6" +octosense-app-contract = "1.7" # --- octos: ONE source for every octos crate (reviewed octos#2715 at # b0759a57: bounded ledger session-directory names, serialized peer purge # against session writes, and provider transport diagnostics that omit @@ -165,6 +167,7 @@ octosense-toolbox = { path = "crates/toolbox" } octosense-l0-chat = { path = "crates/l0-chat" } octosense-oauth-service = { path = "crates/oauth-service" } octosense-markdown-editor = { path = "crates/markdown-editor" } +octosense-wasm-host = { path = "crates/wasm-host" } article-core = { git = "https://github.com/hagency-org/Rinx.git", tag = "v1.1.0" } article-makepad = { git = "https://github.com/hagency-org/Rinx.git", tag = "v1.1.0" } octosense-llm-config = { path = "apps/ai-providers/config" } @@ -194,6 +197,10 @@ keyring = "3" # process with them (OctoSense hosting Rinx). rusqlite = { version = "0.40.1", features = ["bundled"] } log = "0.4" +# Developers' own functions compiled to WebAssembly, run for their app with +# a deadline and a memory cap (crates/wasm-host, ADR 0011). Cranelift compiles; +# no component model, async, GC or threads. +wasmtime = { version = "49.0.2", default-features = false, features = ["cranelift", "runtime", "std", "parallel-compilation"] } # Workspace-wide clippy policy for the crates that opt in (`[lints] # workspace = true`, AppCard's): lints that are real but tracked elsewhere. diff --git a/apps/wasmlab/bundle/AGENT.md b/apps/wasmlab/bundle/AGENT.md new file mode 100644 index 00000000..3be12100 --- /dev/null +++ b/apps/wasmlab/bundle/AGENT.md @@ -0,0 +1,15 @@ +# Wasm Lab + +Wasm Lab's tools are its own Rust functions, run on this device in a sandbox: +they compute an answer from what you give them and reach nothing else (no +files, network, calendar or other app). + +- wasmlab.find_slots: free slots of a given length in a day, around busy + intervals you pass ("HH:MM" pairs). It does not read anyone's calendar; + get the busy times first, from the person or from a tool that has them. +- wasmlab.rank: rank names against a query, best first, tolerating typos. +- wasmlab.diff: a unified line diff of two texts, with added and removed + counts. + +Report what a tool returned. When a tool says the input is wrong (a bad time, +a day that ends before it starts), fix the input or ask; do not guess. diff --git a/apps/wasmlab/bundle/fns/wasmlab.wasm b/apps/wasmlab/bundle/fns/wasmlab.wasm new file mode 100755 index 00000000..cd1faeaa Binary files /dev/null and b/apps/wasmlab/bundle/fns/wasmlab.wasm differ diff --git a/apps/wasmlab/bundle/icon.svg b/apps/wasmlab/bundle/icon.svg new file mode 100644 index 00000000..ca5ea759 --- /dev/null +++ b/apps/wasmlab/bundle/icon.svg @@ -0,0 +1 @@ + diff --git a/apps/wasmlab/bundle/main.splash b/apps/wasmlab/bundle/main.splash new file mode 100644 index 00000000..7b91309b --- /dev/null +++ b/apps/wasmlab/bundle/main.splash @@ -0,0 +1,257 @@ +// BEGIN shared app interface +// Shared system-app interface. Run tools/sync-app-interface.py after editing. +// Bundles carry this prelude so they work in the contained standalone host too. +// Resolve the current theme after a host restyle, not a retained prelude alias. +let theme = mod.theme +let ui_ink = theme.color_text +let ui_page = theme.color_bg_app +let ui_light = dot(ui_ink, #x36b61200) < 0.5 +let ui_surface = if ui_light { #xffffff } else { mix(ui_page, ui_ink, 0.06) } +let ui_field = mix(ui_page, ui_ink, 0.075) +let ui_hover = mix(ui_page, ui_ink, 0.12) +let ui_border = mix(ui_page, ui_ink, 0.22) +let ui_muted = mix(ui_ink, ui_page, 0.24) +let ui_link = mix(ui_ink, #x356bce, 0.25) +let ui_primary = #x285cd6 +let ui_success = mix(ui_ink, #x28a55b, 0.20) +let ui_danger = mix(ui_ink, #xe13b53, 0.20) +let UiButton = ButtonFlat{height: 44 margin: 0 padding: Inset{left: 14 right: 14} + draw_bg +: {border_radius: 12.0 border_size: 0.0 color: #x00000000 color_hover: ui_hover color_down: ui_field color_focus: ui_hover} + draw_text +: {color: ui_link color_hover: ui_link color_down: ui_link color_focus: ui_link text_style: theme.font_bold{font_size: 14}} +} +let UiPrimary = UiButton{ + draw_bg +: {color: ui_primary color_hover: #x3468df color_down: #x204cb8 color_focus: #x204cb8} + draw_text +: {color: #xffffff color_hover: #xffffff color_down: #xffffff color_focus: #xffffff} +} +let UiPill = UiButton{draw_bg +: {color: ui_surface border_radius: 22.0} draw_text +: {color: ui_ink color_hover: ui_ink color_down: ui_ink color_focus: ui_ink}} +let UiField = TextInput{width: Fill height: 48 margin: 0 padding: Inset{left: 14 right: 14 top: 12 bottom: 12} + draw_bg +: {color: ui_field color_hover: ui_field color_focus: ui_field color_empty: ui_field + border_color: ui_border border_color_hover: ui_link border_color_focus: ui_link border_color_empty: ui_border border_radius: 12.0} + draw_text +: {color: ui_ink color_hover: ui_ink color_focus: ui_ink color_empty: ui_muted color_empty_hover: ui_muted color_empty_focus: ui_muted text_style: theme.font_regular{font_size: 15}} + draw_selection.color: #x557dcc55 +} +let UiCard = RoundedView{width: Fill height: Fit flow: Down spacing: 12 padding: 20 + show_bg: true draw_bg +: {color: ui_surface border_radius: 16.0}} +let UiCaption = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_muted text_style: theme.font_regular{font_size: 13 line_spacing: 1.4}}} +let UiTitle = Label{padding: 0 flow: Flow.Right{wrap: true} width: Fill draw_text +: {color: ui_ink text_style: theme.font_bold{font_size: 28 line_spacing: 1.2}}} +// END shared app interface +// Wasm Lab: Rust functions that ship in this bundle as +// fns/wasmlab.wasm (built from apps/wasmlab/guest), run by the shell's +// `wasm` service in a sandbox with a deadline and a memory cap. Each card +// calls one function through host.request and shows what came back and the +// round trip, as this script sees it. +let ground = ui_page +let ink = ui_ink +let secondary = ui_muted + +let APPS = ["Calendar" "Calculator" "Camera" "Clock" "Contacts" "Files" "Mail" "Maps" "Messages" "Music" + "News" "Notes" "Phone" "Photos" "Reminders" "Settings" "Weather" "YouTube" "Terminal" "App Hub" + "Sheets" "Wallet" "Health" "Translate" "Podcasts" "Books" "Voice Memos" "Compass" "Measure" "Stocks" + "Home" "Find My" "Shortcuts" "Magnifier" "Tips" "Fitness" "Recorder" "Gallery" "Browser" "Assistant"] +// Commas between the pairs: `] [` would read as indexing. +let BUSY = [["09:00" "09:30"], ["10:00" "11:00"], ["12:00" "13:00"], ["14:30" "15:00"], ["16:00" "17:30"]] +let OLD = "milk\neggs\nbread\nbutter\napples\ncoffee\n" +let NEW = "milk\neggs\nsourdough bread\nbutter\napples\npears\ncoffee\n" +let SAMPLE = "# Weekly notes\n\nSome *emphasis*, `code` and a [link](https://example.org).\n\n- [x] ship the MVP\n- [ ] measure on the phone\n\n| function | input |\n|---|---|\n| find_slots | busy times |\n| fuzzy_rank | names |\n\n> Quoted, with **bold** inside.\n\n" + +let ranked = [] +let rank_note = "" +let rank_seq = 0 +let duration = 30 +let slots = [] +let slots_note = "" +let diff = nil +let diff_note = "" +let md_note = "" +let md_head = "" +let rogue_note = "" +let runtime = nil +let runtime_note = "" + +// Milliseconds to one decimal. +fn ms(t0){ "" + floor((time_now() - t0) * 10000.0 + 0.5) / 10 + " ms" } + +fn rank(text){ + rank_seq = rank_seq + 1 + let seq = rank_seq + let t0 = time_now() + host.request("wasm.fuzzy_rank", {query: text items: APPS limit: 8}, fn(r){ + if seq != rank_seq { return } + if r.is_ok { + ranked = r.data.ranked + let noun = if ranked.len() == 1 { " match" } else { " matches" } + rank_note = ranked.len() + noun + " in " + ms(t0) + } + else { ranked = [] rank_note = r.error } + ui.rank_list.render() + }) +} + +fn find_slots(minutes){ + duration = minutes + let t0 = time_now() + host.request("wasm.find_slots", {day_start: "08:30" day_end: "18:00" duration: minutes busy: BUSY max: 12}, fn(r){ + if r.is_ok { slots = r.data.slots slots_note = slots.len() + " free slots of " + minutes + " min in " + ms(t0) } + else { slots = [] slots_note = r.error } + ui.durations.render() + ui.slot_list.render() + }) +} + +fn compare(){ + let t0 = time_now() + host.request("wasm.text_diff", {old: OLD new: NEW context: 1}, fn(r){ + if r.is_ok { diff = r.data diff_note = "+" + diff.added + " −" + diff.removed + " in " + ms(t0) } + else { diff = nil diff_note = r.error } + ui.diff_view.render() + }) +} + +fn render_md(copies){ + let text = "" + for i in copies { text = text + SAMPLE } + let t0 = time_now() + host.request("wasm.md_to_html", text, fn(r){ + if r.is_ok { + md_note = floor(text.len() / 100) / 10 + " KB of Markdown → " + floor(r.data.text.len() / 100) / 10 + " KB of HTML in " + ms(t0) + md_head = r.data.text.split("\n")[0] + } else { md_note = r.error md_head = "" } + ui.md_view.render() + }) +} + +fn misbehave(mode){ + rogue_note = "Running “" + mode + "”…" + ui.rogue_view.render() + let t0 = time_now() + host.request("wasm.rogue", {mode: mode}, fn(r){ + if r.is_ok { rogue_note = mode + ": returned (unexpected)" } + else { rogue_note = mode + ": " + r.error + " (after " + ms(t0) + ")" } + ui.rogue_view.render() + // The next call gets a fresh instance. + rank(ui.query.text()) + load_runtime() + }) +} + +fn load_runtime(){ + let t0 = time_now() + host.request("wasm.functions", {}, fn(r){ + if r.is_ok { runtime = r.data runtime_note = "Answered in " + ms(t0) } + else { runtime = nil runtime_note = r.error } + ui.runtime_view.render() + }) +} + +fn module_line(m){ + let how = if m.from_cache { "loaded compiled code from the cache" } else { "compiled" } + m.file + ": " + floor(m.bytes / 1024) + " KiB, " + how + " in " + m.load_ms + " ms; memory " + floor(m.memory_bytes / 1048576) + " MiB; renewed " + m.renewed + "×" +} + +// A missing key reads as `fallback` (as News and Photos do it). +fn optional(it, key, fallback){ + let value = try { it[key] } catch { nil } + if value == nil { return fallback } + value +} + +fn stats_line(name){ + let s = optional(runtime.stats, name, nil) + if s == nil { return name + ": not called yet" } + name + ": " + s.calls + " calls, mean " + s.mean_us + " µs, max " + s.max_us + " µs, " + s.errors + " errors" +} + +fn boot(){ + load_runtime() + rank("cal") + find_slots(30) + compare() + render_md(1) +} +start_timeout(0.05, || boot()) + +let Section = Label{width: Fill text: "" draw_text.color: ink draw_text.text_style: theme.font_bold{font_size: 16}} +let Note = UiCaption{text: ""} +let Line = Label{width: Fill text: "" draw_text.color: ink draw_text.text_style.font_size: 14} +let Code = Label{width: Fill text: "" flow: Flow.Right{wrap: true} draw_text.color: ink draw_text.text_style: theme.font_code{font_size: 12}} +let Choice = UiPill{} +let ChoiceOn = UiPill{ + draw_bg +: {color: ink color_hover: ink color_down: ink} + draw_text +: {color: ground color_hover: ground color_down: ground} +} + +SolidView{width: Fill height: Fill flow: Overlay align: Align{x: 0.5} show_bg: true draw_bg.color: ground + ScrollYView{width: Fill max_width: 760 height: Fill flow: Down spacing: 14 padding: Inset{left: 14 right: 14 top: 16 bottom: 32} + UiTitle{text: "Wasm Lab"} + UiCaption{text: "Rust functions shipped in this app, run by the shell in a sandbox: no files, network or other apps, a 2 s deadline and a 256 MiB memory cap."} + + UiCard{ + Section{text: "Find an app"} + query := UiField{empty_text: "Type part of a name" on_change: |text| rank(text)} + rank_list := View{width: Fill height: Fit flow: Down spacing: 6 on_render: || { + Note{text: rank_note} + for it in ranked { Line{text: it.item + " " + it.score} } + }} + } + + UiCard{ + Section{text: "Free time today"} + UiCaption{text: "Busy 09:00–09:30, 10:00–11:00, 12:00–13:00, 14:30–15:00, 16:00–17:30; the day runs 08:30–18:00."} + durations := View{width: Fill height: Fit flow: Flow.Right{wrap: true} spacing: 8 on_render: || { + for m in [15 30 60 90] { + if m == duration { ChoiceOn{text: m + " min" on_click: || find_slots(m)} } + else { Choice{text: m + " min" on_click: || find_slots(m)} } + } + }} + slot_list := View{width: Fill height: Fit flow: Down spacing: 6 on_render: || { + Note{text: slots_note} + for s in slots { Line{text: s[0] + " – " + s[1]} } + }} + } + + UiCard{ + Section{text: "What changed in the list"} + diff_view := View{width: Fill height: Fit flow: Down spacing: 6 on_render: || { + Note{text: diff_note} + if diff != nil { Code{text: diff.unified} } + }} + UiButton{text: "Compare again" on_click: || compare()} + } + + UiCard{ + Section{text: "Markdown to HTML"} + md_view := View{width: Fill height: Fit flow: Down spacing: 6 on_render: || { + Note{text: md_note} + Code{text: md_head} + }} + View{width: Fill height: Fit flow: Right spacing: 8 + UiButton{text: "Render the sample" on_click: || render_md(1)} + UiButton{text: "Render it ×40" on_click: || render_md(40)} + } + } + + UiCard{ + Section{text: "Misbehave"} + UiCaption{text: "Each ends in an error, never a crash; the next call gets a fresh instance."} + View{width: Fill height: Fit flow: Flow.Right{wrap: true} spacing: 8 + UiButton{text: "Loop forever" on_click: || misbehave("loop")} + UiButton{text: "Allocate endlessly" on_click: || misbehave("alloc")} + UiButton{text: "Panic" on_click: || misbehave("panic")} + UiButton{text: "Recurse" on_click: || misbehave("recurse")} + } + rogue_view := View{width: Fill height: Fit flow: Down on_render: || { Note{text: rogue_note} }} + } + + UiCard{ + Section{text: "Runtime"} + runtime_view := View{width: Fill height: Fit flow: Down spacing: 6 on_render: || { + Note{text: runtime_note} + if runtime != nil { + for m in runtime.modules { Line{text: module_line(m)} } + for name in runtime.functions { Line{text: stats_line(name)} } + } + }} + UiButton{text: "Refresh" on_click: || load_runtime()} + } + } +} diff --git a/apps/wasmlab/bundle/manifest.json b/apps/wasmlab/bundle/manifest.json new file mode 100644 index 00000000..86c693de --- /dev/null +++ b/apps/wasmlab/bundle/manifest.json @@ -0,0 +1,14 @@ +{ + "schema": 1, + "id": "os.wasmlab", + "version": "0.1.0", + "name": "Wasm Lab", + "integrity": { "bundle_blake3": "" }, + "capabilities": ["wasm"], + "agent": { + "profile": "read-only", + "tools": ["ask_user_question"], + "model": { "needs": ["tool_calling"] }, + "instructions": "AGENT.md" + } +} diff --git a/apps/wasmlab/bundle/tools.json b/apps/wasmlab/bundle/tools.json new file mode 100644 index 00000000..1817b5ce --- /dev/null +++ b/apps/wasmlab/bundle/tools.json @@ -0,0 +1,199 @@ +{ + "schema": 1, + "tools": [ + { + "name": "wasmlab.find_slots", + "description": "Find free slots of `duration` minutes between day_start and day_end, around the busy intervals given, every `step` minutes. Computes only: it reads no calendar.", + "input_schema": { + "type": "object", + "properties": { + "day_start": { + "type": "string", + "description": "Start of the day, HH:MM", + "pattern": "^[0-2][0-9]:[0-5][0-9]$" + }, + "day_end": { + "type": "string", + "description": "End of the day, HH:MM", + "pattern": "^[0-2][0-9]:[0-5][0-9]$" + }, + "duration": { + "type": "integer", + "description": "Minutes each slot lasts", + "minimum": 5, + "maximum": 720 + }, + "step": { + "type": "integer", + "description": "Minutes between slot starts (default 15)", + "minimum": 5, + "maximum": 120 + }, + "busy": { + "type": "array", + "description": "Busy intervals as [start, end] pairs of HH:MM", + "maxItems": 200, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "string", + "pattern": "^[0-2][0-9]:[0-5][0-9]$" + } + } + }, + "max": { + "type": "integer", + "description": "Most slots to return (default 10)", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "day_start", + "day_end", + "duration", + "busy" + ], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "slots": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "slots" + ] + }, + "risk": "read", + "background": true, + "shareable": true, + "implemented_by": "host-service", + "host_method": "wasm.find_slots" + }, + { + "name": "wasmlab.rank", + "description": "Rank names against a query, best first: a prefix beats a word start beats a substring beats letters in order; a near miss (a typo) still ranks.", + "input_schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "maxLength": 200 + }, + "items": { + "type": "array", + "items": { + "type": "string", + "maxLength": 500 + }, + "maxItems": 5000 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "query", + "items" + ], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "ranked": { + "type": "array", + "items": { + "type": "object", + "properties": { + "item": { + "type": "string" + }, + "score": { + "type": "number" + } + }, + "required": [ + "item", + "score" + ] + } + } + }, + "required": [ + "ranked" + ] + }, + "risk": "read", + "background": true, + "shareable": true, + "implemented_by": "host-service", + "host_method": "wasm.fuzzy_rank" + }, + { + "name": "wasmlab.diff", + "description": "A unified line diff of two texts, with the number of lines added and removed.", + "input_schema": { + "type": "object", + "properties": { + "old": { + "type": "string", + "maxLength": 200000 + }, + "new": { + "type": "string", + "maxLength": 200000 + }, + "context": { + "type": "integer", + "description": "Unchanged lines around each change (default 3)", + "minimum": 0, + "maximum": 20 + } + }, + "required": [ + "old", + "new" + ], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "unified": { + "type": "string" + }, + "added": { + "type": "integer" + }, + "removed": { + "type": "integer" + } + }, + "required": [ + "unified", + "added", + "removed" + ] + }, + "risk": "read", + "background": true, + "shareable": true, + "implemented_by": "host-service", + "host_method": "wasm.text_diff" + } + ] +} diff --git a/apps/wasmlab/guest/Cargo.lock b/apps/wasmlab/guest/Cargo.lock new file mode 100644 index 00000000..cdc6fbf8 --- /dev/null +++ b/apps/wasmlab/guest/Cargo.lock @@ -0,0 +1,159 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "octosense-guest" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "wasmlab-functions" +version = "0.1.0" +dependencies = [ + "octosense-guest", + "pulldown-cmark", + "serde", + "similar", + "strsim", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/apps/wasmlab/guest/Cargo.toml b/apps/wasmlab/guest/Cargo.toml new file mode 100644 index 00000000..f71d64ce --- /dev/null +++ b/apps/wasmlab/guest/Cargo.toml @@ -0,0 +1,12 @@ +# Wasm Lab's functions, built for wasm32-unknown-unknown (./build.sh), and the +# guest SDK they use. Its own workspace: the root workspace builds natively. +[workspace] +resolver = "2" +members = ["octosense-guest", "functions"] + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/apps/wasmlab/guest/build.sh b/apps/wasmlab/guest/build.sh new file mode 100755 index 00000000..3536d074 --- /dev/null +++ b/apps/wasmlab/guest/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Builds Wasm Lab's functions into the app bundle (bundle/fns/wasmlab.wasm). +# Needs the target once: rustup target add wasm32-unknown-unknown +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cargo build --manifest-path "$here/Cargo.toml" --release --target wasm32-unknown-unknown -p wasmlab-functions +mkdir -p "$here/../bundle/fns" +cp "$here/target/wasm32-unknown-unknown/release/wasmlab_functions.wasm" "$here/../bundle/fns/wasmlab.wasm" +echo "bundle/fns/wasmlab.wasm: $(wc -c < "$here/../bundle/fns/wasmlab.wasm") bytes" diff --git a/apps/wasmlab/guest/functions/Cargo.toml b/apps/wasmlab/guest/functions/Cargo.toml new file mode 100644 index 00000000..1ae61ce1 --- /dev/null +++ b/apps/wasmlab/guest/functions/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "wasmlab-functions" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +octosense-guest = { path = "../octosense-guest" } +serde = { version = "1", features = ["derive"] } +pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } +similar = "2" +strsim = "0.11" diff --git a/apps/wasmlab/guest/functions/src/lib.rs b/apps/wasmlab/guest/functions/src/lib.rs new file mode 100644 index 00000000..3d2301aa --- /dev/null +++ b/apps/wasmlab/guest/functions/src/lib.rs @@ -0,0 +1,358 @@ +//! Wasm Lab's functions: a stand-in for a developer's own Rust crate. Light +//! computing and algorithms an app calls from its script or its agent, built +//! for wasm32 by `../build.sh` and run by the shell's runtime. + +use serde::{Deserialize, Serialize}; + +octosense_guest::abi!(); + +// ---- Markdown to HTML -------------------------------------------------------- + +/// CommonMark to HTML, with tables, task lists, strikethrough and footnotes. +pub fn md_to_html(input: &[u8]) -> Result, String> { + use pulldown_cmark::{html, Options, Parser}; + let text = std::str::from_utf8(input).map_err(|_| "the text is not UTF-8".to_string())?; + let options = Options::ENABLE_TABLES + | Options::ENABLE_TASKLISTS + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_FOOTNOTES; + let mut out = String::with_capacity(text.len() * 3 / 2); + html::push_html(&mut out, Parser::new_ext(text, options)); + Ok(out.into_bytes()) +} +octosense_guest::export!(md_to_html); + +// ---- Free meeting slots ------------------------------------------------------ + +/// A day's free slots of `duration` minutes between `day_start` and +/// `day_end`, every `step` minutes, around the `busy` intervals ("HH:MM"). +#[derive(Deserialize)] +pub struct SlotRequest { + pub day_start: String, + pub day_end: String, + pub duration: u32, + #[serde(default = "fifteen")] + pub step: u32, + #[serde(default)] + pub busy: Vec<[String; 2]>, + #[serde(default = "ten")] + pub max: usize, +} + +#[derive(Serialize, Debug, PartialEq)] +pub struct Slots { + pub slots: Vec<[String; 2]>, +} + +fn fifteen() -> u32 { + 15 +} + +fn ten() -> usize { + 10 +} + +fn minutes(hhmm: &str) -> Result { + let (h, m) = hhmm + .split_once(':') + .ok_or_else(|| format!("{hhmm} is not HH:MM"))?; + let (h, m): (u32, u32) = ( + h.parse().map_err(|_| format!("{hhmm} is not HH:MM"))?, + m.parse().map_err(|_| format!("{hhmm} is not HH:MM"))?, + ); + if h > 24 || m > 59 || (h == 24 && m > 0) { + return Err(format!("{hhmm} is not a time of day")); + } + Ok(h * 60 + m) +} + +fn hhmm(minutes: u32) -> String { + format!("{:02}:{:02}", minutes / 60, minutes % 60) +} + +pub fn find_slots(req: SlotRequest) -> Result { + let (start, end) = (minutes(&req.day_start)?, minutes(&req.day_end)?); + if start >= end { + return Err("the day ends before it starts".into()); + } + if req.duration == 0 || req.step == 0 { + return Err("the duration and the step must be at least a minute".into()); + } + let mut busy = Vec::with_capacity(req.busy.len()); + for [from, to] in &req.busy { + let (from, to) = (minutes(from)?, minutes(to)?); + if from < to { + busy.push((from.max(start), to.min(end))); + } + } + busy.sort_unstable(); + // Merge overlapping and touching busy intervals. + let mut merged: Vec<(u32, u32)> = Vec::with_capacity(busy.len()); + for (from, to) in busy { + match merged.last_mut() { + Some(last) if from <= last.1 => last.1 = last.1.max(to), + _ => merged.push((from, to)), + } + } + let mut slots = Vec::new(); + let mut free_from = start; + for (from, to) in merged.into_iter().chain([(end, end)]) { + // Candidate starts sit on the step grid from the start of the day. + let mut at = start + (free_from - start).div_ceil(req.step) * req.step; + while at + req.duration <= from && slots.len() < req.max { + slots.push([hhmm(at), hhmm(at + req.duration)]); + at += req.step; + } + free_from = free_from.max(to); + } + Ok(Slots { slots }) +} +octosense_guest::export_json!(find_slots); + +// ---- Fuzzy ranking ----------------------------------------------------------- + +/// The items that match `query`, best first: a prefix beats a substring, a +/// substring beats letters in order, and a near miss still ranks (typos). +#[derive(Deserialize)] +pub struct RankRequest { + pub query: String, + pub items: Vec, + #[serde(default = "ten")] + pub limit: usize, +} + +#[derive(Serialize, Debug, PartialEq)] +pub struct Ranked { + pub item: String, + pub score: f64, +} + +/// An object, not a bare list, so an agent tool can return it. +#[derive(Serialize, Debug, PartialEq)] +pub struct Ranking { + pub ranked: Vec, +} + +fn score(query: &str, item: &str) -> f64 { + let (q, it) = (query.to_lowercase(), item.to_lowercase()); + if q.is_empty() { + return 0.0; + } + if it.starts_with(&q) { + return 1.0 - (it.chars().count() - q.chars().count()) as f64 / 200.0; + } + if let Some(at) = it.find(&q) { + // A match at a word start is nearly as good as a prefix. + let word_start = it[..at].ends_with([' ', '-', '_', '.', '/']); + return if word_start { 0.95 } else { 0.85 } - at as f64 / 200.0; + } + // Letters in order: tighter spans score higher. + let mut chars = it.char_indices(); + let mut first = None; + let mut last = 0; + let mut in_order = true; + for c in q.chars() { + match chars.find(|(_, x)| *x == c) { + Some((i, _)) => { + first.get_or_insert(i); + last = i; + } + None => { + in_order = false; + break; + } + } + } + if in_order { + let span = (last - first.unwrap_or(0) + 1) as f64; + return 0.5 + 0.3 * (q.len() as f64 / span).min(1.0); + } + strsim::jaro_winkler(&q, &it) * 0.6 +} + +pub fn fuzzy_rank(req: RankRequest) -> Result { + let mut ranked: Vec = req + .items + .iter() + .map(|item| Ranked { + score: (score(&req.query, item) * 1000.0).round() / 1000.0, + item: item.clone(), + }) + .filter(|r| r.score >= 0.45) + .collect(); + ranked.sort_by(|a, b| { + b.score + .total_cmp(&a.score) + .then_with(|| a.item.cmp(&b.item)) + }); + ranked.truncate(req.limit); + Ok(Ranking { ranked }) +} +octosense_guest::export_json!(fuzzy_rank); + +// ---- Line diff --------------------------------------------------------------- + +/// A unified diff of two texts, line by line, with `context` lines around +/// each change. +#[derive(Deserialize)] +pub struct DiffRequest { + pub old: String, + pub new: String, + #[serde(default = "three")] + pub context: usize, +} + +#[derive(Serialize, Debug, PartialEq)] +pub struct Diff { + pub unified: String, + pub added: usize, + pub removed: usize, +} + +fn three() -> usize { + 3 +} + +pub fn text_diff(req: DiffRequest) -> Result { + use similar::{ChangeTag, TextDiff}; + let diff = TextDiff::from_lines(&req.old, &req.new); + let (mut added, mut removed) = (0, 0); + for change in diff.iter_all_changes() { + match change.tag() { + ChangeTag::Insert => added += 1, + ChangeTag::Delete => removed += 1, + ChangeTag::Equal => {} + } + } + let unified = diff + .unified_diff() + .context_radius(req.context) + .header("old", "new") + .to_string(); + Ok(Diff { + unified, + added, + removed, + }) +} +octosense_guest::export_json!(text_diff); + +// ---- Misbehaviour, for the containment checks -------------------------------- + +#[derive(Deserialize)] +pub struct RogueRequest { + pub mode: String, +} + +/// Misbehaves on purpose: `loop` never returns, `alloc` takes memory until +/// it cannot, `panic` panics, `recurse` overflows the stack. +pub fn rogue(req: RogueRequest) -> Result { + match req.mode.as_str() { + "loop" => { + let mut n = 0u64; + loop { + n = std::hint::black_box(n.wrapping_add(1)); + } + } + "alloc" => { + let mut hoard: Vec> = Vec::new(); + loop { + hoard.push(vec![1; 16 << 20]); + std::hint::black_box(&hoard); + } + } + "panic" => panic!("rogue: a deliberate panic"), + "recurse" => Ok(depth(0).to_string()), + other => Err(format!("rogue has no mode {other}")), + } +} +octosense_guest::export_json!(rogue); + +#[allow(unconditional_recursion)] +fn depth(n: u64) -> u64 { + std::hint::black_box(depth(std::hint::black_box(n + 1))) + 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn slots(busy: &[[&str; 2]], duration: u32) -> Vec<[String; 2]> { + let req = SlotRequest { + day_start: "09:00".into(), + day_end: "12:00".into(), + duration, + step: 30, + busy: busy + .iter() + .map(|[a, b]| [a.to_string(), b.to_string()]) + .collect(), + max: 10, + }; + find_slots(req).unwrap().slots + } + + #[test] + fn free_slots_go_around_busy_time_on_the_step_grid() { + let found = slots( + &[["09:30", "10:15"], ["10:00", "10:40"], ["11:30", "12:30"]], + 30, + ); + let found: Vec = found.iter().map(|[a, b]| format!("{a}-{b}")).collect(); + assert_eq!(found, ["09:00-09:30", "11:00-11:30"]); + assert_eq!(slots(&[], 60).len(), 5); + } + + #[test] + fn bad_times_are_refused() { + let req = SlotRequest { + day_start: "9am".into(), + day_end: "12:00".into(), + duration: 30, + step: 15, + busy: vec![], + max: 10, + }; + assert!(find_slots(req).is_err()); + } + + #[test] + fn a_prefix_beats_a_substring_beats_letters_in_order() { + let items = ["Calculator", "Calendar", "Mail", "Local calls", "Clock"] + .map(String::from) + .to_vec(); + let ranked = fuzzy_rank(RankRequest { + query: "cal".into(), + items, + limit: 10, + }) + .unwrap() + .ranked; + let names: Vec<&str> = ranked.iter().map(|r| r.item.as_str()).collect(); + assert_eq!(&names[..2], ["Calendar", "Calculator"]); + assert!(names.contains(&"Local calls")); + assert!(!names.contains(&"Mail")); + } + + #[test] + fn a_diff_counts_its_lines() { + let diff = text_diff(DiffRequest { + old: "a\nb\nc\n".into(), + new: "a\nB\nc\nd\n".into(), + context: 1, + }) + .unwrap(); + assert_eq!((diff.added, diff.removed), (2, 1)); + assert!(diff.unified.contains("-b\n+B\n")); + } + + #[test] + fn markdown_becomes_html() { + let html = + String::from_utf8(md_to_html(b"# Hi\n\n- [x] done\n\n| a |\n|---|\n| 1 |\n").unwrap()) + .unwrap(); + assert!( + html.contains("

Hi

") && html.contains("checkbox") && html.contains("") + ); + } +} diff --git a/apps/wasmlab/guest/octosense-guest/Cargo.toml b/apps/wasmlab/guest/octosense-guest/Cargo.toml new file mode 100644 index 00000000..6ea63a4f --- /dev/null +++ b/apps/wasmlab/guest/octosense-guest/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "octosense-guest" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +publish = false + +[dependencies] +serde_json = "1" diff --git a/apps/wasmlab/guest/octosense-guest/src/lib.rs b/apps/wasmlab/guest/octosense-guest/src/lib.rs new file mode 100644 index 00000000..b97c86e5 --- /dev/null +++ b/apps/wasmlab/guest/octosense-guest/src/lib.rs @@ -0,0 +1,164 @@ +//! The guest side of OctoSense's WebAssembly functions: what a Rust crate +//! needs so an app can call its functions through the shell's runtime +//! (`crates/wasm-host`, whose docs define the ABI). +//! +//! ```ignore +//! octosense_guest::abi!(); +//! +//! fn shout(input: &[u8]) -> Result, String> { +//! Ok(input.to_ascii_uppercase()) +//! } +//! octosense_guest::export!(shout); +//! +//! #[derive(serde::Deserialize)] +//! struct Order { items: Vec } +//! fn total(order: Order) -> Result { +//! Ok(order.items.iter().sum()) +//! } +//! octosense_guest::export_json!(total); +//! ``` +//! +//! Build the crate as a `cdylib` for `wasm32-unknown-unknown`. The module +//! exports `memory`, `octo_alloc`, `octo_free` and each exported function, +//! and imports only `octo.log`. + +use std::alloc::{alloc, dealloc, Layout}; + +#[doc(hidden)] +pub use serde_json; + +#[cfg(target_arch = "wasm32")] +#[link(wasm_import_module = "octo")] +extern "C" { + #[link_name = "log"] + fn octo_log(ptr: *const u8, len: usize); +} + +/// Writes a line to the app's log. The host keeps a bounded number of +/// lines, each cut to a bounded length. +pub fn log(line: &str) { + #[cfg(target_arch = "wasm32")] + unsafe { + octo_log(line.as_ptr(), line.len()) + } + #[cfg(not(target_arch = "wasm32"))] + eprintln!("{line}"); +} + +/// Exports `octo_alloc` and `octo_free`, which the host uses to pass input +/// in and take output out. Invoke it once in the crate. +#[macro_export] +macro_rules! abi { + () => { + const _: () = { + #[export_name = "octo_alloc"] + pub extern "C" fn __octo_alloc(len: usize) -> *mut u8 { + $crate::__alloc(len) + } + + #[export_name = "octo_free"] + pub unsafe extern "C" fn __octo_free(ptr: *mut u8, len: usize) { + $crate::__free(ptr, len) + } + }; + }; +} + +/// Exports `fn(&[u8]) -> Result, String>` under its own name. +#[macro_export] +macro_rules! export { + ($name:ident) => { + const _: () = { + #[export_name = stringify!($name)] + pub extern "C" fn __octo_export(ptr: *const u8, len: usize) -> i64 { + $crate::__init(); + let input = unsafe { $crate::__input(ptr, len) }; + $crate::__output($name(input)) + } + }; + }; +} + +/// Exports `fn(In) -> Result` under its own name, where `In` +/// is deserialized from the input as JSON and `Out` serialized to it. +#[macro_export] +macro_rules! export_json { + ($name:ident) => { + const _: () = { + #[export_name = stringify!($name)] + pub extern "C" fn __octo_export(ptr: *const u8, len: usize) -> i64 { + $crate::__init(); + let input = unsafe { $crate::__input(ptr, len) }; + $crate::__output( + $crate::serde_json::from_slice(input) + .map_err(|e| { + ::std::format!("the input is not what {} takes: {e}", stringify!($name)) + }) + .and_then($name) + .and_then(|out| { + $crate::serde_json::to_vec(&out).map_err(|e| e.to_string()) + }), + ) + } + }; + }; +} + +#[doc(hidden)] +pub fn __alloc(len: usize) -> *mut u8 { + if len == 0 { + return std::ptr::NonNull::dangling().as_ptr(); + } + // SAFETY: the size is non-zero and the alignment is 1. + unsafe { alloc(Layout::from_size_align_unchecked(len, 1)) } +} + +/// # Safety +/// `ptr` and `len` are a buffer from [`__alloc`] or [`__output`]. +#[doc(hidden)] +pub unsafe fn __free(ptr: *mut u8, len: usize) { + if len != 0 { + dealloc(ptr, Layout::from_size_align_unchecked(len, 1)); + } +} + +/// # Safety +/// `ptr` and `len` are the input buffer the host wrote. +#[doc(hidden)] +pub unsafe fn __input<'a>(ptr: *const u8, len: usize) -> &'a [u8] { + if len == 0 { + &[] + } else { + std::slice::from_raw_parts(ptr, len) + } +} + +/// Packs a result for the host: a status byte (`0` ok, `1` error) and the +/// output or the error text, as `(ptr << 32) | len`. +#[doc(hidden)] +pub fn __output(result: Result, String>) -> i64 { + let mut buf = Vec::new(); + match result { + Ok(bytes) => { + buf.reserve_exact(bytes.len() + 1); + buf.push(0); + buf.extend_from_slice(&bytes); + } + Err(text) => { + buf.reserve_exact(text.len() + 1); + buf.push(1); + buf.extend_from_slice(text.as_bytes()); + } + } + let boxed = buf.into_boxed_slice(); + let len = boxed.len(); + let ptr = Box::into_raw(boxed) as *mut u8 as usize; + ((ptr as u64) << 32 | len as u64) as i64 +} + +/// Logs a panic's message before the module traps, so the app can tell why. +#[doc(hidden)] +pub fn __init() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| std::panic::set_hook(Box::new(|info| log(&format!("panic: {info}"))))); +} diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index de35baf7..8284f602 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -72,6 +72,8 @@ octosense-ai-host = { workspace = true } octosense-mail-service = { workspace = true, optional = true } octosense-oauth-service = { workspace = true, optional = true, features = ["host"] } octosense-markdown-editor = { workspace = true, optional = true } +# Apps' own WebAssembly functions (the `wasm` service, ADR 0011). +octosense-wasm-host = { workspace = true, optional = true } octosense-calendar-service = { workspace = true, optional = true } octosense-news-service = { workspace = true, optional = true } # App Hub's host-service registry: the `glance` service registers into it. @@ -159,6 +161,9 @@ app-aichat = ["dep:makepad-aichat"] # development builds without it (build.rs); a release build without it # honours only the `--dev-grant-all` launch flag. dev-mode = [] +# The `wasm` host service (ADR 0011), which runs an app's own +# WebAssembly functions (crates/wasm-host). Off by default. +wasm-lab = ["app-hub", "dep:octosense-wasm-host"] # The system toolbox offered to app agents as host-routed tools (ADR 0002 # §6, ADR 0004 §12; src/host_tools/toolbox.rs): the `toolbox` owner in the # host-tool relay. Off by default. diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 750fa38f..52205359 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -252,6 +252,9 @@ pub fn register_host_services() { // published the same way. register_calendar_services(); register_news(); + // Apps' own WebAssembly functions (ADR 0011). + #[cfg(feature = "wasm-lab")] + crate::wasm_service::register(); // After every service of the shell's own: the notice service never // stands in for one. let served = crate::glance_notice::serve_system_apps(); diff --git a/crates/shell/src/host_tools/script_apps.rs b/crates/shell/src/host_tools/script_apps.rs index 915663c5..9c7edaf8 100644 --- a/crates/shell/src/host_tools/script_apps.rs +++ b/crates/shell/src/host_tools/script_apps.rs @@ -192,7 +192,7 @@ pub fn load(app: &str) -> Result<(), String> { /// `app`'s admitted bundle: a system app's packed bundle, or an installed /// one, with App Hub's apps root. -fn admitted_bundle(app: &str) -> Result<(PathBuf, PathBuf), String> { +pub(crate) fn admitted_bundle(app: &str) -> Result<(PathBuf, PathBuf), String> { // Never a native app's tools, executor or grants (ADR 0004 §3, §7). crate::apps::check_script_app_id(app)?; let root = octosense_appstore::data_root_if_set().ok_or("App Hub has no apps root yet")?; diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 2381e546..3b39c225 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -100,6 +100,8 @@ pub mod connected_backends; pub mod glance_digest; #[cfg(any(feature = "app-hub", native_mobile))] pub mod glance_notice; +#[cfg(feature = "wasm-lab")] +pub mod wasm_service; pub mod glance_panel; pub mod glance_sheet; mod card_presentation; diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs new file mode 100644 index 00000000..3b2952b9 --- /dev/null +++ b/crates/shell/src/wasm_service.rs @@ -0,0 +1,425 @@ +//! The `wasm` service (ADR 0011, feature `wasm-lab`): an app's own +//! functions, written in Rust and shipped in its bundle as `fns/*.wasm`, +//! run for that app by `octosense-wasm-host` (Wasmtime, compiled by +//! Cranelift, with a deadline and a memory cap). They are for light +//! computing and algorithms beside the app's script: parsing, ranking, +//! scheduling, diffing. Splash's own compute handles kernels. +//! +//! - `wasm.functions` answers what the app's modules export, how they +//! loaded and how each function has run so far. +//! - `wasm.` calls one. Its input is the request's arguments: a +//! string as its text, anything else as JSON. Its output comes back as +//! JSON, or as `{"text": …}` when it is not JSON. Its own error, a trap or +//! its deadline is the request's error. +//! +//! **Whose code.** A module comes only from the calling app's own admitted +//! bundle (digest-checked, [`script_apps::admitted_bundle`]): no argument +//! names a file, and no app reaches another's functions. The Card runner's +//! gate and the tool executor both require the app's `wasm` capability, and +//! the service checks the admitted manifest again before it loads anything. +//! +//! **Where it runs.** Requests arrive on the UI thread. Each app gets one +//! worker thread holding its instances, so a slow function holds up only +//! its own app. Compiled code is cached in the host directory +//! (`wasm-cache`), keyed by the module's digest. An instance that trapped or +//! ran past its deadline is replaced before the next call. +//! +//! Not done yet: an app updated while the shell runs keeps its old +//! functions until the shell restarts, and a worker lives as long as the +//! shell. + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{channel, Receiver, SendError, Sender}; +use std::sync::{Mutex, OnceLock}; +use std::time::Instant; + +use octosense_appstore::services::{HostService, Replier, ServiceCall, ServiceHost}; +use octosense_wasm_host::{Instance, Limits, Program, Runtime}; +use serde_json::{json, Value}; + +use crate::host_tools::script_apps; + +/// The most modules one app's bundle may carry. +const MAX_MODULES: usize = 8; + +/// One engine, one epoch ticker and one code cache for the process. +static RUNTIME: OnceLock> = OnceLock::new(); + +/// Each app's worker, by app id. +static WORKERS: Mutex>>> = Mutex::new(None); + +struct Job { + method: String, + args: Value, + host_dir: PathBuf, + reply: Replier, +} + +/// The `wasm` family for the Card runner and the tool executor. +pub struct WasmService; + +impl HostService for WasmService { + fn family(&self) -> &'static str { + "wasm" + } + + fn call(&mut self, call: ServiceCall, reply: Replier, _host: &mut dyn ServiceHost) { + if call.from_sheet { + reply.send(Err("the wasm service has no sheet".into())); + return; + } + let app = call.app_id.clone(); + let job = Job { method: call.method().to_string(), host_dir: call.host_dir, args: call.args, reply }; + let mut workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + let workers = workers.get_or_insert_with(HashMap::new); + // A worker that ended (only a panic ends one) is replaced. + let job = match workers.get(&app) { + Some(jobs) => match jobs.send(job) { + Ok(()) => return, + Err(SendError(job)) => job, + }, + None => job, + }; + let (jobs, queue) = channel(); + let name = app.clone(); + let spawned = std::thread::Builder::new().name(format!("wasm {app}")).spawn(move || work(name, queue)); + if let Err(error) = spawned { + job.reply.send(Err(format!("cannot start {app}'s functions: {error}"))); + return; + } + let _ = jobs.send(job); + workers.insert(app, jobs); + } +} + +pub fn register() { + octosense_appstore::services::register_host_service(Box::new(WasmService)); +} + +/// One app's worker: loads its modules on the first request (again on the +/// next one, if that failed), then answers each request in turn. +fn work(app: String, jobs: Receiver) { + let mut lab: Option = None; + for job in jobs { + if lab.is_none() { + match Lab::load(&app, &job.host_dir) { + Ok(loaded) => lab = Some(loaded), + Err(error) => { + makepad_widgets::log!("wasm {app}: {error}"); + job.reply.send(Err(error)); + continue; + } + } + } + if let Some(lab) = lab.as_mut() { + job.reply.send(lab.answer(&job.method, &job.args)); + } + } +} + +fn runtime(host_dir: &Path) -> Result<&'static Runtime, String> { + RUNTIME + .get_or_init(|| Runtime::new(Limits::default(), Some(host_dir.join("wasm-cache")))) + .as_ref() + .map_err(Clone::clone) +} + +/// One app's loaded modules. +struct Lab { + app: String, + runtime: &'static Runtime, + modules: Vec, + /// Which module exports each function. + owner: BTreeMap, + stats: BTreeMap, +} + +struct Module { + file: String, + bytes: usize, + load_ms: f64, + from_cache: bool, + program: Program, + instance: Instance, + /// Fresh instances made after a trap or a deadline. + renewed: u32, +} + +/// How one function has run: in the worker, from input to output. +#[derive(Default)] +struct Stats { + calls: u64, + errors: u64, + total_us: f64, + max_us: f64, +} + +impl Lab { + fn load(app: &str, host_dir: &Path) -> Result { + let (_, bundle) = script_apps::admitted_bundle(app)?; + if !script_apps::from_bundle(&bundle)?.families.contains("wasm") { + return Err(format!("{app} was not granted the wasm service")); + } + let runtime = runtime(host_dir)?; + let mut files: Vec = std::fs::read_dir(bundle.join("fns")) + .map_err(|_| format!("{app}'s bundle has no fns directory"))? + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.extension().is_some_and(|ext| ext == "wasm") + && std::fs::symlink_metadata(path).is_ok_and(|m| m.is_file()) + }) + .collect(); + files.sort(); + if files.is_empty() || files.len() > MAX_MODULES { + return Err(format!("{app}'s bundle must carry 1 to {MAX_MODULES} fns/*.wasm modules")); + } + let mut lab = Lab { app: app.to_string(), runtime, modules: Vec::new(), owner: BTreeMap::new(), stats: BTreeMap::new() }; + for path in files { + let file = path.file_name().unwrap_or_default().to_string_lossy().into_owned(); + let bytes = std::fs::read(&path).map_err(|e| format!("{file}: {e}"))?; + let started = Instant::now(); + let program = runtime.load(&bytes).map_err(|e| format!("{file}: {e}"))?; + let instance = runtime.instantiate(&program).map_err(|e| format!("{file}: {e}"))?; + let load_ms = started.elapsed().as_secs_f64() * 1e3; + for function in program.functions() { + if lab.owner.insert(function.clone(), lab.modules.len()).is_some() { + return Err(format!("{file}: {function} is exported by another module too")); + } + } + let from_cache = program.from_cache(); + makepad_widgets::log!( + "wasm {app}: {file} ({} KiB) {} in {load_ms:.1} ms: {}", + bytes.len() / 1024, + if from_cache { "loaded from the cache" } else { "compiled" }, + program.functions().join(", ") + ); + lab.modules.push(Module { file, bytes: bytes.len(), load_ms, from_cache, program, instance, renewed: 0 }); + } + Ok(lab) + } + + fn answer(&mut self, method: &str, args: &Value) -> Result { + if method == "functions" { + // Also in the log: where there is no remote instrument (an + // Android build), the numbers are read from logcat. + let described = self.describe(); + makepad_widgets::log!("wasm {}: {described}", self.app); + return Ok(described); + } + let Some(&index) = self.owner.get(method) else { + return Err(format!("{} has no function {method:?}", self.app)); + }; + let module = &mut self.modules[index]; + if module.instance.spent() { + module.instance = self.runtime.instantiate(&module.program).map_err(|e| e.to_string())?; + module.renewed += 1; + } + let input = match args { + Value::String(text) => text.clone().into_bytes(), + other => serde_json::to_vec(other).map_err(|e| e.to_string())?, + }; + let started = Instant::now(); + let result = module.instance.call(method, &input); + let us = started.elapsed().as_secs_f64() * 1e6; + for line in module.instance.take_logs() { + makepad_widgets::log!("wasm {}: {line}", self.app); + } + let stats = self.stats.entry(method.to_string()).or_default(); + stats.calls += 1; + stats.total_us += us; + stats.max_us = stats.max_us.max(us); + let output = result.map_err(|error| { + stats.errors += 1; + error.to_string() + })?; + Ok(serde_json::from_slice(&output).unwrap_or_else(|_| json!({"text": String::from_utf8_lossy(&output)}))) + } + + fn describe(&self) -> Value { + let modules: Vec = self + .modules + .iter() + .map(|m| { + json!({"file": m.file, "bytes": m.bytes, "load_ms": round(m.load_ms), + "from_cache": m.from_cache, "memory_bytes": m.instance.memory_bytes(), "renewed": m.renewed}) + }) + .collect(); + let stats: serde_json::Map = self + .stats + .iter() + .map(|(name, s)| { + let mean = if s.calls == 0 { 0.0 } else { s.total_us / s.calls as f64 }; + (name.clone(), json!({"calls": s.calls, "errors": s.errors, "mean_us": round(mean), "max_us": round(s.max_us)})) + }) + .collect(); + json!({"functions": self.owner.keys().collect::>(), "modules": modules, "stats": stats}) + } +} + +fn round(value: f64) -> f64 { + (value * 10.0).round() / 10.0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_host::app_peers::host_tools::{HostToolCall, ToolExecutor, ToolReply}; + use crate::host_tools::script_apps::{self, HostServiceExecutor}; + use std::sync::Arc; + use std::time::Duration; + + /// Wasm Lab's agent gets exactly its three function tools, each routed + /// to the app's own function on the `wasm` service. + #[test] + fn wasm_lab_offers_its_functions_as_tools() { + let dir = script_apps::tests::stamped_bundle("wasmlab", "tools", |_, _| {}); + let loaded = script_apps::from_bundle(&dir).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + let names: Vec<&str> = loaded.tools.iter().filter_map(|t| t["name"].as_str()).collect(); + assert_eq!(names, ["wasmlab.find_slots", "wasmlab.rank", "wasmlab.diff"]); + assert_eq!(loaded.host_methods["wasmlab.find_slots"], "wasm.find_slots"); + assert_eq!(loaded.host_methods["wasmlab.rank"], "wasm.fuzzy_rank"); + assert_eq!(loaded.host_methods["wasmlab.diff"], "wasm.text_diff"); + assert!(loaded.tools.iter().all(|t| t["risk"] == "read" && t["output_schema"]["type"] == "object")); + assert_eq!(loaded.families.iter().collect::>(), ["wasm"]); + assert_eq!(loaded.generic, ["ask_user_question"]); + } + + struct NoSheet; + impl ServiceHost for NoSheet { + fn open_sheet(&mut self, _body: String) {} + fn close_sheet(&mut self) {} + } + + static NEXT_HEAP: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1 << 40); + + /// What the app's script would get from `host.request(service, args)`. + fn request(app: &str, service: &str, args: Value, host_dir: &Path) -> Result { + let heap = NEXT_HEAP.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let call = ServiceCall { app_id: app.into(), service: service.into(), args, from_sheet: false, may_prompt: true, host_dir: host_dir.into() }; + octosense_appstore::services::dispatch(call, heap, 1, &mut NoSheet); + for _ in 0..1000 { + if let Some((_, _, answer)) = octosense_appstore::services::take_replies_for(&[heap]).pop() { + return answer.map(|text| serde_json::from_str(&text).unwrap()); + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("{service} was never answered"); + } + + /// Register a stamped copy of `apps/wasmlab/bundle` as the system app + /// `id`, as a build that selects it does. + fn ship(id: &'static str, tag: &str, edit: impl FnOnce(&Path, &mut Value)) { + let dir = script_apps::tests::stamped_bundle("wasmlab", tag, |dir, manifest| { + manifest["id"] = json!(id); + edit(dir, manifest); + }); + let packed = octosense_app_hub::pack::pack_system_app(&dir).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + octosense_appstore::system::register_system_app(octosense_appstore::system::SystemApp { + id, + name: "Wasm Lab", + pack: Box::leak(packed.pack_json.into_boxed_str()), + assets: &[], + }); + } + + /// The whole path, in a process of its own (the apps root and the + /// registries are process-wide): Wasm Lab's tools reach its functions + /// through the executor and the service, its script's requests do too, + /// rogue code ends in an error and a fresh instance, and an app without + /// the grant reaches nothing. + #[test] + fn the_apps_own_functions_answer_its_tools_and_its_script() { + const CHILD: &str = "OCTOSENSE_TEST_WASM_SERVICE"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "wasm_service::tests::the_apps_own_functions_answer_its_tools_and_its_script", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + return; + } + let root = std::env::temp_dir().join(format!("octosense-wasm-service-{}", std::process::id())); + let host_dir = root.join(".host"); + octosense_appstore::set_data_root(root.clone()); + ship("os.wasmlab", "service", |_, _| {}); + // The same bundle without the capability (and so without its tools). + ship("os.wasmplain", "plain", |dir, manifest| { + manifest["capabilities"] = json!([]); + manifest.as_object_mut().unwrap().remove("agent"); + std::fs::remove_file(dir.join("tools.json")).unwrap(); + std::fs::remove_file(dir.join("AGENT.md")).unwrap(); + }); + register(); + + // An agent's tool call, as the relay hands it to the app's executor. + let (_, bundle) = script_apps::admitted_bundle("os.wasmlab").unwrap(); + let loaded = script_apps::from_bundle(&bundle).unwrap(); + let executor = HostServiceExecutor { + app: "os.wasmlab".into(), + tools: loaded.host_service_tools, + methods: loaded.host_methods, + families: loaded.families, + host_dir: host_dir.clone(), + }; + let sent: Arc>> = Arc::default(); + let into = sent.clone(); + let call = HostToolCall::parse(&json!({"peer": "p", "session_id": "s", "turn_id": "t", "call_id": "c1", + "name": "wasmlab.find_slots", + "args": {"day_start": "09:00", "day_end": "12:00", "duration": 30, "busy": [["09:30", "11:00"]]}})) + .unwrap(); + executor.execute(call, ToolReply::new("c1", move |v| into.lock().unwrap().push(v))); + for _ in 0..1000 { + script_apps::poll(); + if !sent.lock().unwrap().is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + let answer = sent.lock().unwrap().pop().expect("the tool answered"); + assert_eq!(answer["ok"], true, "{answer}"); + // Every 15 minutes, the default step. + assert_eq!(answer["data"]["slots"], json!([["09:00", "09:30"], ["11:00", "11:30"], ["11:15", "11:45"], ["11:30", "12:00"]]), "{answer}"); + + // The script's own requests: a string goes in as its text, and an + // output that is not JSON comes back as text. + let html = request("os.wasmlab", "wasm.md_to_html", json!("# Hi"), &host_dir).unwrap(); + assert_eq!(html["text"], "

Hi

\n"); + let ranked = request("os.wasmlab", "wasm.fuzzy_rank", json!({"query": "cal", "items": ["Mail", "Calendar"]}), &host_dir).unwrap(); + assert_eq!(ranked["ranked"][0]["item"], "Calendar"); + let error = request("os.wasmlab", "wasm.find_slots", json!({"day_start": "12:00", "day_end": "09:00", "duration": 30, "busy": []}), &host_dir).unwrap_err(); + assert!(error.contains("ends before it starts"), "{error}"); + let error = request("os.wasmlab", "wasm.nothing", json!({}), &host_dir).unwrap_err(); + assert!(error.contains("no function"), "{error}"); + + // Rogue code: an error each time, and the next call still answers. + for mode in ["loop", "alloc", "panic", "recurse"] { + let error = request("os.wasmlab", "wasm.rogue", json!({"mode": mode}), &host_dir).unwrap_err(); + assert!(error.contains("deadline") || error.contains("trapped"), "{mode}: {error}"); + let ranked = request("os.wasmlab", "wasm.fuzzy_rank", json!({"query": "m", "items": ["Mail"]}), &host_dir).unwrap(); + assert_eq!(ranked["ranked"][0]["item"], "Mail", "{mode}"); + } + let described = request("os.wasmlab", "wasm.functions", json!({}), &host_dir).unwrap(); + assert_eq!(described["functions"], json!(["find_slots", "fuzzy_rank", "md_to_html", "rogue", "text_diff"])); + assert_eq!(described["modules"][0]["file"], "wasmlab.wasm"); + assert_eq!(described["modules"][0]["renewed"], 4); + assert_eq!(described["stats"]["rogue"]["errors"], 4); + assert!(host_dir.join("wasm-cache").read_dir().unwrap().next().is_some(), "compiled code is cached"); + + // No grant, no functions: the service checks the admitted manifest + // itself, whoever dispatched the request. + let error = request("os.wasmplain", "wasm.fuzzy_rank", json!({"query": "m", "items": []}), &host_dir).unwrap_err(); + assert!(error.contains("was not granted the wasm service"), "{error}"); + // And a sheet never reaches it. + let heap = NEXT_HEAP.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let call = ServiceCall { app_id: "os.wasmlab".into(), service: "wasm.functions".into(), args: json!({}), from_sheet: true, may_prompt: true, host_dir: host_dir.clone() }; + octosense_appstore::services::dispatch(call, heap, 1, &mut NoSheet); + let (_, _, answer) = octosense_appstore::services::take_replies_for(&[heap]).pop().unwrap(); + assert!(answer.unwrap_err().contains("no sheet")); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/crates/wasm-host/Cargo.toml b/crates/wasm-host/Cargo.toml new file mode 100644 index 00000000..3117e85b --- /dev/null +++ b/crates/wasm-host/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "octosense-wasm-host" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +publish = false + +[dependencies] +wasmtime = { workspace = true } +sha2 = "0.10" +# Not used directly: Wasmtime reads a trap's registers through libc's +# ucontext_t, and before 0.2.190 Android aarch64's lacked the kernel's +# padding after the signal mask, so the faulting pc was misread, no trap was +# recognised as wasm and every trap ended the app. +libc = "0.2.190" + +[dev-dependencies] +wat = "1" +serde_json = { workspace = true } +# The sample functions natively, to check the module against them. +wasmlab-functions = { path = "../../apps/wasmlab/guest/functions" } diff --git a/crates/wasm-host/examples/measure.rs b/crates/wasm-host/examples/measure.rs new file mode 100644 index 00000000..2813830c --- /dev/null +++ b/crates/wasm-host/examples/measure.rs @@ -0,0 +1,113 @@ +//! Startup and per-call latency of Wasm Lab's functions in the runtime, +//! against the same functions called natively. +//! +//! cargo run --release -p octosense-wasm-host --example measure + +use std::time::{Duration, Instant}; + +use octosense_wasm_host::{Limits, Runtime}; +use serde_json::{json, Value}; + +const MODULE: &[u8] = include_bytes!("../../../apps/wasmlab/bundle/fns/wasmlab.wasm"); + +/// Median of `runs` timings of `f`, in microseconds. +fn median_us(runs: usize, mut f: impl FnMut()) -> f64 { + let mut times: Vec = (0..runs) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed() + }) + .collect(); + times.sort(); + times[runs / 2].as_secs_f64() * 1e6 +} + +fn markdown() -> String { + let mut text = String::new(); + for i in 0..200 { + text.push_str(&format!( + "## Section {i}\n\nSome *emphasis*, **strong** text, `code` and a [link](https://example.org/{i}).\n\n- [x] done {i}\n- [ ] todo {i}\n\n| key | value |\n|---|---|\n| a{i} | {i} |\n\n" + )); + } + text +} + +fn day() -> Value { + let busy: Vec<[String; 2]> = (0..30) + .map(|i| { + let start = 8 * 60 + i * 19; + [format!("{:02}:{:02}", start / 60, start % 60), format!("{:02}:{:02}", (start + 11) / 60, (start + 11) % 60)] + }) + .collect(); + json!({"day_start": "08:00", "day_end": "19:00", "duration": 30, "step": 5, "busy": busy, "max": 50}) +} + +fn items() -> Value { + let words = ["calendar", "calculator", "camera", "clock", "mail", "maps", "notes", "news", "photos", "weather"]; + let items: Vec = (0..500).map(|i| format!("{} {} {i}", words[i % 10], words[(i * 7) % 10])).collect(); + json!({"query": "cal not", "items": items, "limit": 20}) +} + +fn texts() -> Value { + let old: Vec = (0..1000).map(|i| format!("line {i}: the quick brown fox")).collect(); + let mut new = old.clone(); + for i in (0..1000).step_by(50) { + new[i] = format!("line {i}: the quick red fox"); + } + new.insert(500, "an inserted line".into()); + json!({"old": old.join("\n"), "new": new.join("\n"), "context": 3}) +} + +fn main() { + println!("module: {} KiB", MODULE.len() / 1024); + let cache = std::env::temp_dir().join(format!("octosense-wasm-measure-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&cache); + + let t = Instant::now(); + let runtime = Runtime::new(Limits::default(), Some(cache.clone())).unwrap(); + let program = runtime.load(MODULE).unwrap(); + println!("compile (Cranelift, first load): {:.1} ms", t.elapsed().as_secs_f64() * 1e3); + let again = Runtime::new(Limits::default(), Some(cache.clone())).unwrap(); + let t = Instant::now(); + let cached = again.load(MODULE).unwrap(); + println!("load from the cache: {:.2} ms (from cache: {})", t.elapsed().as_secs_f64() * 1e3, cached.from_cache()); + let t = Instant::now(); + let mut guest = runtime.instantiate(&program).unwrap(); + println!("instantiate: {:.3} ms", t.elapsed().as_secs_f64() * 1e3); + let _ = std::fs::remove_dir_all(&cache); + + let md = markdown(); + let (day, items, texts) = (day(), items(), texts()); + let json_in = |v: &Value| serde_json::to_vec(v).unwrap(); + + println!("\n{:<44} {:>10} {:>10} {:>7}", "per call (median of 200)", "wasm us", "native us", "ratio"); + let rows: Vec<(String, f64, f64)> = vec![ + ( + format!("md_to_html ({} KiB of Markdown)", md.len() / 1024), + median_us(200, || drop(guest.call("md_to_html", md.as_bytes()).unwrap())), + median_us(200, || drop(wasmlab_functions::md_to_html(md.as_bytes()).unwrap())), + ), + ( + "find_slots (30 busy intervals, 5-min grid)".into(), + median_us(200, || drop(guest.call("find_slots", &json_in(&day)).unwrap())), + median_us(200, || drop(serde_json::to_vec(&wasmlab_functions::find_slots(serde_json::from_slice(&json_in(&day)).unwrap()).unwrap()).unwrap())), + ), + ( + "fuzzy_rank (500 items)".into(), + median_us(200, || drop(guest.call("fuzzy_rank", &json_in(&items)).unwrap())), + median_us(200, || drop(serde_json::to_vec(&wasmlab_functions::fuzzy_rank(serde_json::from_slice(&json_in(&items)).unwrap()).unwrap()).unwrap())), + ), + ( + "text_diff (1000 lines, 21 changes)".into(), + median_us(200, || drop(guest.call("text_diff", &json_in(&texts)).unwrap())), + median_us(200, || drop(serde_json::to_vec(&wasmlab_functions::text_diff(serde_json::from_slice(&json_in(&texts)).unwrap()).unwrap()).unwrap())), + ), + ]; + for (name, wasm, native) in rows { + println!("{name:<44} {wasm:>10.1} {native:>10.1} {:>6.1}x", wasm / native); + } + let empty = json!({"query": "x", "items": [], "limit": 1}); + let call = median_us(2000, || drop(guest.call("fuzzy_rank", &json_in(&empty)).unwrap())); + println!("\nsmallest call (fuzzy_rank over no items, JSON both ways): {call:.1} us"); +} diff --git a/crates/wasm-host/src/lib.rs b/crates/wasm-host/src/lib.rs new file mode 100644 index 00000000..364fa47b --- /dev/null +++ b/crates/wasm-host/src/lib.rs @@ -0,0 +1,570 @@ +//! Developers' own functions, compiled to WebAssembly, run for their app in +//! Wasmtime (Cranelift): light computing and algorithms an app ships beside +//! its script (ADR 0011). +//! +//! # The guest ABI +//! +//! A module is core WebAssembly (the guest crate `octosense-guest` writes all +//! of this for a Rust function): +//! - it exports `memory`, `octo_alloc(len: i32) -> i32` and +//! `octo_free(ptr: i32, len: i32)`; +//! - every other export of type `(i32, i32) -> i64` is a function the app may +//! call: it takes its input bytes at `(ptr, len)` and returns +//! `(out_ptr << 32) | out_len` of a buffer it allocated, which holds a +//! status byte (`0` ok, `1` error) and then the output, or the error text; +//! - its only import may be `octo.log(ptr: i32, len: i32)`. +//! +//! # Containment +//! +//! A module reaches nothing but its own memory and `octo.log`: no file, +//! network, clock or other import exists, and a module that asks for one is +//! refused when it loads. Every call has a deadline (Wasmtime's epoch +//! interruption), the memory and the wasm stack have caps, and input and +//! output have a size limit. A trap, a deadline or a limit ends the call +//! with an error; it never ends the process. The instance is spent then +//! (its module's state may be half-updated) and refuses further calls; a +//! fresh instance of the same program costs a fraction of a millisecond. + +use std::collections::HashMap; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; +use wasmtime::{ + Caller, Config, Engine, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, Trap, + TypedFunc, ValType, +}; + +/// How often the epoch advances: the granularity of a deadline. +const TICK: Duration = Duration::from_millis(10); + +/// The most one `octo.log` line may hold; longer ones are cut. +const LOG_LINE: usize = 1024; + +/// The most log lines an instance keeps until they are read. +const LOG_LINES: usize = 64; + +/// What every instance of a [`Runtime`] may use. +#[derive(Clone, Debug)] +pub struct Limits { + /// How long one call may run. + pub deadline: Duration, + /// The most linear memory an instance may have. + pub memory_bytes: usize, + /// The largest module the runtime loads. + pub module_bytes: usize, + /// The largest input, and the largest output, of a call. + pub io_bytes: usize, + /// The wasm stack of a call. + pub stack_bytes: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { + deadline: Duration::from_secs(2), + memory_bytes: 256 << 20, + module_bytes: 8 << 20, + io_bytes: 16 << 20, + stack_bytes: 512 << 10, + } + } +} + +/// Why a module cannot load. +#[derive(Debug, Clone, PartialEq)] +pub enum LoadError { + /// It is bigger than [`Limits::module_bytes`]. + TooLarge(usize), + /// It is not valid WebAssembly for this engine. + Invalid(String), + /// It imports something other than `octo.log`. + Import(String), + /// It lacks `memory`, `octo_alloc` or `octo_free`, or one has the wrong type. + Export(String), +} + +impl fmt::Display for LoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LoadError::TooLarge(n) => write!(f, "the module is {n} bytes, over the limit"), + LoadError::Invalid(why) => write!(f, "not a valid module: {why}"), + LoadError::Import(what) => { + write!(f, "the module imports {what}; only octo.log is provided") + } + LoadError::Export(what) => write!(f, "the module does not export {what}"), + } + } +} + +impl std::error::Error for LoadError {} + +/// Why a call failed. +#[derive(Debug, Clone, PartialEq)] +pub enum CallError { + /// The module has no such function. + NoSuchFunction(String), + /// The call ran past [`Limits::deadline`]. + Deadline, + /// Its input or output is bigger than [`Limits::io_bytes`]. + TooLarge(usize), + /// The function trapped: a panic, an out-of-bounds access, a stack + /// overflow, memory over its cap. The text says which. + Trap(String), + /// The function returned an error of its own. + Guest(String), + /// An earlier call on this instance trapped or ran past its deadline, so + /// it takes no more calls: instantiate the program again. + Spent, +} + +impl fmt::Display for CallError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CallError::NoSuchFunction(name) => write!(f, "no function {name}"), + CallError::Deadline => write!(f, "the call ran past its deadline"), + CallError::TooLarge(n) => write!(f, "{n} bytes is over the input/output limit"), + CallError::Trap(why) => write!(f, "the function trapped: {why}"), + CallError::Guest(why) => write!(f, "{why}"), + CallError::Spent => write!(f, "the instance stopped after an earlier trap"), + } + } +} + +impl std::error::Error for CallError {} + +/// The engine and the limits every instance shares. Its epoch ticker runs +/// for as long as the runtime or any of its instances lives: an instance +/// never outlives its deadline. +pub struct Runtime { + engine: Engine, + limits: Limits, + cache_dir: Option, + ticker: Arc, +} + +/// A loaded module: checked, compiled (or taken from the cache). +#[derive(Clone)] +pub struct Program { + module: Module, + functions: Vec, + from_cache: bool, +} + +impl Program { + /// The functions an app may call, sorted. + pub fn functions(&self) -> &[String] { + &self.functions + } + + /// Whether the compiled code came from the cache. + pub fn from_cache(&self) -> bool { + self.from_cache + } +} + +struct State { + limits: StoreLimits, + logs: Vec, +} + +/// One app's instance of a [`Program`]: its own memory and its own state. +/// It may move to another thread. +pub struct Instance { + store: Store, + memory: Memory, + alloc: TypedFunc, + free: TypedFunc<(i32, i32), ()>, + functions: HashMap>, + deadline_ticks: u64, + io_bytes: usize, + /// A call trapped or ran past its deadline. The module's own state may + /// be half-updated then (a Rust guest's stack pointer and allocator live + /// in its memory and globals), so the instance takes no more calls. + spent: bool, + _ticker: Arc, +} + +impl Runtime { + /// A runtime with these limits. Compiled code is cached in `cache_dir` + /// when one is given (keyed by the module's digest and the engine). + pub fn new(limits: Limits, cache_dir: Option) -> Result { + let mut config = Config::new(); + config.epoch_interruption(true); + config.max_wasm_stack(limits.stack_bytes); + let engine = Engine::new(&config).map_err(|e| format!("{e:#}"))?; + let ticker = Arc::new(Ticker::start(engine.clone())); + Ok(Runtime { + engine, + limits, + cache_dir, + ticker, + }) + } + + pub fn limits(&self) -> &Limits { + &self.limits + } + + /// Checks and compiles `bytes`, or takes its compiled code from the cache. + pub fn load(&self, bytes: &[u8]) -> Result { + if bytes.len() > self.limits.module_bytes { + return Err(LoadError::TooLarge(bytes.len())); + } + let cached = self.cache_path(bytes); + let mut from_cache = false; + let module = match cached.as_ref().and_then(|path| std::fs::read(path).ok()) { + // SAFETY: the cache holds only what `Module::serialize` wrote for + // this engine (the key includes its compatibility hash), in a + // directory that belongs to the shell. + Some(code) => match unsafe { Module::deserialize(&self.engine, &code) } { + Ok(module) => { + from_cache = true; + module + } + Err(_) => self.compile(bytes, cached.as_ref())?, + }, + None => self.compile(bytes, cached.as_ref())?, + }; + let functions = check(&module)?; + Ok(Program { + module, + functions, + from_cache, + }) + } + + fn compile(&self, bytes: &[u8], cache: Option<&PathBuf>) -> Result { + let module = + Module::new(&self.engine, bytes).map_err(|e| LoadError::Invalid(format!("{e:#}")))?; + if let Some(path) = cache { + // Check before caching: a refused module is never stored. + check(&module)?; + if let Ok(code) = module.serialize() { + let tmp = path.with_extension("tmp"); + if std::fs::write(&tmp, &code).is_ok() && std::fs::rename(&tmp, path).is_ok() { + // Read it back off this thread: an on-access scanner + // (Microsoft Defender's, on a managed Mac) holds the + // first open of a new file for about a second, so let + // that happen now rather than at the next load. + let path = path.clone(); + let _ = std::thread::Builder::new() + .name("wasm-cache".into()) + .spawn(move || drop(std::fs::read(path))); + } + } + } + Ok(module) + } + + fn cache_path(&self, bytes: &[u8]) -> Option { + let dir = self.cache_dir.as_ref()?; + std::fs::create_dir_all(dir).ok()?; + let mut engine = std::collections::hash_map::DefaultHasher::new(); + self.engine + .precompile_compatibility_hash() + .hash(&mut engine); + let digest = Sha256::digest(bytes); + let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); + Some(dir.join(format!("{hex}-{:016x}.cwasm", engine.finish()))) + } + + /// A fresh instance of `program`, with its own memory. + pub fn instantiate(&self, program: &Program) -> Result { + let limits = StoreLimitsBuilder::new() + .memory_size(self.limits.memory_bytes) + .instances(1) + .memories(1) + .tables(1) + .trap_on_grow_failure(true) + .build(); + let mut store = Store::new( + &self.engine, + State { + limits, + logs: Vec::new(), + }, + ); + store.limiter(|state| &mut state.limits); + store.epoch_deadline_trap(); + // Instantiation runs the start function and data initialisers: a + // deadline covers it too. + let deadline_ticks = self + .limits + .deadline + .as_millis() + .div_ceil(TICK.as_millis()) + .max(1) as u64; + store.set_epoch_deadline(deadline_ticks); + let mut linker = Linker::new(&self.engine); + linker + .func_wrap( + "octo", + "log", + |mut caller: Caller<'_, State>, ptr: i32, len: i32| { + let Some(memory) = caller.get_export("memory").and_then(|e| e.into_memory()) + else { + return; + }; + let len = (len.max(0) as usize).min(LOG_LINE); + let mut buf = vec![0u8; len]; + if memory.read(&caller, ptr as u32 as usize, &mut buf).is_ok() + && caller.data().logs.len() < LOG_LINES + { + let line = String::from_utf8_lossy(&buf).into_owned(); + caller.data_mut().logs.push(line); + } + }, + ) + .map_err(|e| LoadError::Invalid(format!("{e:#}")))?; + let instance = linker + .instantiate(&mut store, &program.module) + .map_err(|e| LoadError::Invalid(format!("{e:#}")))?; + let memory = instance + .get_memory(&mut store, "memory") + .ok_or_else(|| LoadError::Export("memory".into()))?; + let alloc = instance + .get_typed_func::(&mut store, "octo_alloc") + .map_err(|_| LoadError::Export("octo_alloc(i32) -> i32".into()))?; + let free = instance + .get_typed_func::<(i32, i32), ()>(&mut store, "octo_free") + .map_err(|_| LoadError::Export("octo_free(i32, i32)".into()))?; + let mut functions = HashMap::new(); + for name in &program.functions { + let f = instance + .get_typed_func::<(i32, i32), i64>(&mut store, name) + .map_err(|_| LoadError::Export(format!("{name}(i32, i32) -> i64")))?; + functions.insert(name.clone(), f); + } + Ok(Instance { + store, + memory, + alloc, + free, + functions, + deadline_ticks, + io_bytes: self.limits.io_bytes, + spent: false, + _ticker: self.ticker.clone(), + }) + } +} + +/// The imports and exports a module must have; returns its functions. +fn check(module: &Module) -> Result, LoadError> { + for import in module.imports() { + let ok = import.module() == "octo" + && import.name() == "log" + && import.ty().func().is_some_and(|f| { + f.params().map(|t| t.is_i32()).eq([true, true]) && f.results().len() == 0 + }); + if !ok { + return Err(LoadError::Import(format!( + "{}.{}", + import.module(), + import.name() + ))); + } + } + let mut functions = Vec::new(); + let mut memory = false; + let mut alloc = false; + let mut free = false; + for export in module.exports() { + let ty = export.ty(); + match export.name() { + "memory" => memory = ty.memory().is_some(), + "octo_alloc" => { + alloc = ty + .func() + .is_some_and(|f| sig(f.params(), f.results(), &[ValType::I32], &[ValType::I32])) + } + "octo_free" => { + free = ty.func().is_some_and(|f| { + sig(f.params(), f.results(), &[ValType::I32, ValType::I32], &[]) + }) + } + name => { + if ty.func().is_some_and(|f| { + sig( + f.params(), + f.results(), + &[ValType::I32, ValType::I32], + &[ValType::I64], + ) + }) { + functions.push(name.to_string()); + } + } + } + } + for (have, what) in [ + (memory, "memory"), + (alloc, "octo_alloc(i32) -> i32"), + (free, "octo_free(i32, i32)"), + ] { + if !have { + return Err(LoadError::Export(what.into())); + } + } + functions.sort(); + Ok(functions) +} + +fn sig( + params: impl ExactSizeIterator, + results: impl ExactSizeIterator, + want_params: &[ValType], + want_results: &[ValType], +) -> bool { + let same = |a: ValType, b: &ValType| { + matches!( + (a, b), + (ValType::I32, ValType::I32) | (ValType::I64, ValType::I64) + ) + }; + params.len() == want_params.len() + && results.len() == want_results.len() + && params.zip(want_params).all(|(a, b)| same(a, b)) + && results.zip(want_results).all(|(a, b)| same(a, b)) +} + +impl Instance { + /// The functions this instance has, sorted. + pub fn functions(&self) -> Vec { + let mut names: Vec = self.functions.keys().cloned().collect(); + names.sort(); + names + } + + /// Calls `name` with `input` and returns its output. After a trap or a + /// deadline the instance is spent ([`CallError::Spent`]): instantiate + /// the program again, which is cheap. A function's own error leaves the + /// instance as it was. + pub fn call(&mut self, name: &str, input: &[u8]) -> Result, CallError> { + if self.spent { + return Err(CallError::Spent); + } + let f = self + .functions + .get(name) + .cloned() + .ok_or_else(|| CallError::NoSuchFunction(name.into()))?; + if input.len() > self.io_bytes { + return Err(CallError::TooLarge(input.len())); + } + let result = self.run(f, input); + if matches!(result, Err(CallError::Deadline | CallError::Trap(_))) { + self.spent = true; + } + result + } + + /// Whether a call trapped or ran past its deadline ([`Instance::call`]). + pub fn spent(&self) -> bool { + self.spent + } + + fn run(&mut self, f: TypedFunc<(i32, i32), i64>, input: &[u8]) -> Result, CallError> { + self.store.set_epoch_deadline(self.deadline_ticks); + let len = input.len() as i32; + let ptr = self.alloc.call(&mut self.store, len).map_err(trap)?; + self.memory + .write(&mut self.store, ptr as u32 as usize, input) + .map_err(|_| CallError::Trap("octo_alloc returned memory out of bounds".into()))?; + let packed = f.call(&mut self.store, (ptr, len)).map_err(trap)?; + self.free.call(&mut self.store, (ptr, len)).map_err(trap)?; + let out_ptr = (packed as u64 >> 32) as usize; + let out_len = (packed as u64 & 0xffff_ffff) as usize; + if out_len > self.io_bytes + 1 { + self.free + .call(&mut self.store, (out_ptr as i32, out_len as i32)) + .map_err(trap)?; + return Err(CallError::TooLarge(out_len)); + } + let mut out = vec![0u8; out_len]; + self.memory + .read(&self.store, out_ptr, &mut out) + .map_err(|_| CallError::Trap("the output is out of bounds".into()))?; + self.free + .call(&mut self.store, (out_ptr as i32, out_len as i32)) + .map_err(trap)?; + match out.first() { + Some(0) => { + out.remove(0); + Ok(out) + } + Some(1) => Err(CallError::Guest( + String::from_utf8_lossy(&out[1..]).into_owned(), + )), + _ => Err(CallError::Trap("the output has no status byte".into())), + } + } + + /// The lines the module logged since the last read. + pub fn take_logs(&mut self) -> Vec { + std::mem::take(&mut self.store.data_mut().logs) + } + + /// The instance's linear memory, in bytes. + pub fn memory_bytes(&self) -> usize { + self.memory.data_size(&self.store) + } +} + +fn trap(error: wasmtime::Error) -> CallError { + match error.downcast_ref::() { + Some(Trap::Interrupt) => CallError::Deadline, + Some(trap) => CallError::Trap(trap.to_string()), + // A limit the store enforces: say which, without the wasm backtrace + // wrapped around it. + None => { + let cause = error.root_cause().to_string(); + if cause.contains("growing memory") { + CallError::Trap(format!("memory over its cap ({cause})")) + } else { + CallError::Trap(cause) + } + } + } +} + +/// Advances the engine's epoch every [`TICK`] until dropped. +struct Ticker { + stop: Arc, +} + +impl Ticker { + fn start(engine: Engine) -> Ticker { + let stop = Arc::new(AtomicBool::new(false)); + let flag = stop.clone(); + std::thread::Builder::new() + .name("wasm-epoch".into()) + .spawn(move || { + // Advance by the time that has passed, not by the sleeps: a + // sleep can overrun, and a deadline is wall-clock time. + let started = Instant::now(); + let mut ticks = 0u128; + while !flag.load(Ordering::Relaxed) { + std::thread::sleep(TICK); + let due = started.elapsed().as_nanos() / TICK.as_nanos(); + while ticks < due { + engine.increment_epoch(); + ticks += 1; + } + } + }) + .expect("the wasm epoch thread"); + Ticker { stop } + } +} + +impl Drop for Ticker { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } +} diff --git a/crates/wasm-host/tests/guest.rs b/crates/wasm-host/tests/guest.rs new file mode 100644 index 00000000..aaf5cd2a --- /dev/null +++ b/crates/wasm-host/tests/guest.rs @@ -0,0 +1,170 @@ +//! Wasm Lab's real Rust functions (apps/wasmlab/guest, built into +//! apps/wasmlab/bundle/fns/wasmlab.wasm by its build.sh) in the runtime: +//! each gives exactly what the same code gives natively, and each way the +//! rogue function misbehaves ends in an error. + +use std::time::{Duration, Instant}; + +use octosense_wasm_host::{CallError, Instance, Limits, Program, Runtime}; +use serde_json::{json, Value}; + +const MODULE: &[u8] = include_bytes!("../../../apps/wasmlab/bundle/fns/wasmlab.wasm"); + +fn lab() -> (Runtime, Program) { + let runtime = Runtime::new( + Limits { + deadline: Duration::from_millis(500), + memory_bytes: 64 << 20, + ..Limits::default() + }, + None, + ) + .unwrap(); + let program = runtime.load(MODULE).unwrap(); + (runtime, program) +} + +fn guest() -> Instance { + let (runtime, program) = lab(); + // The instance outlives the runtime, and keeps its deadline. + runtime.instantiate(&program).unwrap() +} + +fn call_json(guest: &mut Instance, name: &str, input: Value) -> Value { + let out = guest + .call(name, &serde_json::to_vec(&input).unwrap()) + .unwrap(); + serde_json::from_slice(&out).unwrap() +} + +#[test] +fn the_module_offers_its_functions_and_imports_only_log() { + assert_eq!( + guest().functions(), + [ + "find_slots", + "fuzzy_rank", + "md_to_html", + "rogue", + "text_diff" + ] + ); +} + +#[test] +fn markdown_matches_native() { + let text = "# Notes\n\nSome *emphasis*, `code` and a [link](https://example.org).\n\n- [x] done\n- [ ] todo\n\n| a | b |\n|---|---|\n| 1 | 2 |\n"; + let wasm = guest().call("md_to_html", text.as_bytes()).unwrap(); + assert_eq!( + wasm, + wasmlab_functions::md_to_html(text.as_bytes()).unwrap() + ); +} + +#[test] +fn slots_match_native() { + let input = json!({"day_start": "08:30", "day_end": "18:00", "duration": 45, "step": 15, + "busy": [["09:00", "10:00"], ["09:45", "11:15"], ["13:00", "14:00"], ["16:50", "17:05"]], "max": 20}); + let wasm = call_json(&mut guest(), "find_slots", input.clone()); + let native = wasmlab_functions::find_slots(serde_json::from_value(input).unwrap()).unwrap(); + assert_eq!(wasm, serde_json::to_value(native).unwrap()); + assert_eq!(wasm["slots"][0], json!(["11:15", "12:00"])); +} + +#[test] +fn ranking_matches_native() { + let items = [ + "Calendar", + "Calculator", + "Camera", + "Local calls", + "Mail", + "Clock", + "Weather", + "Cal Newport notes", + ]; + let input = json!({"query": "cal", "items": items, "limit": 5}); + let wasm = call_json(&mut guest(), "fuzzy_rank", input.clone()); + let native = wasmlab_functions::fuzzy_rank(serde_json::from_value(input).unwrap()).unwrap(); + assert_eq!(wasm, serde_json::to_value(native).unwrap()); +} + +#[test] +fn diff_matches_native() { + let input = json!({"old": "one\ntwo\nthree\nfour\n", "new": "one\n2\nthree\nfour\nfive\n", "context": 1}); + let wasm = call_json(&mut guest(), "text_diff", input.clone()); + let native = wasmlab_functions::text_diff(serde_json::from_value(input).unwrap()).unwrap(); + assert_eq!(wasm, serde_json::to_value(native).unwrap()); +} + +#[test] +fn bad_input_is_the_functions_own_error() { + let error = guest() + .call("find_slots", b"{\"day_start\": 9}") + .unwrap_err(); + assert!( + matches!(&error, CallError::Guest(why) if why.contains("find_slots")), + "{error:?}" + ); + let error = guest() + .call( + "find_slots", + br#"{"day_start": "18:00", "day_end": "09:00", "duration": 30}"#, + ) + .unwrap_err(); + assert_eq!( + error, + CallError::Guest("the day ends before it starts".into()) + ); +} + +#[test] +fn rogue_code_ends_in_an_error_and_spends_only_its_own_instance() { + let (runtime, program) = lab(); + let rogue = |mode: &str| { + let mut guest = runtime.instantiate(&program).unwrap(); + let error = guest + .call("rogue", json!({ "mode": mode }).to_string().as_bytes()) + .unwrap_err(); + assert!(guest.spent(), "{mode}"); + assert_eq!( + guest.call("fuzzy_rank", br#"{"query": "m", "items": []}"#), + Err(CallError::Spent) + ); + (error, guest) + }; + + let started = Instant::now(); + assert_eq!(rogue("loop").0, CallError::Deadline); + assert!(started.elapsed() < Duration::from_secs(2)); + + let (error, guest) = rogue("alloc"); + assert!( + matches!(&error, CallError::Trap(why) if why.starts_with("memory over its cap") && !why.contains("backtrace")), + "{error:?}" + ); + assert!(guest.memory_bytes() <= 64 << 20); + + let (error, mut guest) = rogue("panic"); + assert!(matches!(&error, CallError::Trap(_)), "{error:?}"); + let logs = guest.take_logs(); + assert!( + logs.iter().any(|l| l.contains("rogue: a deliberate panic")), + "{logs:?}" + ); + + let (error, _) = rogue("recurse"); + assert!( + matches!(&error, CallError::Trap(why) if why.contains("stack")), + "{error:?}" + ); + + // A fresh instance of the same program answers after all of it. + let mut fresh = runtime.instantiate(&program).unwrap(); + let out = call_json( + &mut fresh, + "fuzzy_rank", + json!({"query": "mail", "items": ["Mail", "Maps"]}), + ); + assert_eq!(out["ranked"][0]["item"], "Mail"); +} diff --git a/crates/wasm-host/tests/runtime.rs b/crates/wasm-host/tests/runtime.rs new file mode 100644 index 00000000..0bf449b7 --- /dev/null +++ b/crates/wasm-host/tests/runtime.rs @@ -0,0 +1,255 @@ +//! The runtime against a hand-written guest that follows the ABI: a bump +//! allocator, a function that works, and functions that misbehave in each +//! way the containment has to stop. + +use std::time::{Duration, Instant}; + +use octosense_wasm_host::{CallError, Instance, Limits, LoadError, Runtime}; + +const GUEST: &str = r#" +(module + (import "octo" "log" (func $log (param i32 i32))) + (memory (export "memory") 1) + (global $heap (mut i32) (i32.const 1024)) + (global $count (mut i32) (i32.const 0)) + (data (i32.const 16) "\01nope") + (data (i32.const 32) "\00") + + (func $alloc (export "octo_alloc") (param $len i32) (result i32) + (local $p i32) + (local.set $p (global.get $heap)) + (block $fits + (br_if $fits (i32.le_u (i32.add (local.get $p) (local.get $len)) + (i32.mul (memory.size) (i32.const 65536)))) + (drop (memory.grow (i32.add (i32.shr_u (local.get $len) (i32.const 16)) (i32.const 1))))) + (global.set $heap (i32.and (i32.add (i32.add (local.get $p) (local.get $len)) (i32.const 7)) + (i32.const -8))) + (local.get $p)) + + (func (export "octo_free") (param i32 i32)) + + (func $pack (param $p i32) (param $n i32) (result i64) + (i64.or (i64.shl (i64.extend_i32_u (local.get $p)) (i64.const 32)) + (i64.extend_i32_u (local.get $n)))) + + ;; The input in upper case. + (func (export "upper") (param $ptr i32) (param $len i32) (result i64) + (local $out i32) (local $i i32) (local $c i32) + (local.set $out (call $alloc (i32.add (local.get $len) (i32.const 1)))) + (i32.store8 (local.get $out) (i32.const 0)) + (block $done + (loop $next + (br_if $done (i32.ge_u (local.get $i) (local.get $len))) + (local.set $c (i32.load8_u (i32.add (local.get $ptr) (local.get $i)))) + (if (i32.and (i32.ge_u (local.get $c) (i32.const 97)) (i32.le_u (local.get $c) (i32.const 122))) + (then (local.set $c (i32.sub (local.get $c) (i32.const 32))))) + (i32.store8 (i32.add (i32.add (local.get $out) (i32.const 1)) (local.get $i)) (local.get $c)) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br $next))) + (call $pack (local.get $out) (i32.add (local.get $len) (i32.const 1)))) + + ;; An error of the guest's own. + (func (export "fail") (param i32 i32) (result i64) + (call $pack (i32.const 16) (i32.const 5))) + + ;; An endless loop. + (func (export "spin") (param i32 i32) (result i64) + (loop $forever (br $forever)) + (unreachable)) + + ;; A panic. + (func (export "boom") (param i32 i32) (result i64) + (unreachable)) + + ;; Runaway recursion. + (func $deep (export "deep") (param i32 i32) (result i64) + (call $deep (local.get 0) (local.get 1))) + + ;; A gigabyte more memory. + (func (export "hog") (param i32 i32) (result i64) + (drop (memory.grow (i32.const 16384))) + (call $pack (i32.const 32) (i32.const 1))) + + ;; Logs its input. + (func (export "say") (param $ptr i32) (param $len i32) (result i64) + (call $log (local.get $ptr) (local.get $len)) + (call $pack (i32.const 32) (i32.const 1))) + + ;; How many times it was called, as one digit. + (func (export "count") (param i32 i32) (result i64) + (global.set $count (i32.add (global.get $count) (i32.const 1))) + (i32.store8 (i32.const 40) (i32.const 0)) + (i32.store8 (i32.const 41) (i32.add (i32.const 48) (global.get $count))) + (call $pack (i32.const 40) (i32.const 2)))) +"#; + +fn limits() -> Limits { + Limits { + deadline: Duration::from_millis(200), + memory_bytes: 64 << 20, + ..Limits::default() + } +} + +fn instance(runtime: &Runtime) -> Instance { + let program = runtime.load(&wat::parse_str(GUEST).unwrap()).unwrap(); + runtime.instantiate(&program).unwrap() +} + +#[test] +fn a_function_gets_its_input_and_returns_its_output() { + let runtime = Runtime::new(limits(), None).unwrap(); + let mut guest = instance(&runtime); + assert_eq!( + guest.functions(), + ["boom", "count", "deep", "fail", "hog", "say", "spin", "upper"] + ); + assert_eq!(guest.call("upper", b"hello, Octo").unwrap(), b"HELLO, OCTO"); + assert_eq!(guest.call("upper", b"").unwrap(), b""); + assert_eq!( + guest.call("nope", b""), + Err(CallError::NoSuchFunction("nope".into())) + ); +} + +#[test] +fn a_guest_error_comes_back_as_its_text_and_the_instance_goes_on() { + let runtime = Runtime::new(limits(), None).unwrap(); + let mut guest = instance(&runtime); + assert_eq!(guest.call("fail", b""), Err(CallError::Guest("nope".into()))); + assert!(!guest.spent()); + assert_eq!(guest.call("upper", b"still here").unwrap(), b"STILL HERE"); +} + +#[test] +fn an_endless_loop_stops_at_its_deadline_and_spends_the_instance() { + let runtime = Runtime::new(limits(), None).unwrap(); + let program = runtime.load(&wat::parse_str(GUEST).unwrap()).unwrap(); + let mut guest = runtime.instantiate(&program).unwrap(); + let started = Instant::now(); + assert_eq!(guest.call("spin", b""), Err(CallError::Deadline)); + let took = started.elapsed(); + // The deadline is 200 ms of wall-clock time, give or take a tick. + assert!( + took >= Duration::from_millis(190) && took < Duration::from_millis(400), + "stopped after {took:?}" + ); + // Its state may be half-updated: it takes no more calls, and a fresh + // instance of the same program does. + assert!(guest.spent()); + assert_eq!(guest.call("upper", b"x"), Err(CallError::Spent)); + let mut fresh = runtime.instantiate(&program).unwrap(); + assert_eq!(fresh.call("upper", b"still here").unwrap(), b"STILL HERE"); +} + +#[test] +fn memory_beyond_the_cap_ends_the_call() { + let runtime = Runtime::new(limits(), None).unwrap(); + let mut guest = instance(&runtime); + assert!(matches!(guest.call("hog", b""), Err(CallError::Trap(_)))); + assert!(guest.memory_bytes() <= 64 << 20); +} + +#[test] +fn a_trap_is_an_error_not_a_crash() { + let runtime = Runtime::new(limits(), None).unwrap(); + let mut guest = instance(&runtime); + let error = guest.call("boom", b"").unwrap_err(); + assert!( + matches!(&error, CallError::Trap(why) if why.contains("unreachable")), + "{error:?}" + ); + assert_eq!(guest.call("upper", b"x"), Err(CallError::Spent)); +} + +#[test] +fn runaway_recursion_traps_at_the_stack_cap() { + let runtime = Runtime::new(limits(), None).unwrap(); + let error = instance(&runtime).call("deep", b"").unwrap_err(); + assert!( + matches!(&error, CallError::Trap(why) if why.contains("stack")), + "{error:?}" + ); +} + +#[test] +fn log_is_the_only_import() { + let runtime = Runtime::new(limits(), None).unwrap(); + let mut guest = instance(&runtime); + guest.call("say", b"hi from the guest").unwrap(); + assert_eq!(guest.take_logs(), ["hi from the guest"]); + let wasi = r#"(module + (import "wasi_snapshot_preview1" "fd_write" (func (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "octo_alloc") (param i32) (result i32) (i32.const 0)) + (func (export "octo_free") (param i32 i32)))"#; + assert_eq!( + runtime.load(&wat::parse_str(wasi).unwrap()).err(), + Some(LoadError::Import("wasi_snapshot_preview1.fd_write".into())) + ); + let no_alloc = + r#"(module (memory (export "memory") 1) (func (export "octo_free") (param i32 i32)))"#; + assert!(matches!( + runtime.load(&wat::parse_str(no_alloc).unwrap()), + Err(LoadError::Export(_)) + )); +} + +#[test] +fn instances_keep_their_own_state() { + let runtime = Runtime::new(limits(), None).unwrap(); + let (mut a, mut b) = (instance(&runtime), instance(&runtime)); + for _ in 0..3 { + a.call("count", b"").unwrap(); + } + assert_eq!(a.call("count", b"").unwrap(), b"4"); + assert_eq!(b.call("count", b"").unwrap(), b"1"); +} + +#[test] +fn an_instance_can_run_on_another_thread() { + let runtime = Runtime::new(limits(), None).unwrap(); + let mut guest = instance(&runtime); + let out = std::thread::spawn(move || guest.call("upper", b"worker").unwrap()) + .join() + .unwrap(); + assert_eq!(out, b"WORKER"); +} + +#[test] +fn input_over_the_limit_is_refused() { + let runtime = Runtime::new( + Limits { + io_bytes: 16, + ..limits() + }, + None, + ) + .unwrap(); + assert_eq!( + instance(&runtime).call("upper", &[b'a'; 17]), + Err(CallError::TooLarge(17)) + ); +} + +#[test] +fn compiled_code_is_cached_by_digest() { + let dir = + std::env::temp_dir().join(format!("octosense-wasm-host-cache-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let bytes = wat::parse_str(GUEST).unwrap(); + let first = Runtime::new(limits(), Some(dir.clone())).unwrap(); + assert!(!first.load(&bytes).unwrap().from_cache()); + let second = Runtime::new(limits(), Some(dir.clone())).unwrap(); + let program = second.load(&bytes).unwrap(); + assert!(program.from_cache()); + assert_eq!( + second + .instantiate(&program) + .unwrap() + .call("upper", b"cached") + .unwrap(), + b"CACHED" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index bd9430bd..a470e04e 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -71,5 +71,7 @@ app-aichat = ["octosense-shell/app-aichat"] # Developer mode from Settings and OCTOSENSE_DEV_MODE in a release build # (debug builds have it already; ADR 0004 §13). dev-mode = ["octosense-shell/dev-mode"] +# Apps' own WebAssembly functions (ADR 0011; off by default). +wasm-lab = ["octosense-shell/wasm-lab"] # The system toolbox offered to app agents (off by default; ADR 0002 §6). toolbox-peers = ["octosense-shell/toolbox-peers"] diff --git a/desktop/system-apps-wasm-lab.json b/desktop/system-apps-wasm-lab.json new file mode 100644 index 00000000..c0158fdd --- /dev/null +++ b/desktop/system-apps-wasm-lab.json @@ -0,0 +1,15 @@ +{ + "schema": 1, + "source": "../apps", + "apps": [ + "news", + "photos", + "maps", + "mail", + "calendar", + "ai-providers", + "youtube", + "wasmlab" + ], + "assets": {} +} diff --git a/docs/adr/0011-apps-own-functions-in-webassembly.md b/docs/adr/0011-apps-own-functions-in-webassembly.md new file mode 100644 index 00000000..241d69f6 --- /dev/null +++ b/docs/adr/0011-apps-own-functions-in-webassembly.md @@ -0,0 +1,161 @@ +# ADR 0011: An app's own functions, in WebAssembly + +English | [简体中文](0011-apps-own-functions-in-webassembly.zh-CN.md) + +Status: Proposed. Implemented behind the shell's `wasm-lab` feature (off by +default), with the contract side in App Hub #140 (app-contract 1.7.0). +Validated on macOS in a hidden desktop shell and on an Android +phone (Redmi Note 12) in a separately packaged Home; iOS and OpenHarmony not +tried. + +## Context + +A script app is Splash: contained, interpreted, and limited to what its +runtime offers. Some of an app's logic is better written in Rust: a parser, a +ranking or scheduling algorithm, a diff, a format conversion, or an existing +crate that does exactly the job. Today the only ways to run Rust for an app +are a native app (linked into the shell, fully trusted: not for third +parties) or a host service (the shell's own code, written by us). Splash's +compute kernels cover numeric work, not general Rust. + +We want a developer to ship their own Rust functions inside their bundle, and +the shell to run them for that app only, with no new trust: nothing the +functions can reach that the app could not already reach, and nothing at all +outside their own input and output. + +## Decision + +1. **Modules.** A bundle may carry core WebAssembly modules as `fns/*.wasm` + (at most 8). A module exports `memory`, `octo_alloc(len) -> ptr`, + `octo_free(ptr, len)` and its functions, each `(ptr, len) -> i64` returning + `(out_ptr << 32) | out_len` of an output that starts with a status byte (0 + ok, 1 error). Its only import may be `octo.log(ptr, len)`. A small guest + crate (`apps/wasmlab/guest/octosense-guest`) writes all of this for + `fn(&[u8]) -> Result, String>` and for serde types. No component + model and no WASI in this version. +2. **Runtime.** `crates/wasm-host` runs modules in Wasmtime 49, compiled by + Cranelift when they load. Compiled code is cached in the host directory, + keyed by the module's digest and the engine's compatibility hash. Every + call has a deadline (2 s, epoch interruption against wall-clock time); + memory (256 MiB), the wasm stack (512 KiB), the module (8 MiB) and input + and output (16 MiB) have caps. A trap or a deadline ends the call with an + error, never the process, and spends the instance: the next call gets a + fresh one, because a Rust guest's stack pointer and allocator may be + half-updated. +3. **The `wasm` host service** (shell feature `wasm-lab`). `wasm.` + calls one of the calling app's functions; `wasm.functions` lists them, how + they loaded and how they have run. A string argument goes in as its text, + anything else as JSON; JSON output comes back as data, anything else as + `{"text": …}`. Modules come only from the calling app's own admitted, + digest-checked bundle, never from an argument, and the service checks the + admitted manifest's grant itself. Each app has one worker thread, so no + call runs on the UI thread and a slow function holds up only its own app. +4. **The `wasm` capability** joins App Hub's closed capability list. A store + says: "Runs its own functions in a sandbox on this device; they reach no + files, network or other apps." +5. **Agent tools.** A tool may name `host_method: "wasm."`: the + app's agent (and, if shared, other callers) runs the app's own function. + It needs the `wasm` capability, and unlike shared host methods it has no + risk floor and no `private_data` requirement: the function sees only the + arguments it is given. + +## Alternatives measured + +CoreMark on the M5 Max (higher is faster; native Rust is about 45,000): + +| Runtime | CoreMark | Notes | +| --- | --- | --- | +| Wasmtime + Cranelift (chosen) | 44,534 | 7.4 MiB in the binary; compiles when a module loads | +| WasmEdge AOT | 30,432 | 153 MiB library; 0.2–0.36 s to compile | +| stitch (interpreter) | 2,942 | makepad-friendly, no JIT; needs a host-call fix (makepad #106) | +| Pulley (Wasmtime's interpreter) | 2,587 | no JIT: the iOS option | +| WasmEdge interpreter | 374 | | + +- **Native dynamic libraries** give no isolation; Theseus OS's model (a + trusted compiler, one address space) does not apply to a phone OS. +- **Upstream Makepad's JIT** compiles Splash, not Rust. +- **Splash compute kernels** stay the answer for numeric kernels. + +## Measurements (Wasm Lab, `apps/wasmlab`) + +Light algorithms only: Markdown to HTML (pulldown-cmark), free slots around +busy times, fuzzy ranking (strsim), a line diff (similar). + +Medians of 200 calls, release builds, three runs on the desktop and three on +the phone (`cargo run --release -p octosense-wasm-host --example measure`; +on the phone the same example, cross-compiled and run over adb): + +| | Desktop (M5 Max) | Phone (Redmi Note 12, Snapdragon 685) | +| --- | --- | --- | +| Module | 433 KiB | 433 KiB | +| Compile (first load) | 27–33 ms | 378–421 ms | +| Load from the cache | 1.0–1.4 ms in a new process; 4.8–6.4 ms right after writing | 5.0–7.0 ms | +| Instantiate | 0.07 ms | 0.19–0.27 ms | +| md_to_html, 32 KiB | 405–412 µs (2.1–2.2× native) | 2,325–2,328 µs (1.7×) | +| find_slots, 30 busy intervals | 6.7 µs (1.7×) | 40 µs (1.5×) | +| fuzzy_rank, 500 items | 138–146 µs (1.6–1.7×) | 841–845 µs (1.4×) | +| text_diff, 1000 lines | 231–254 µs (1.6–1.7×) | 1,434 µs (1.9×) | +| Smallest call (JSON both ways) | 0.3 µs | 2.5–2.6 µs | + +Building the guest with wasm SIMD (`+simd128`) did not narrow the Markdown +gap (2.2–2.3×): it is Cranelift's code generation against LLVM's, not +vectorisation. Wasmtime and Cranelift add about 7.5 MiB of code to the +Android library (symbol sizes; 7.4 MiB measured on the desktop). + +In the shell, a script's `host.request` to its function and back takes about +2 ms on the desktop and 1.8–2 ms on the phone, almost all of it the UI event +loop; the function itself takes 18–72 µs on the desktop and 47–190 µs on the +phone. In Home on the phone the module compiled in 670 ms on the first launch +(the app was still starting) and loaded from the cache in 7.4–11 ms after. +The four misbehaving modes (an endless loop, endless allocation, a panic, +runaway recursion) each ended in an error after 2,000 ms, 140 ms, 2.2 ms and +3 ms, and Home kept running. + +Two platform findings: + +- **Android traps needed libc 0.2.190.** With libc 0.2.189 every trap that + Wasmtime catches by signal (a panic's `unreachable`, an out-of-bounds load, + a stack overflow) killed Home, and a plain binary too. Wasmtime's handler + ran but read the faulting pc through libc's `ucontext_t`, whose aarch64 + Android layout lacked the kernel's padding after the signal mask, so no + trap looked like wasm. 0.2.190 fixed the layout; `crates/wasm-host` now + requires it. Explicit traps (`signals_based_traps(false)`) also worked + but cost 19–38% per call and doubled the compile time. +- **A desktop scanner can make the cache slower than compiling.** On a + managed Mac, Microsoft Defender holds the first open of a freshly written + cache file for about a second, and again the first time a new build of the + shell opens it (1,086 ms; 2.3 ms on the next launch). The writer reads the + file back in the background, which covers the first case only. Compiling + takes 30–60 ms there, so a desktop could skip the cache or race the two; + on the phone the cache saves about 0.4 s. + +## Consequences + +- Third-party Rust runs in OctoSense apps at 1.4–2.2× native speed for these + workloads, with no new trust and no change to the default build. +- The Android APK grows by 3.5 MiB (209.1 to 212.8 MB, with Wasm Lab's own + bundle); the runtime is about 7.5 MiB of code. +- Compiling costs about 0.4 s per module on a mid-range phone (0.67 s while + Home is starting). The cache makes later loads 5–11 ms; compiling at + install time (store apps) or at build time (system apps) would remove the + first one. +- A store bundle may carry `fns/*.wasm` under App Hub #140's gate (a core + module, at most 8, only with the `wasm` capability). So far only a system + app has run functions on a device. +- iOS allows no JIT for apps and no downloaded native code: there, Pulley + (about 17× slower than Cranelift). A shipped system app's functions could + be translated to native code when the app is built; a store app's could + not. OpenHarmony's JIT policy is unknown (**unverified**). +- The limits are per call and per instance. Nothing yet stops an app from + keeping one core busy with back-to-back calls, or from using 256 MiB in + each of its eight modules; a per-app CPU and memory budget is follow-up + work. A request that App Hub has timed out still runs in the worker. +- An app updated while the shell runs keeps its old functions until the + shell restarts; workers live as long as the shell. + +## Open questions + +- Typed interfaces (the component model and WIT) instead of JSON in and out. +- Any host import beyond `octo.log` (a clock, randomness) would be a new + capability, not a default. +- Epoch deadlines are cheap but not deterministic; fuel would be, at a cost. diff --git a/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md b/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md new file mode 100644 index 00000000..0c1bec3d --- /dev/null +++ b/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md @@ -0,0 +1,127 @@ +# ADR 0011:应用自带的 WebAssembly 函数 + +[English](0011-apps-own-functions-in-webassembly.md) | 简体中文 + +状态:提议中。在外壳的 `wasm-lab` 特性(默认关闭)之后实现,契约部分在 App Hub #140 +(app-contract 1.7.0)。已在 macOS 的隐藏桌面外壳中,以及一部 +Android 手机(Redmi Note 12)上单独打包的 Home 中验证;iOS 和 OpenHarmony 尚未尝试。 + +## 背景 + +脚本应用使用 Splash:受隔离、解释执行,能力以运行时提供的为限。应用里有些逻辑更适合用 +Rust 写:解析器、排序或排程算法、差异比较、格式转换,或者现成的、正好能完成任务的 crate。 +目前为应用运行 Rust 只有两种方式:原生应用(链接进外壳,完全受信任,不适合第三方)或宿主 +服务(外壳自己的代码,由我们编写)。Splash 的计算内核适合数值计算,不适合通用 Rust。 + +我们希望开发者能把自己的 Rust 函数放进应用包里,由外壳只为这个应用运行,而且不引入新的 +信任:函数能接触的不超过应用本来能接触的,除了自己的输入和输出,什么都接触不到。 + +## 决定 + +1. **模块。** 应用包可以在 `fns/*.wasm` 中携带核心 WebAssembly 模块(最多 8 个)。 + 模块导出 `memory`、`octo_alloc(len) -> ptr`、`octo_free(ptr, len)` 和它的函数; + 每个函数的签名是 `(ptr, len) -> i64`,返回 `(out_ptr << 32) | out_len`,输出的第一个 + 字节是状态(0 成功,1 出错)。唯一允许的导入是 `octo.log(ptr, len)`。一个小的客体 crate + (`apps/wasmlab/guest/octosense-guest`)为 `fn(&[u8]) -> Result, String>` 和 + serde 类型生成这一切。这一版不用组件模型,也不用 WASI。 +2. **运行时。** `crates/wasm-host` 在 Wasmtime 49 中运行模块,模块加载时由 Cranelift + 编译。编译结果缓存在宿主目录中,以模块摘要和引擎兼容性哈希为键。每次调用都有截止时间 + (2 秒,按实际经过的时间做 epoch 中断);内存(256 MiB)、wasm 栈(512 KiB)、模块 + (8 MiB)以及输入输出(16 MiB)都有上限。陷阱或超时只让这次调用出错,绝不结束进程, + 并作废这个实例:下次调用会得到新实例,因为 Rust 客体的栈指针和分配器可能只更新了一半。 +3. **`wasm` 宿主服务**(外壳特性 `wasm-lab`)。`wasm.` 调用发起请求的应用自己的 + 函数;`wasm.functions` 列出这些函数、它们的加载情况和运行统计。字符串参数按文本传入, + 其他参数按 JSON 传入;JSON 输出作为数据返回,其他输出返回为 `{"text": …}`。模块只来自 + 发起请求的应用自己那个已准入、经过摘要校验的应用包,绝不来自参数;服务还会自己检查已准入 + 清单中的授权。每个应用有一个工作线程,所以没有调用在 UI 线程上运行,一个慢函数只会拖慢 + 它自己的应用。 +4. **`wasm` 能力**加入 App Hub 封闭的能力列表。商店的说明是:“在此设备上的沙盒中运行它 + 自带的函数;这些函数接触不到文件、网络或其他应用。” +5. **Agent 工具。** 工具可以写 `host_method: "wasm."`:应用的 Agent(以及在 + 共享时的其他调用者)运行这个应用自己的函数。它需要 `wasm` 能力;与共享宿主方法不同,它 + 没有风险下限,也不要求 `private_data`:函数只能看到传给它的参数。 + +## 实测过的替代方案 + +M5 Max 上的 CoreMark(越高越快;原生 Rust 约 45,000): + +| 运行时 | CoreMark | 说明 | +| --- | --- | --- | +| Wasmtime + Cranelift(选定) | 44,534 | 二进制增加 7.4 MiB;模块加载时编译 | +| WasmEdge AOT | 30,432 | 库 153 MiB;编译 0.2–0.36 秒 | +| stitch(解释器) | 2,942 | 与 Makepad 契合,无 JIT;需要一个宿主调用修复(makepad #106) | +| Pulley(Wasmtime 的解释器) | 2,587 | 无 JIT:iOS 的选项 | +| WasmEdge 解释器 | 374 | | + +- **原生动态库**没有隔离;Theseus OS 的模型(受信任的编译器、单一地址空间)不适用于手机 + 操作系统。 +- **上游 Makepad 的 JIT** 编译的是 Splash,不是 Rust。 +- **Splash 计算内核**仍然是数值内核的答案。 + +## 测量(Wasm Lab,`apps/wasmlab`) + +只有轻量算法:Markdown 转 HTML(pulldown-cmark)、在忙碌时段之间找空闲时段、模糊排序 +(strsim)、按行差异比较(similar)。 + +200 次调用的中位数,release 构建,桌面运行三次、手机运行三次 +(`cargo run --release -p octosense-wasm-host --example measure`;手机上是同一个示例, +交叉编译后通过 adb 运行): + +| | 桌面(M5 Max) | 手机(Redmi Note 12,骁龙 685) | +| --- | --- | --- | +| 模块 | 433 KiB | 433 KiB | +| 编译(首次加载) | 27–33 毫秒 | 378–421 毫秒 | +| 从缓存加载 | 新进程中 1.0–1.4 毫秒;刚写入后 4.8–6.4 毫秒 | 5.0–7.0 毫秒 | +| 实例化 | 0.07 毫秒 | 0.19–0.27 毫秒 | +| md_to_html,32 KiB | 405–412 微秒(原生的 2.1–2.2 倍) | 2,325–2,328 微秒(1.7 倍) | +| find_slots,30 个忙碌时段 | 6.7 微秒(1.7 倍) | 40 微秒(1.5 倍) | +| fuzzy_rank,500 项 | 138–146 微秒(1.6–1.7 倍) | 841–845 微秒(1.4 倍) | +| text_diff,1000 行 | 231–254 微秒(1.6–1.7 倍) | 1,434 微秒(1.9 倍) | +| 最小调用(双向 JSON) | 0.3 微秒 | 2.5–2.6 微秒 | + +用 wasm SIMD(`+simd128`)构建客体没有缩小 Markdown 的差距(2.2–2.3 倍):差距来自 +Cranelift 与 LLVM 的代码生成,而不是向量化。Wasmtime 和 Cranelift 给 Android 库增加约 +7.5 MiB 代码(按符号大小;桌面上测得 7.4 MiB)。 + +在外壳中,脚本用 `host.request` 调用自己的函数并收到结果,桌面上约 2 毫秒,手机上 +1.8–2 毫秒,几乎全部花在 UI 事件循环上;函数本身在桌面上用 18–72 微秒,在手机上用 +47–190 微秒。在手机的 Home 中,模块首次启动时编译用了 670 毫秒(应用当时还在启动),之后 +从缓存加载用 7.4–11 毫秒。四种失控模式(死循环、无限分配、panic、无限递归)分别在 +2,000 毫秒、140 毫秒、2.2 毫秒和 3 毫秒后以错误结束,Home 一直在运行。 + +两个平台发现: + +- **Android 上的陷阱需要 libc 0.2.190。** 使用 libc 0.2.189 时,Wasmtime 通过信号捕获的 + 每一种陷阱(panic 的 `unreachable`、越界读取、栈溢出)都会让 Home 退出,普通的命令行 + 程序也一样。Wasmtime 的信号处理函数确实运行了,但它通过 libc 的 `ucontext_t` 读取出错 + 位置的 pc,而 aarch64 Android 的这个结构缺少内核在信号掩码后面加的填充,所以没有一个 + 陷阱被认出是 wasm。0.2.190 修正了这个布局;`crates/wasm-host` 现在要求这个版本。显式陷阱 + (`signals_based_traps(false)`)也能工作,但每次调用慢 19–38%,编译时间翻倍。 +- **桌面上的扫描程序会让缓存比编译还慢。** 在受管理的 Mac 上,Microsoft Defender 会把新写入 + 的缓存文件的第一次打开挡住约一秒;外壳的新构建第一次打开它时又会被挡一次(1,086 毫秒; + 下次启动 2.3 毫秒)。写入方会在后台把文件读一遍,但只能覆盖第一种情况。那里编译只要 + 30–60 毫秒,所以桌面可以不用缓存,或让两者竞速;在手机上,缓存能省下约 0.4 秒。 + +## 影响 + +- 对这些工作负载,第三方 Rust 在 OctoSense 应用中的运行时间是原生的 1.4–2.2 倍,不引入 + 新的信任,默认构建不变。 +- Android APK 增加 3.5 MiB(从 209.1 MB 到 212.8 MB,含 Wasm Lab 自己的应用包);运行时 + 约为 7.5 MiB 代码。 +- 在中端手机上,每个模块编译约 0.4 秒(Home 启动期间 0.67 秒)。有了缓存,之后的加载只要 + 5–11 毫秒;在安装时编译(商店应用)或在构建时编译(系统应用)可以省掉第一次。 +- 在 App Hub #140 的商店检查下,商店应用包可以携带 `fns/*.wasm`(核心模块,最多 8 个, + 只能配合 `wasm` 能力)。到目前为止,只有系统应用在设备上运行过函数。 +- iOS 不允许应用使用 JIT,也不允许下载原生代码:那里只能用 Pulley(比 Cranelift 慢约 + 17 倍)。随系统发布的应用,其函数可以在构建应用时转成原生代码;商店应用不行。 + OpenHarmony 的 JIT 策略未知(**未验证**)。 +- 限制是按调用、按实例计算的。目前还没有办法阻止一个应用用连续调用占满一个核心,或在 + 它的八个模块里各用 256 MiB;按应用的 CPU 和内存预算是后续工作。App Hub 已判定超时的 + 请求,仍会在工作线程里运行。 +- 外壳运行期间更新的应用,在外壳重启前仍使用旧函数;工作线程与外壳同寿命。 + +## 未决问题 + +- 用带类型的接口(组件模型和 WIT)代替 JSON 输入输出。 +- `octo.log` 之外的任何宿主导入(时钟、随机数)都应是新的能力,而不是默认提供。 +- epoch 截止时间开销小但不确定;fuel 是确定的,但有代价。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 2d1e20aa..d72d6b9b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,6 +17,7 @@ How these decisions fit together in the code on `main`, and which parts are stil | [0007](0007-composable-mail-action-cards.md) | Composable Mail cards with editing, chat and approved actions | Implementation in progress; phone acceptance pending | | [0008](0008-quiet-android-mail-jobs.md) | Quiet Android Mail jobs and native card notifications | Implemented in this change; device acceptance in progress | | [0010](0010-shared-oauth-and-connected-apps.md) | Shared OAuth and independently installed connected apps | Implementation in progress; live sign-in passed on macOS; GitHub writes, Gmail sends and device acceptance pending | +| [0011](0011-apps-own-functions-in-webassembly.md) | An app's own functions, in WebAssembly | Proposed; behind the `wasm-lab` feature, validated on macOS and an Android phone | | [0012](0012-app-host-api-discovery.md) | Discoverable host APIs, signed backend operations and live script tools | Implemented in source; contract 1.6.0 published; host release and phone acceptance pending | ## Home (phone shell) decisions, 2026-09-16 to 2026-09-25 diff --git a/docs/adr/README.zh-CN.md b/docs/adr/README.zh-CN.md index c5c79790..34478caf 100644 --- a/docs/adr/README.zh-CN.md +++ b/docs/adr/README.zh-CN.md @@ -17,6 +17,7 @@ OctoSense 仓库的决策:Shell、Shell 服务、系统应用,以及桌面 | [0007](0007-composable-mail-action-cards.zh-CN.md) | 可组合的 Mail 卡片:编辑、聊天与经批准的操作 | 实现中;手机验收待完成 | | [0008](0008-quiet-android-mail-jobs.zh-CN.md) | Android 静默邮件后台任务与原生卡片通知 | 本次变更已实现;设备验收进行中 | | [0010](0010-shared-oauth-and-connected-apps.zh-CN.md) | 共享 OAuth 与独立安装的连接账户应用 | 实现中;真实账户登录已在 macOS 上通过;GitHub 写入、Gmail 发信和设备验收待完成 | +| [0011](0011-apps-own-functions-in-webassembly.zh-CN.md) | 应用自带的 WebAssembly 函数 | 提议中;在 `wasm-lab` 特性之后,已在 macOS 和一部 Android 手机上验证 | | [0012](0012-app-host-api-discovery.zh-CN.md) | 可发现宿主 API、签名后端操作与活动脚本工具 | 源码已实现;契约 1.6.0 已发布;宿主发布和手机验收待完成 | ## Home(手机 Shell)的决策,2026-09-16 至 2026-09-25 diff --git a/native-apps.json b/native-apps.json index 28e18406..bebf9363 100644 --- a/native-apps.json +++ b/native-apps.json @@ -100,7 +100,7 @@ "id": "apphub", "feature": "app-hub", "crate": "octosense-app-hub-app", - "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "b1109a397ae45349546d50889a1706f6c58fb923", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "2e5768964f15916cdb66cc6824f7671dafc6445a", "local": null }, "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"], "bin": null, "default_features": true, diff --git a/phone/Cargo.toml b/phone/Cargo.toml index 7710c3c2..cff97330 100644 --- a/phone/Cargo.toml +++ b/phone/Cargo.toml @@ -98,5 +98,7 @@ app-weather = ["octosense-shell/app-weather"] # END native-apps: features app-aichat = ["dep:makepad-aichat", "octosense-shell/app-aichat"] dev-mode = ["octosense-shell/dev-mode"] +# Apps' own WebAssembly functions (ADR 0011; off by default). +wasm-lab = ["octosense-shell/wasm-lab"] # The system toolbox offered to app agents (off by default; ADR 0002 §6). toolbox-peers = ["octosense-shell/toolbox-peers"] diff --git a/phone/system-apps-wasm-lab.json b/phone/system-apps-wasm-lab.json new file mode 100644 index 00000000..6ba0c6bb --- /dev/null +++ b/phone/system-apps-wasm-lab.json @@ -0,0 +1,20 @@ +{ + "schema": 1, + "source": "../apps", + "apps": [ + "news", + "photos", + "maps", + "camera", + "mail", + "calendar", + "ai-providers", + "youtube", + "wasmlab" + ], + "assets": { + "photos": { + "photos": "../apps/photos/resources/photos" + } + } +} diff --git a/tools/sync-app-interface.py b/tools/sync-app-interface.py index dfab7707..c2b72c81 100644 --- a/tools/sync-app-interface.py +++ b/tools/sync-app-interface.py @@ -14,7 +14,7 @@ def main(): args = parser.parse_args() prelude = START + (ROOT / "apps/interface.splash").read_text() + END stale = [] - for name in ("news", "photos", "mail", "calendar", "maps", "ai-providers", "youtube"): + for name in ("news", "photos", "mail", "calendar", "maps", "ai-providers", "youtube", "wasmlab"): path = ROOT / "apps" / name / "bundle/main.splash" text = path.read_text() body = text.split(END, 1)[1] if text.startswith(START) else text