From f2df0ce362ca52252bc6998f210531ea4b2068a2 Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 19:51:44 +0000 Subject: [PATCH 01/22] Prepare v3 Nix packaging and OpenSSL build inputs --- flake.nix | 239 ++++++++++++++++++++---------------------------------- 1 file changed, 90 insertions(+), 149 deletions(-) diff --git a/flake.nix b/flake.nix index e61ce59..4be7cb2 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Sausage: grow a system of WASI plugins by talking to it"; + description = "Sausage: a world of WASI programs and durable agents"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; @@ -16,18 +16,10 @@ inherit system; overlays = [ rust-overlay.overlays.default ]; }; - # The toolchain that builds the host and, at runtime, the plugins. rust = pkgs.rust-bin.stable.latest.default.override { targets = [ "wasm32-wasip2" ]; extensions = [ "rust-src" ]; }; - # The native cargo plugin needs Cargo, rustc (with wasm32-wasip2), and - # the native linker, even when the host itself is started with a bare - # PATH. Keep this same runtime path in the packaged host and exercise - # the installed binary below with PATH deliberately empty. - # mold links host code much faster than GNU ld. Only on Linux, and only - # for the host target: plugins for wasm32-wasip2 link with rust-lld. Nix - # brings mold along, so a build without Nix keeps the default linker. mold = pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.mold ]; hostTarget = pkgs.lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] pkgs.stdenv.hostPlatform.rust.rustcTarget); @@ -35,176 +27,125 @@ moldEnv = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { ${moldVar} = "-C link-arg=-fuse-ld=mold"; }; - runtimePath = pkgs.lib.makeBinPath ([ rust pkgs.stdenv.cc ] ++ mold); - # A WASI clang with wasi-libc, so that build scripts (the `cc` crate) - # can compile C for wasm32-wasip2. Its headers are WASI 0.1: C code - # that needs sockets does not build. wasiCc = pkgs.pkgsCross.wasi32.stdenv.cc; wasiCcEnv = { CC_wasm32_wasip2 = "${wasiCc}/bin/${wasiCc.targetPrefix}cc"; AR_wasm32_wasip2 = "${wasiCc}/bin/${wasiCc.targetPrefix}ar"; }; craneLib = (crane.mkLib pkgs).overrideToolchain rust; + # Preserve the embedded JavaScript and WIT alongside Cargo's sources. + hostSrc = pkgs.lib.cleanSourceWith { + src = ./.; + filter = path: type: + let relative = pkgs.lib.removePrefix (toString ./. + "/") path; + in !(pkgs.lib.hasPrefix "userland/" relative || pkgs.lib.hasPrefix "spikes/" relative) + && (craneLib.filterCargoSources path type + || pkgs.lib.hasSuffix ".wit" path || pkgs.lib.hasSuffix ".js" path); + }; + v8Archive = pkgs.fetchurl { + url = "https://github.com/denoland/rusty_v8/releases/download/v150.4.0/librusty_v8_release_${pkgs.stdenv.hostPlatform.rust.rustcTarget}.a.gz"; + hash = { + x86_64-linux = "sha256-WGn9twcbHyHyAKl86X0gElh34PMc2ALtmd4sU/SIsGw="; + aarch64-linux = "sha256-txd9Uq0zNycv4NO453gjnIIalcJdWVnexiue/WVPfdM="; + x86_64-darwin = "sha256-5ex9E/kUgT6/IB1Ee/j9J2h7exkuFsR/KCb+VBUXHyk="; + aarch64-darwin = "sha256-zNj4FIW4IsWxiuun+d65KaM4LYasZzu/DzZvBod+axA="; + }.${system}; + }; common = { pname = "sausage"; - version = "0.1.0"; - src = ./.; - nativeBuildInputs = mold; + version = "0.3.0"; + src = hostSrc; + strictDeps = true; + RUSTY_V8_ARCHIVE = v8Archive; + buildInputs = [ pkgs.openssl ]; + nativeBuildInputs = [ pkgs.pkg-config pkgs.cmake pkgs.python3 pkgs.perl ] ++ mold; } // moldEnv; - # The test packages for `checks.tests`. - testPackages = "--workspace"; cargoArtifacts = craneLib.buildDepsOnly (common // { doCheck = false; - # The dependencies of the host binary and of the tests. - buildPhaseCargoCommand = '' - cargoWithProfile build --locked -p sausage - cargoWithProfile test --locked --no-run ${testPackages} + }); + brushSource = pkgs.fetchFromGitHub { + owner = "reubeno"; + repo = "brush"; + rev = "943553433ae04f5b3bd55d2cf207e39eb9ef2af7"; + hash = "sha256-a1eMkqx4nBqJ8R+h+rxtU35pAungcEnXoxwZnb1poH8="; + }; + userlandSrc = pkgs.lib.cleanSourceWith { + src = ./.; + filter = path: type: + let relative = pkgs.lib.removePrefix (toString ./. + "/") path; + in type == "directory" || pkgs.lib.hasPrefix "userland/" relative + || pkgs.lib.hasPrefix "host/wit/" relative; + }; + userlandCommon = { + pname = "sausage-userland"; + version = "0.3.0"; + src = userlandSrc; + strictDeps = true; + cargoVendorDir = craneLib.vendorMultipleCargoDeps { + cargoLockList = [ ./userland/Cargo.lock ./userland/brush/Cargo.lock ]; + }; + nativeBuildInputs = [ pkgs.nushell pkgs.wasm-tools pkgs.git pkgs.pkg-config ] ++ mold; + postPatch = '' + cp -r ${brushSource} userland/brush/source + chmod -R u+w userland/brush/source + # The fixed-output source fetch replaces the development checkout's Git check. + substituteInPlace userland/brush/build.nu --replace-fail \ + ${pkgs.lib.escapeShellArg "if (^git -C $source rev-parse HEAD | str trim) != $revision { error make {msg: 'unexpected brush revision'} }"} \ + '# Revision verified by the fixed-output Nix fetch.' + patchShebangs userland/cwd-init/link.nu ''; + preBuild = '' + export CARGO_TARGET_DIR="$PWD/target" + ''; + buildPhaseCargoCommand = "nu userland/bundle.nu"; + doCheck = false; + } // wasiCcEnv // moldEnv; + # Keep the real local patch crates: registry dependencies use their full APIs. + userlandArtifacts = craneLib.buildDepsOnly (userlandCommon // { + dummySrc = userlandSrc; + }); + userland = craneLib.mkCargoDerivation (userlandCommon // { + cargoArtifacts = userlandArtifacts; + installPhaseCommand = '' + mkdir -p $out + cp -a target/userland/bin $out/bin + ''; + doInstallCargoArtifacts = false; }); sausage = craneLib.buildPackage (common // { inherit cargoArtifacts; - doNotRemoveReferencesToRustToolchain = true; cargoExtraArgs = "--locked -p sausage"; doCheck = false; - nativeBuildInputs = [ pkgs.makeWrapper ] ++ mold; - # The native `cargo` plugin runs cargo at runtime. Build scripts need a C linker, - # and C code for WASI needs the WASI clang. + nativeBuildInputs = common.nativeBuildInputs ++ [ pkgs.makeWrapper ]; postInstall = '' - wrapProgram $out/bin/sausage --prefix PATH : ${runtimePath} \ - --set-default CC_wasm32_wasip2 ${wasiCcEnv.CC_wasm32_wasip2} \ - --set-default AR_wasm32_wasip2 ${wasiCcEnv.AR_wasm32_wasip2} \ - ${pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux "--set-default ${moldVar} '-C link-arg=-fuse-ld=mold'"} + wrapProgram $out/bin/sausage --set-default SAUSAGE_USERLAND ${userland}/bin ''; meta.mainProgram = "sausage"; }); - # `nix run .#loop -- ` runs the newest `sausage run ` - # (without a folder: bare `sausage`, i.e. ./world, created if needed) - # from the public repo, again and again: after a crash or a call of - # `core.restart` (exit code 75) it refreshes the flake and starts the host again, so - # the world updates itself. `quit` (exit code 0) ends the loop. - # SAUSAGE_FLAKE overrides where it fetches from (any flake URL, - # e.g. a fork or a local checkout). - repoUrl = "git+https://gitea.surma.technology/surma/sausage"; - loop = pkgs.writeShellScriptBin "sausage-loop" '' - url="''${SAUSAGE_FLAKE:-${repoUrl}}" - # No folder: bare `sausage` (creates ./world first if needed). - if [ $# -gt 0 ]; then set -- run "$@"; fi - while true; do - SAUSAGE_LOOP=1 nix run --refresh "$url#default" -- "$@" - code=$? - if [ "$code" -eq 0 ]; then exit 0; fi - echo "sausage-loop: sausage exited with $code, starting the newest version in 2 s …" >&2 - sleep 2 - done - ''; - in - { + in { packages.default = sausage; - - # `nix flake check` runs every test of the workspace. The tests build - # WASM plugins with cargo, offline, from the vendored crates of - # Cargo.lock. - # The binary the host loop runs (`nix run`), built from the same - # cached dependencies as the tests. checks.package = sausage; checks.tests = craneLib.cargoTest (common // wasiCcEnv // { inherit cargoArtifacts; - cargoExtraArgs = "--locked ${testPackages}"; - # Nothing builds on the test run's target folder: don't store it. + cargoExtraArgs = "--workspace --lib --test harness --test p1 --test layers --test regressions --test streams --test wasi"; + preCheck = '' + mkdir -p target/userland + ln -s ${userland}/bin target/userland/bin + ''; doInstallCargoArtifacts = false; - # The SDK tests run sample plugins with the wasmtime CLI. - nativeBuildInputs = [ pkgs.wasmtime ] ++ mold; }); - # Exercise the installed host exactly as the loop launches it: the - # caller has no Cargo on PATH, so only the packaged runtime toolchain - # can run `cargo.build`: the tests under WASI and the wasm32-wasip2 - # build, with C code from the packaged WASI clang. - checks.runtime-toolchain = pkgs.runCommand "sausage-runtime-toolchain" { } '' - set -eu - root="$TMPDIR/runtime-toolchain" - world="$root/world" - emptyPath="$root/empty-bin" - mkdir -p "$emptyPath" "$root/home" "$root/cargo-home" - mkdir -p "$world/plugins" "$world/state" "$world/config" - echo "[]" > "$world/config/boot.yaml" - source="$world/plugins/probe/source" - mkdir -p "$source/src" "$source/tests" - cat > "$source/Cargo.toml" <<'TOML' - [package] - name = "runtime-probe" - version = "0.1.0" - edition = "2024" - TOML - # C code for WASI, compiled with the packaged WASI clang. - printf 'int probe_add(int a, int b) { return a + b; }\n' > "$source/src/add.c" - cat > "$source/build.rs" <<'RUST' - use std::process::Command; - fn main() { - if std::env::var("TARGET").unwrap() != "wasm32-wasip2" { - return; - } - let out = std::env::var("OUT_DIR").unwrap(); - let cc = std::env::var("CC_wasm32_wasip2").expect("CC_wasm32_wasip2"); - let ar = std::env::var("AR_wasm32_wasip2").expect("AR_wasm32_wasip2"); - let object = format!("{out}/add.o"); - assert!(Command::new(cc).args(["-c", "src/add.c", "-o", &object]).status().unwrap().success()); - assert!(Command::new(ar).args(["crs", &format!("{out}/libadd.a"), &object]).status().unwrap().success()); - println!("cargo:rustc-link-search=native={out}"); - println!("cargo:rustc-link-lib=static=add"); - } - RUST - cat > "$source/src/main.rs" <<'RUST' - #[cfg(target_os = "wasi")] - unsafe extern "C" { - fn probe_add(a: i32, b: i32) -> i32; - } - fn main() { - #[cfg(target_os = "wasi")] - assert_eq!(unsafe { probe_add(2, 3) }, 5); - } - RUST - cat > "$source/tests/probe.rs" <<'RUST' - #[test] - fn test_runs_under_wasi() { - assert_eq!(2 + 2, 4); - } - RUST - echo "runtime-check: run packaged host with an empty PATH" - coproc host { - ${pkgs.coreutils}/bin/env -i PATH="$emptyPath" HOME="$root/home" TMPDIR="$root" \ - CARGO_HOME="$root/cargo-home" ${sausage}/bin/sausage run "$world" - } - hostInput=''${host[1]} - hostOutput=''${host[0]} - hostPid=$host_PID - trap 'kill "$hostPid" 2>/dev/null || true' EXIT - printf '%s\n' 'cargo.build {"plugin":"probe"}' >&"$hostInput" - built=0 - while IFS= read -r -t 600 line <&"$hostOutput"; do - echo "$line" - case "$line" in - *'error:'*) - echo "packaged cargo.build failed with an empty caller PATH" >&2 - exit 1 - ;; - *'"passed": 1'*) built=1; break ;; - esac - done - if [ "$built" -ne 1 ]; then - echo "packaged host did not build the probe, or its test did not pass" >&2 - exit 1 - fi - printf '%s\n' 'quit' >&"$hostInput" - wait "$hostPid" - mkdir -p "$out" - touch "$out/passed" + checks.shell = pkgs.runCommand "sausage-shell" { } '' + ${sausage}/bin/sausage sh "$TMPDIR/world" -c \ + 'ls /bin | wc -l; echo hi | tr a-z A-Z; git --version; rg --version' > output + cat output + grep -qx HI output + grep -q 'git version' output + grep -q ripgrep output + touch $out ''; - apps.default = { type = "app"; program = "${sausage}/bin/sausage"; }; - apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; }; - devShells.default = pkgs.mkShell (wasiCcEnv // moldEnv // { - packages = [ rust pkgs.wasmtime pkgs.wasm-tools ] ++ mold; + packages = [ rust pkgs.wasmtime pkgs.wasm-tools pkgs.openssl pkgs.pkg-config ] ++ mold; shellHook = '' export CARGO_HOME="$PWD/.cargo-home" ''; From 944504117dd02b01f529f3906c999e72a3607390 Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 22:19:53 +0000 Subject: [PATCH 02/22] Remove v2 code and document the v3 world --- .gitignore | 3 +- AGENTS.md | 6 +- Cargo.toml | 5 +- DECISIONS.md | 120 - PROTOCOL.md | 622 ----- README.md | 145 +- agent/Cargo.toml | 14 - agent/src/history.rs | 88 - agent/src/llm.rs | 657 ----- agent/src/main.rs | 211 -- agent/src/overflow.rs | 258 -- agent/src/prompt.md | 10 - agent/src/tools.rs | 268 -- cli/Cargo.toml | 42 - cli/src/activation.rs | 302 --- cli/src/agent.yaml | 52 - cli/src/boot.yaml | 12 - cli/src/cargo.rs | 836 ------- cli/src/components.rs | 217 -- cli/src/console.rs | 314 --- cli/src/core.rs | 75 - cli/src/execution.rs | 129 - cli/src/filesystem.rs | 324 --- cli/src/graph.rs | 288 --- cli/src/host.rs | 170 -- cli/src/journal.rs | 411 ---- cli/src/lib.rs | 31 - cli/src/locks.rs | 90 - cli/src/main.rs | 190 -- cli/src/native.rs | 35 - cli/src/process.rs | 178 -- cli/src/restart.rs | 129 - cli/src/routing.rs | 397 --- cli/src/runtime.rs | 440 ---- cli/src/services.rs | 166 -- cli/src/services/tests.rs | 356 --- cli/src/test_runner.rs | 293 --- cli/src/upgrades.rs | 192 -- cli/src/upgrades/tests.rs | 115 - cli/src/watcher.rs | 84 - cli/src/watches.rs | 178 -- cli/src/world.rs | 71 - cli/tests/crates/cargo-c/Cargo.toml | 12 - cli/tests/crates/cargo-c/build.rs | 5 - cli/tests/crates/cargo-c/src/add.c | 1 - cli/tests/crates/cargo-c/src/main.rs | 25 - cli/tests/crates/cargo-fail/Cargo.toml | 6 - cli/tests/crates/cargo-fail/src/main.rs | 3 - cli/tests/crates/cargo-hang/Cargo.toml | 6 - cli/tests/crates/cargo-hang/build.rs | 7 - cli/tests/crates/cargo-hang/src/main.rs | 1 - cli/tests/crates/cargo-test-fail/Cargo.toml | 6 - cli/tests/crates/cargo-test-fail/src/main.rs | 13 - cli/tests/crates/cargo-valid/Cargo.toml | 15 - cli/tests/crates/cargo-valid/build.rs | 4 - cli/tests/crates/cargo-valid/fixture.txt | 1 - cli/tests/crates/cargo-valid/src/main.rs | 165 -- .../crates/cargo-valid/tests/integration.rs | 5 - cli/tests/host/activation.rs | 1262 ---------- cli/tests/host/agent.rs | 669 ----- cli/tests/host/cargo.rs | 1107 --------- cli/tests/host/cli.rs | 450 ---- cli/tests/host/common.rs | 43 - cli/tests/host/console.rs | 298 --- cli/tests/host/core.rs | 2181 ----------------- cli/tests/host/main.rs | 10 - cli/tests/host/sdk.rs | 399 --- cli/tests/host/watches.rs | 174 -- cli/tests/plugins/activation/Cargo.toml | 9 - cli/tests/plugins/activation/src/main.rs | 154 -- cli/tests/plugins/probe/Cargo.toml | 8 - cli/tests/plugins/probe/src/main.rs | 589 ----- examples/consumer/Cargo.toml | 7 - examples/consumer/imports/notes.json | 85 - examples/consumer/imports/secrets.json | 128 - examples/consumer/imports/www.json | 94 - examples/consumer/src/main.rs | 81 - examples/notes/Cargo.toml | 7 - examples/notes/src/main.rs | 84 - examples/secrets/Cargo.toml | 7 - examples/secrets/src/main.rs | 90 - examples/www/Cargo.toml | 8 - examples/www/src/main.rs | 97 - interface/Cargo.toml | 23 - interface/src/client.rs | 202 -- interface/src/compat.rs | 548 ----- interface/src/document.rs | 35 - interface/src/lib.rs | 10 - interface/tests/client.rs | 32 - interface/tests/compat.rs | 374 --- interface/tests/compat_params.rs | 13 - interface/tests/review_client.rs | 29 - sdk/Cargo.toml | 21 - sdk/imports/notes.json | 25 - sdk/imports/www.json | 25 - sdk/macros/Cargo.toml | 15 - sdk/macros/src/lib.rs | 364 --- sdk/src/calls.rs | 261 -- sdk/src/discovery.rs | 193 -- sdk/src/endpoints.rs | 249 -- sdk/src/error.rs | 61 - sdk/src/helpers.rs | 19 - sdk/src/imports.rs | 47 - sdk/src/lib.rs | 426 ---- sdk/src/lines.rs | 70 - sdk/src/locks.rs | 67 - sdk/src/sqlite.rs | 43 - sdk/src/transport.rs | 441 ---- sdk/tests/plugins/consumer/Cargo.toml | 7 - sdk/tests/plugins/consumer/imports/probe.json | 291 --- sdk/tests/plugins/consumer/src/main.rs | 42 - sdk/tests/plugins/probe/Cargo.toml | 8 - sdk/tests/plugins/probe/src/main.rs | 328 --- sdk/tests/review.rs | 90 - sdk/tests/sdk.rs | 186 -- sdk/tests/ui/enum.rs | 8 - sdk/tests/ui/enum.stderr | 5 - sdk/tests/ui/field_docs.rs | 5 - sdk/tests/ui/field_docs.stderr | 5 - sdk/tests/ui/hooks.rs | 22 - sdk/tests/ui/hooks.stderr | 19 - sdk/tests/ui/method_docs.rs | 5 - sdk/tests/ui/method_docs.stderr | 5 - sdk/tests/ui/missing_docs.rs | 4 - sdk/tests/ui/missing_docs.stderr | 5 - sdk/tests/ui/must_use.rs | 11 - sdk/tests/ui/must_use.stderr | 64 - sdk/tests/ui/params.rs | 8 - sdk/tests/ui/params.stderr | 34 - sdk/tests/ui/schemars.rs | 9 - sdk/tests/ui/schemars.stderr | 5 - sdk/tests/ui/serde.rs | 9 - sdk/tests/ui/serde.stderr | 5 - sdk/tests/ui/signature.rs | 8 - sdk/tests/ui/signature.stderr | 5 - sdk/tests/ui/subset.rs | 8 - sdk/tests/ui/subset.stderr | 22 - sdk/tests/ui/superseded.rs | 11 - sdk/tests/ui/superseded.stderr | 5 - sdk/tests/ui/tuple.rs | 5 - sdk/tests/ui/tuple.stderr | 5 - sdk/tests/wasi.rs | 475 ---- 142 files changed, 34 insertions(+), 22123 deletions(-) delete mode 100644 DECISIONS.md delete mode 100644 PROTOCOL.md delete mode 100644 agent/Cargo.toml delete mode 100644 agent/src/history.rs delete mode 100644 agent/src/llm.rs delete mode 100644 agent/src/main.rs delete mode 100644 agent/src/overflow.rs delete mode 100644 agent/src/prompt.md delete mode 100644 agent/src/tools.rs delete mode 100644 cli/Cargo.toml delete mode 100644 cli/src/activation.rs delete mode 100644 cli/src/agent.yaml delete mode 100644 cli/src/boot.yaml delete mode 100644 cli/src/cargo.rs delete mode 100644 cli/src/components.rs delete mode 100644 cli/src/console.rs delete mode 100644 cli/src/core.rs delete mode 100644 cli/src/execution.rs delete mode 100644 cli/src/filesystem.rs delete mode 100644 cli/src/graph.rs delete mode 100644 cli/src/host.rs delete mode 100644 cli/src/journal.rs delete mode 100644 cli/src/lib.rs delete mode 100644 cli/src/locks.rs delete mode 100644 cli/src/main.rs delete mode 100644 cli/src/native.rs delete mode 100644 cli/src/process.rs delete mode 100644 cli/src/restart.rs delete mode 100644 cli/src/routing.rs delete mode 100644 cli/src/runtime.rs delete mode 100644 cli/src/services.rs delete mode 100644 cli/src/services/tests.rs delete mode 100644 cli/src/test_runner.rs delete mode 100644 cli/src/upgrades.rs delete mode 100644 cli/src/upgrades/tests.rs delete mode 100644 cli/src/watcher.rs delete mode 100644 cli/src/watches.rs delete mode 100644 cli/src/world.rs delete mode 100644 cli/tests/crates/cargo-c/Cargo.toml delete mode 100644 cli/tests/crates/cargo-c/build.rs delete mode 100644 cli/tests/crates/cargo-c/src/add.c delete mode 100644 cli/tests/crates/cargo-c/src/main.rs delete mode 100644 cli/tests/crates/cargo-fail/Cargo.toml delete mode 100644 cli/tests/crates/cargo-fail/src/main.rs delete mode 100644 cli/tests/crates/cargo-hang/Cargo.toml delete mode 100644 cli/tests/crates/cargo-hang/build.rs delete mode 100644 cli/tests/crates/cargo-hang/src/main.rs delete mode 100644 cli/tests/crates/cargo-test-fail/Cargo.toml delete mode 100644 cli/tests/crates/cargo-test-fail/src/main.rs delete mode 100644 cli/tests/crates/cargo-valid/Cargo.toml delete mode 100644 cli/tests/crates/cargo-valid/build.rs delete mode 100644 cli/tests/crates/cargo-valid/fixture.txt delete mode 100644 cli/tests/crates/cargo-valid/src/main.rs delete mode 100644 cli/tests/crates/cargo-valid/tests/integration.rs delete mode 100644 cli/tests/host/activation.rs delete mode 100644 cli/tests/host/agent.rs delete mode 100644 cli/tests/host/cargo.rs delete mode 100644 cli/tests/host/cli.rs delete mode 100644 cli/tests/host/common.rs delete mode 100644 cli/tests/host/console.rs delete mode 100644 cli/tests/host/core.rs delete mode 100644 cli/tests/host/main.rs delete mode 100644 cli/tests/host/sdk.rs delete mode 100644 cli/tests/host/watches.rs delete mode 100644 cli/tests/plugins/activation/Cargo.toml delete mode 100644 cli/tests/plugins/activation/src/main.rs delete mode 100644 cli/tests/plugins/probe/Cargo.toml delete mode 100644 cli/tests/plugins/probe/src/main.rs delete mode 100644 examples/consumer/Cargo.toml delete mode 100644 examples/consumer/imports/notes.json delete mode 100644 examples/consumer/imports/secrets.json delete mode 100644 examples/consumer/imports/www.json delete mode 100644 examples/consumer/src/main.rs delete mode 100644 examples/notes/Cargo.toml delete mode 100644 examples/notes/src/main.rs delete mode 100644 examples/secrets/Cargo.toml delete mode 100644 examples/secrets/src/main.rs delete mode 100644 examples/www/Cargo.toml delete mode 100644 examples/www/src/main.rs delete mode 100644 interface/Cargo.toml delete mode 100644 interface/src/client.rs delete mode 100644 interface/src/compat.rs delete mode 100644 interface/src/document.rs delete mode 100644 interface/src/lib.rs delete mode 100644 interface/tests/client.rs delete mode 100644 interface/tests/compat.rs delete mode 100644 interface/tests/compat_params.rs delete mode 100644 interface/tests/review_client.rs delete mode 100644 sdk/Cargo.toml delete mode 100644 sdk/imports/notes.json delete mode 100644 sdk/imports/www.json delete mode 100644 sdk/macros/Cargo.toml delete mode 100644 sdk/macros/src/lib.rs delete mode 100644 sdk/src/calls.rs delete mode 100644 sdk/src/discovery.rs delete mode 100644 sdk/src/endpoints.rs delete mode 100644 sdk/src/error.rs delete mode 100644 sdk/src/helpers.rs delete mode 100644 sdk/src/imports.rs delete mode 100644 sdk/src/lib.rs delete mode 100644 sdk/src/lines.rs delete mode 100644 sdk/src/locks.rs delete mode 100644 sdk/src/sqlite.rs delete mode 100644 sdk/src/transport.rs delete mode 100644 sdk/tests/plugins/consumer/Cargo.toml delete mode 100644 sdk/tests/plugins/consumer/imports/probe.json delete mode 100644 sdk/tests/plugins/consumer/src/main.rs delete mode 100644 sdk/tests/plugins/probe/Cargo.toml delete mode 100644 sdk/tests/plugins/probe/src/main.rs delete mode 100644 sdk/tests/review.rs delete mode 100644 sdk/tests/sdk.rs delete mode 100644 sdk/tests/ui/enum.rs delete mode 100644 sdk/tests/ui/enum.stderr delete mode 100644 sdk/tests/ui/field_docs.rs delete mode 100644 sdk/tests/ui/field_docs.stderr delete mode 100644 sdk/tests/ui/hooks.rs delete mode 100644 sdk/tests/ui/hooks.stderr delete mode 100644 sdk/tests/ui/method_docs.rs delete mode 100644 sdk/tests/ui/method_docs.stderr delete mode 100644 sdk/tests/ui/missing_docs.rs delete mode 100644 sdk/tests/ui/missing_docs.stderr delete mode 100644 sdk/tests/ui/must_use.rs delete mode 100644 sdk/tests/ui/must_use.stderr delete mode 100644 sdk/tests/ui/params.rs delete mode 100644 sdk/tests/ui/params.stderr delete mode 100644 sdk/tests/ui/schemars.rs delete mode 100644 sdk/tests/ui/schemars.stderr delete mode 100644 sdk/tests/ui/serde.rs delete mode 100644 sdk/tests/ui/serde.stderr delete mode 100644 sdk/tests/ui/signature.rs delete mode 100644 sdk/tests/ui/signature.stderr delete mode 100644 sdk/tests/ui/subset.rs delete mode 100644 sdk/tests/ui/subset.stderr delete mode 100644 sdk/tests/ui/superseded.rs delete mode 100644 sdk/tests/ui/superseded.stderr delete mode 100644 sdk/tests/ui/tuple.rs delete mode 100644 sdk/tests/ui/tuple.stderr delete mode 100644 sdk/tests/wasi.rs diff --git a/.gitignore b/.gitignore index b588a40..cab49e2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,8 @@ target/ /world /result -# Lock files of test and example crates: they resolve against the workspace lock. +# Lock files of test crates resolve against the workspace lock. **/tests/**/Cargo.lock -/examples/*/Cargo.lock # Cargo homes that the devshell creates where it is entered. .cargo-home/ diff --git a/AGENTS.md b/AGENTS.md index 31cf10f..e4e2089 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Sausage is a world of WASI programs and durable agents. It is meant to be read a including its own. Keep it that way. This branch (`v3`) rebuilds Sausage. The plan and its decisions (D1, D2, …) are in -`~/research/sausage-v3/PLAN.md`. `PROTOCOL.md`, `DECISIONS.md` and `README.md` still describe v2. +`~/research/sausage-v3/PLAN.md`. `README.md` describes the current build and layout. ## The boundary @@ -37,8 +37,6 @@ This branch (`v3`) rebuilds Sausage. The plan and its decisions (D1, D2, …) ar about Sausage. Host code reads and writes the world only through it. - `userland/`: the default CLIs of a world, a separate workspace built for `wasm32-wasip2`. - `spikes/`: accepted prototypes. Read them for evidence, copy from them, do not build on them. -- `cli/`, `interface/`, `sdk/`, `agent/`, `examples/`: v2 code, outside the workspace. Copy what v3 - needs. They are deleted at the end of P1. ## Programs in the world @@ -54,7 +52,7 @@ nix develop -c cargo clippy --workspace --all-targets -- -D warnings nix develop -c cargo build --manifest-path userland/Cargo.toml --release ``` -`nix flake check` still describes v2 and is updated later in P1. +`nix flake check` checks the packaged host, its bundle, and sandbox-compatible tests. Tests must stay fast: diff --git a/Cargo.toml b/Cargo.toml index 0954500..55612a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,8 @@ resolver = "3" members = ["host", "vfs"] default-members = ["host"] -# Standalone crates with their own empty [workspace]: v2 code kept as reference -# until the end of P1, the spikes, and the userland (its own wasm32-wasip2 workspace). -exclude = ["cli", "interface", "sdk", "agent", "examples", "spikes", "userland"] +# Reference spikes and the separate wasm32-wasip2 userland workspace. +exclude = ["spikes", "userland"] # Wasmtime compiles components with Cranelift. Optimize dependencies in dev # builds, so that tests and debug runs start guests quickly. diff --git a/DECISIONS.md b/DECISIONS.md deleted file mode 100644 index afc38f9..0000000 --- a/DECISIONS.md +++ /dev/null @@ -1,120 +0,0 @@ -# Decisions - -Why Sausage is the way it is. `PROTOCOL.md` and the code own the details. -When this file disagrees with them, they win. - -## The shape - -- **JSON-RPC, not component interfaces.** Every language can read and write - lines of JSON, and models know JSON-RPC from LSP and MCP. A call has a - result, like an LLM tool call. -- **A process per call.** WASI has no threads, so processes give - parallelism. A new version applies at the next call, and old processes - retire after a grace period. -- **Attached by default.** A process does not outlive its caller by - accident. A long-running process must ask for `detach`. -- **Services in one file.** Only the core starts and restarts them, so - upgrades and singletons need no logic in plugins. -- **Files to inspect, calls to act.** This keeps the core API small, and the - agent's file tools are enough to explore the world. -- **The core accepts only `.wasm` files.** It knows no source language. - `cargo` is one way to get a `.wasm` file. -- **The world folder is the source of truth.** Copy a plugin folder into - another world, and it works there. -- **The agent is a normal plugin.** It can read and upgrade its own source, - and change how it does inference. - -## Interfaces - -- **Generated imports and interface checks, without version numbers.** A - model that translates a description by hand makes errors. A generator does - not. The check catches the most common error in a world that changes - itself: a change to one plugin breaks another one. -- **A small schema subset.** With Draft 7 and a fixed subset, our own check - can decide every case. It never has to guess. -- **Docs are part of the interface.** Other agents work from them, so the - SDK requires a doc comment on each method, type, and field. -- **The check covers calls, not state.** Old and new versions share - `/state`. A plugin keeps its state format compatible, or migrates it in - `rpc.retired`. - -## Minimal on purpose - -The core does only what a plugin cannot do for itself. Everything else can be -a plugin, or a change that the agent makes to itself. - -- **No permissions, no limits, no hiding.** Any process can call, upgrade, - or stop any plugin, read and write every file, and use the network. The - checks protect against mistakes, not against plugins. Locks are advisory. -- **Locks are in the core.** A plugin cannot see that another process died, - so it cannot release that process's lock. The core can. -- **Our own VFS, in its own crate.** It works on Linux and macOS, it makes - `/proc` live, and plugin-served files need it later. Mounts now, overlays - later. -- **TCP endpoints for now.** They need no host code. VFS pipes can replace - them later behind the same SDK API. -- **Simple scheduling for now.** An epoch tick every 10 milliseconds keeps - the core responsive and makes kills reliable. -- **The agent is dumb.** It has a short prompt, a few tools, an append-only - history that it sends in full, and no compaction. When the context is too - large, it stops sending the oldest turns, so it never gets stuck. - -## Small choices - -- **Restart is a clean stop supervised by `#loop`.** `core.restart` requires - the host startup marker `SAUSAGE_LOOP=1`, which the loop exports for its - invocation. Without a supervisor, restarting would only stop the world. - A single pending request answers first, allows 250 ms for delivery, then - signals the existing shutdown path; it does not use `process::exit` or - bypass detached-process cleanup, lock release, or journal draining. The - executable returns dedicated restart code **75** after cleanup. `#loop` - refreshes and starts again on 75 (and other nonzero exits); 0 stops it. - `core.exit` is a compatibility alias with the same supervisor gate, - response grace, and coalescing; new callers use `core.restart`. - **HARD RULE: the mediator and all agents NEVER call `core.restart` without - the person's explicit approval each time.** This - approval rule also covers legacy `core.exit`; it must not bypass the gate. - -- **YAML for configuration, JSON for data.** A person edits configuration, so - it needs comments. `init` writes commented templates instead of asking - questions, so there is one way to configure a world. -- **Plain POSIX files.** Programs like `tempfile`, git, and SQLite expect - normal files. Atomic writes use a temporary file and `rename`. -- **Tests run under WASI.** `cargo test` on the host cannot find failures - that occur only in WASI, for example a missing WASI function or a panic at - start. The test runner uses the same Wasmtime and the same mounts as the - core. -- **Tests never run without a timeout.** `cargo.build` serializes its work - folder, so one hung test or build script must not block all later builds. - `timeout_secs` defaults to 900 seconds after mutex acquisition, excluding - queue time; `test_timeout_secs` defaults to 120 seconds per WASI instance. - Both are optional positive integers, never null/unlimited. Test timeouts name - the failing test and let other instances finish. Build deadlines and - cancellation kill cargo's process group before dropping the mutex; timeout - errors retain the phase, elapsed time, and recent output. Source preparation - and publication retain serialization while their blocking filesystem work - finishes, even if cancelled, to avoid overlapping writes to the work folder. -- **The SDK is not published.** `cargo` points `sausage-plugin = "0.1"` at - its own copy, which it also shows in `/plugins/cargo/sdk/`. -- **Compiled code is a cache, not state.** It lives outside the world, in - one cache folder that all worlds share: `wasmtime/` (owned by Wasmtime, - whose cleanup deletes unknown entries), `cargo-target/`, and - `sdk//`. A path that depends only on the content of the SDK lets - cargo reuse the compiled SDK in every world. Tests run in the release - profile, so that they share compiled crates with the release build. -- **mold links host code where Nix provides it.** On Linux, the dev shell, - the Nix builds, and the packaged `sausage` (for the proc macros and build - scripts of plugins) link with mold, much faster than GNU ld. Plugins for - `wasm32-wasip2` link with rust-lld. Without Nix, the default linker stays. -- **No LTO.** Measured: fat LTO makes the binary 28 % smaller, but compiles - plugins and answers calls at most 5–8 % faster, for 4 more minutes per - release build. Thin LTO brings nothing. -- **Discovery has a deadline of 10 seconds.** It is an internal call of the - core, not a limit on plugins. Without it, one plugin that does not answer - would block every activation. -- **A broken plugin blocks nobody.** A plugin without a document is already - broken, so the check cannot protect it. The graph shows it as - "unavailable", and `core.revert` and `force` always work. -- **`rpc.retired` is best effort.** It does not run if the host stops first. - A persistent queue of retirements would be more core for a rare case, so a - new version checks its own migration marker instead. diff --git a/PROTOCOL.md b/PROTOCOL.md deleted file mode 100644 index 1dd9509..0000000 --- a/PROTOCOL.md +++ /dev/null @@ -1,622 +0,0 @@ -# The Sausage protocol - -This is the contract between the core and the plugins. A plugin in any -language that follows it works in a Sausage world. The core shows this file -at `/proc/core/PROTOCOL.md`. `DECISIONS.md` in the repository says why the -contract is the way it is. - -Rust plugins use the SDK `sausage-plugin`. Its source is at -`/plugins/cargo/sdk/`, and its crate docs explain each item. - -## 1. Terms - -| Term | Meaning | -| --- | --- | -| world | The folder that a `sausage` host runs. It is `/` for every process. | -| core | The host. It runs processes, routes calls, serves the virtual files, and keeps the journal. | -| plugin | A folder `/plugins//` with a `plugin.wasm`. A plugin does not run by itself. | -| process | One run of a plugin version. Its ID is `-`, for example `notes-0197f3a2-7c1e-7d2b-9a41-5f0c2e8b1d34`. | -| call | A JSON-RPC request. `notes.add` calls the method `add` of the plugin `notes`. | -| interface | A set of methods. A plugin has one main interface. It can also declare endpoint interfaces. | -| endpoint | An interface that a running process serves on a TCP port. An endpoint file tells others where it is. | -| service | A call in `/config/boot.yaml`. The core starts it at boot, restarts it after a crash, and starts it again after an upgrade of its plugin. | -| import | An interface of another plugin that a plugin uses. | -| provider, consumer | The plugin that has an interface, and a plugin that imports it. | -| journal | The central log of the world. | - -## 2. The world folder - -```text -/ - plugins// - plugin.wasm the current version - plugin.prev.wasm the version before, for `core.revert` - source/ the source. `cargo.build` builds it from here. - imports/.json descriptions of the imported plugins - state// persistent state of a plugin, by convention - logs/core/journal.sqlite the journal - state/core/ console history (state, not logs) - config/ global configuration - boot.yaml the services - agent.yaml the API, base URL, model, and key of the agent -``` - -- Every process can read and write all of it. There are no permissions. -- Configuration files are YAML, with comments. Data and interface files are - JSON. -- Plugin names use `a-z`, `0-9`, `-`, and `_`. The name `core` is reserved. -- The world folder must not contain `proc`, `run`, or `self`. These names are - mount points (section 4). The host does not start a world that has them. - -## 3. Calls and processes - -### 3.1 The wire protocol - -- A plugin is a `wasi:cli/command` component for `wasm32-wasip2`. -- stdin and stdout carry JSON-RPC 2.0, one message per line. -- A line is a message only if it is a JSON object with `"jsonrpc": "2.0"`. - The core discards every other line on stdout. For the first discarded line - of a process, it writes one warning to the journal. -- Each line on stderr goes to the journal. Log to stderr. -- `params` is always an object. Batches are not supported. -- Write each message in one write, and write a newline before it. Then a - `print!` without a newline cannot damage a message. - -### 3.2 Method names - -- A method name has the form `.`. The core splits it at the - first `.`. -- `notes.add` starts a new process of `notes` and sends it the request `add`. - `notes.rpc.discover` sends it `rpc.discover`. -- `core.` is a method of the core (section 9). -- Method names that start with `rpc.` are reserved: `rpc.discover`, - `rpc.superseded`, `rpc.retired`, and `rpc.endpoint`. - -### 3.3 What a call does - -1. The core gives the call a UUIDv7 and starts the process `-` - with the current version of the plugin. -2. The core writes the request to the stdin of the process. The request ID is - the process ID, and the method has no plugin prefix. -3. The process writes the response to stdout. The core sends the response to - the caller, with the caller's request ID. -4. If the process ends before it answers, the caller gets the error -32002. - Its `data` has the exit status and the last journal lines of the process. - Before the core decides this, it reads the stdout of the process to the - end. - -- A process can write calls to stdout at any time, to the core and to - plugins. The responses come on stdin. A process can have many calls in - flight. -- The core keys each pending request by the process and the exact JSON ID. - The core drops a response with an unknown or late ID. -- A notification to `plugin.method` (without an ID) starts an attached - process. That process gets the notification without an ID, and nobody gets - a result. - -### 3.4 Detached calls - -`core.call {"method": "www.start", "params": {...}, "detach": true}` makes the -call and returns its result to the caller. The new process has no parent, so -it does not end with its caller. - -Sent as a notification (without an ID), the same `core.call` starts the -process and forgets it. - -### 3.5 Notifications to the caller - -A process can tell its caller about progress. It sends the notification -`core.notify {"name": "text", "params": {...}}`. The core gives it to the -caller as `core.notify {"call": , "name": "text", -"params": {...}}`. After the call has ended, the core drops such -notifications. - -So a process never needs to know who called it. - -### 3.6 Lifetime - -A process lives until `main` returns. A process that uses the SDK lives -while one of these runs: - -- a handler of a call, -- a background task, -- an endpoint that listens. - -A process has three states: - -1. **running** -2. **stopping**: the core has closed stdin. EOF on stdin means "stop now". - - New calls of the process fail at once, and its pending calls fail. - - A stop hook can still write files and send notifications. It cannot - make calls, because no response can arrive. - - After 5 seconds, the core kills the process. -3. **ended**: the core releases the locks of the process (section 6) and - removes its folder in `/proc`. - -Rules: - -- `core.stop {pid}` starts the stopping state. `core.kill {pid}` kills the - process at once. -- When a process ends, the core stops each of its attached children. All - children stop in parallel, so a whole tree takes 5 seconds at most. -- A detached process has no parent. Only `core.stop`, `core.kill`, an - upgrade (section 3.7), and the end of the host stop it. -- At host shutdown, the core starts no new processes, and it stops every - process in parallel. - -Note: on Unix, children outlive their parent. Here, an attached child ends -with its parent. Only `detach` makes a process independent. - -### 3.7 Upgrades - -A process is an instance of one plugin version. When a new version becomes -active (section 10.3): - -1. New calls get the new version at once. -2. At grace start, the core sends each process of an older version the - JSON-RPC notification `rpc.superseded {"current": ""}` - on stdin, without an ID. The process is still running: calls and responses - continue to work. It can stop accepting work, finish its in-flight calls, - and exit early. Ignoring the notice changes nothing. The grace period is - the upper bound before the core stops the process (section 3.6); its - duration is 5 seconds for every plugin. Discovery metadata, including an - explicit `x-sausage-grace`, does not change it. After grace, stdin closes - and the separate 5-second stop budget applies (section 3.6): at most two - 5-second windows. Discovery-only processes do not receive this notice or - enter upgrade grace; they follow the discovery lifetime (section 10.1). -3. When the old process of a service has ended, the core starts the service - again with the new version. -4. When the last process of an older version has ended, the core starts a - process of the new version and calls `rpc.retired {"previous": - ""}`. A plugin can use this hook to migrate its - state, or to write a marker that its other new processes wait for. The - result goes to the journal. - -During the grace period, an old process can call a method that a new -provider no longer has. - -If the host stops before the last old process has ended, `rpc.retired` does -not run for that version. So a new version must also work with state that -was not migrated. Check for a migration marker, and migrate when it is -missing. - -### 3.8 Environment and network - -- `SAUSAGE_PID`: the process ID. -- `SAUSAGE_PLUGIN`: the plugin name. -- `SAUSAGE_PARENT`: the parent: `console`, `core`, or a process ID. - -Every process gets the network: outgoing HTTP and HTTPS through `wasi:http`, -and TCP, UDP, and DNS through `wasi:sockets`. - -A process that computes for a long time does not block other processes. The -core interrupts it every 10 milliseconds. - -### 3.9 Errors - -The standard JSON-RPC codes apply. The core adds these codes: - -| Code | Meaning | -| --- | --- | -| -32001 | There is no such plugin. | -| -32002 | The process ended without an answer. `data` has the exit status and the last journal lines. | -| -32003 | The activation was rejected. `data` has the reasons. | -| -32004 | `cargo.build` failed. `data` has the `output` of the failed step and the `tests`. | - -## 4. The filesystem - -Each process gets one preopen, `/`. The core resolves each path through a -mount table: - -| Path | What it is | Access | -| --- | --- | --- | -| `/` | the world folder | read-write | -| `/proc` | generated by the core | read-only | -| `/self` | the folder `/proc//`, in memory | read-write | -| `/run` | in memory, shared, empty at each host start | read-write | - -A listing of `/` shows `proc`, `run`, and `self`. A rename across two of -these areas fails. - -```text -/proc/ - core/ - PROTOCOL.md this file - graph.json plugins, imports, and the result of each check - graph.dot the same graph for Graphviz - locks.json held locks and waiting processes - plugins// - discover.json the rpc.discover document of the current plugin.wasm - client.rs the Rust code that import!("") generates - / - info.json plugin, version, method, params, parent, detached, start time, state - log the journal lines of this process - the files that the process writes to /self -``` - -Rules: - -- A process writes its own folder only through `/self`. Writes through - `/proc` always fail. -- In a process folder, the names `info.json`, `log`, and `fs` belong to the - core. A write to `/self/info.json` fails. -- A read of a generated file returns a snapshot that ends. For example, - `/proc//log` returns the lines up to the time of the open. -- The folder of a process goes away when the process ends. Its files are in - memory. Large or persistent data belongs in `/state`. -- Do not send a `/self` path to another process. There it means a different - folder, like `localhost` on another machine. - -Principle: **inspect through files, act through calls.** - -### File watching - -`core.watch {"path": "/state/session/mailbox"}` stays open for the caller -process's lifetime. It needs a request ID and a running plugin caller. -Its first `core.notify` is `"armed"`, with empty params. Only then scan the -files. Writes before arming are visible in that scan; writes after arming -produce `"changed"` notifications (also with empty params). Rescan after -each notification; files, not event counts, are the truth. - -Notifications follow successful plugin-originated file mutations: creation, -truncation, descriptor/stream writes, rename (both source and destination), -and deletion, including mounted `/run` and `/self` files. Paths are normalized -in the virtual namespace; `/self` means the caller's `/proc/` folder. -A watch matches itself and descendants, not sibling prefixes. Moving or -removing an ancestor also invalidates it. Paths need not exist when armed. -Open file writes follow plugin renames; unlinked/replaced handles no longer -name visible files. External host writes are outside this contract. - -Changes may coalesce, including a change already visible in the initial -scan, but a change after the last notification always produces another. -Delivery holds no filesystem locks. Observation does not serialize descriptor -IO; only overlapping opens and rename/removal wait for one another. -Watch the narrowest useful path: delivery is not rate-limited, so watching a -busy parent can cause a rescan for every write. - -The host-side watch ends only when its caller process exits, not on a change -notification; there is no cancellation API or polling/retry loop. SDK callers -should poll the watch future alongside their handler's work and drop it on -return, not keep it in a background task that prevents an idle process from -exiting. Dropping the future removes the SDK's pending entry and callback, so -late notifications and responses are ignored; it does not cancel the host -watch. A long-lived service keeps one watch per purpose for its lifetime, -rather than repeatedly opening and dropping watches. - -## 5. Endpoints - -An endpoint is a JSON-RPC interface that a running process serves over TCP, -for example the control interface of a web server. - -- The process binds a free port on 127.0.0.1. When the listener is ready, it - writes an endpoint file in one atomic step (write a temporary file, then - rename it). The file has one line: the address, the owner process, and the - interface. Example: `tcp:127.0.0.1:41234 www-0197… control`. -- Usually the file is in `/self`. A service that others must find uses a - fixed path in `/run`, for example `/run/secrets`. -- A client reads the file and connects. Its first request is - `rpc.endpoint`, which returns `{"owner", "plugin", "interface"}`. If they - do not match the file, the client disconnects. So a stale file or a reused - port cannot connect a client to the wrong process. -- JSON-RPC runs on the connection, one message per line, in both directions. - Notifications of a handler (`core.notify`) go over the connection. -- A plugin declares its endpoint interfaces in `rpc.discover` - (`x-sausage-endpoints`). - -## 6. Locks - -- `core.lock {"path": "/state/notes/notes.json"}` returns `{"held": true}` - when the caller holds the lock. -- Each acquisition gets its own place in a FIFO queue. The same process - cannot take one lock twice: its second acquisition waits behind its first. -- With `"wait": false`, the call returns at once with `{"held": true}` or - `{"held": false}`. -- `core.unlock {path}` (a notification) releases the lock. -- When a process ends, the core releases all of its locks, also after a - crash or a kill. It waits until the last file operation of the process has - returned. So a write of a killed process cannot land after the next - process got the lock. -- Locks are advisory. The core does not block writes to the file: the path - is only a name. -- SQLite has no file locks in WASI. Hold the lock while the database is open. - -## 7. The journal - -- New entries go to `/logs/core/journal.sqlite`. Only the core writes it. - An existing `/state/core/journal.sqlite` stays in place and is opened - read-only; queries merge both databases by entry ID before applying the - limit. The host never renames a live SQLite/WAL trio. Console history stays - in `/state/core`. No journal or log files are automatically pruned. -- If a journal transaction fails, the complete batch (including original - IDs and timestamps) is saved in an exclusive, synced JSON file under - `/logs/core/failed-batches/`; the host reports its path on stderr. These - files are recovery artifacts, not query results: recovery/import is - deliberate, using IDs to avoid duplicates, and never deletes the source. - If spooling also fails, entries stay in memory and retry on the next - command (no polling); at writer shutdown their full text is reported to - host stderr. Storage failure plus host loss can still lose unsaved data. -- Each entry has a UUIDv7 (it sorts by time), the process ID, the plugin, the - stream (`stderr` or `core`), and the line. -- Traps retain their full diagnostic text in the journal; short exit-status - displays are UTF-8-boundary-safe. -- The core writes its own events too: start (with method and params), exit - status, stop, kill, upgrades, and the warning about discarded stdout. -- `core.log {plugin?, pid?, since?, until?, grep?, limit?}` queries the - journal. `/proc//log` shows the lines of one running process. - -## 8. Boot and services - -At start, the core starts each service in `/config/boot.yaml`, in order: - -```yaml -- method: secrets.serve - params: {} -- method: www.start - params: - port: 8080 -``` - -- A service is a detached call with the parent `core`. The core waits for - its result before it starts the next one. The result shows that the - service is ready. -- If a service fails before answering, the core writes the error to the - journal and continues booting. A failure to admit a process (for example, - a missing plugin or invalid parameters) is logged, not retried in a loop. -- The core supervises the process lifetime, not its readiness response. A - nonzero exit, trap or failed host task restarts the same service with its - original method and parameters, including a crash before readiness. A - readiness response alone never triggers a restart; a clean exit does not - count as a crash or trigger an immediate restart. -- More than 5 crashes within 10 seconds gives up on that service and writes - `service gave up` to the journal. Each service has an independent sliding - window; crashes exactly 10 seconds old expire. There is no retry delay or - periodic liveness check. Giving up is latched for this host lifetime: - neither elapsed time nor an upgrade re-arms it. -- After an upgrade of the plugin of a service, the same supervisor starts - it again with the current version after its old process ends (section - 3.7). Superseded exits are retirement, not crashes. Admission and cleanup - reserve a single owner/attempt, so an upgrade racing a crash cannot start - two owners. A matching boot-service start from `rpc.retired` is ignored - (returns `null`), even while restart is pending or after giving up; older - plugins' fallback starts do not bypass supervision. -- An explicit `core.stop` or `core.kill` disables automatic attempts for - that service, including during an upgrade. Host shutdown starts no new - processes. The core does not restart other detached processes. -- A singleton is a service with a fixed endpoint in `/run`. Only the core - starts it, so two instances cannot start at the same time. - -## 9. The core API - -| Method | Params | Result | -| --- | --- | --- | -| `core.call` | `method`, `params`, `detach`? | the result of the method | -| `core.watch` | `path` | stays open; `armed`, then `changed` notifications (section 4) | -| `core.notify` | `name`, `params` | none (a notification) | -| `core.stop` | `pid` | `{}` when the process has ended | -| `core.kill` | `pid` | `{}` | -| `core.lock` | `path`, `wait`? | `{"held": bool}` | -| `core.unlock` | `path` | none (a notification) | -| `core.activate` | `plugin` and `wasm`, or `plugins: [{plugin, wasm}]`. `force`? | short activation summaries, keyed by plugin | -| `core.revert` | `plugin`, `force`? | like `core.activate` | -| `core.log` | filters (section 7) | journal entries | -| `core.restart` | none | `{}`. With `SAUSAGE_LOOP=1`, schedule clean shutdown and exit code 75. | -| `core.exit` | none | Alias of `core.restart`: same supervisor gate, response, coalescing, and exit code 75. | - -### Host restart - -**HARD RULE: the mediator and all agents NEVER call `core.restart` without -the person's explicit approval each time.** The same approval rule applies -to the legacy alias `core.exit`; it uses the same gate and pending request. - -`core.restart {}` is enabled only when the host starts with `SAUSAGE_LOOP=1`. -The host captures the marker at startup. An absent marker (or any value other -than `1`) returns error -32600: `restart needs the loop supervisor -(SAUSAGE_LOOP not set); start with SAUSAGE_LOOP=1`, without scheduling an exit. -This is a supervisor marker, not a permission system. - -The method returns `{}` immediately and schedules the shutdown request after -250 milliseconds to let the response reach the caller before stdin closes. -Repeated requests while a restart is pending return `{}` without scheduling -another restart or extending that delay. The executable then uses the same -`Host::stop` path as a normal stop: reject new processes, stop all processes -(including detached ones) in parallel with the normal five-second grace, -wait for outstanding file operations, release locks, and drain journal writes. -Only after that does the executable return the dedicated restart exit code -**75**. The library signals this path; it never kills its embedding process. - -`nix run .#loop -- ` sets `SAUSAGE_LOOP=1` on the host invocation and -starts the newest host again after exit code 75 (and, as before, other nonzero -exits). Exit code 0 ends the loop. If using an older loop script, the person -must set `SAUSAGE_LOOP=1` in that supervisor's environment; do not set it for -an unsupervised host. The NixEnv service invokes this flake's `#loop`, so a -new loop script supplies the marker without changes to NixEnv. - -Everything else is a file. - -## 10. Interfaces - -### 10.1 `rpc.discover` - -Each plugin answers `rpc.discover` with an OpenRPC 1.2 document. The schemas -are JSON Schema Draft 7, as OpenRPC requires. Shared schemas are in -`components.schemas`, and `$ref`s point to `#/components/schemas/`. -Params are by name. The document can have these extension fields: - -- `x-sausage-params` for each method with params: a `$ref` to the schema of - the params object. -- `x-sausage-endpoints`: the endpoint interfaces by name, each with its - `methods`. -- `x-sausage-imports`: for each provider, the part of its document that the - plugin uses. -- `x-sausage-grace`: legacy grace declaration in seconds. The core ignores - this field; upgrade grace is always 5 seconds (section 3.7). - -The schema of params describes what the provider can read. The schema of a -result describes what the provider writes. Descriptions are part of the -interface: other agents read them to use it. - -The core starts a process only to call `rpc.discover`, and it stops that -process after the answer. Do not do expensive work at start. If no answer -comes within 10 seconds, the discovery fails. - -### 10.2 Compatibility - -A provider can add methods, optional params, and result fields. For each -imported method: - -1. The provider has the method. -2. Each params value that the consumer can send is valid for the provider. -3. Each result that the provider can return is valid for the consumer. - -- The same rules apply to endpoint interfaces. -- There are no version numbers. This check replaces them. -- The check covers a fixed subset of JSON Schema: objects with `properties` - and `required`, the primitive types, arrays, optional and nullable values, - string enums, integer ranges, and `$ref`. A construct outside the subset - fails the check. -- The check covers calls, not state. Old and new processes share the files in - `/state`, and `core.revert` restores only code. A plugin must keep its - state format compatible, or migrate it in `rpc.retired`. - -### 10.3 Activation - -`core.activate {"plugin": "notes", "wasm": "/state/cargo/builds/7/notes.wasm"}`: - -1. The core copies the file. All further steps use this copy, even if the - file changes. -2. It starts the copy and calls `rpc.discover`. -3. It gets the current documents of all plugins (section 10.4). A plugin - whose discovery fails is "unavailable". Its imports cannot be checked, - but it does not block the activation of other plugins. -4. It checks the imports of the new version against the current providers. -5. It checks each current consumer of the plugin against the new version. -6. If all checks pass, `plugin.wasm` becomes `plugin.prev.wasm`, and the copy - becomes `plugin.wasm`. Then the upgrade starts (section 3.7). If the copy - has the same hash as the current version, nothing changes. - -If a check fails, nothing changes, and the error -32003 lists each problem, -with a counterexample where possible. - -The result is an object keyed by plugin name. Each value contains: - -- `plugin`: the plugin name. -- `wasm`: the installed world path (`/plugins//plugin.wasm`). -- `hash`: the SHA-256 of the installed copy. -- `previous`: the replaced version's SHA-256, or `null` on first installation. - For an identical-hash no-op, this is the current hash. -- `method_count`: the number of methods in the new main interface. -- `added`, `removed`, `changed`: sorted method-name arrays compared with the - previous discovery document. A change to a method description or any - transitively referenced local schema counts as changed. Unrelated schemas - and endpoint interfaces do not affect these arrays. With no previous - document, all methods are added. -- `interface_check`: `"passed"` after successful checks, or `"skipped"` with - `force: true` (not a claim of compatibility). - -The result does not include schemas. The full document remains available -through `.rpc.discover` and `/proc/core/plugins//discover.json`. -An identical-hash no-op and `core.revert` return the same summary shape. - -- The core runs one activation at a time. -- `plugins: [...]` activates several plugins together. The checks then use - the new set. -- `force: true` skips steps 4 and 5. -- `core.revert {plugin}` activates `plugin.prev.wasm` in the same way, and - the current version becomes `plugin.prev.wasm`. -- A person can also copy a file to `plugin.wasm` by hand. That skips the - checks. - -### 10.4 The graph - -- The core keeps the `rpc.discover` documents of all plugins, by file hash. - At start, it gets them in the background. -- A file watcher on `/plugins/*/plugin.wasm` updates them, for example after - a copy by hand. `/proc/core/graph.json` then shows each broken import, and - the journal records it. -- An import of a plugin that does not exist is "missing". An import of a - plugin whose discovery failed is "unavailable". - -## 11. Building and tests - -The core has one native plugin: `cargo`. `cargo.build {"plugin": "notes"}` -builds `/plugins/notes/source/`: - -1. It copies the source once. The tests and the build both use this copy. -2. It runs the tests under WASI, in the release profile. -3. It builds the release `.wasm` for `wasm32-wasip2`. The tests and the build - share the compiled dependencies. -4. It returns `{"wasm": "/state/cargo/builds//notes.wasm", "tests": - {"passed", "failed", "output"}, "warnings", "logs"}`. If a step fails, it returns - an error with the compiler or test output. - -- Before running commands, it creates `/logs/cargo//.stdout.log` - and `.stderr.log` (UTC date, unique build ID). Both test and release phases - append their raw bytes directly to these files, before any parsing, progress - filtering or UTF-8 conversion. This includes successful JSON build output. - `/logs` is a top-level world folder, created if missing, not under `/state`. -- Success results and error -32004 `data` include `logs`: - `{"stdout":{"path":"/logs/cargo/...stdout.log","bytes":123}, - "stderr":{"path":"/logs/cargo/...stderr.log","bytes":45},"total_bytes":168}`. - Counts are raw bytes, not characters. `logs` is null only if log creation - itself fails. Preparation failures otherwise include empty log files. - Inline diagnostics may be filtered or shortened; the files are the full truth. -- Logs remain after failure, total/test timeout, and cancellation. Cancellation - need not return a result; its files remain discoverable in `/logs/cargo/`. - Pruning numeric `/state/cargo/builds/` artifact folders does not prune logs. -- Optional `timeout_secs` is an integer of at least 1, default **900** (not - null or unlimited). The wall-clock budget starts when the build acquires its - mutex, excluding queue time, and covers tests and release compilation together. - On expiry, cargo and its descendant process group are killed and the mutex is - released. Error -32004 has reason/message `timeout`, with `data.reason`, - `data.elapsed_secs`, `data.step` (`tests` or `release build`), `data.tests`, - and `data.output` containing the last 50 output lines. -- Optional `test_timeout_secs` is an integer of at least 1, default **120**. - Each WASI test instance has this wall-clock budget. On expiry, the instance - is killed, its named test fails with `timed out after N s`, and remaining - tests continue. The overall build budget still applies. -- Cancellation kills cargo's process group, including build scripts and test - runners, before releasing the build mutex. An attached build also stops when - its caller ends (under the normal process stopping deadline). -- A call to `cargo.build` gets a process ID, a folder in `/proc`, and journal - entries, like other calls. -- `/plugins/cargo/sdk/` is the SDK used by builds. The dependency - `sausage-plugin = "0.1"` points directly to this folder, including its macros - and interface crate. Edits apply to the next build without a host restart. -- The host seeds a missing SDK folder from its embedded copy at startup or - before a build. An existing folder is never overwritten or repaired, even - after a host upgrade. To reset to the host's bundled SDK, remove the whole - folder; the next startup or build re-seeds it. Back up local edits first. -- Compiled crates stay in the host's cache, outside the world, and all worlds - share them. So a second build compiles only what changed. -- Build scripts can compile C: `CC_wasm32_wasip2` and `AR_wasm32_wasip2` - point to a WASI clang. -- The build does not activate the result. Call `core.activate` next. - -Tests: - -- Each test runs in its own instance, so a panic ends only its own test. -- The normal Rust test setup works: `#[test]`, `tests/*.rs`, name filters, - and `--ignored`. `#[should_panic]` cannot work, because WASI cannot catch - a panic. -- Each test gets an empty temporary world as `/`, the package folder at its - host path, and its own `/proc`, `/self`, and `/run`. -- The current folder of a test is `/`, not the package folder. Read fixture - files through `env!("CARGO_MANIFEST_DIR")`. -- The test runner answers `core.lock`, `core.unlock`, `core.notify`, and - `core.log`. A call to a plugin fails: a test has no other plugins. - -## 12. Embedded agent tool output - -The agent shipped by `sausage init` uses a context-size guard of 50 KiB or -2000 lines, not a data-retention limit. Oversized tool results, including -errors, are saved in full under `/logs/agent//default/` before history -and provider delivery receive an excerpt. Overflow JSON string values are -rendered as physical text lines so the reference's one-based inclusive line -ranges can be followed with `read_file {path, line_offset, line_limit}` -(zero-based offset; default 200 lines). Huge single lines give exact byte -ranges and continuation offsets. Command/log/build/test results show the -tail; other tools show the head. Reading an existing `/logs` file excerpts -that same file without making another archive. Archives are exclusive and -never automatically deleted. If archival fails, the agent reports the error -to stderr and keeps the complete result instead of discarding it. Existing -history formats and paths are unchanged. diff --git a/README.md b/README.md index 5337d93..8e83941 100644 --- a/README.md +++ b/README.md @@ -1,126 +1,39 @@ -# Sausage +# Sausage v3 -You can see how the sausage is made. +Sausage is a world of WASI programs and durable agents. The Rust host combines a process table, a shared virtual filesystem, and a durable JavaScript harness. Agents and their programs see the same world. The filesystem prevents mistakes, not attacks between agents. The host credentials manager is the intended security boundary. Hierarchical coordination and the credentials manager remain later phases of the plan. -Sausage is a world of small WASI programs ("plugins") that you grow by -talking to it. A plugin is a `.wasm` file with JSON-RPC methods. Each call -to a plugin starts a new process. The agent is a plugin too: it writes new -plugins in Rust, builds them, tests them, activates them, and can change its -own code. - -## Start - -You need [Nix](https://nixos.org/download) with flakes, and an LLM: the OpenAI -Responses API, an OpenAI-compatible Chat Completions API (OpenRouter, Ollama, -and most other providers), or the Anthropic Messages API. No clone needed: +## Build and run ```sh -nix run git+https://gitea.surma.technology/surma/sausage +nix build +result/bin/sausage run /path/to/world +result/bin/sausage sh /path/to/world -c 'echo hi | tr a-z A-Z; git --version' ``` -This creates a world in `./world` the first time (it builds the agent: the -first build on a machine takes a few minutes, later worlds reuse the compiled -crates), then starts it with a console. Later runs start the -existing world. The two steps on their own: +The package includes the userland bundle. +At world creation, the host copies its tools into `/bin` as `.wasm` files. +The console uses `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` from the host environment. +Secrets do not enter the world. + +The host creates `world/` for the shared filesystem and `host/` for private runtime state under the supplied directory. +Services come from `/config/services.yaml` in the world. + +## Repository + +- `host/`: the Rust host, process table, and durable harness. Its checked-in JavaScript bundles need no npm during a Nix build. +- `vfs/`: the shared virtual filesystem and WASI adapters. +- `userland/`: the default tools, in a separate `wasm32-wasip2` workspace. `bundle.nu` builds and assembles them. +- `spikes/`: reference prototypes, not production dependencies. + +The design and decisions live in `~/research/sausage-v3/PLAN.md`. + +## Development ```sh -nix run git+https://gitea.surma.technology/surma/sausage -- init # creates ./world and builds the agent -nix run git+https://gitea.surma.technology/surma/sausage -- run # starts the world and a console +nix develop +cargo test --workspace +nu userland/bundle.nu +nix flake check ``` -`init` asks nothing. It writes `world/config/agent.yaml`, with a comment for -each field and an example for each API. Edit it before the first chat: choose -the API, the base URL, the model, the key, and the thinking level. Until then, a chat answers with -an error that names the missing field. Both commands take another folder as -an argument, for example `... -- run my-world`. - -Compiled code is cached outside the world, in `$XDG_CACHE_HOME/sausage/` -(`~/.cache/sausage/` without the variable). All worlds share it. -`--cache-dir ` changes it for `init` and `run`. - -To keep a world on the newest version, run it in a loop: - -```sh -nix run git+https://gitea.surma.technology/surma/sausage#loop # ./world, created if needed -nix run git+https://gitea.surma.technology/surma/sausage#loop -- my-world # an existing world -``` - -When the host crashes or a plugin calls `core.restart`, the loop fetches the -newest commit and starts it again. The loop sets `SAUSAGE_LOOP=1` to enable -that RPC; without it, restart returns an error and leaves the host running. -Restart answers first, then cleanly stops all processes and exits with code -75. `quit` (code 0) ends the loop. Set `SAUSAGE_FLAKE` to run another flake -URL instead, for example your fork. - -**The mediator and all agents NEVER call `core.restart` without the person's -explicit approval each time.** This also applies to legacy `core.exit`, an -alias with the same supervisor gate, response-before-exit behavior, and -request coalescing. -See [the protocol](PROTOCOL.md#host-restart) for the shutdown contract. - -From a clone (`git clone https://gitea.surma.technology/surma/sausage`), the -same commands are `nix run`, `nix run -- init`, `nix run -- run`, and -`nix run .#loop`. - -### Headless - -`--headless` (or `SAUSAGE_HEADLESS=1`) turns the console input off. The host -runs until SIGTERM or SIGINT (exit code 0) or until a plugin calls -`core.restart` (requires `SAUSAGE_LOOP=1`; exit code 75 restarts the loop): - -```sh -nix run git+https://gitea.surma.technology/surma/sausage#loop -- --headless my-world -``` - -## The console - -| Input | Effect | -| --- | --- | -| text | a message to the agent (`agent.chat`) | -| `. {json}` | a call. The console prints the result. | -| `ls [path]`, `cat ` | the filesystem as a process sees it, with `/proc` and `/run` | -| `methods ` | the methods of a plugin, with params and docs | -| `ps` | the running processes | -| `log [plugin or pid] [-n N]` | the journal | -| `activate `, `revert ` | activate a new version, or go back | -| `stop `, `kill ` | stop a process | -| `help`, `quit` | | - -## The world folder - -```text -plugins//plugin.wasm the current version of a plugin -plugins//source/ its source -state// the state of a plugin, by convention -state/core/journal.sqlite the journal: stderr of all processes, and core events -config/boot.yaml ordered boot services, supervised for crashes (PROTOCOL §8) -config/agent.yaml the configuration of the agent -``` - -Boot services restart on process crashes, not on readiness responses. More -than 5 crashes within 10 seconds stops automatic attempts for that service -and journals `service gave up`; an upgrade does not reset that decision. -Explicitly stopping or killing a service also leaves it stopped for this -host lifetime. See [Boot and services](PROTOCOL.md#8-boot-and-services). - -Rust builds use the SDK in `plugins/cargo/sdk/` directly. The host seeds this -folder only when it is missing; restarts and host upgrades preserve existing -files. SDK edits take effect on the next `cargo.build`, without a restart. -To reset to the host's bundled SDK, back up any edits and remove the whole -folder; the next startup or build re-seeds it. - -Each process also sees `/proc` (the processes and the state of the core), -`/self` (its own folder in `/proc`), and `/run` (shared files in memory). - -To replace a plugin by hand, copy a `.wasm` file to its `plugin.wasm`. That -skips the interface checks. `revert ` goes back to the version -before the last activation. - -## More - -- [PROTOCOL.md](PROTOCOL.md): calls, processes, the filesystem, locks, - interfaces, and `cargo`. Read this to write a plugin in any language. -- [DECISIONS.md](DECISIONS.md): why Sausage is the way it is. -- [`sdk/`](sdk/): `sausage-plugin`, the SDK for Rust plugins. -- [`examples/`](examples/): small plugins. -- [AGENTS.md](AGENTS.md): how to work on Sausage itself. +The development shell provides Rust with `wasm32-wasip2`, wasmtime, and wasm-tools. diff --git a/agent/Cargo.toml b/agent/Cargo.toml deleted file mode 100644 index 18dbea0..0000000 --- a/agent/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "agent" -version = "0.1.0" -edition = "2024" -description = "The Sausage agent plugin." - -[dependencies] -sausage-plugin = { version = "0.1", path = "../sdk" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -serde_yaml_ng = "0.10" - -[target.'cfg(target_os = "wasi")'.dependencies] -wstd = "0.6" diff --git a/agent/src/history.rs b/agent/src/history.rs deleted file mode 100644 index ebf6619..0000000 --- a/agent/src/history.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! History window selection and context-size error detection. - -use crate::llm::Entry; - -/// Recognize provider errors that permit a retry with fewer complete turns. -pub fn is_too_large(error: &str) -> bool { - let Some((status, body)) = error - .strip_prefix("HTTP ") - .and_then(|error| error.split_once(':')) - else { - return false; - }; - if status.split_whitespace().next() == Some("413") { - return true; - } - if status.split_whitespace().next() != Some("400") { - return false; - } - let body = body.to_ascii_lowercase(); - body.contains("context_length_exceeded") - || body.contains("maximum context length") - || body.contains("context length exceeded") - || body.contains("too large") - || (body.contains("too long") - && ["context", "input", "prompt"] - .iter() - .any(|term| body.contains(term))) -} - -/// Find a user-turn boundary without discarding any of the current turn. -pub fn next_turn_start(history: &[Entry], from: usize, current: usize) -> Option { - let end = current.min(history.len().checked_sub(1)?); - (from..=end).find(|&index| matches!(&history[index], Entry::User { .. })) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn detects_context_size_errors_only_for_http_400_or_413() { - assert!(is_too_large("HTTP 400: context_length_exceeded")); - assert!(is_too_large("HTTP 413: prompt is too long")); - assert!(is_too_large("HTTP 413: {\"type\":\"request_too_large\"}")); - assert!(is_too_large("HTTP 413: {}")); - assert!(is_too_large("HTTP 400: maximum context length")); - assert!(is_too_large("HTTP 400: request is too large")); - assert!(!is_too_large("HTTP 500: prompt is too long")); - assert!(!is_too_large("HTTP 400: invalid token")); - } - - #[test] - fn history_skip_advances_by_whole_user_turns() { - let history = vec![ - Entry::User { - text: "first".into(), - }, - Entry::ToolCall { - id: "a".into(), - name: "tool".into(), - arguments: "{}".into(), - }, - Entry::ToolResult { - id: "a".into(), - output: "done".into(), - }, - Entry::User { - text: "second".into(), - }, - Entry::ToolCall { - id: "b".into(), - name: "tool".into(), - arguments: "{}".into(), - }, - Entry::ToolResult { - id: "b".into(), - output: "done".into(), - }, - Entry::User { - text: "current".into(), - }, - ]; - assert_eq!(next_turn_start(&history, 0, 6), Some(0)); - assert_eq!(next_turn_start(&history, 1, 6), Some(3)); - assert_eq!(next_turn_start(&history, 4, 6), Some(6)); - assert_eq!(next_turn_start(&history, usize::MAX, 6).unwrap_or(6), 6); - } -} diff --git a/agent/src/llm.rs b/agent/src/llm.rs deleted file mode 100644 index 5d24396..0000000 --- a/agent/src/llm.rs +++ /dev/null @@ -1,657 +0,0 @@ -//! Inference through OpenAI Responses, Chat Completions, and Anthropic Messages. -//! -//! The conversation is stored in a neutral format, so the agent can switch -//! between the three shapes. - -use sausage_plugin::schema; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -#[cfg(target_os = "wasi")] -use wstd::http::{Body, Client, Request}; - -/// Inference configuration from `/config/agent.yaml`. -#[derive(Deserialize, Clone, Default)] -#[serde(default)] -pub struct Config { - /// `openai-responses`, `openai-chat-completions`, or `anthropic-messages`. - pub api: String, - /// The SDK base URL: versioned for OpenAI, unversioned for Anthropic. - pub base_url: String, - /// The provider's model identifier. - pub model: String, - /// The API credential sent in the provider's authentication header. - pub token: String, - /// Permit an empty token and omit authentication headers. - pub allow_no_token: bool, - /// An optional cap on generated tokens. - #[serde(default)] - pub max_tokens: Option, - /// The thinking level of each turn. Unset: the model's default. - #[serde(default)] - pub thinking: Option, -} - -/// How much the model thinks before it answers. -/// -/// OpenAI Responses gets `reasoning.effort`, Chat Completions gets `reasoning_effort`, and Anthropic -/// gets adaptive thinking with `output_config.effort`. If the model rejects a level, the agent uses -/// the nearest level that the model accepts, never `off`. -#[schema] -#[derive(Clone, Copy, PartialEq, Debug)] -#[serde(rename_all = "lowercase")] -pub enum Thinking { - /// No thinking: OpenAI gets the effort `none`, and Anthropic gets disabled thinking. If an - /// OpenAI model cannot switch thinking off, the agent uses `low`. - Off, - /// The least thinking. - Low, - /// Moderate thinking. - Medium, - /// Much thinking. - High, - /// More thinking than `high`, where the model supports it. - Xhigh, - /// The most thinking that the model supports. - Max, -} - -impl Thinking { - /// The effort that an OpenAI request carries: `none`, or one of `LEVELS`. - fn effort(self) -> &'static str { - match self { - Thinking::Off => "none", - Thinking::Low => "low", - Thinking::Medium => "medium", - Thinking::High => "high", - Thinking::Xhigh => "xhigh", - Thinking::Max => "max", - } - } -} - -impl Config { - pub fn load() -> Result { - let bytes = std::fs::read("/config/agent.yaml") - .map_err(|e| format!("/config/agent.yaml: missing configuration file: {e}"))?; - Self::parse(&bytes) - } - - fn parse(bytes: &[u8]) -> Result { - let cfg = serde_yaml_ng::from_slice::>(bytes) - .map_err(|e| format!("/config/agent.yaml: {e}"))? - .unwrap_or_default(); - let invalid = if ![ - "openai-responses", - "openai-chat-completions", - "anthropic-messages", - ] - .contains(&cfg.api.as_str()) - { - Some("api (missing or unknown API shape)") - } else if cfg.base_url.trim().is_empty() { - Some("base_url") - } else if cfg.model.trim().is_empty() { - Some("model") - } else if (cfg.token.trim().is_empty() && !cfg.allow_no_token) - || cfg.token == "CHANGE_ME" - || cfg.token.starts_with("YOUR_") - { - Some("token (set a key, or set token: \"\" and allow_no_token: true)") - } else { - None - }; - if let Some(field) = invalid { - return Err(format!("/config/agent.yaml: configure field {field}")); - } - Ok(cfg) - } - - fn bearer(&self) -> Vec<(&'static str, String)> { - if self.token.is_empty() { - vec![] - } else { - vec![("authorization", format!("Bearer {}", self.token))] - } - } -} - -/// Thinking levels, lowest first. -pub const LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"]; - -/// The level nearest to `want` (lower on a tie) among those a 400 error lists in quotes (else all levels), not -/// yet `tried`. None: nothing left, so the request goes out without an effort field (model default). -fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option { - let listed: Vec<&str> = error - .split('\'') - .skip(1) - .step_by(2) - .filter(|w| LEVELS.contains(w)) - .collect(); - let pool = if listed.iter().any(|l| *l != want) { - listed - } else { - LEVELS.to_vec() - }; - // `none` is below all levels, so its nearest level is `low`. - let at = LEVELS - .iter() - .position(|l| *l == want) - .map_or(-1, |i| i as i32); - pool.into_iter() - .filter(|l| *l != want && !tried.iter().any(|t| t == l)) - .min_by_key(|l| { - let i = LEVELS.iter().position(|x| x == l).unwrap_or(1) as i32; - ((i - at).abs(), i > at) - }) - .map(str::to_string) -} - -#[cfg(test)] -mod tests { - use super::fallback_effort; - - #[test] - fn yaml_configuration_and_credentials() { - use super::Config; - for text in [ - "", - "api: unknown", - "api: openai-responses\nbase_url: url\nmodel: model\ntoken: CHANGE_ME", - "api: openai-responses\nbase_url: url\nmodel: model\ntoken: ''", - ] { - assert!( - Config::parse(text.as_bytes()) - .err() - .unwrap() - .contains("/config/agent.yaml") - ); - } - let cfg = Config::parse(b"api: openai-chat-completions\nbase_url: http://localhost/v1\nmodel: local\ntoken: ''\nallow_no_token: true").unwrap(); - assert!(cfg.bearer().is_empty()); - assert_eq!(cfg.thinking, None); - } - - #[test] - fn thinking_levels() { - use super::{Config, LEVELS, Thinking}; - let base = "api: openai-responses\nbase_url: url\nmodel: model\ntoken: key\nthinking: "; - let parse = |level: &str| Config::parse(format!("{base}{level}").as_bytes()); - assert_eq!(parse("off").unwrap().thinking, Some(Thinking::Off)); - assert_eq!(Thinking::Off.effort(), "none"); - for level in LEVELS { - assert_eq!(parse(level).unwrap().thinking.unwrap().effort(), level); - } - let error = parse("hihg").err().unwrap(); - assert!(error.contains("/config/agent.yaml"), "{error}"); - assert!(error.contains("thinking"), "{error}"); - } - - #[test] - fn chat_request_and_response_mapping() { - use super::*; - let cfg = Config { - model: "test".into(), - max_tokens: Some(100), - ..Config::default() - }; - let response = json!({"choices":[{"message":{"content":"interim","tool_calls":[{"id":"one","function":{"name":"read_file","arguments":"{}"}},{"id":"two","function":{"name":"read_file","arguments":"{}"}}]}}]}); - let mut history = chat_entries(&response); - assert_eq!(history.len(), 3); - history.push(Entry::ToolResult { - id: "one".into(), - output: "error".into(), - }); - let tools = [Tool { - name: "read_file", - description: "read", - parameters: json!({"type":"object"}), - }]; - let body = chat_body(&cfg, "system", &tools, &history, Some("high")); - assert_eq!(body["messages"][1]["content"], "interim"); - assert_eq!( - body["messages"][1]["tool_calls"].as_array().unwrap().len(), - 2 - ); - assert_eq!( - body["messages"][2], - json!({"role":"tool","tool_call_id":"one","content":"error"}) - ); - assert_eq!(body["tools"][0]["function"]["name"], "read_file"); - assert_eq!(body["reasoning_effort"], "high"); - assert_eq!(body["max_completion_tokens"], 100); - assert!( - matches!(&chat_entries(&json!({"choices":[{"message":{"content":"final"}}]}))[0], Entry::Assistant {text,..} if text == "final") - ); - } - - #[test] - fn nearest_accepted_level() { - let anthropic = - "HTTP 400: output_config.effort: Input should be 'low', 'medium', 'high' or 'max'"; - assert_eq!( - fallback_effort("xhigh", anthropic, &["xhigh".into()]).as_deref(), - Some("high") - ); - let openai = "HTTP 400: Invalid value: 'max'. Supported values are: 'none', 'minimal', 'low', 'medium', and 'high'."; - assert_eq!( - fallback_effort("max", openai, &["max".into()]).as_deref(), - Some("high") - ); - // A thinking-only model rejects `none`: use the lowest level. - let glm = "HTTP 400: GLM-5.3 is a thinking-only model; disabling thinking (reasoning_effort='none') is not supported."; - assert_eq!( - fallback_effort("none", glm, &["none".into()]).as_deref(), - Some("low") - ); - // No list in the error: step to the neighbour, never to off. - assert_eq!( - fallback_effort("max", "HTTP 400: effort max not supported", &["max".into()]) - .as_deref(), - Some("xhigh") - ); - } -} - -/// One append-only entry in the provider-neutral conversation. -#[derive(Serialize, Deserialize, Clone, Debug)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum Entry { - /// A person's contribution starts a new turn. - User { - /// The complete contribution. - text: String, - }, - /// Text produced by the model. - Assistant { - /// The generated text. - text: String, - /// The optional provider phase, preserved for subsequent requests. - #[serde(default, skip_serializing_if = "Option::is_none")] - phase: Option, - }, - /// A requested tool operation. - ToolCall { - /// The provider's correlation identifier. - id: String, - /// The tool name. - name: String, - /// The JSON parameter object as text. - arguments: String, - }, - /// The outcome of a tool operation, including tool errors. - ToolResult { - /// The correlation identifier of the tool call. - id: String, - /// The JSON outcome as text. - output: String, - }, - /// A provider item that only this API shape understands (OpenAI reasoning, Anthropic thinking). - Opaque { - /// The API shape that owns this entry. - api: String, - /// The model that produced this item. Legacy items without a model are not replayed. - #[serde(default)] - model: String, - /// The original provider object. - item: Value, - }, -} - -/// A tool contract shared by all API shapes. -pub struct Tool { - /// The function identifier exposed to the model. - pub name: &'static str, - /// The explanation of the tool's behavior. - pub description: &'static str, - /// The JSON Schema for named parameters. - pub parameters: Value, -} - -/// Request the next model step, with provider-specific reasoning items intact. -pub async fn complete( - cfg: &Config, - system: &str, - tools: &[Tool], - history: &[Entry], -) -> Result, String> { - let want = cfg.thinking.map(|level| level.effort().to_string()); - let mut effort = want.clone(); - let mut tried = Vec::new(); - loop { - let result = match cfg.api.as_str() { - "openai-responses" => openai(cfg, system, tools, history, effort.as_deref()).await, - "openai-chat-completions" => chat(cfg, system, tools, history, effort.as_deref()).await, - "anthropic-messages" => anthropic(cfg, system, tools, history, effort.as_deref()).await, - other => return Err(format!("unknown API shape {other:?}")), - }; - match (result, effort.clone()) { - // The model rejected the effort level: retry with the nearest accepted one (thinking stays on). - (Err(e), Some(current)) if e.starts_with("HTTP 400") && e.contains("effort") => { - tried.push(current.clone()); - effort = fallback_effort(want.as_deref().unwrap_or(""), &e, &tried); - eprintln!( - "agent: {} rejected thinking effort {current:?}; retrying with {effort:?}: {e}", - cfg.model - ); - } - (result, _) => return result, - } - } -} - -/// Native unit tests exercise history logic without an HTTP runtime. -#[cfg(not(target_os = "wasi"))] -async fn post(_url: &str, _headers: &[(&str, String)], _body: &Value) -> Result { - Err("inference requires the WASI HTTP runtime".into()) -} - -/// Send JSON through wasi:http and retain error bodies for context-size detection. -#[cfg(target_os = "wasi")] -async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result { - let mut req = Request::post(url).header("content-type", "application/json"); - for (k, v) in headers { - req = req.header(*k, v.as_str()); - } - let req = req - .body(Body::from(body.to_string())) - .map_err(|e| format!("request: {e}"))?; - let client = Client::new(); - let resp = client - .send(req) - .await - .map_err(|e| format!("HTTP request failed: {e:?}"))?; - let status = resp.status(); - let mut body = resp.into_body(); - let text = body - .str_contents() - .await - .map_err(|e| format!("reading the response failed: {e:?}"))? - .to_string(); - if !status.is_success() { - return Err(format!("HTTP {status}: {text}")); - } - serde_json::from_str(&text).map_err(|e| format!("invalid JSON in response: {e}")) -} - -/// Translate neutral history to OpenAI Responses and preserve returned reasoning items. -async fn openai( - cfg: &Config, - system: &str, - tools: &[Tool], - history: &[Entry], - effort: Option<&str>, -) -> Result, String> { - let mut input = Vec::new(); - for e in history { - match e { - Entry::User { text } => input.push(json!({"role": "user", "content": text})), - Entry::Assistant { text, phase } => { - let mut item = json!({"role": "assistant", "content": text}); - if let Some(phase) = phase { - item["phase"] = json!(phase); - } - input.push(item); - } - Entry::ToolCall { id, name, arguments } => { - input.push(json!({"type": "function_call", "call_id": id, "name": name, "arguments": arguments})) - } - Entry::ToolResult { id, output } => { - input.push(json!({"type": "function_call_output", "call_id": id, "output": output})) - } - Entry::Opaque { api, model, item } if api == "openai-responses" && model == &cfg.model => input.push(item.clone()), - Entry::Opaque { .. } => {} - } - } - let tools: Vec = tools - .iter() - .map(|t| json!({"type": "function", "name": t.name, "description": t.description, "parameters": t.parameters})) - .collect(); - let mut body = json!({ - "model": cfg.model, - "instructions": system, - "input": input, - "tools": tools, - "store": false, - "include": ["reasoning.encrypted_content"], - }); - if let Some(max_tokens) = cfg.max_tokens { - body["max_output_tokens"] = json!(max_tokens); - } - if let Some(effort) = effort { - body["reasoning"] = json!({"effort": effort}); - } - let url = format!("{}/responses", cfg.base_url.trim_end_matches('/')); - let resp = post(&url, &cfg.bearer(), &body).await?; - let mut out = Vec::new(); - for item in resp["output"].as_array().cloned().unwrap_or_default() { - match item["type"].as_str() { - Some("message") => { - let text: String = item["content"] - .as_array() - .map(|parts| { - parts - .iter() - .filter(|p| p["type"] == "output_text") - .filter_map(|p| p["text"].as_str()) - .collect() - }) - .unwrap_or_default(); - let phase = item["phase"].as_str().map(str::to_string); - if !text.is_empty() || phase.is_some() { - out.push(Entry::Assistant { text, phase }); - } - } - Some("function_call") => out.push(Entry::ToolCall { - id: item["call_id"].as_str().unwrap_or_default().to_string(), - name: item["name"].as_str().unwrap_or_default().to_string(), - arguments: item["arguments"].as_str().unwrap_or("{}").to_string(), - }), - Some("reasoning") => out.push(Entry::Opaque { - api: "openai-responses".into(), - model: cfg.model.clone(), - item, - }), - _ => {} - } - } - if resp["status"] == "incomplete" - && !out - .iter() - .any(|entry| matches!(entry, Entry::Assistant { text, .. } if !text.is_empty())) - { - return Err(format!( - "incomplete response: {}", - resp["incomplete_details"] - )); - } - Ok(out) -} - -/// Translate neutral history to Anthropic Messages and merge consecutive roles. -async fn anthropic( - cfg: &Config, - system: &str, - tools: &[Tool], - history: &[Entry], - effort: Option<&str>, -) -> Result, String> { - let disabled = cfg.thinking == Some(Thinking::Off); - // Merge consecutive blocks of the same role into one message. - let mut messages: Vec<(String, Vec)> = Vec::new(); - let mut push = |role: &str, block: Value| match messages.last_mut() { - Some((r, blocks)) if r == role => blocks.push(block), - _ => messages.push((role.to_string(), vec![block])), - }; - for e in history { - match e { - Entry::User { text } => push("user", json!({"type": "text", "text": text})), - Entry::Assistant { text, .. } => { - push("assistant", json!({"type": "text", "text": text})) - } - Entry::ToolCall { - id, - name, - arguments, - } => { - let input: Value = serde_json::from_str(arguments).unwrap_or(json!({})); - push( - "assistant", - json!({"type": "tool_use", "id": id, "name": name, "input": input}), - ) - } - Entry::ToolResult { id, output } => push( - "user", - json!({"type": "tool_result", "tool_use_id": id, "content": output}), - ), - // Thinking blocks go back unchanged (with their signature): required next to tool use. - Entry::Opaque { api, model, item } - if api == "anthropic-messages" && model == &cfg.model && !disabled => - { - push("assistant", item.clone()) - } - Entry::Opaque { .. } => {} - } - } - let messages: Vec = messages - .into_iter() - .map(|(role, content)| json!({"role": role, "content": content})) - .collect(); - let tools: Vec = tools - .iter() - .map( - |t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}), - ) - .collect(); - let mut body = json!({ - "model": cfg.model, - "system": system, - "messages": messages, - "tools": tools, - "max_tokens": cfg.max_tokens.unwrap_or(32000), - }); - // Unset: the model's default. Newer models think by default. - match cfg.thinking { - None => {} - Some(Thinking::Off) => body["thinking"] = json!({"type": "disabled"}), - Some(_) => { - body["thinking"] = json!({"type": "adaptive"}); - if let Some(effort) = effort { - body["output_config"] = json!({"effort": effort}); - } - } - } - let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/')); - let mut headers = vec![("anthropic-version", "2023-06-01".to_string())]; - if !cfg.token.is_empty() { - headers.push(("x-api-key", cfg.token.clone())); - } - let resp = post(&url, &headers, &body).await?; - let mut out = Vec::new(); - for block in resp["content"].as_array().cloned().unwrap_or_default() { - match block["type"].as_str() { - Some("text") => { - let text = block["text"].as_str().unwrap_or_default().to_string(); - if !text.is_empty() { - out.push(Entry::Assistant { text, phase: None }); - } - } - Some("tool_use") => out.push(Entry::ToolCall { - id: block["id"].as_str().unwrap_or_default().to_string(), - name: block["name"].as_str().unwrap_or_default().to_string(), - arguments: block["input"].to_string(), - }), - Some("thinking") | Some("redacted_thinking") => out.push(Entry::Opaque { - api: "anthropic-messages".into(), - model: cfg.model.clone(), - item: block, - }), - _ => {} - } - } - Ok(out) -} - -/// Translate neutral entries into the Chat Completions message format. -fn chat_body( - cfg: &Config, - system: &str, - tools: &[Tool], - history: &[Entry], - effort: Option<&str>, -) -> Value { - let mut messages = vec![json!({"role":"system", "content":system})]; - for entry in history { - match entry { - Entry::User { text } => messages.push(json!({"role":"user","content":text})), - Entry::Assistant { text, .. } => { - messages.push(json!({"role":"assistant","content":text})) - } - Entry::ToolCall { - id, - name, - arguments, - } => { - if messages.last().unwrap()["role"] != "assistant" { - messages.push(json!({"role":"assistant","content":null})); - } - let last = messages.last_mut().unwrap(); - if !last["tool_calls"].is_array() { - last["tool_calls"] = json!([]); - } - last["tool_calls"].as_array_mut().unwrap().push(json!({"id":id,"type":"function","function":{"name":name,"arguments":arguments}})); - } - Entry::ToolResult { id, output } => { - messages.push(json!({"role":"tool","tool_call_id":id,"content":output})) - } - Entry::Opaque { .. } => {} - } - } - let tools: Vec = tools.iter().map(|t| json!({"type":"function","function":{"name":t.name,"description":t.description,"parameters":t.parameters}})).collect(); - let mut body = json!({"model":cfg.model,"messages":messages,"tools":tools}); - if let Some(max) = cfg.max_tokens { - body["max_completion_tokens"] = json!(max); - } - if let Some(effort) = effort { - body["reasoning_effort"] = json!(effort); - } - body -} - -/// Map text and tool calls back into the shared history. -fn chat_entries(response: &Value) -> Vec { - let message = &response["choices"][0]["message"]; - let mut entries = Vec::new(); - if let Some(text) = message["content"].as_str().filter(|s| !s.is_empty()) { - entries.push(Entry::Assistant { - text: text.into(), - phase: None, - }); - } - for call in message["tool_calls"].as_array().into_iter().flatten() { - entries.push(Entry::ToolCall { - id: call["id"].as_str().unwrap_or_default().into(), - name: call["function"]["name"].as_str().unwrap_or_default().into(), - arguments: call["function"]["arguments"] - .as_str() - .unwrap_or("{}") - .into(), - }); - } - entries -} - -async fn chat( - cfg: &Config, - system: &str, - tools: &[Tool], - history: &[Entry], - effort: Option<&str>, -) -> Result, String> { - let response = post( - &format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')), - &cfg.bearer(), - &chat_body(cfg, system, tools, history, effort), - ) - .await?; - Ok(chat_entries(&response)) -} diff --git a/agent/src/main.rs b/agent/src/main.rs deleted file mode 100644 index 6f0df2e..0000000 --- a/agent/src/main.rs +++ /dev/null @@ -1,211 +0,0 @@ -//! Answer chat calls with an LLM and tools, under one lock for the complete turn. -mod history; -mod llm; -mod tools; - -use history::{is_too_large, next_turn_start}; -use llm::{Config, Entry, Thinking}; -use sausage_plugin::{Ctx, Error, Result, lock, rpc, schema}; -use serde_json::json; -use std::io::Write; - -/// The instructions sent with every inference request. -const SYSTEM: &str = include_str!("prompt.md"); -/// The shared, append-only conversation. -const HISTORY: &str = "/state/agent/history.jsonl"; -/// The first retained turn after a context-size rejection. -const SKIP: &str = "/state/agent/history.skip"; -/// Each chat process reads the shared history after it acquires the lock. -struct Agent; -/// The person's next contribution to the conversation. -#[schema] -struct ChatParams { - /// The complete text of the person's request. - text: String, - /// The thinking level of this turn only. Without it, the turn uses `thinking` from - /// `/config/agent.yaml`. - #[serde(default, skip_serializing_if = "Option::is_none")] - thinking: Option, -} - -#[rpc(grace_seconds = 600)] -impl Agent { - /// Answer a person, with interim text notifications and a final text result. - /// - /// The history lock covers inference and tools, so concurrent chats cannot interleave turns. - async fn chat(&self, ctx: &Ctx, p: ChatParams) -> Result { - let _guard = lock(HISTORY).await?; - std::fs::create_dir_all("/state/agent")?; - let mut cfg = Config::load().map_err(|error| Error::new(-32603, error))?; - cfg.thinking = p.thinking.or(cfg.thinking); - let mut entries = load_history(HISTORY)?; - repair_tools(&mut entries)?; - let current = entries.len(); - append(&mut entries, Entry::User { text: p.text })?; - let mut start = std::fs::read_to_string(SKIP) - .ok() - .and_then(|s| s.trim().parse().ok()) - .unwrap_or(0); - start = next_turn_start(&entries, start, current).unwrap_or(current); - let definitions = tools::definitions(); - loop { - let response = match llm::complete(&cfg, SYSTEM, &definitions, &entries[start..]).await - { - Ok(response) => response, - Err(error) => { - if is_too_large(&error) - && let Some(next) = next_turn_start(&entries, start + 1, current) - { - start = next; - continue; - } - return Err(Error::new(-32603, error)); - } - }; - std::fs::write(SKIP, start.to_string())?; - let has_tools = response - .iter() - .any(|entry| matches!(entry, Entry::ToolCall { .. })); - let mut final_text = String::new(); - let mut calls = Vec::new(); - for entry in response { - match &entry { - Entry::Assistant { text, phase } - if has_tools || phase.as_deref() == Some("commentary") => - { - ctx.notify("text", json!({"text":text}))? - } - Entry::Assistant { text, .. } => { - if !final_text.is_empty() && !text.is_empty() { - final_text.push_str("\n\n"); - } - final_text.push_str(text); - } - Entry::ToolCall { - id, - name, - arguments, - } => calls.push((id.clone(), name.clone(), arguments.clone())), - _ => {} - } - append(&mut entries, entry)?; - } - if !has_tools { - return Ok(final_text); - } - for (id, name, arguments) in calls { - let args = serde_json::from_str::(&arguments); - let output = match &args { - Ok(args) => tools::run(&name, args) - .await - .unwrap_or_else(|error| json!({"error":error})), - Err(error) => json!({"error":format!("arguments are not valid JSON: {error}")}), - }; - let output = if name == "read_file" - && args - .as_ref() - .ok() - .and_then(|a| a["path"].as_str()) - .is_some_and(|p| p.starts_with("/logs/")) - { - output["content"] - .as_str() - .map(str::to_owned) - .unwrap_or_else(|| output.to_string()) - } else { - output.to_string() - }; - let tail = args - .as_ref() - .ok() - .and_then(|a| a["method"].as_str()) - .is_some_and(|m| { - [".exec", ".log", ".build", ".test"] - .iter() - .any(|suffix| m.ends_with(suffix)) - }); - let output = tools::overflow::guard(output, "default", &id, tail); - append(&mut entries, Entry::ToolResult { id, output })?; - } - } - } -} - -/// Read persisted entries in their original order. A new world has no history file. -fn load_history(path: &str) -> Result> { - match std::fs::read_to_string(path) { - Ok(text) => Ok(text - .lines() - .filter_map(|line| match serde_json::from_str(line) { - Ok(entry) => Some(entry), - Err(error) => { - eprintln!("skip damaged history line: {error}"); - None - } - }) - .collect::>()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), - Err(error) => Err(error.into()), - } -} -/// Persist an entry before it enters the current inference context. -fn append(history: &mut Vec, entry: Entry) -> Result<()> { - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(HISTORY)?; - file.write_all(format!("{}\n", serde_json::to_string(&entry)?).as_bytes())?; - history.push(entry); - Ok(()) -} -/// Complete interrupted tool pairs before a new inference request. -fn repair_tools(history: &mut Vec) -> Result<()> { - let answered: std::collections::HashSet<_> = history - .iter() - .filter_map(|entry| match entry { - Entry::ToolResult { id, .. } => Some(id.clone()), - _ => None, - }) - .collect(); - let dangling: Vec<_> = history - .iter() - .filter_map(|entry| match entry { - Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()), - _ => None, - }) - .collect(); - for id in dangling { - append( - history, - Entry::ToolResult { - id, - output: json!({"error":"interrupted before the tool finished"}).to_string(), - }, - )?; - } - Ok(()) -} -/// Start the plugin's JSON-RPC transport. -fn main() { - sausage_plugin::run(Agent); -} - -#[cfg(test)] -mod tests { - #[test] - fn damaged_history_keeps_valid_entries() { - let path = if cfg!(target_os = "wasi") { - std::path::PathBuf::from("/history-test.jsonl") - } else { - std::env::temp_dir().join(format!("sausage-history-{}", std::process::id())) - }; - std::fs::write( - &path, - "{\"kind\":\"user\",\"text\":\"preserved\"}\n{\"kind\":", - ) - .unwrap(); - let history = super::load_history(path.to_str().unwrap()).unwrap(); - assert_eq!(history.len(), 1); - std::fs::remove_file(path).unwrap(); - } -} diff --git a/agent/src/overflow.rs b/agent/src/overflow.rs deleted file mode 100644 index 9e29c97..0000000 --- a/agent/src/overflow.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! Context-size guard; full output is in the referenced file. Never a data limit. -use std::io::{self, Write}; -use std::sync::atomic::{AtomicU64, Ordering}; - -// One policy point: Pi's 50 KiB / 2000 lines, including room for the reference. -const CONTEXT_LIMIT: (usize, usize) = (50 * 1024, 2000); -static NEXT: AtomicU64 = AtomicU64::new(0); - -fn component(s: &str) -> String { - s.bytes() - .take(80) - .map(|b| { - if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' { - b as char - } else { - '_' - } - }) - .collect() -} - -pub fn exceeds_limit(output: &str) -> bool { - output.len() > CONTEXT_LIMIT.0 || output.lines().count() > CONTEXT_LIMIT.1 -} - -/// Save before excerpting. On storage failure return all data, not a false reference. -pub fn guard(output: String, session: &str, id: &str, tail: bool) -> String { - if !exceeds_limit(&output) { - return output; - } - // Decode only results that crossed the context guard. Archive physical - // string lines, not JSON escapes, even if decoding makes the text smaller. - let output = serde_json::from_str::(&output) - .map(|value| super::render(&value)) - .unwrap_or(output); - let pid = std::env::var("SAUSAGE_PID").unwrap_or_else(|_| "test".into()); - let dir = format!("/logs/agent/{}/{}", date(), component(session)); - let saved = (|| -> io::Result { - std::fs::create_dir_all(&dir)?; - // create_new prevents overwriting even if an ID is reused or a process restarts. - loop { - let path = format!( - "{dir}/{}-{}-{}.txt", - component(id), - component(&pid), - NEXT.fetch_add(1, Ordering::Relaxed) - ); - match std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - { - Ok(mut f) => { - f.write_all(output.as_bytes())?; - return Ok(path); - } - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(e), - } - } - })(); - match saved { - Ok(path) => excerpt(&output, &path, tail, 0, output.len()), - Err(e) => { - eprintln!("overflow archive failed: {e}; retaining complete tool result"); - output - } - } -} - -fn date() -> String { - let days = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - / 86400; - let z = days as i64 + 719468; - let era = z / 146097; - let doe = z - era * 146097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = mp + if mp < 10 { 3 } else { -9 }; - format!("{:04}-{m:02}-{d:02}", y + i64::from(m <= 2)) -} - -/// Excerpt an existing archive in place. Byte/line ranges refer to that file, -/// including when a caller requests an oversized line or the entire archive. -pub fn existing(bytes: &[u8], path: &str, args: &serde_json::Value) -> String { - let text = String::from_utf8_lossy(bytes); - let (mut from, mut to) = if let Some(line) = args["line_offset"].as_u64() { - let from = text - .split_inclusive('\n') - .take(line as usize) - .map(str::len) - .sum::(); - let len = text[from..] - .split_inclusive('\n') - .take(args["line_limit"].as_u64().unwrap_or(200) as usize) - .map(str::len) - .sum::(); - (from, from + len) - } else { - let from = (args["offset"].as_u64().unwrap_or(0) as usize).min(text.len()); - ( - from, - args["max_bytes"].as_u64().map_or(text.len(), |n| { - from.saturating_add(n as usize).min(text.len()) - }), - ) - }; - while !text.is_char_boundary(from) { - from += 1; - } - while to > from && !text.is_char_boundary(to) { - to -= 1; - } - to = to.max(from); - excerpt(&text, path, false, from, to) -} - -fn excerpt(output: &str, path: &str, tail: bool, from: usize, to: usize) -> String { - let selected = &output[from..to]; - let lines: Vec<&str> = selected.split_inclusive('\n').collect(); - let budget = CONTEXT_LIMIT.0.saturating_sub(1024); - let mut start = 0; - let mut end = lines.len(); - let mut bytes = 0; - if tail { - start = end; - while start > 0 - && end - start < CONTEXT_LIMIT.1.saturating_sub(2) - && bytes + lines[start - 1].len() <= budget - { - start -= 1; - bytes += lines[start].len(); - } - } else { - end = 0; - while end < lines.len() - && end < CONTEXT_LIMIT.1.saturating_sub(2) - && bytes + lines[end].len() <= budget - { - bytes += lines[end].len(); - end += 1; - } - } - let mut shown = lines[start..end].concat(); - // A single huge line: preserve valid UTF-8 and explicitly label a partial line. - let partial = start == end; - if partial && !lines.is_empty() { - if tail { - start = lines.len() - 1; - let line = lines[start]; - let mut from = line.len().saturating_sub(budget); - while !line.is_char_boundary(from) { - from += 1; - } - shown.push_str(&line[from..]); - } else { - let mut to = budget.min(lines[0].len()); - while !lines[0].is_char_boundary(to) { - to -= 1; - } - shown.push_str(&lines[0][..to]); - } - } - let mut byte_start = from + lines[..start].iter().map(|s| s.len()).sum::(); - if partial && tail { - byte_start = to - shown.len(); - } - let byte_end = byte_start + shown.len(); - let line_start = output[..byte_start].bytes().filter(|b| *b == b'\n').count(); - let line_end = line_start + shown.split_inclusive('\n').count(); - let total_lines = output.split_inclusive('\n').count(); - let partial = partial - || (from > 0 && !output[..from].ends_with('\n')) - || (to < output.len() && !output[..to].ends_with('\n')); - let read = if partial { - format!( - "Shown byte range [{byte_start},{byte_end}); continue with read_file path={path}, offset={byte_end}, max_bytes={budget} (or choose an earlier byte offset)" - ) - } else { - format!( - "Read with read_file path={path}, line_offset={line_start}, line_limit={}; next line_offset={line_end}", - line_end - line_start - ) - }; - format!( - "{shown}\n[Showing lines {}-{} of {}{}; total {} bytes. Full output: {path}. {read}]", - line_start + 1, - line_end, - total_lines, - if partial { " (partial line)" } else { "" }, - output.len() - ) -} - -/// Zero-based line offsets; byte-based reads remain unchanged when absent. -pub fn read_lines(bytes: &[u8], offset: usize, limit: usize) -> String { - String::from_utf8_lossy(bytes) - .split_inclusive('\n') - .skip(offset) - .take(limit) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn full_archive_and_context_guard() { - let original = "😀 line\n".repeat(5400); - for tail in [false, true] { - let shown = guard(original.clone(), "guard-test", "call", tail); - assert!(shown.len() <= CONTEXT_LIMIT.0); - assert!(shown.lines().count() <= CONTEXT_LIMIT.1); - assert!(shown.contains(if tail { - "Showing lines 3403-5400" - } else { - "Showing lines 1-1998" - })); - let path = shown - .split("Full output: ") - .nth(1) - .unwrap() - .split(". Read with") - .next() - .unwrap(); - assert_eq!(std::fs::read_to_string(path).unwrap(), original); - } - assert_eq!(guard("small".into(), "test", "id", false), "small"); - } - #[test] - fn archive_failure_does_not_discard_data() { - std::fs::create_dir_all(format!("/logs/agent/{}", date())).unwrap(); - std::fs::write(format!("/logs/agent/{}/not-a-directory", date()), "block").unwrap(); - let output = "no loss\n".repeat(6000); - assert_eq!( - guard(output.clone(), "not-a-directory", "test", false), - output - ); - } - #[test] - fn huge_line_and_exact_boundaries() { - for tail in [false, true] { - let result = guard("😀".repeat(40000), "test", "utf8", tail); - assert!(result.len() <= CONTEXT_LIMIT.0); - assert!(result.contains("partial line")); - } - let exact = "a\n".repeat(CONTEXT_LIMIT.1); - assert_eq!(guard(exact.clone(), "test", "exact", false), exact); - assert_eq!(read_lines(b"one\ntwo\nthree", 1, 1), "two\n"); - assert_eq!(read_lines(b"one", 3, 1), ""); - } -} diff --git a/agent/src/prompt.md b/agent/src/prompt.md deleted file mode 100644 index 91dba5e..0000000 --- a/agent/src/prompt.md +++ /dev/null @@ -1,10 +0,0 @@ -You are the Sausage agent. Answer the person. Use tools to inspect or change the world. - -Read the contract at `/proc/core/PROTOCOL.md`. The Rust plugin SDK and its documentation are at `/plugins/cargo/sdk/`. -Build a plugin with `cargo.build`. Activate its WASM file with `core.activate`. -Your own source is at `/plugins/agent/source/`. You can change and rebuild it like any other plugin. -HARD RULE: the mediator and all agents NEVER call `core.restart` (or its alias `core.exit`) without the person's explicit approval each time. -Restart requires the host to run under `#loop` (`SAUSAGE_LOOP=1`); it answers before clean shutdown with exit code 75. -Never call `agent.chat`. It would wait on the history lock that your current turn holds. -Write logs to stderr. Use call results and notifications for communication. -The interface check does not cover persistent state. Preserve state compatibility when you change a plugin. diff --git a/agent/src/tools.rs b/agent/src/tools.rs deleted file mode 100644 index 3190202..0000000 --- a/agent/src/tools.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! Tool descriptions and file operations for the agent. - -use crate::llm::Tool; -use serde_json::{Value, json}; -use std::io; - -#[path = "overflow.rs"] -pub mod overflow; - -/// Human-readable, lossless text representation of tool JSON. String values -/// retain real newlines, with JSON-quoted field/index labels for structure. -fn render(value: &Value) -> String { - fn visit(value: &Value, label: &str, out: &mut String) { - match value { - Value::Object(fields) => { - if fields.is_empty() { - out.push_str(&format!("{label}: {{}}\n")); - } - for (key, value) in fields { - visit( - value, - &format!("{label}[{}]", serde_json::to_string(key).unwrap()), - out, - ); - } - } - Value::Array(items) => { - if items.is_empty() { - out.push_str(&format!("{label}: []\n")); - } - for (i, value) in items.iter().enumerate() { - visit(value, &format!("{label}[{i}]"), out); - } - } - Value::String(text) => { - out.push_str(&format!("{label} (string, {} bytes):\n", text.len())); - out.push_str(text); - out.push('\n'); - } - _ => { - out.push_str(&format!("{label}: {value}\n")); - } - } - } - let mut out = String::new(); - visit(value, "$", &mut out); - out -} - -/// Describe the five tools sent to either inference API. -pub fn definitions() -> Vec { - vec![ - Tool { - name: "call", - description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller. Large results show an excerpt with a full-output /logs reference.", - parameters: schema( - json!({ - "method": {"type": "string", "description": "Qualified plugin method"}, - "params": {"type": "object", "description": "Named method parameters"}, - "detach": {"type": "boolean", "description": "Detach the new process. Default false."} - }), - &["method", "params"], - ), - }, - Tool { - name: "read_file", - description: "Read a text file in the world, including `/proc`. `offset`/`max_bytes` select bytes; `line_offset`/`line_limit` select zero-based lines (default 200). Large results show an excerpt with a full-output /logs reference.", - parameters: schema( - json!({ - "path": {"type": "string"}, - "offset": {"type": "integer", "description": "Byte offset (default 0)"}, - "max_bytes": {"type": "integer", "description": "Optional maximum number of bytes to read"}, - "line_offset": {"type": "integer", "description": "Zero-based starting line; overrides bytes"}, - "line_limit": {"type": "integer", "description": "Number of lines; default 200"} - }), - &["path"], - ), - }, - Tool { - name: "write_file", - description: "Write a text file in the world. Parent folders are created. Large results show an excerpt with a full-output /logs reference.", - parameters: schema( - json!({"path": {"type": "string"}, "content": {"type": "string"}}), - &["path", "content"], - ), - }, - Tool { - name: "list_dir", - description: "List entries in a world directory. Returns each name, type, and size. Large results show an excerpt with a full-output /logs reference.", - parameters: schema(json!({"path": {"type": "string"}}), &["path"]), - }, - Tool { - name: "copy_dir", - description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders. Large results show an excerpt with a full-output /logs reference.", - parameters: schema( - json!({"from": {"type": "string"}, "to": {"type": "string"}}), - &["from", "to"], - ), - }, - ] -} - -/// Create the named-parameter object required by a tool. -fn schema(properties: Value, required: &[&str]) -> Value { - json!({"type": "object", "properties": properties, "required": required}) -} - -/// Execute a tool. The chat loop returns errors to the model as tool results. -pub async fn run(name: &str, args: &Value) -> sausage_plugin::Result { - if name == "call" { - let method = args["method"] - .as_str() - .ok_or_else(|| sausage_plugin::Error::new(-32602, "missing method"))?; - let call = sausage_plugin::call(method, args.get("params").cloned().unwrap_or(json!({}))); - return if args["detach"] == true { - call.detach().await - } else { - call.await - }; - } - Ok(match name { - "read_file" => read_file(args), - "write_file" => write_file(args), - "list_dir" => list_dir(args), - "copy_dir" => copy_dir(args), - other => Ok(json!({"error": format!("unknown tool {other}")})), - }?) -} - -/// Read an optional byte range and report the complete file size. -fn read_file(args: &Value) -> io::Result { - let path = args["path"].as_str().unwrap_or_default(); - let bytes = std::fs::read(path)?; - if path.starts_with("/logs/") { - return Ok( - json!({"path":path,"size":bytes.len(),"content":overflow::existing(&bytes, path, args)}), - ); - } - if let Some(line) = args["line_offset"].as_u64() { - return Ok( - json!({"path":path,"size":bytes.len(),"line_offset":line,"content":overflow::read_lines(&bytes, line as usize, args["line_limit"].as_u64().unwrap_or(200) as usize)}), - ); - } - let offset = (args["offset"].as_u64().unwrap_or(0) as usize).min(bytes.len()); - let end = args["max_bytes"].as_u64().map_or(bytes.len(), |max| { - offset.saturating_add(max as usize).min(bytes.len()) - }); - Ok(json!({ - "path": path, - "size": bytes.len(), - "offset": offset, - "content": String::from_utf8_lossy(&bytes[offset..end]), - "truncated": end < bytes.len(), - })) -} - -/// Create parent directories and replace a text file. -fn write_file(args: &Value) -> io::Result { - let path = args["path"].as_str().unwrap_or_default(); - if let Some(parent) = std::path::Path::new(path).parent() { - let _ = std::fs::create_dir_all(parent); - } - std::fs::write(path, args["content"].as_str().unwrap_or_default())?; - Ok(json!({"written": path})) -} - -/// Return directory metadata in name order. -fn list_dir(args: &Value) -> io::Result { - let mut entries: Vec = std::fs::read_dir(args["path"].as_str().unwrap_or_default())? - .filter_map(|entry| entry.ok()) - .map(|entry| { - let metadata = entry.metadata().ok(); - json!({ - "name": entry.file_name().to_string_lossy(), - "type": if metadata.as_ref().is_some_and(|meta| meta.is_dir()) { "dir" } else { "file" }, - "size": metadata.map(|meta| meta.len()), - }) - }) - .collect(); - entries.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str())); - Ok(json!({"entries": entries})) -} - -/// Copy a tree and report its file count. -fn copy_dir(args: &Value) -> io::Result { - let from = std::fs::canonicalize(args["from"].as_str().unwrap_or_default())?; - let to = args["to"].as_str().unwrap_or_default(); - std::fs::create_dir_all(to)?; - if std::fs::canonicalize(to)?.starts_with(&from) { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "destination is inside the source", - )); - } - let count = copy_tree( - args["from"].as_str().unwrap_or_default(), - args["to"].as_str().unwrap_or_default(), - )?; - Ok(json!({"copied_files": count})) -} - -/// Copy files recursively without build artifacts in target directories. -fn copy_tree(from: &str, to: &str) -> io::Result { - std::fs::create_dir_all(to)?; - let mut count = 0; - for entry in std::fs::read_dir(from)? { - let entry = entry?; - let name = entry.file_name().to_string_lossy().to_string(); - let src = format!("{from}/{name}"); - let dst = format!("{to}/{name}"); - if entry.file_type()?.is_dir() { - if name != "target" { - count += copy_tree(&src, &dst)?; - } - } else { - std::fs::copy(src, dst)?; - count += 1; - } - } - Ok(count) -} - -#[cfg(test)] -mod tests { - #[test] - fn overflow_json_can_be_followed_by_line_without_rearchiving() { - let value = serde_json::json!({"stdout":"hello 😀\n".repeat(4000),"exit":1,"error":{"text":"failure\n".repeat(4000)}}); - let expected = super::render(&value); - let shown = super::overflow::guard(value.to_string(), "json-test", "call", true); - assert!(shown.len() <= 50 * 1024 && shown.lines().count() <= 2000); - let path = shown - .split("Full output: ") - .nth(1) - .unwrap() - .split(". Read with") - .next() - .unwrap(); - assert_eq!(std::fs::read_to_string(path).unwrap(), expected); - let parent = std::path::Path::new(path).parent().unwrap(); - let count = std::fs::read_dir(parent).unwrap().count(); - let args = serde_json::json!({"path":path,"line_offset":30,"line_limit":5}); - let read = super::read_file(&args).unwrap(); - let read = read["content"].as_str().unwrap(); - assert!(read.contains("Showing lines 31-35")); - assert!(read.contains(path)); - assert_eq!(std::fs::read_dir(parent).unwrap().count(), count); - assert!( - super::overflow::existing( - expected.as_bytes(), - path, - &serde_json::json!({"line_offset":u64::MAX}) - ) - .contains("Full output:") - ); - } - #[test] - fn copy_rejects_a_destination_inside_the_source() { - let root = if cfg!(target_os = "wasi") { - std::path::PathBuf::from("/copy-test") - } else { - std::env::temp_dir().join(format!("sausage-copy-{}", std::process::id())) - }; - std::fs::create_dir_all(&root).unwrap(); - let result = super::copy_dir(&serde_json::json!({"from":root,"to":root.join("target")})); - let _ = std::fs::remove_dir_all(root); - assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput); - } -} diff --git a/cli/Cargo.toml b/cli/Cargo.toml deleted file mode 100644 index dbebbf6..0000000 --- a/cli/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -[package] -name = "sausage" -version = "0.1.0" -edition = "2024" -description = "Grow a system of WASI plugins by talking to it." - -[[bin]] -name = "sausage" -path = "src/main.rs" - -[dependencies] -anyhow = "1" -bytes = "1" -clap = { version = "4", features = ["derive", "env"] } -http = "1" -http-body-util = "0.1" -include_dir = "0.7" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -serde_yaml_ng = "0.10" -tokio = { version = "1", features = ["full"] } -toml_edit = "0.25" -wasmtime = "49" -wasmtime-wasi = "49" -wasmtime-wasi-http = "49" - -uuid = { version = "1", features = ["v7"] } -sha2 = "0.10" -rusqlite = { version = "0.38", features = ["bundled"] } -sausage-vfs = { version = "0.1.0", path = "../vfs" } -sausage-interface = { version = "0.1.0", path = "../interface" } -notify = "8.2.0" -rustyline = "19" -nix = { version = "0.31.3", features = ["signal", "process", "term"] } -chrono = { version = "0.4.42", default-features = false, features = ["clock"] } -tempfile = "3.27.0" - -[dev-dependencies] -tokio = { version = "1", features = ["test-util"] } -hyper = { version = "1.12.0", features = ["server", "http1"] } -hyper-util = { version = "0.1.21", features = ["tokio"] } -sausage-interface = { path = "../interface" } diff --git a/cli/src/activation.rs b/cli/src/activation.rs deleted file mode 100644 index cbd561e..0000000 --- a/cli/src/activation.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! Serialized candidate-copy activation and revert under PROTOCOL.md section 10.3. -use crate::{ - Host, RpcResult, error, - graph::{Interface, io_error, problems, read_hashed}, -}; -use serde_json::{Value, json}; -use std::{collections::BTreeMap, path::PathBuf}; - -/// Method descriptions plus their reachable local references, without expanding cycles. -fn methods(document: &Value) -> BTreeMap { - fn references(value: &Value, document: &Value, found: &mut BTreeMap) { - match value { - Value::Object(object) => { - if let Some(reference) = object.get("$ref").and_then(Value::as_str) - && let Some(pointer) = reference.strip_prefix('#') - && !found.contains_key(reference) - && let Some(target) = document.pointer(pointer) - { - found.insert(reference.into(), target.clone()); - references(target, document, found); - } - for child in object.values() { - references(child, document, found); - } - } - Value::Array(array) => { - for child in array { - references(child, document, found); - } - } - _ => {} - } - } - document["methods"] - .as_array() - .into_iter() - .flatten() - .filter_map(|method| { - let name = method["name"].as_str()?; - let mut found = BTreeMap::new(); - references(method, document, &mut found); - Some((name.into(), json!({"method":method,"references":found}))) - }) - .collect() -} - -struct Candidate { - plugin: String, - copy: PathBuf, - interface: Interface, -} -impl Host { - /// Copies first, discovers that copy, checks the combined graph, then installs it. - pub(crate) async fn activation(&self, _caller: &str, method: &str, params: Value) -> RpcResult { - let host = self.clone(); - let method = method.to_owned(); - tokio::spawn(async move { host.activation_body(&method, params).await }) - .await - .map_err(|failure| error(-32603, &failure.to_string()))? - } - async fn activation_body(&self, method: &str, params: Value) -> RpcResult { - let _activation = self.0.activations.lock().await; - let mut candidates = Vec::new(); - let result = self - .activate_candidates(method, ¶ms, &mut candidates) - .await; - for candidate in candidates { - let _ = tokio::fs::remove_file(candidate.copy).await; - } - result - } - /// Prepares every member before graph checks or changes to installed files. - async fn activate_candidates( - &self, - method: &str, - params: &Value, - candidates: &mut Vec, - ) -> RpcResult { - let requests = params - .get("plugins") - .and_then(Value::as_array) - .cloned() - .unwrap_or_else(|| vec![params.clone()]); - if requests.is_empty() { - return Err(error(-32602, "plugins must not be empty")); - } - for request in requests { - let plugin = request["plugin"] - .as_str() - .ok_or_else(|| error(-32602, "plugin must be a string"))?; - if plugin.is_empty() - || !plugin.bytes().all(|byte| { - byte.is_ascii_lowercase() - || byte.is_ascii_digit() - || byte == b'_' - || byte == b'-' - }) - || candidates - .iter() - .any(|candidate| candidate.plugin == plugin) - { - return Err(error(-32602, "invalid or duplicate plugin")); - } - let directory = self.0.world.plugin_dir(plugin); - tokio::fs::create_dir_all(&directory) - .await - .map_err(io_error)?; - let source = if method == "revert" { - directory.join("plugin.prev.wasm") - } else { - let source = PathBuf::from( - request["wasm"] - .as_str() - .ok_or_else(|| error(-32602, "wasm must be a string"))?, - ); - if source.starts_with(&self.0.world.root) { - source - } else { - self.0 - .world - .root - .join(source.strip_prefix("/").unwrap_or(&source)) - } - }; - let copy = directory.join(format!("activate-{}.wasm", uuid::Uuid::now_v7())); - // Read once. Discovery and installation both use the same private bytes. - let (bytes, hash) = read_hashed(source).await.map_err(io_error)?; - tokio::fs::write(©, &bytes).await.map_err(io_error)?; - self.0 - .journal - .write("core", plugin, "core", "activation copied candidate"); - let discovery = self.discover_copy(plugin, ©, hash).await; - let interface = match discovery { - Ok(interface) => interface, - Err(error) => { - let _ = tokio::fs::remove_file(copy).await; - return Err(error); - } - }; - self.0 - .journal - .write("core", plugin, "core", "activation discovered candidate"); - candidates.push(Candidate { - plugin: plugin.into(), - copy, - interface, - }); - } - let names: Vec<_> = candidates - .iter() - .map(|candidate| candidate.plugin.clone()) - .collect(); - self.refresh_graph_except(&names).await?; - let mut previous = self.0.graph.lock().unwrap().plugins.clone(); - // Startup discovery may not yet have visited a replaced plugin. Discover - // its immutable old copy for the diff, but never prevent repairing it. - for candidate in candidates.iter() { - if let Some(old) = previous.get_mut(&candidate.plugin) - && old.document.is_null() - && old.error.is_none() - { - if old.hash == candidate.interface.hash { - old.document = candidate.interface.document.clone(); - continue; - } - let directory = self.0.world.plugin_dir(&candidate.plugin); - if let Ok((bytes, hash)) = read_hashed(directory.join("plugin.wasm")).await { - let copy = directory.join(format!("discover-{}.wasm", uuid::Uuid::now_v7())); - if tokio::fs::write(©, bytes).await.is_ok() - && let Ok(interface) = - self.discover_copy(&candidate.plugin, ©, hash).await - { - *old = interface; - } - let _ = tokio::fs::remove_file(copy).await; - } - } - } - let mut proposed = previous.clone(); - for candidate in candidates.iter() { - proposed.insert(candidate.plugin.clone(), candidate.interface.clone()); - } - if params["force"] != true { - let failures = problems(&proposed, Some(&names)); - if !failures.is_empty() { - self.0.journal.write( - "core", - "core", - "core", - &format!("activation rejected {}", json!(failures)), - ); - let details = failures - .iter() - .map(|problem| { - format!( - "- {} imports {}: {}", - problem["consumer"], - problem["provider"], - problem["display"] - .as_str() - .unwrap_or_else(|| problem["message"] - .as_str() - .unwrap_or("incompatible import")) - ) - }) - .collect::>() - .join("\n"); - return Err( - json!({"code":-32003,"message":format!("{} was not activated: consumers break.\n{details}\nUpdate consumers first, or activate both together.",names.join(", ")),"data":failures}), - ); - } - } - self.install_candidates(candidates, previous, proposed, params["force"] != true) - .await - } - /// Installs staged files and snapshots all old admissions under the shared write gate. - async fn install_candidates( - &self, - candidates: &[Candidate], - previous: BTreeMap, - proposed: BTreeMap, - checked: bool, - ) -> RpcResult { - let admission = self.0.admission.write().await; - // Every candidate already has a temporary name before any installed file changes. - for candidate in candidates { - if previous - .get(&candidate.plugin) - .is_some_and(|old| old.hash == candidate.interface.hash) - { - continue; - } - let directory = self.0.world.plugin_dir(&candidate.plugin); - let current = directory.join("plugin.wasm"); - if tokio::fs::try_exists(¤t).await.map_err(io_error)? { - let backup = candidate.copy.with_extension("previous"); - tokio::fs::hard_link(¤t, &backup) - .await - .map_err(io_error)?; - tokio::fs::rename(&backup, directory.join("plugin.prev.wasm")) - .await - .map_err(io_error)?; - } - tokio::fs::rename(&candidate.copy, ¤t) - .await - .map_err(io_error)?; - } - let mut upgrades = Vec::new(); - let mut summaries = serde_json::Map::new(); - for candidate in candidates { - let old = previous.get(&candidate.plugin); - let before = methods(old.map(|old| &old.document).unwrap_or(&Value::Null)); - let after = methods(&candidate.interface.document); - let added: Vec<_> = after - .keys() - .filter(|name| !before.contains_key(*name)) - .collect(); - let removed: Vec<_> = before - .keys() - .filter(|name| !after.contains_key(*name)) - .collect(); - let changed: Vec<_> = after - .iter() - .filter(|(name, method)| before.get(*name).is_some_and(|old| old != *method)) - .map(|(name, _)| name) - .collect(); - summaries.insert(candidate.plugin.clone(), json!({ - "plugin": candidate.plugin, - "wasm": format!("/plugins/{}/plugin.wasm", candidate.plugin), - "hash": candidate.interface.hash, - "previous": old.map(|old| &old.hash).filter(|hash| !hash.is_empty()), - "method_count": candidate.interface.document["methods"].as_array().map_or(0, Vec::len), - "added": added, - "removed": removed, - "changed": changed, - "interface_check": if checked { "passed" } else { "skipped" }, - })); - if let Some(old) = previous - .get(&candidate.plugin) - .filter(|old| !old.hash.is_empty() && old.hash != candidate.interface.hash) - { - upgrades.push(self.snapshot_upgrade( - &candidate.plugin, - old.hash.clone(), - &candidate.interface.hash, - )); - } - self.0.journal.write( - "core", - &candidate.plugin, - "core", - &format!("activation installed {}", candidate.interface.hash), - ); - } - self.publish_graph(proposed); - drop(admission); - for upgrade in upgrades { - self.begin_upgrade(upgrade); - } - Ok(Value::Object(summaries)) - } -} diff --git a/cli/src/agent.yaml b/cli/src/agent.yaml deleted file mode 100644 index ceefeb2..0000000 --- a/cli/src/agent.yaml +++ /dev/null @@ -1,52 +0,0 @@ -# The configuration of the agent. Edit it before the first chat. -# api selects the request format. Choose one complete example below. -api: openai-responses -# OpenAI base URLs include /v1. Do not include /responses or /chat/completions. -base_url: https://api.openai.com/v1 -# model is the provider's model identifier. -model: gpt-6.1-sol -# token is the API key. Replace CHANGE_ME with your key. -# An empty token requires allow_no_token: true. Then no authentication header is sent. -token: CHANGE_ME -allow_no_token: false -# thinking is how much the model thinks before it answers: -# off, low, medium, high, xhigh, or max. Remove the line to use the model's default, -# for example for a model without thinking. -# If the model rejects a level, the agent uses the nearest level that the model accepts. -# If the model cannot switch thinking off, off becomes low. -# A caller can set another level for one turn: agent.chat {"text": "...", "thinking": "high"}. -# The agent reads this file at the start of each turn, so a change applies to the next turn. -thinking: medium -# Optional output token cap. Anthropic defaults to 32000 if omitted. -# max_tokens: 32000 - -# OpenAI Responses example (base_url includes the version): -# api: openai-responses -# base_url: https://api.openai.com/v1 -# model: gpt-6.1-sol -# token: YOUR_OPENAI_KEY -# allow_no_token: false -# thinking: medium - -# OpenRouter example (OpenAI Chat Completions, the format of most providers): -# api: openai-chat-completions -# base_url: https://openrouter.ai/api/v1 -# model: anthropic/claude-sonnet-5.5 -# token: YOUR_OPENROUTER_KEY -# allow_no_token: false -# thinking: medium - -# Local Ollama example (OpenAI Chat Completions, no key): -# api: openai-chat-completions -# base_url: http://localhost:11434/v1 -# model: llama3.2 -# token: "" -# allow_no_token: true - -# Anthropic Messages example (base_url excludes the version): -# api: anthropic-messages -# base_url: https://api.anthropic.com -# model: claude-sonnet-5-5 -# token: YOUR_ANTHROPIC_KEY -# allow_no_token: false -# thinking: medium diff --git a/cli/src/boot.yaml b/cli/src/boot.yaml deleted file mode 100644 index 698ad16..0000000 --- a/cli/src/boot.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# The core starts these services in order. An empty list starts no services. -# Each method is a plugin.method name. params contains its named arguments. -# The core waits for each result before it starts the next service. -# After a plugin upgrade, the core restarts that plugin's services. -# Host restart is separate: #loop sets SAUSAGE_LOOP=1 to enable core.restart -# (and its alias core.exit). It answers before clean shutdown with exit code 75. -# Agents need the person's explicit approval each time; never add it as a service. -# Example (replace [] with this list): -# - method: www.start -# params: -# port: 8080 -[] diff --git a/cli/src/cargo.rs b/cli/src/cargo.rs deleted file mode 100644 index fe935d8..0000000 --- a/cli/src/cargo.rs +++ /dev/null @@ -1,836 +0,0 @@ -//! Native cargo builds, one immutable source copy, SDK installation, and process-group cancellation. -use crate::{Host, world::World}; -use anyhow::{Context, Result, bail}; -use include_dir::{Dir, include_dir}; -use serde_json::{Value, json}; -use std::time::Duration; -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; -use tokio::{process::Command, time::Instant}; - -/// Default wall-clock budget after acquiring the build mutex (not queue time). -pub const DEFAULT_TIMEOUT_SECS: u64 = 900; -/// Default wall-clock budget for each WASI test instance. -pub const DEFAULT_TEST_TIMEOUT_SECS: u64 = 120; - -/// Wall-clock budgets for a build and its individual WASI tests. -#[derive(Clone, Copy, Debug)] -pub struct BuildOptions { - pub timeout_secs: u64, - pub test_timeout_secs: u64, -} -impl Default for BuildOptions { - fn default() -> Self { - Self { - timeout_secs: DEFAULT_TIMEOUT_SECS, - test_timeout_secs: DEFAULT_TEST_TIMEOUT_SECS, - } - } -} -/// Optional RPC seconds are positive integers; null does not disable a deadline. -fn seconds(params: &Value, name: &str, default: u64) -> Result { - match params.get(name) { - None => Ok(default), - Some(value) => value - .as_u64() - .filter(|seconds| *seconds >= 1) - .ok_or_else(|| { - crate::error(-32602, &format!("{name} must be an integer of at least 1")) - }), - } -} - -/// The native build plugin. -pub const ID: &str = "cargo"; -/// The target used for builds and tests. -pub const TARGET: &str = "wasm32-wasip2"; -static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/src"); -static MACROS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/macros/src"); -static INTERFACE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../interface/src"); - -#[derive(Clone, Default, Debug, serde::Serialize)] -/// Test totals and captured output. -pub struct TestResults { - pub passed: u64, - pub failed: u64, - pub output: String, -} -/// Raw, unfiltered output shared by the test and release phases of one build. -#[derive(Clone, Debug, serde::Serialize)] -pub struct BuildLogs { - pub stdout: StreamLog, - pub stderr: StreamLog, - pub total_bytes: u64, -} -#[derive(Clone, Debug, serde::Serialize)] -pub struct StreamLog { - pub path: String, - pub bytes: u64, -} -/// Persistent descriptors are inherited by cargo, so cancellation cannot lose -/// bytes already written by cargo or its descendants. No buffered writer is used. -struct LogFiles { - stdout: std::fs::File, - stderr: std::fs::File, - stdout_path: PathBuf, - stderr_path: PathBuf, -} -impl LogFiles { - fn create(world: &Path) -> Result { - let directory = world - .join("logs/cargo") - .join(chrono::Utc::now().format("%Y-%m-%d").to_string()); - std::fs::create_dir_all(&directory).context("create cargo log directory")?; - let id = uuid::Uuid::now_v7(); - let stdout_path = directory.join(format!("{id}.stdout.log")); - let stderr_path = directory.join(format!("{id}.stderr.log")); - let create = |path: &Path| { - std::fs::OpenOptions::new() - .create_new(true) - .append(true) - .open(path) - .with_context(|| format!("create {}", path.display())) - }; - Ok(Self { - stdout: create(&stdout_path)?, - stderr: create(&stderr_path)?, - stdout_path, - stderr_path, - }) - } - fn info(&self, world: &Path) -> Result { - let stream = |file: &std::fs::File, path: &Path| -> Result { - Ok(StreamLog { - path: format!("/{}", path.strip_prefix(world)?.display()), - bytes: file.metadata()?.len(), - }) - }; - let stdout = stream(&self.stdout, &self.stdout_path)?; - let stderr = stream(&self.stderr, &self.stderr_path)?; - Ok(BuildLogs { - total_bytes: stdout.bytes + stderr.bytes, - stdout, - stderr, - }) - } - fn read(&self, path: &Path, offset: u64) -> Result> { - use std::io::{Read, Seek, SeekFrom}; - let mut file = - std::fs::File::open(path).with_context(|| format!("read {}", path.display()))?; - file.seek(SeekFrom::Start(offset))?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes)?; - Ok(bytes) - } -} -/// A successful build, usable without a running host. -#[derive(Debug)] -pub struct BuildOutput { - pub logs: Option, - pub wasm: PathBuf, - pub warnings: String, - pub tests: TestResults, -} -/// A failed compiler or test phase, with its complete diagnostics. -#[derive(Debug)] -pub struct BuildFailure { - pub logs: Option, - pub reason: String, - pub tests: TestResults, - pub output: String, - pub elapsed_secs: Option, - pub step: Option<&'static str>, -} -impl std::fmt::Display for BuildFailure { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "{}\n{}", self.reason, self.output) - } -} -impl std::error::Error for BuildFailure {} -impl From for BuildFailure { - fn from(error: anyhow::Error) -> Self { - Self { - logs: None, - reason: "build preparation failed".into(), - output: format!("{error:#}"), - tests: TestResults::default(), - elapsed_secs: None, - step: None, - } - } -} -/// A host-independent builder. Each builder serializes access to its work and target folders. -pub struct Cargo { - world: World, - builds: Arc>, - runner: PathBuf, - target: PathBuf, - sdk: PathBuf, -} -impl Cargo { - /// Seeds a missing world SDK and selects the current sausage executable as runner. - pub fn new(world: World, cache: &Path) -> Result { - let mut runner = std::env::current_exe()?; - if runner - .parent() - .is_some_and(|parent| parent.file_name().is_some_and(|name| name == "deps")) - { - runner = runner.parent().unwrap().parent().unwrap().join("sausage"); - } - Self::with_runner(world, runner, cache) - } - /// Seeds a missing world SDK and accepts an explicit sausage binary, including for init integrations. - pub fn with_runner(world: World, runner: PathBuf, cache: &Path) -> Result { - let sdk = world.plugin_dir(ID).join("sdk"); - Self::seed_sdk(&sdk)?; - Ok(Self { - sdk, - target: std::path::absolute(cache)?.join("cargo-target"), - world, - runner, - builds: Arc::new(tokio::sync::Mutex::new(())), - }) - } - /// Seeds only an absent SDK; an existing world owns all of its SDK files. - fn seed_sdk(sdk: &Path) -> Result<()> { - if sdk.try_exists()? { - return Ok(()); - } - let mut files = std::collections::BTreeMap::new(); - collect_files(&SDK, Path::new("src"), &mut files); - collect_files(&MACROS, Path::new("macros/src"), &mut files); - collect_files(&INTERFACE, Path::new("interface/src"), &mut files); - for (manifest, destination, interface_path) in [ - ( - include_str!("../../sdk/Cargo.toml"), - PathBuf::from("Cargo.toml"), - Some("interface"), - ), - ( - include_str!("../../sdk/macros/Cargo.toml"), - PathBuf::from("macros/Cargo.toml"), - Some("../interface"), - ), - ( - include_str!("../../interface/Cargo.toml"), - PathBuf::from("interface/Cargo.toml"), - None, - ), - ] { - let mut document: toml_edit::DocumentMut = manifest.parse()?; - if !document.contains_key("workspace") { - document["workspace"] = toml_edit::table(); - } - if let Some(path) = interface_path { - for section in ["dependencies", "dev-dependencies"] { - if let Some(dependency) = document - .get_mut(section) - .and_then(|table| table.get_mut("sausage-interface")) - { - dependency["path"] = toml_edit::value(path); - } - } - } - files.insert(destination, document.to_string().into_bytes()); - } - let parent = sdk.parent().context("SDK has no parent")?; - std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; - let staging = tempfile::Builder::new() - .prefix("seed-sdk-") - .tempdir_in(parent) - .with_context(|| format!("stage SDK in {}", parent.display()))?; - for (path, bytes) in &files { - write_changed(&staging.path().join(path), bytes)?; - } - // Another builder may have seeded the world while we prepared the files. - if !sdk.try_exists()? - && let Err(error) = std::fs::rename(staging.path(), sdk) - && !sdk.try_exists()? - { - return Err(error).with_context(|| format!("seed {}", sdk.display())); - } - Ok(()) - } - /// Registers cargo.build as a cancellable native process. - pub fn register(self, host: &Host) { - let builder = Arc::new(self); - host.register_native(ID,Arc::new(move |_context,method,params| { - let builder=builder.clone(); - Box::pin(async move { - if method=="rpc.discover" { return Ok(json!({"openrpc":"1.2.6","info":{"title":"cargo","version":"native"},"methods":[{"name":"build","paramStructure":"by-name","description":"Copies source, runs bounded WASI tests, then builds a release component. Deadlines kill cargo and its descendants; cancellation releases the build mutex.","params":[{"name":"plugin","required":true,"schema":{"type":"string"}},{"name":"timeout_secs","required":false,"description":"Total wall-clock limit in seconds for tests and release build after acquiring the build mutex (queue time excluded). Default 900; minimum 1. Cannot be disabled.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TIMEOUT_SECS}},{"name":"test_timeout_secs","required":false,"description":"Wall-clock limit in seconds for each WASI test instance. Default 120; minimum 1. A timed-out test is named and fails; remaining tests continue.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TEST_TIMEOUT_SECS}}],"result":{"name":"build","schema":{"type":"object","required":["wasm","tests","warnings","logs"],"properties":{"logs":{"type":"object","required":["stdout","stderr","total_bytes"],"properties":{"stdout":{"type":"object","required":["path","bytes"],"properties":{"path":{"type":"string"},"bytes":{"type":"integer","minimum":0}}},"stderr":{"type":"object","required":["path","bytes"],"properties":{"path":{"type":"string"},"bytes":{"type":"integer","minimum":0}}},"total_bytes":{"type":"integer","minimum":0}}},"wasm":{"type":"string"},"warnings":{"type":"string"},"tests":{"type":"object","required":["passed","failed","output"],"properties":{"passed":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"output":{"type":"string"}}}}}},"errors":[{"code":-32004,"message":"Build failed"}]}]})); } - if method!="build" { return Err(crate::error(-32601,"cargo method not found")); } - let plugin=params["plugin"].as_str().ok_or_else(||crate::error(-32602,"plugin must be a string"))?; - let options = BuildOptions { - timeout_secs: seconds(¶ms, "timeout_secs", DEFAULT_TIMEOUT_SECS)?, - test_timeout_secs: seconds(¶ms, "test_timeout_secs", DEFAULT_TEST_TIMEOUT_SECS)?, - }; - match builder.build_with_options(&builder.world.plugin_dir(plugin).join("source"), options).await { - Ok(output)=>Ok(json!({"wasm":format!("/{}",output.wasm.strip_prefix(&builder.world.root).unwrap().display()),"tests":output.tests,"warnings":output.warnings,"logs":output.logs})), - Err(failure)=>Err(json!({"code":-32004,"message":failure.reason,"data":{"reason":failure.reason,"tests":failure.tests,"output":failure.output,"elapsed_secs":failure.elapsed_secs,"step":failure.step,"logs":failure.logs}})), - } - }) - })); - } - /// Copies source once, runs WASI tests, then publishes a release component under state/cargo/builds. - pub async fn build(&self, source: &Path) -> std::result::Result { - self.build_with_options(source, BuildOptions::default()) - .await - } - /// Like build, with explicit positive wall-clock budgets. Waiting for the mutex is unbounded. - pub async fn build_with_options( - &self, - source: &Path, - options: BuildOptions, - ) -> std::result::Result { - let logs = LogFiles::create(&self.world.root)?; - let result = self.build_logged(source, options, &logs).await; - let info = logs.info(&self.world.root)?; - match result { - Ok(mut output) => { - output.logs = Some(info); - Ok(output) - } - Err(mut failure) => { - failure.logs = Some(info); - Err(failure) - } - } - } - async fn build_logged( - &self, - source: &Path, - options: BuildOptions, - logs: &LogFiles, - ) -> std::result::Result { - if options.timeout_secs == 0 || options.test_timeout_secs == 0 { - return Err(anyhow::anyhow!("timeouts must be at least 1 second").into()); - } - let build_guard = self.builds.clone().lock_owned().await; - let started = Instant::now(); - let deadline = started - .checked_add(Duration::from_secs(options.timeout_secs)) - .ok_or_else(|| anyhow::anyhow!("timeout_secs is too large"))?; - let work = self.world.state_dir(ID).join("work"); - let target = self.target.clone(); - let temporary = self.world.state_dir(ID).join("tmp"); - let cleanup = temporary.clone(); - let source = source.to_path_buf(); - let copy = work.clone(); - let sdk = self.sdk.clone(); - let build_guard = tokio::task::spawn_blocking(move || -> Result<_> { - Self::seed_sdk(&sdk)?; - if cleanup.exists() { - std::fs::remove_dir_all(&cleanup) - .with_context(|| format!("remove {}", cleanup.display()))?; - } - std::fs::create_dir_all(&cleanup) - .with_context(|| format!("create {}", cleanup.display()))?; - if copy.exists() { - std::fs::remove_dir_all(©) - .with_context(|| format!("remove {}", copy.display()))?; - } - copy_source(&source, ©, true)?; - let manifest = copy.join("Cargo.toml"); - let text = std::fs::read_to_string(&manifest) - .with_context(|| format!("read {}", manifest.display()))?; - std::fs::write( - &manifest, - prepare_manifest(&text, &sdk) - .with_context(|| format!("prepare {}", manifest.display()))?, - ) - .with_context(|| format!("write {}", manifest.display()))?; - Ok(build_guard) - }) - .await - .map_err(anyhow::Error::from)??; - let mut test = Command::new("cargo"); - test.args([ - "test", - "--no-fail-fast", - "--release", - "--target", - TARGET, - "--target-dir", - ]) - .arg(&target) - .current_dir(&work) - .env("TMPDIR", &temporary) - .env( - "SAUSAGE_TEST_TIMEOUT_SECS", - options.test_timeout_secs.to_string(), - ) - .arg("--config") - .arg(format!( - "target.wasm32-wasip2.runner={}", - json!([self.runner, "test-runner"]) - )); - let output = run_command( - test, - started, - deadline, - "tests", - TestResults::default(), - logs, - ) - .await?; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let tests = TestResults { - passed: test_count(&stdout, "passed"), - failed: if output.status.success() { - 0 - } else { - test_count(&stdout, "failed").max(1) - }, - output: strip_progress(&format!("{stdout}\n{stderr}")), - }; - if !output.status.success() { - return Err(BuildFailure { - logs: None, - reason: "WASI tests failed".into(), - output: tests.output.clone(), - tests, - elapsed_secs: None, - step: None, - }); - } - let mut build = Command::new("cargo"); - build - .args([ - "build", - "--release", - "--target", - TARGET, - "--message-format=json-render-diagnostics", - "--target-dir", - ]) - .arg(&target) - .current_dir(&work); - let output = run_command( - build, - started, - deadline, - "release build", - tests.clone(), - logs, - ) - .await?; - let result = async { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let diagnostics = stderr.to_string(); - let mut artifacts = Vec::new(); - for line in stdout.lines() { - if let Ok(message) = serde_json::from_str::(line) - && message["reason"] == "compiler-artifact" - && message["target"]["kind"] - .as_array() - .is_some_and(|kinds| kinds.iter().any(|kind| kind == "bin")) - && let Some(executable) = message["executable"].as_str() - { - artifacts.push(PathBuf::from(executable)); - } - } - if !output.status.success() { - bail!("{diagnostics}"); - } - if artifacts.len() != 1 { - bail!( - "cargo must build exactly one binary, found {}", - artifacts.len() - ); - } - let artifact = artifacts.pop().unwrap(); - let builds = self.world.state_dir(ID).join("builds"); - let destination = tokio::task::spawn_blocking(move || { - let _guard = build_guard; - publish(&builds, &artifact) - }) - .await??; - Ok(BuildOutput { - logs: None, - wasm: destination, - warnings: strip_progress(&diagnostics), - tests: tests.clone(), - }) - } - .await; - result.map_err(|error: anyhow::Error| BuildFailure { - logs: None, - reason: "release build failed".into(), - output: format!("{error:#}"), - tests, - elapsed_secs: None, - step: None, - }) - } -} -/// Kills cargo and every descendant, including tests and build scripts, on cancellation. -struct ProcessGroup(u32); -impl Drop for ProcessGroup { - fn drop(&mut self) { - let _ = nix::sys::signal::killpg( - nix::unistd::Pid::from_raw(self.0 as i32), - nix::sys::signal::Signal::SIGKILL, - ); - } -} -/// Starts one process group and retains its guard across all output collection. -async fn run_command( - mut command: Command, - started: Instant, - deadline: Instant, - step: &'static str, - mut tests: TestResults, - logs: &LogFiles, -) -> std::result::Result { - command - .process_group(0) - .kill_on_drop(true) - .stdin(std::process::Stdio::null()) - // Child writes go straight to persistent files: no pipe buffers or reader - // tasks can be dropped on cancellation. Parsing happens only afterwards. - .stdout(logs.stdout.try_clone().context("clone stdout log")?) - .stderr(logs.stderr.try_clone().context("clone stderr log")?); - let out_start = logs.stdout.metadata().context("stat stdout log")?.len(); - let err_start = logs.stderr.metadata().context("stat stderr log")?.len(); - let mut child = command.spawn().context("start cargo")?; - let group = ProcessGroup(child.id().unwrap()); - - let (status, timed_out) = { - let collect = child.wait(); - tokio::pin!(collect); - tokio::select! { - biased; - _ = tokio::time::sleep_until(deadline) => { - // Kill the entire tree before waiting for EOF/reaping cargo. The guard also - // covers cancellation while collecting, and runs before the mutex drops. - let _ = nix::sys::signal::killpg(nix::unistd::Pid::from_raw(group.0 as i32), nix::sys::signal::Signal::SIGKILL); - (collect.await, true) - } - status = &mut collect => (status, false), - } - }; - let status = status.map_err(anyhow::Error::from)?; - // Also stop any descendants that kept the streams open after cargo exited. - drop(group); - let out = logs.read(&logs.stdout_path, out_start)?; - let err = logs.read(&logs.stderr_path, err_start)?; - if timed_out { - let output = format!( - "{}\n{}", - String::from_utf8_lossy(&out), - String::from_utf8_lossy(&err) - ); - let output = output - .trim_end() - .lines() - .rev() - .take(50) - .collect::>() - .into_iter() - .rev() - .collect::>() - .join("\n"); - if step == "tests" { - tests.passed = test_count(&String::from_utf8_lossy(&out), "passed"); - tests.failed = test_count(&String::from_utf8_lossy(&out), "failed"); - tests.output = output.clone(); - } - return Err(BuildFailure { - logs: None, - reason: "timeout".into(), - tests, - output, - elapsed_secs: Some(started.elapsed().as_secs_f64()), - step: Some(step), - }); - } - Ok(std::process::Output { - status, - stdout: out, - stderr: err, - }) -} -/// Copies only source, excluding build outputs and git internals at every level. -fn copy_source(source: &Path, destination: &Path, root: bool) -> Result<()> { - std::fs::create_dir_all(destination) - .with_context(|| format!("create {}", destination.display()))?; - for entry in std::fs::read_dir(source).with_context(|| format!("read {}", source.display()))? { - let entry = entry.with_context(|| format!("read entry in {}", source.display()))?; - if root && matches!(entry.file_name().to_str(), Some("target" | ".git")) { - continue; - } - let from = entry.path(); - let to = destination.join(entry.file_name()); - if from.is_dir() { - copy_source(&from, &to, false)?; - } else { - std::fs::copy(&from, &to) - .with_context(|| format!("copy {} to {}", from.display(), to.display()))?; - } - } - Ok(()) -} -/// Repoints SDK dependencies without discarding feature flags and detaches the copied crate's workspace. -fn prepare_manifest(text: &str, sdk: &Path) -> Result { - let mut document: toml_edit::DocumentMut = text.parse()?; - rewrite_dependencies(document.as_table_mut(), sdk); - if !document.contains_key("workspace") { - document["workspace"] = toml_edit::table(); - } - Ok(document.to_string()) -} -/// Rewrites dependency entries recursively, including target and workspace tables. -fn rewrite_dependencies(table: &mut dyn toml_edit::TableLike, sdk: &Path) { - for (name, item) in table.iter_mut() { - if matches!( - &*name, - "dependencies" | "dev-dependencies" | "build-dependencies" - ) { - if let Some(dependencies) = item.as_table_like_mut() { - for (name, dependency) in dependencies.iter_mut() { - if dependency - .get("workspace") - .and_then(toml_edit::Item::as_bool) - == Some(true) - { - continue; - } - if &*name == "sausage-plugin" - || dependency.get("package").and_then(toml_edit::Item::as_str) - == Some("sausage-plugin") - { - if dependency.is_str() { - let version = dependency.as_str().unwrap().to_string(); - *dependency = toml_edit::value(toml_edit::InlineTable::new()); - dependency["version"] = toml_edit::value(version); - } - dependency["path"] = toml_edit::value(sdk.display().to_string()); - } - } - } - } else if let Some(nested) = item.as_table_like_mut() { - rewrite_dependencies(nested, sdk); - } - } -} -/// Publishes a complete artifact and retains only the ten newest numeric build folders. -fn publish(builds: &Path, artifact: &Path) -> Result { - std::fs::create_dir_all(builds).with_context(|| format!("create {}", builds.display()))?; - let mut numbers: Vec = std::fs::read_dir(builds) - .with_context(|| format!("read {}", builds.display()))? - .filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok()) - .collect(); - numbers.sort_unstable(); - let number = numbers.last().copied().unwrap_or(0) + 1; - let destination = builds - .join(number.to_string()) - .join(artifact.file_name().unwrap()); - crate::world::write_atomic( - &destination, - &std::fs::read(artifact).with_context(|| format!("read {}", artifact.display()))?, - ) - .with_context(|| format!("publish {}", destination.display()))?; - for old in numbers.iter().take(numbers.len().saturating_sub(9)) { - let path = builds.join(old.to_string()); - std::fs::remove_dir_all(&path).with_context(|| format!("remove {}", path.display()))?; - } - Ok(destination) -} -/// Writes embedded sources only when their bytes changed. -fn write_changed(path: &Path, bytes: &[u8]) -> Result<()> { - if std::fs::read(path).ok().as_deref() != Some(bytes) { - std::fs::create_dir_all(path.parent().unwrap()) - .with_context(|| format!("create parent of {}", path.display()))?; - std::fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?; - } - Ok(()) -} -/// Collects embedded source files for seeding a missing world SDK. -fn collect_files( - directory: &Dir<'_>, - destination: &Path, - files: &mut std::collections::BTreeMap>, -) { - for file in directory.files() { - files.insert(destination.join(file.path()), file.contents().to_vec()); - } - for directory in directory.dirs() { - collect_files(directory, destination, files); - } -} -/// Counts only final libtest summaries, never individual status lines. -fn test_count(output: &str, field: &str) -> u64 { - output - .lines() - .filter_map(|line| line.strip_prefix("test result:")) - .flat_map(|summary| summary.split(';')) - .filter_map(|part| { - let words: Vec<_> = part.split_whitespace().collect(); - let index = words.iter().position(|word| *word == field)?; - words.get(index.checked_sub(1)?)?.parse::().ok() - }) - .sum() -} -/// Removes progress lines while retaining compiler diagnostics. -fn strip_progress(output: &str) -> String { - output - .lines() - .filter(|line| { - ![ - "Compiling ", - "Finished ", - "Blocking ", - "Updating ", - "Locking ", - "Adding ", - ] - .iter() - .any(|prefix| line.trim_start().starts_with(prefix)) - }) - .collect::>() - .join("\n") -} - -#[cfg(test)] -mod timeout_tests { - use super::*; - - /// Streams retain exact bytes, including non-UTF8, progress and JSON; phases append. - #[tokio::test] - async fn raw_streams_append_before_parsing() { - let directory = tempfile::tempdir().unwrap(); - let logs = LogFiles::create(directory.path()).unwrap(); - for _ in 0..2 { - let mut command = Command::new("sh"); - command.args(["-c", "printf '\\377raw\\r'; printf ' Compiling progress\\n{\"reason\":\"compiler-artifact\"}\\n' >&2; exit 3"]); - let now = Instant::now(); - let output = tokio::time::timeout( - Duration::from_secs(5), - run_command( - command, - now, - now + Duration::from_secs(3), - "tests", - TestResults::default(), - &logs, - ), - ) - .await - .unwrap() - .unwrap(); - assert!(!output.status.success()); - assert_eq!(output.stdout, b"\xffraw\r"); - assert!(String::from_utf8_lossy(&output.stderr).contains("Compiling progress")); - } - assert_eq!( - std::fs::read(&logs.stdout_path).unwrap(), - b"\xffraw\r\xffraw\r" - ); - let info = logs.info(directory.path()).unwrap(); - assert_eq!(info.stdout.bytes, 10); - assert_eq!(info.total_bytes, info.stdout.bytes + info.stderr.bytes); - assert!(info.stdout.path.starts_with("/logs/cargo/")); - assert!(!directory.path().join("state/cargo").exists()); - } - - /// Cancellation leaves kernel-written data intact, even without a newline. - #[tokio::test] - async fn cancelled_command_keeps_logs() { - use notify::Watcher; - let directory = tempfile::tempdir().unwrap(); - let logs = LogFiles::create(directory.path()).unwrap(); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let mut watcher = - notify::recommended_watcher(move |event: notify::Result| { - if let Ok(event) = event { - if event - .paths - .iter() - .any(|path| path.file_name().is_some_and(|name| name == "ready")) - { - let _ = tx.send(()); - } - } - }) - .unwrap(); - watcher - .watch(directory.path(), notify::RecursiveMode::NonRecursive) - .unwrap(); - let mut command = Command::new("sh"); - command.args(["-c", "printf 'partial\\377'; printf 'stderr\\r' >&2; touch \"$1/ready\"; exec sleep 1000", "sh"]).arg(directory.path()); - let now = Instant::now(); - let task = tokio::spawn(async move { - run_command( - command, - now, - now + Duration::from_secs(60), - "tests", - TestResults::default(), - &logs, - ) - .await - }); - tokio::time::timeout(Duration::from_secs(5), rx.recv()) - .await - .unwrap() - .unwrap(); - task.abort(); - assert!(task.await.unwrap_err().is_cancelled()); - let paths: Vec<_> = std::fs::read_dir(directory.path().join("logs/cargo")) - .unwrap() - .flat_map(|day| std::fs::read_dir(day.unwrap().path()).unwrap()) - .map(|file| file.unwrap().path()) - .collect(); - assert_eq!(paths.len(), 2); - for path in paths { - let expected: &[u8] = if path.to_string_lossy().ends_with(".stdout.log") { - b"partial\xff" - } else { - b"stderr\r" - }; - assert_eq!(std::fs::read(path).unwrap(), expected); - } - } - - /// Deadline errors retain the shared start time, phase, test totals, and output tail. - #[tokio::test] - async fn timeout_diagnostics() { - let mut command = Command::new("sh"); - command.args([ - "-c", - "i=0; while [ $i -lt 100 ]; do echo diagnostic-$i; i=$((i+1)); done; exec sleep 1000", - ]); - let directory = tempfile::tempdir().unwrap(); - let logs = LogFiles::create(directory.path()).unwrap(); - let now = Instant::now(); - let failure = run_command( - command, - now - Duration::from_secs(10), - now + Duration::from_secs(1), - "release build", - TestResults { - passed: 7, - failed: 0, - output: "successful tests".into(), - }, - &logs, - ) - .await - .unwrap_err(); - assert_eq!(failure.reason, "timeout"); - assert_eq!(failure.step, Some("release build")); - assert!(failure.elapsed_secs.unwrap() >= 11.0); - assert_eq!(failure.tests.passed, 7); - assert_eq!(failure.tests.output, "successful tests"); - assert_eq!(failure.output.lines().count(), 50); - assert!(failure.output.contains("diagnostic-99")); - assert!(!failure.output.contains("diagnostic-0\n")); - let raw = std::fs::read_to_string(&logs.stdout_path).unwrap(); - assert_eq!(raw.lines().count(), 100); - assert!(raw.starts_with("diagnostic-0\n")); - } -} diff --git a/cli/src/components.rs b/cli/src/components.rs deleted file mode 100644 index cd74b0f..0000000 --- a/cli/src/components.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! Metadata-keyed component loads and weak compiled versions; candidate copies never enter the path cache. - -#[cfg(test)] -mod tests { - use super::*; - /// PROTOCOL 10.3: a pending component load does not occupy admission. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn loading_does_not_hold_admission() { - let folder = - std::env::temp_dir().join(format!("sausage-admission-{}", uuid::Uuid::now_v7())); - let world = crate::world::World::create(&folder).unwrap(); - let path = world.plugin_dir("probe").join("plugin.wasm"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, []).unwrap(); - let host = crate::Host::start_with_cache(&folder, &folder.join("cache")) - .await - .unwrap(); - let entry = host - .0 - .components - .paths - .lock() - .unwrap() - .entry(path) - .or_default() - .clone(); - let blocked = entry.lock().await; - let caller = host.clone(); - let pending = - tokio::spawn(async move { caller.call("probe.echo", serde_json::json!({})).await }); - tokio::time::timeout(std::time::Duration::from_secs(5), async { - while Arc::strong_count(&entry) < 3 { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - let admitted = host.0.admission.try_write().is_ok(); - drop(blocked); - assert!(pending.await.unwrap().is_err()); - host.stop().await; - std::fs::remove_dir_all(folder).unwrap(); - assert!(admitted, "a component load held the admission gate"); - } - /// PROTOCOL 10.3: temporary discovery and activation copies never accumulate cached paths. - #[tokio::test] - async fn candidate_copies_do_not_enter_path_cache() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - let target = root.join("target/core/guests"); - assert!( - std::process::Command::new("cargo") - .args([ - "build", - "--offline", - "--target", - "wasm32-wasip2", - "--manifest-path" - ]) - .arg(root.join("cli/tests/plugins/probe/Cargo.toml")) - .arg("--target-dir") - .arg(&target) - .status() - .unwrap() - .success() - ); - let folder = - std::env::temp_dir().join(format!("sausage-component-cache-{}", uuid::Uuid::now_v7())); - std::fs::create_dir_all(&folder).unwrap(); - let runtime = Runtime::new(&root.join("target/core/cache-test")).unwrap(); - let cache = Components::default(); - for name in ["discover-test.wasm", "activate-test.wasm"] { - let path = folder.join(name); - std::fs::copy(target.join("wasm32-wasip2/debug/probe.wasm"), &path).unwrap(); - let loaded = cache.load(path.clone(), runtime.clone()).await.unwrap(); - assert!(cache.paths.lock().unwrap().is_empty()); - let weak = Arc::downgrade(&loaded.prepared); - drop(loaded); - std::fs::remove_file(path).unwrap(); - assert!(weak.upgrade().is_none()); - } - std::fs::remove_dir(folder).unwrap(); - } -} - -use crate::{ - error, - runtime::{Prepared, Runtime}, -}; -use sha2::{Digest, Sha256}; -use std::{ - collections::HashMap, - path::{Path, PathBuf}, - sync::{Arc, Mutex, Weak}, - time::SystemTime, -}; -use tokio::sync::{Mutex as AsyncMutex, OnceCell}; - -#[derive(Clone, PartialEq, Eq)] -pub(crate) struct Fingerprint { - size: u64, - modified: SystemTime, - #[cfg(unix)] - identity: (u64, u64, i64, i64), -} -impl Fingerprint { - pub(crate) async fn read(path: &Path) -> Result { - let metadata = tokio::fs::metadata(path) - .await - .map_err(|_| error(-32001, "no such plugin"))?; - Ok(Self { - size: metadata.len(), - modified: metadata - .modified() - .map_err(|e| error(-32603, &e.to_string()))?, - #[cfg(unix)] - identity: { - use std::os::unix::fs::MetadataExt; - ( - metadata.dev(), - metadata.ino(), - metadata.ctime(), - metadata.ctime_nsec(), - ) - }, - }) - } -} -#[derive(Clone)] -pub(crate) struct Loaded { - pub(crate) prepared: Arc, - pub(crate) hash: String, - pub(crate) fingerprint: Fingerprint, - // Retain the per-hash cell only while a cached path refers to this version. - _compiled: Arc>>, -} -type PathEntry = Arc>>; -#[derive(Default)] -pub(crate) struct Components { - paths: Mutex>, - compiled: Mutex>>>>, -} -impl Components { - pub(crate) async fn load( - &self, - path: PathBuf, - runtime: Arc, - ) -> Result { - let candidate = path - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with("activate-") || n.starts_with("discover-")); - let entry = if candidate { - Arc::default() - } else { - self.paths - .lock() - .unwrap() - .entry(path.clone()) - .or_default() - .clone() - }; - let mut cached = entry.lock().await; - let fingerprint = match Fingerprint::read(&path).await { - Ok(metadata) => metadata, - Err(error) => { - *cached = None; - return Err(error); - } - }; - if let Some(version) = cached.as_ref() - && version.fingerprint == fingerprint - { - return Ok(version.clone()); - } - let (bytes, hash) = tokio::task::spawn_blocking(move || -> std::io::Result<_> { - let bytes = std::fs::read(path)?; - let hash = format!("{:x}", Sha256::digest(&bytes)); - Ok((bytes, hash)) - }) - .await - .map_err(|e| error(-32603, &e.to_string()))? - .map_err(|e| error(-32603, &e.to_string()))?; - let compiled = { - let mut versions = self.compiled.lock().unwrap(); - versions.retain(|_, v| v.strong_count() > 0); - if let Some(cell) = versions.get(&hash).and_then(Weak::upgrade) { - cell - } else { - let cell = Arc::new(OnceCell::new()); - versions.insert(hash.clone(), Arc::downgrade(&cell)); - cell - } - }; - let prepared = compiled - .get_or_try_init(|| async move { - tokio::task::spawn_blocking(move || -> anyhow::Result<_> { - let component = runtime.compile(&bytes)?; - Ok(Arc::new(runtime.prepare(&component))) - }) - .await - .map_err(|e| error(-32603, &e.to_string()))? - .map_err(|e| error(-32603, &e.to_string())) - }) - .await? - .clone(); - let loaded = Loaded { - prepared, - hash, - fingerprint, - _compiled: compiled, - }; - if !candidate { - *cached = Some(loaded.clone()); - } - Ok(loaded) - } -} diff --git a/cli/src/console.rs b/cli/src/console.rs deleted file mode 100644 index 4199f61..0000000 --- a/cli/src/console.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! Sequential console commands, line editing, and live notifications. -use crate::Host; -use anyhow::Result; -use rustyline::{DefaultEditor, ExternalPrinter, error::ReadlineError}; -use sausage_vfs::{Layer, Open}; -use serde_json::{Value, json}; - -const HELP: &str = "text chat with the agent -plugin.method {json} call a plugin -ls [path], cat , methods -ps, log [plugin or pid] [-n N] -activate , revert -stop , kill , help, quit"; - -/// Read a complete file through the host filesystem. -async fn read(host: &Host, path: &str) -> Result { - let file = host - .filesystem() - .await? - .open( - path, - Open { - follow: true, - ..Open::default() - }, - ) - .await?; - let mut bytes = Vec::new(); - loop { - let chunk = file.read(bytes.len() as u64, 65536).await?; - if chunk.is_empty() { - return Ok(String::from_utf8(bytes)?); - } - bytes.extend(chunk); - } -} - -/// Recognize the process IDs accepted by stop, kill, and log. -fn is_pid(text: &str) -> bool { - text.rsplit_once('-').is_some() - && text - .get(text.len().saturating_sub(36)..) - .is_some_and(|id| uuid::Uuid::try_parse(id).is_ok()) -} - -/// Render method signatures and their documentation. -fn methods(plugin: &str, doc: &Value) -> String { - let mut lines = Vec::new(); - for method in doc["methods"].as_array().into_iter().flatten() { - let params = method["params"].as_array().cloned().unwrap_or_default(); - let signature = params - .iter() - .map(|p| { - format!( - "{}: {}", - p["name"].as_str().unwrap_or("?"), - p["schema"]["type"].as_str().unwrap_or("any") - ) - }) - .collect::>() - .join(", "); - lines.push(format!( - "{plugin}.{}({signature}) -> {}", - method["name"].as_str().unwrap_or("?"), - method["result"]["schema"]["type"].as_str().unwrap_or("any") - )); - for field in ["summary", "description"] { - if let Some(text) = method[field].as_str() { - lines.push(format!(" {text}")); - } - } - for p in params { - if let Some(text) = p["description"].as_str() { - lines.push(format!(" {}: {text}", p["name"].as_str().unwrap_or("?"))); - } - } - } - lines.join("\n") -} - -/// Parse journal filters, or treat invalid arguments as chat. -fn log_params(words: &[&str]) -> Option { - let mut params = json!({}); - let mut words = words.iter(); - while let Some(word) = words.next() { - if *word == "-n" { - params["limit"] = json!(words.next()?.parse::().ok()?); - } else if params.get("plugin").is_none() && params.get("pid").is_none() { - params[if is_pid(word) { "pid" } else { "plugin" }] = json!(word); - } else { - return None; - } - } - Some(params) -} - -/// Execute one line and return its unlabelled result. -pub async fn execute_line(host: &Host, line: &str) -> Result { - let line = line.trim(); - if line.is_empty() { - return Ok(String::new()); - } - let (command, rest) = line.split_once(char::is_whitespace).unwrap_or((line, "")); - let rest = rest.trim(); - let words: Vec<_> = rest.split_whitespace().collect(); - match (command, words.as_slice()) { - ("help", []) => return Ok(HELP.into()), - ("quit", []) => return Ok("Goodbye.".into()), - ("cat", [path]) => return read(host, path).await, - ("ls", [] | [_]) => { - let mut entries = host - .filesystem() - .await? - .list(words.first().copied().unwrap_or("/")) - .await?; - entries.sort_by(|a, b| a.name.cmp(&b.name)); - return Ok(entries - .iter() - .map(|e| format!("{}{}", e.name, if e.directory { "/" } else { "" })) - .collect::>() - .join("\n")); - } - ("methods", [plugin]) => { - let _activation = host.0.activations.lock().await; - host.refresh_graph() - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - return Ok(methods( - plugin, - &serde_json::from_str( - &read(host, &format!("/proc/core/plugins/{plugin}/discover.json")).await?, - )?, - )); - } - ("ps", []) => { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis(); - let mut processes: Vec<_> = host.processes().into_iter().collect(); - processes.sort_by(|a, b| a.0.cmp(&b.0)); - let mut lines = vec!["PID PLUGIN METHOD PARENT STATE AGE".into()]; - lines.extend(processes.iter().map(|(pid, p)| { - format!( - "{pid} {} {} {} {} {}s", - p.plugin, - p.method, - p.parent.as_deref().unwrap_or("-"), - p.state, - now.saturating_sub(p.start_time) / 1000 - ) - })); - return Ok(lines.join("\n")); - } - _ => {} - } - let explicit = (rest.is_empty() || rest.starts_with('{')) - && command - .split_once('.') - .is_some_and(|(p, m)| !p.is_empty() && !m.is_empty()); - let (method, params) = match (command, words.as_slice()) { - ("activate", [plugin, wasm]) => { - ("core.activate".into(), json!({"plugin":plugin,"wasm":wasm})) - } - ("revert", [plugin]) => ("core.revert".into(), json!({"plugin":plugin})), - ("stop" | "kill", [pid]) if is_pid(pid) => (format!("core.{command}"), json!({"pid":pid})), - ("log", _) if log_params(&words).is_some() => { - ("core.log".into(), log_params(&words).unwrap()) - } - _ if explicit => ( - command.into(), - if rest.is_empty() { - json!({}) - } else { - serde_json::from_str(rest)? - }, - ), - _ => ("agent.chat".into(), json!({"text":line})), - }; - let value = match host.call(&method, params).await { - Ok(value) => value, - Err(error) => return Ok(format!("error: {}", serde_json::to_string_pretty(&error)?)), - }; - if method == "agent.chat" && !explicit { - return Ok(value["text"] - .as_str() - .or_else(|| value.as_str()) - .map(str::to_owned) - .unwrap_or(serde_json::to_string_pretty(&value)?)); - } - if method == "core.log" && command == "log" { - return value - .as_array() - .unwrap() - .iter() - .map(|e| { - let time = chrono::DateTime::parse_from_rfc3339(e["time"].as_str().unwrap())? - .with_timezone(&chrono::Local); - let pid = e["pid"].as_str().unwrap(); - let prefix = format!("{}-", e["plugin"].as_str().unwrap()); - let short = pid - .strip_prefix(&prefix) - .map(|id| format!("{prefix}{}", &id[..8])) - .unwrap_or(pid.into()); - Ok(format!( - "{} {short} {} {}", - time.format("%H:%M:%S%.3f"), - e["stream"].as_str().unwrap(), - e["line"].as_str().unwrap() - )) - }) - .collect::>>() - .map(|lines| lines.join("\n")); - } - Ok(serde_json::to_string_pretty(&value)?) -} - -/// Dim text notifications and preserve other payloads. -pub fn notification_text(value: &Value) -> String { - let params = &value["params"]; - if params["name"] != "text" { - return value.to_string(); - } - let payload = ¶ms["params"]; - let text = payload["text"] - .as_str() - .or_else(|| payload.as_str()) - .map(str::to_owned) - .unwrap_or(payload.to_string()); - format!("\x1b[2m{text}\x1b[0m") -} - -/// Read and execute one line at a time. Interrupting a wait does not cancel its host process. -pub async fn run_input(host: Host) -> Result<()> { - let terminal = std::io::IsTerminal::is_terminal(&std::io::stdin()); - let history = host.0.world.state_dir("core").join("history"); - let mut editor = if terminal { - Some(DefaultEditor::new()?) - } else { - None - }; - if let Some(editor) = &mut editor { - let _ = editor.load_history(&history); - } - let mut printer = editor - .as_mut() - .map(|e| e.create_external_printer()) - .transpose()?; - let (request, requests) = - std::sync::mpsc::channel::>>>(); - std::thread::spawn(move || { - for response in requests { - let line = (|| -> Result<_> { - if let Some(editor) = &mut editor { - match editor.readline("> ") { - Ok(line) => { - editor.add_history_entry(&line)?; - editor.save_history(&history)?; - Ok(Some(line)) - } - Err(ReadlineError::Interrupted | ReadlineError::Eof) => Ok(None), - Err(e) => Err(e.into()), - } - } else { - std::io::stdin() - .lines() - .next() - .transpose() - .map_err(Into::into) - } - })(); - if response.send(line).is_err() { - break; - } - } - }); - let mut notifications = host.subscribe(); - let mut notification_task = tokio::task::JoinSet::new(); - notification_task.spawn(async move { - loop { - match notifications.recv().await { - Ok(value) => { - let text = notification_text(&value); - if let Some(printer) = &mut printer { - let _ = printer.print(format!("{text}\n")); - } else { - println!("{text}"); - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(_) => break, - } - } - }); - let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?; - loop { - let (send, line) = tokio::sync::oneshot::channel(); - request.send(send)?; - let line = - tokio::select! { result = line => result??, _ = interrupt.recv() => return Ok(()) }; - let Some(line) = line else { - return Ok(()); - }; - let text = tokio::select! { - result = execute_line(&host, &line) => result.unwrap_or_else(|e| format!("error: {e}")), - _ = interrupt.recv() => "stopped waiting; the call continues, see ps".into(), - }; - if !text.is_empty() { - println!("{text}"); - } - if line.trim() == "quit" { - return Ok(()); - } - } -} diff --git a/cli/src/core.rs b/cli/src/core.rs deleted file mode 100644 index 0f6fc0c..0000000 --- a/cli/src/core.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! The native core methods and their JSON parameters. -use crate::{Host, RpcResult, error}; -use serde_json::{Value, json}; - -impl Host { - /// Execute a core method without starting a guest process. - pub(crate) async fn core( - &self, - caller: &str, - id: Option, - method: &str, - p: Value, - ) -> RpcResult { - match method { - "watch" => self.watch(caller, id, required_str(&p, "path")?).await, - "call" => { - self.route( - caller.into(), - id, - required_str(&p, "method")?.into(), - p.get("params").cloned().unwrap_or(json!({})), - p["detach"].as_bool().unwrap_or(false), - ) - .await - } - "notify" => { - self.notify( - caller, - required_str(&p, "name")?, - p.get("params").cloned().unwrap_or(json!({})), - ); - Ok(Value::Null) - } - "kill" => { - let pid = required_str(&p, "pid")?; - let ended = self - .0 - .state - .lock() - .unwrap() - .processes - .get(pid) - .map(|p| p.ended.subscribe()); - self.kill(pid); - if let Some(mut ended) = ended { - let _ = ended.wait_for(|ended| *ended).await; - } - Ok(json!({})) - } - "stop" => { - if let Some(mut n) = self.begin_stop(required_str(&p, "pid")?) { - let _ = n.wait_for(|ended| *ended).await; - } - Ok(json!({})) - } - "lock" => { - self.lock(caller, required_str(&p, "path")?, p["wait"] != false) - .await - } - "unlock" => { - self.unlock(caller, required_str(&p, "path")?); - Ok(Value::Null) - } - "log" => Ok(self.0.journal.query(p).await), - "activate" | "revert" => self.activation(caller, method, p).await, - "restart" | "exit" => self.request_restart(), - _ => Err(error(-32601, "method not found")), - } - } -} -fn required_str<'a>(params: &'a Value, name: &str) -> Result<&'a str, Value> { - params[name] - .as_str() - .ok_or_else(|| error(-32602, &format!("{name} must be a string"))) -} diff --git a/cli/src/execution.rs b/cli/src/execution.rs deleted file mode 100644 index cd8122d..0000000 --- a/cli/src/execution.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! Execution of native futures and WASI processes, including transport drainage. -use crate::{ - Host, NativeContext, NativePlugin, - process::ProcessInfo, - runtime::{Instance, InstanceConfig}, -}; -use serde_json::{Value, json}; -use tokio::sync::mpsc; - -impl Host { - /// Run one process and retain its cleanup guard until all transport reads finish. - pub(crate) async fn run_process( - &self, - pid: String, - info: ProcessInfo, - component: Option>, - native: Option, - incoming: mpsc::UnboundedReceiver, - has_id: bool, - ) -> String { - if let Some(native) = native { - let context = NativeContext { - host: self.clone(), - pid: pid.clone(), - }; - let result = native(context, info.method, info.params).await; - self.answer(&pid, result); - return "exit 0".into(); - } - let result = self - .run_guest(&pid, &info, &component.unwrap(), incoming, has_id) - .await; - result.unwrap_or_else(|error| error.to_string()) - } - - /// Drain stdout before deciding whether the guest returned an answer. - async fn run_guest( - &self, - pid: &str, - info: &ProcessInfo, - component: &crate::runtime::Prepared, - mut incoming: mpsc::UnboundedReceiver, - has_id: bool, - ) -> anyhow::Result { - let (memory, tracker, store_ended) = { - let state = self.0.state.lock().unwrap(); - let p = state.processes.get(pid).unwrap(); - (p.memory.clone(), p.files.clone(), p.store_ended.clone()) - }; - let config = InstanceConfig { - filesystem: self.mounts(Some(pid.into()), memory, tracker).await?, - store_ended, - network: true, - env: vec![ - ("SAUSAGE_PID".into(), pid.into()), - ("SAUSAGE_PLUGIN".into(), info.plugin.clone()), - ( - "SAUSAGE_PARENT".into(), - info.parent.clone().unwrap_or_else(|| "core".into()), - ), - ], - }; - let mut instance = self - .0 - .runtime - .start_logged( - component, - config, - self.0.journal.clone(), - pid.into(), - info.plugin.clone(), - ) - .await?; - let mut initial = json!({ - "jsonrpc": "2.0", - "method": info.method, - "params": info.params, - }); - if has_id { - initial["id"] = json!(pid); - } - let mut stdin_open = self.forward_input(pid, &mut instance, Some(initial)).await; - loop { - tokio::select! { - line = instance.stdout.recv() => { - if !self.stdout_line(pid,line) { break; } - } - value = incoming.recv(), if stdin_open => { - stdin_open = self.forward_input(pid,&mut instance,value).await; - } - } - } - Ok(instance.wait().await.to_string()) - } - - /// Close stdin only after the queued responses reach the guest. - async fn forward_input( - &self, - pid: &str, - instance: &mut Instance, - value: Option, - ) -> bool { - if let Some(value) = value - && self.write_input(pid, instance, value).await.is_ok() - { - return true; - } - instance.stdin.take(); - false - } - async fn write_input( - &self, - pid: &str, - instance: &mut Instance, - value: Value, - ) -> std::io::Result<()> { - instance - .send_line(&value.to_string(), |line| self.output(pid, line)) - .await - } - fn stdout_line(&self, pid: &str, line: Option) -> bool { - if let Some(line) = line { - self.output(pid, line); - true - } else { - false - } - } -} diff --git a/cli/src/filesystem.rs b/cli/src/filesystem.rs deleted file mode 100644 index d0d83f5..0000000 --- a/cli/src/filesystem.rs +++ /dev/null @@ -1,324 +0,0 @@ -//! Per-process mount tables, reserved self files, and the live process namespace. -use crate::Host; -use sausage_vfs::{ - Entry, File, Generated, Layer, Memory, Mounts, Open, Process, Result, Stat, async_trait, -}; -use std::{io, sync::Arc}; - -fn denied() -> Result { - Err(io::ErrorKind::ReadOnlyFilesystem.into()) -} -fn missing() -> Result { - Err(io::ErrorKind::NotFound.into()) -} -fn directory() -> Stat { - Stat { - directory: true, - symlink: false, - size: 0, - accessed: None, - modified: None, - } -} -fn entry(name: String, directory: bool) -> Entry { - Entry { - name, - directory, - symlink: false, - } -} -fn reserved(path: &str) -> bool { - matches!( - path.trim_start_matches('/').split('/').next(), - Some("info.json" | "log" | "fs") - ) -} - -impl Host { - /// Create a console filesystem view without adding a process. Its `/self` is private and ephemeral. - pub async fn filesystem(&self) -> anyhow::Result { - self.mounts(None, Memory::default(), Process::default()) - .await - } - pub(crate) async fn mounts( - &self, - pid: Option, - memory: Memory, - tracker: Process, - ) -> anyhow::Result { - let root = self.0.world.root.clone(); - let operations = tracker.clone(); - let host = tracker - .clone() - .blocking(move || sausage_vfs::Host::new(root, tracker)) - .await?; - let mut mounts = Mounts::default(); - mounts.mount( - "/", - self.0 - .observer - .layer(Arc::new(host), "/", operations.clone()), - false, - ); - mounts.mount("/proc", Arc::new(Proc(self.clone())), true); - mounts.mount( - "/self", - self.0.observer.layer( - Arc::new(SelfFolder { - host: self.clone(), - pid: pid.clone(), - memory, - }), - &format!("/proc/{}", pid.as_deref().unwrap_or("console")), - operations.clone(), - ), - false, - ); - mounts.mount( - "/run", - self.0 - .observer - .layer(Arc::new(self.0.run.clone()), "/run", operations), - false, - ); - Ok(mounts) - } - fn core_files(&self) -> Generated { - let mut files = Generated::default(); - let protocol = include_bytes!("../../PROTOCOL.md"); - files.insert("/PROTOCOL.md", move || Ok(protocol.to_vec())); - let graph = self.graph_json(); - files.insert("/graph.json", move || Ok(graph.clone())); - let dot = self.graph_dot(); - files.insert("/graph.dot", move || Ok(dot.clone())); - let locks = { - let state = self.0.state.lock().unwrap(); - let locks: std::collections::BTreeMap<_, _> = state - .locks - .iter() - .map(|(path, lock)| { - let waiting: Vec<_> = lock.queue.iter().map(|(pid, _)| pid).collect(); - let info = serde_json::json!({ - "owner": lock.owner, - "waiting": waiting, - }); - (path.clone(), info) - }) - .collect(); - serde_json::to_vec(&locks).unwrap() - }; - files.insert("/locks.json", move || Ok(locks.clone())); - for name in self.discovered_plugins() { - if let Some(doc) = self.discover_document(&name) { - let bytes = serde_json::to_vec(&doc).unwrap(); - files.insert(&format!("/plugins/{name}/discover.json"), move || { - Ok(bytes.clone()) - }); - files.insert(&format!("/plugins/{name}/client.rs"), move || { - sausage_interface::client_rs(&name, &doc) - .map(String::into_bytes) - .map_err(io::Error::other) - }); - } - } - files - } -} -struct Proc(Host); -impl Proc { - fn folder(&self, path: &str) -> Result<(SelfFolder, String)> { - let (pid, rest) = path - .trim_start_matches('/') - .split_once('/') - .unwrap_or((path.trim_start_matches('/'), "")); - let memory = self - .0 - .0 - .state - .lock() - .unwrap() - .processes - .get(pid) - .map(|p| p.memory.clone()) - .ok_or(io::ErrorKind::NotFound)?; - Ok(( - SelfFolder { - host: self.0.clone(), - pid: Some(pid.into()), - memory, - }, - format!("/{rest}"), - )) - } -} -#[async_trait] -impl Layer for Proc { - async fn stat(&self, path: &str, follow: bool) -> Result { - let path = path.trim_end_matches('/'); - let path = if path.is_empty() { "/" } else { path }; - if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") { - return Ok(directory()); - } - if let Some(path) = path.strip_prefix("/core/") { - return self.0.core_files().stat(&format!("/{path}"), follow).await; - } - let (folder, path) = self.folder(path)?; - folder.stat(&path, follow).await - } - async fn list(&self, path: &str) -> Result> { - let path = path.trim_end_matches('/'); - let path = if path.is_empty() { "/" } else { path }; - if path == "/" { - let mut entries = vec![entry("core".into(), true)]; - entries.extend(self.0.processes().into_keys().map(|pid| entry(pid, true))); - entries.sort_by(|a, b| a.name.cmp(&b.name)); - return Ok(entries); - } - if path.trim_end_matches('/') == "/core/plugins" { - return Ok(self - .0 - .discovered_plugins() - .into_iter() - .filter(|name| self.0.discover_document(name).is_some()) - .map(|name| entry(name, true)) - .collect()); - } - if path.trim_end_matches('/') == "/core" { - let mut entries = self.0.core_files().list("/").await?; - if !entries.iter().any(|e| e.name == "plugins") { - entries.push(entry("plugins".into(), true)); - } - return Ok(entries); - } - if let Some(path) = path.strip_prefix("/core/") { - return self.0.core_files().list(&format!("/{path}")).await; - } - let (folder, path) = self.folder(path)?; - folder.list(&path).await - } - async fn open(&self, path: &str, options: Open) -> Result> { - let path = path.trim_end_matches('/'); - let path = if path.is_empty() { "/" } else { path }; - if options.write || options.create || options.truncate { - return denied(); - } - if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") { - return Memory::default().open("/", options).await; - } - if let Some(path) = path.strip_prefix("/core/") { - return self.0.core_files().open(&format!("/{path}"), options).await; - } - let (folder, path) = self.folder(path)?; - folder.open(&path, options).await - } - async fn mkdir(&self, _: &str) -> Result<()> { - denied() - } - async fn remove_file(&self, _: &str) -> Result<()> { - denied() - } - async fn remove_dir(&self, _: &str) -> Result<()> { - denied() - } - async fn rename(&self, _: &str, _: &str) -> Result<()> { - denied() - } -} -struct SelfFolder { - host: Host, - pid: Option, - memory: Memory, -} -impl SelfFolder { - async fn generated(&self, path: &str) -> Result { - let pid = self.pid.as_ref().ok_or(io::ErrorKind::NotFound)?; - let bytes = match path { - "/info.json" => { - let info = self - .host - .0 - .state - .lock() - .unwrap() - .processes - .get(pid) - .map(|p| p.info.clone()) - .ok_or(io::ErrorKind::NotFound)?; - serde_json::to_vec(&info).unwrap() - } - "/log" => { - let lines = self - .host - .0 - .journal - .query(serde_json::json!({"pid":pid,"limit":i64::MAX})) - .await; - let mut bytes = Vec::new(); - for line in lines.as_array().unwrap() { - serde_json::to_writer(&mut bytes, line).unwrap(); - bytes.push(b'\n'); - } - bytes - } - _ => return missing(), - }; - let mut files = Generated::default(); - files.insert(path, move || Ok(bytes.clone())); - Ok(files) - } -} -#[async_trait] -impl Layer for SelfFolder { - async fn stat(&self, path: &str, follow: bool) -> Result { - if reserved(path) { - self.generated(path).await?.stat(path, follow).await - } else { - self.memory.stat(path, follow).await - } - } - async fn list(&self, path: &str) -> Result> { - let mut entries = self.memory.list(path).await?; - if path == "/" && self.pid.is_some() { - entries.extend([entry("info.json".into(), false), entry("log".into(), false)]); - } - Ok(entries) - } - async fn open(&self, path: &str, options: Open) -> Result> { - if reserved(path) { - if options.write || options.create || options.truncate { - return denied(); - } - self.generated(path).await?.open(path, options).await - } else { - self.memory.open(path, options).await - } - } - async fn mkdir(&self, path: &str) -> Result<()> { - if reserved(path) { - denied() - } else { - self.memory.mkdir(path).await - } - } - async fn remove_file(&self, path: &str) -> Result<()> { - if reserved(path) { - denied() - } else { - self.memory.remove_file(path).await - } - } - async fn remove_dir(&self, path: &str) -> Result<()> { - if reserved(path) || path == "/" { - denied() - } else { - self.memory.remove_dir(path).await - } - } - async fn rename(&self, from: &str, to: &str) -> Result<()> { - if reserved(from) || reserved(to) || from == "/" || to == "/" { - denied() - } else { - self.memory.rename(from, to).await - } - } -} diff --git a/cli/src/graph.rs b/cli/src/graph.rs deleted file mode 100644 index 38ed297..0000000 --- a/cli/src/graph.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! Cached discovery documents and import checks under PROTOCOL.md section 10.4. -//! Refreshes use immutable copies. The proc layer only reads serialized cache entries. -use crate::{Host, RpcResult}; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; -use std::{ - collections::BTreeMap, - path::{Path, PathBuf}, -}; - -#[derive(Clone)] -pub(crate) struct Interface { - pub(crate) hash: String, - pub(crate) document: Value, - pub(crate) error: Option, -} -pub(crate) struct Graph { - pub(crate) plugins: BTreeMap, - json: Vec, - dot: Vec, -} - -impl Default for Graph { - /// Exposes a valid empty graph before background discovery completes. - fn default() -> Self { - Self { - plugins: BTreeMap::new(), - json: b"{\"plugins\":{}}".to_vec(), - dot: b"digraph plugins {}\n".to_vec(), - } - } -} - -/// Hashes exactly the bytes copied for discovery or installation. -pub(crate) fn hash(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) -} - -/// Reads and hashes one immutable snapshot without blocking an async worker. -pub(crate) async fn read_hashed(path: PathBuf) -> std::io::Result<(Vec, String)> { - tokio::task::spawn_blocking(move || -> std::io::Result<_> { - let bytes = std::fs::read(path)?; - let hash = hash(&bytes); - Ok((bytes, hash)) - }) - .await - .map_err(std::io::Error::other)? -} - -/// Lists broken imports, optionally restricted to changed consumers or providers. -pub(crate) fn problems( - plugins: &BTreeMap, - changed: Option<&[String]>, -) -> Vec { - let mut failures = Vec::new(); - for (consumer, interface) in plugins { - if let Some(imports) = interface.document["x-sausage-imports"].as_object() { - for (provider, expected) in imports { - if changed - .is_some_and(|names| !names.contains(consumer) && !names.contains(provider)) - { - continue; - } - let Some(actual) = plugins.get(provider) else { - failures.push(json!({"consumer":consumer,"provider":provider,"status":"missing","message":"the provider does not exist"})); - continue; - }; - if let Some(error) = &actual.error { - failures.push(json!({"consumer":consumer,"provider":provider,"status":"unavailable","message":"discovery failed","error":error})); - continue; - } - for problem in sausage_interface::compat::check(expected, &actual.document) { - failures.push(json!({"consumer":consumer,"provider":provider,"status":"broken","method":problem.method,"message":problem.message,"counterexample":problem.counterexample,"display":problem.to_string()})); - } - } - } - } - failures -} - -impl Host { - /// The cached JSON graph. This does not perform filesystem or RPC operations. - pub(crate) fn graph_json(&self) -> Vec { - self.0.graph.lock().unwrap().json.clone() - } - /// The cached DOT graph, with an edge for every import. - pub(crate) fn graph_dot(&self) -> Vec { - self.0.graph.lock().unwrap().dot.clone() - } - /// Plugin names currently present in the discovery cache. - pub(crate) fn discovered_plugins(&self) -> Vec { - self.0 - .graph - .lock() - .unwrap() - .plugins - .keys() - .cloned() - .collect() - } - /// The current document, if discovery succeeded. - pub(crate) fn discover_document(&self, plugin: &str) -> Option { - self.0 - .graph - .lock() - .unwrap() - .plugins - .get(plugin) - .filter(|entry| entry.error.is_none()) - .map(|entry| entry.document.clone()) - } - /// Discovers a private copy and caches successful documents by file hash. - pub(crate) async fn discover_copy( - &self, - plugin: &str, - path: &Path, - hash: String, - ) -> Result { - let request = json!(uuid::Uuid::now_v7().to_string()); - let discovery = self.route_from( - "core".into(), - Some(request.clone()), - format!("{plugin}.rpc.discover"), - json!({}), - true, - Some(path.to_path_buf()), - ); - let result = tokio::time::timeout(std::time::Duration::from_secs(10), discovery).await; - let processes: Vec<_> = self - .0 - .state - .lock() - .unwrap() - .processes - .iter() - .filter(|(_, process)| process.id.as_ref() == Some(&request)) - .map(|(pid, _)| pid.clone()) - .collect(); - for pid in processes { - self.begin_stop(&pid); - } - let document = result - .map_err(|_| crate::error(-32003, "plugin discovery timed out after 10 seconds"))??; - if !document["methods"].is_array() { - return Err(crate::error( - -32003, - "rpc.discover did not return an OpenRPC document", - )); - } - Ok(Interface { - hash, - document, - error: None, - }) - } - /// Refreshes every installed plugin while the caller holds the activation mutex. - pub(crate) async fn refresh_graph(&self) -> RpcResult { - self.refresh_graph_except(&[]).await - } - pub(crate) async fn refresh_graph_except(&self, candidates: &[String]) -> RpcResult { - let mut directory = tokio::fs::read_dir(self.0.world.root.join("plugins")) - .await - .map_err(io_error)?; - let mut plugins = BTreeMap::new(); - while let Some(entry) = directory.next_entry().await.map_err(io_error)? { - if !entry.file_type().await.map_err(io_error)?.is_dir() { - continue; - } - let name = entry.file_name().to_string_lossy().into_owned(); - let source = entry.path().join("plugin.wasm"); - let (bytes, hash) = match read_hashed(source).await { - Ok(snapshot) => snapshot, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - plugins.insert( - name, - Interface { - hash: String::new(), - document: Value::Null, - error: Some(io_error(error)), - }, - ); - continue; - } - }; - let previous = self.0.graph.lock().unwrap().plugins.get(&name).cloned(); - let interface = if candidates.contains(&name) { - previous - .filter(|entry| entry.hash == hash) - .unwrap_or(Interface { - hash, - document: Value::Null, - error: None, - }) - } else if let Some(previous) = - previous.filter(|entry| entry.hash == hash && entry.error.is_none()) - { - previous - } else { - let copy = entry - .path() - .join(format!("discover-{}.wasm", uuid::Uuid::now_v7())); - let result = match tokio::fs::write(©, &bytes).await { - Ok(()) => self.discover_copy(&name, ©, hash.clone()).await, - Err(error) => Err(io_error(error)), - }; - let _ = tokio::fs::remove_file(©).await; - match result { - Ok(interface) => interface, - Err(error) => Interface { - hash, - document: Value::Null, - error: Some(error), - }, - } - }; - plugins.insert(name, interface); - } - let admission = self.0.admission.write().await; - let old_plugins = self.0.graph.lock().unwrap().plugins.clone(); - let mut upgrades = Vec::new(); - for (plugin, current) in &plugins { - if let Some(old) = old_plugins.get(plugin).filter(|old| { - !old.hash.is_empty() && old.hash != current.hash && current.error.is_none() - }) { - upgrades.push(self.snapshot_upgrade(plugin, old.hash.clone(), ¤t.hash)); - } - } - self.publish_graph(plugins); - drop(admission); - for upgrade in upgrades { - self.begin_upgrade(upgrade); - } - Ok(json!({})) - } - /// Rebuilds serialized snapshots after a watcher refresh or an activation. - pub(crate) fn publish_graph(&self, plugins: BTreeMap) { - let failures = problems(&plugins, None); - for failure in &failures { - self.0 - .journal - .write("core", "core", "core", &format!("broken import {failure}")); - } - let mut entries = serde_json::Map::new(); - let mut dot = String::from("digraph plugins {\n"); - for (name, interface) in &plugins { - dot.push_str(&format!(" {};\n", json!(name))); - let mut imports = serde_json::Map::new(); - for provider in interface.document["x-sausage-imports"] - .as_object() - .into_iter() - .flat_map(|imports| imports.keys()) - { - let issues: Vec<_> = failures - .iter() - .filter(|problem| { - problem["consumer"] == *name && problem["provider"] == *provider - }) - .cloned() - .collect(); - let status = issues - .first() - .and_then(|problem| problem["status"].as_str()) - .unwrap_or("compatible"); - imports.insert(provider.clone(), json!({"status":status,"problems":issues})); - dot.push_str(&format!( - " {} -> {} [label={}];\n", - json!(name), - json!(provider), - json!(status) - )); - } - entries.insert( - name.clone(), - json!({"hash":interface.hash,"imports":imports,"error":interface.error}), - ); - } - dot.push_str("}\n"); - let mut graph = self.0.graph.lock().unwrap(); - graph.json = serde_json::to_vec(&json!({"plugins":entries})).unwrap(); - graph.dot = dot.into_bytes(); - graph.plugins = plugins; - } -} -/// Converts a filesystem failure into a JSON-RPC internal error. -pub(crate) fn io_error(error: std::io::Error) -> Value { - crate::error(-32603, &error.to_string()) -} diff --git a/cli/src/host.rs b/cli/src/host.rs deleted file mode 100644 index 4b23ed4..0000000 --- a/cli/src/host.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Host startup, boot calls, and the public library API. -use crate::{RpcResult, components, journal, runtime::Runtime, world::World}; -use crate::{ - locks::Lock, - native::NativePlugin, - process::{Process, ProcessInfo}, -}; -use serde_json::{Value, json}; -use std::{ - collections::HashMap, - path::Path, - sync::{Arc, Mutex}, -}; -use tokio::sync::watch; -use uuid::Uuid; -pub(crate) struct State { - pub(crate) processes: HashMap, - pub(crate) locks: HashMap, - pub(crate) native: HashMap, - pub(crate) services: Vec, -} -pub(crate) struct Inner { - pub(crate) world: World, - pub(crate) runtime: Arc, - pub(crate) state: Mutex, - pub(crate) journal: Arc, - pub(crate) notifications: tokio::sync::broadcast::Sender, - pub(crate) exit: watch::Sender, - pub(crate) loop_supervised: bool, - pub(crate) restart_pending: std::sync::atomic::AtomicBool, - pub(crate) components: components::Components, - /// Calls hold it for reading from the choice of a version until their - /// process is in the table. Activation holds it for writing while it - /// installs a version, so that it sees every process of the old version. - pub(crate) admission: tokio::sync::RwLock<()>, - /// Set when the host starts to stop. After that, the core starts no new - /// processes, and upgrade tasks do nothing. - pub(crate) shutdown: std::sync::atomic::AtomicBool, - pub(crate) run: sausage_vfs::Memory, - pub(crate) watches: crate::watches::Watches, - pub(crate) observer: sausage_vfs::observe::Observer, - pub(crate) graph: Mutex, - pub(crate) activations: tokio::sync::Mutex<()>, - pub(crate) watcher: Mutex>, - pub(crate) retirements: Mutex>, -} -#[derive(Clone)] -/// A cloneable handle to a world and its running processes. -pub struct Host(pub(crate) Arc); - -impl Host { - /// Start a world with the default compile cache and its ordered boot calls. - pub async fn start(path: &Path) -> anyhow::Result { - let base = std::env::var_os("XDG_CACHE_HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| { - std::path::PathBuf::from(std::env::var_os("HOME").unwrap_or_default()) - .join(".cache") - }); - Self::start_with_cache(path, &base.join("sausage")).await - } - /// Start a world with an explicit compile cache folder. - pub async fn start_with_cache(path: &Path, cache: &Path) -> anyhow::Result { - let path = path.to_path_buf(); - let cargo_cache = cache.to_path_buf(); - let cache = cache.to_path_buf(); - let (world, runtime) = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { - Ok((World::open(&path)?, Runtime::new(&cache)?)) - }) - .await??; - let journal = journal::Journal::open_with_legacy( - world.root.join("logs/core/journal.sqlite"), - Some(world.state_dir("core").join("journal.sqlite")), - ) - .await?; - let host = Self::with_parts(world, runtime, journal); - let cargo_world = host.0.world.clone(); - let cargo = tokio::task::spawn_blocking(move || { - crate::cargo::Cargo::new(cargo_world, &cargo_cache) - }) - .await??; - cargo.register(&host); - host.boot().await?; - Ok(host) - } - /// Creates an empty test world with a shared engine and an in-memory journal. - pub async fn test_world(path: &Path, runtime: Arc) -> anyhow::Result { - std::fs::create_dir_all(path)?; - Ok(Self::with_parts( - World::open(path)?, - runtime, - journal::Journal::memory().await?, - )) - } - fn with_parts(world: World, runtime: Arc, journal: journal::Journal) -> Self { - let watches = crate::watches::Watches::default(); - let events = watches.clone(); - let observer = - sausage_vfs::observe::Observer::new(move |path| crate::watches::changed(&events, path)); - Self(Arc::new(Inner { - world, - runtime, - journal: Arc::new(journal), - state: Mutex::new(State { - processes: HashMap::new(), - locks: HashMap::new(), - native: HashMap::new(), - services: Vec::new(), - }), - notifications: tokio::sync::broadcast::channel(1024).0, - exit: watch::channel(false).0, - loop_supervised: crate::restart::enabled(std::env::var_os("SAUSAGE_LOOP").as_deref()), - restart_pending: std::sync::atomic::AtomicBool::new(false), - components: components::Components::default(), - admission: tokio::sync::RwLock::new(()), - shutdown: std::sync::atomic::AtomicBool::new(false), - run: sausage_vfs::Memory::default(), - watches, - observer, - graph: Mutex::new(crate::graph::Graph::default()), - activations: tokio::sync::Mutex::new(()), - watcher: Mutex::new(None), - retirements: Mutex::new(std::collections::HashSet::new()), - })) - } - /// Register a native plugin for subsequent calls. - pub fn register_native(&self, name: &str, plugin: NativePlugin) { - self.0 - .state - .lock() - .unwrap() - .native - .insert(name.into(), plugin); - } - /// Subscribe to notifications addressed to the console. - pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { - self.0.notifications.subscribe() - } - /// Snapshot the metadata of each process that has not ended. - pub fn processes(&self) -> HashMap { - self.0 - .state - .lock() - .unwrap() - .processes - .iter() - .map(|(p, s)| (p.clone(), s.info.clone())) - .collect() - } - /// Make a console-side call and wait for its JSON result. - pub async fn call(&self, method: &str, params: Value) -> RpcResult { - self.route( - "console".into(), - Some(json!(Uuid::now_v7().to_string())), - method.into(), - params, - false, - ) - .await - } - /// Wait until core.restart (or its alias core.exit) requests shutdown after its response grace. - pub async fn exit_requested(&self) { - let mut exit = self.0.exit.subscribe(); - let _ = exit.wait_for(|requested| *requested).await; - } - /// Return whether a restart has reached the normal shutdown path. - pub fn restart_requested(&self) -> bool { - *self.0.exit.borrow() - } -} diff --git a/cli/src/journal.rs b/cli/src/journal.rs deleted file mode 100644 index 5b26b14..0000000 --- a/cli/src/journal.rs +++ /dev/null @@ -1,411 +0,0 @@ -//! A dedicated SQLite writer batches entries and serves ordered queries without propagating disk failures into the host. -use rusqlite::{Connection, OpenFlags, params}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::io::Write; -use std::{ - collections::HashSet, - path::PathBuf, - sync::{Mutex, mpsc}, -}; -use tokio::sync::oneshot; -#[derive(Debug, Serialize, Deserialize)] -struct Entry { - time: String, - id: String, - pid: String, - plugin: String, - stream: String, - line: String, -} -enum Command { - Write(Entry), - Query(Value, oneshot::Sender), -} -pub(crate) struct Journal { - sender: mpsc::Sender, - warned: Mutex>, - spool: PathBuf, -} -impl Journal { - pub(crate) async fn memory() -> anyhow::Result { - Self::open(PathBuf::from(":memory:")).await - } - pub(crate) async fn open(path: PathBuf) -> anyhow::Result { - Self::open_with_legacy(path, None).await - } - pub(crate) async fn open_with_legacy( - path: PathBuf, - legacy: Option, - ) -> anyhow::Result { - let spool = path - .parent() - .unwrap_or(std::path::Path::new(".")) - .join("failed-batches"); - let worker_spool = spool.clone(); - let (sender, receiver) = mpsc::channel(); - let (ready, initialized) = oneshot::channel(); - std::thread::spawn(move || { - match (|| -> anyhow::Result<_> { - let db = open_database(path)?; - // Never rename, checkpoint, or write the old SQLite/WAL files. - let old = legacy - .filter(|p| p.exists()) - .map(|p| Connection::open_with_flags(p, OpenFlags::SQLITE_OPEN_READ_ONLY)) - .transpose()?; - Ok((db, old)) - })() { - Ok((mut db, old)) => { - let _ = ready.send(Ok(())); - let mut pending = Vec::new(); - while let Ok(first) = receiver.recv() { - let mut batch = vec![first]; - batch.extend(receiver.try_iter().take(255)); - pending = write_batch(&mut db, old.as_ref(), &worker_spool, pending, batch); - } - if !pending.is_empty() { - // Both storage locations failed. Last resort: retain full text in host stderr. - eprintln!( - "journal shutdown with unpersisted entries: {}", - serde_json::to_string(&pending).unwrap() - ); - } - } - Err(error) => { - eprintln!("journal open failed: {error}"); - let _ = ready.send(Err(error)); - } - } - }); - initialized.await??; - Ok(Self { - sender, - warned: Mutex::new(HashSet::new()), - spool, - }) - } - pub(crate) fn write(&self, pid: &str, plugin: &str, stream: &str, line: &str) { - let entry = Entry { - time: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), - id: uuid::Uuid::now_v7().to_string(), - pid: pid.into(), - plugin: plugin.into(), - stream: stream.into(), - line: line.into(), - }; - if let Err(mpsc::SendError(Command::Write(entry))) = self.sender.send(Command::Write(entry)) - { - eprintln!("journal writer stopped; saving entry outside SQLite"); - if let Err(error) = spool_batch(&self.spool, std::slice::from_ref(&entry)) { - eprintln!( - "journal spool failed: {error}; unpersisted entry: {}", - serde_json::to_string(&entry).unwrap() - ); - } - } - } - pub(crate) fn warning(&self, pid: &str, plugin: &str) { - if self.warned.lock().unwrap().insert(pid.into()) { - self.write(pid, plugin, "core", "warning: discarded stdout"); - } - } - pub(crate) fn forget(&self, pid: &str) { - self.warned.lock().unwrap().remove(pid); - } - pub(crate) fn query_later(&self, filters: Value) -> oneshot::Receiver { - let (tx, rx) = oneshot::channel(); - if self.sender.send(Command::Query(filters, tx)).is_err() { - eprintln!("journal writer stopped"); - } - rx - } - pub(crate) async fn query(&self, filters: Value) -> Value { - self.query_later(filters) - .await - .unwrap_or_else(|_| json!([])) - } -} -fn open_database(path: PathBuf) -> anyhow::Result { - if path != std::path::Path::new(":memory:") { - std::fs::create_dir_all(path.parent().unwrap())?; - } - let db = Connection::open(path)?; - db.execute_batch("PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - CREATE TABLE IF NOT EXISTS journal (id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT); - CREATE INDEX IF NOT EXISTS journal_pid ON journal(pid); - CREATE INDEX IF NOT EXISTS journal_plugin ON journal(plugin);")?; - Ok(db) -} -/// Exclusive, synced JSON files preserve failed transactions without a busy retry loop. -/// Recovery is deliberate: files are never automatically removed or replayed. -fn spool_batch(dir: &std::path::Path, entries: &[Entry]) -> anyhow::Result { - std::fs::create_dir_all(dir)?; - let path = dir.join(format!("{}.json", uuid::Uuid::now_v7())); - // A failed/partial write must not look like a complete recovery batch. - let mut file = tempfile::NamedTempFile::new_in(dir)?; - serde_json::to_writer(&mut file, entries)?; - file.write_all(b"\n")?; - file.as_file().sync_all()?; - file.persist_noclobber(&path)?; - std::fs::File::open(dir)?.sync_all()?; - eprintln!( - "journal failed batch preserved: {} ({} entries)", - path.display(), - entries.len() - ); - Ok(path) -} - -fn write_batch( - db: &mut Connection, - old: Option<&Connection>, - spool: &std::path::Path, - mut pending: Vec, - batch: Vec, -) -> Vec { - let mut queries = Vec::new(); - for command in batch { - match command { - Command::Write(entry) => pending.push(entry), - Command::Query(filters, response) => queries.push((filters, response)), - } - } - if !pending.is_empty() { - let write = (|| -> rusqlite::Result<()> { - let transaction = db.transaction()?; - for e in &pending { - transaction.execute("INSERT OR IGNORE INTO journal (id,time,pid,plugin,stream,line) VALUES (?1,?2,?3,?4,?5,?6)",params![e.id,e.time,e.pid,e.plugin,e.stream,e.line])?; - } - transaction.commit() - })(); - match write { - Ok(()) => pending.clear(), - Err(error) => { - eprintln!("journal write failed: {error}"); - match spool_batch(spool, &pending) { - Ok(_) => pending.clear(), - Err(error) => eprintln!( - "journal spool failed: {error}; retaining {} entries in memory until next command", - pending.len() - ), - } - } - } - } - for (filters, response) in queries { - let value = query_all(db, old, &filters).unwrap_or_else(|error| { - eprintln!("journal query failed: {error}"); - json!([]) - }); - let _ = response.send(value); - } - pending -} - -fn query_all( - db: &Connection, - old: Option<&Connection>, - filters: &Value, -) -> rusqlite::Result { - let mut entries = query_database(db, filters)?.as_array().unwrap().clone(); - if let Some(old) = old { - entries.extend( - query_database(old, filters)? - .as_array() - .unwrap() - .iter() - .cloned(), - ); - entries.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str())); - entries.dedup_by(|a, b| a["id"] == b["id"]); - let limit = filters["limit"].as_i64().unwrap_or(100); - if limit >= 0 { - entries.drain(..entries.len().saturating_sub(limit as usize)); - } - } - Ok(json!(entries)) -} -fn query_database(db: &Connection, filters: &Value) -> rusqlite::Result { - let mut statement = db.prepare( - "SELECT id,time,pid,plugin,stream,line FROM journal - WHERE (?1 IS NULL OR plugin=?1) AND (?2 IS NULL OR pid=?2) - AND (?3 IS NULL OR id>=?3) AND (?4 IS NULL OR id<=?4) - AND (?5 IS NULL OR instr(line,?5)>0) ORDER BY id DESC LIMIT ?6", - )?; - let rows=statement.query_map(params![filters["plugin"].as_str(),filters["pid"].as_str(),filters["since"].as_str(),filters["until"].as_str(),filters["grep"].as_str(),filters["limit"].as_i64().unwrap_or(100)],|row| Ok(json!({ - "id":row.get::<_,String>(0)?,"time":row.get::<_,String>(1)?,"pid":row.get::<_,String>(2)?,"plugin":row.get::<_,String>(3)?,"stream":row.get::<_,String>(4)?,"line":row.get::<_,String>(5)?, - })))?; - let mut entries: Vec<_> = rows.collect::>()?; - entries.reverse(); - Ok(json!(entries)) -} - -#[cfg(test)] -mod tests { - use super::*; - fn entry(line: &str) -> Entry { - Entry { - id: uuid::Uuid::now_v7().to_string(), - time: "2026-10-26T12:00:00.000Z".into(), - pid: "pid".into(), - plugin: "test".into(), - stream: "core".into(), - line: line.into(), - } - } - - #[test] - fn failed_transaction_is_spooled_in_full_and_queries_finish() { - let dir = tempfile::tempdir().unwrap(); - let mut db = open_database(PathBuf::from(":memory:")).unwrap(); - db.execute_batch("PRAGMA query_only=ON").unwrap(); - let original = "😀 full failure\n".repeat(6000); - let (tx, mut rx) = oneshot::channel(); - let pending = write_batch( - &mut db, - None, - dir.path(), - vec![], - vec![ - Command::Write(entry(&original)), - Command::Query(json!({}), tx), - ], - ); - assert!(pending.is_empty()); - assert_eq!(rx.try_recv().unwrap(), json!([])); - let path = std::fs::read_dir(dir.path()) - .unwrap() - .next() - .unwrap() - .unwrap() - .path(); - let saved: Vec = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); - assert_eq!(saved.len(), 1); - assert_eq!(saved[0].line, original); - assert_eq!(saved[0].time, "2026-10-26T12:00:00.000Z"); - } - - #[test] - fn failed_spool_retries_on_next_command_without_duplicates() { - let dir = tempfile::tempdir().unwrap(); - let blocked = dir.path().join("blocked"); - std::fs::write(&blocked, "not a directory").unwrap(); - let mut db = open_database(PathBuf::from(":memory:")).unwrap(); - db.execute_batch("PRAGMA query_only=ON").unwrap(); - let pending = write_batch( - &mut db, - None, - &blocked, - vec![], - vec![Command::Write(entry("retained"))], - ); - assert_eq!(pending.len(), 1); - db.execute_batch("PRAGMA query_only=OFF").unwrap(); - let (tx, mut rx) = oneshot::channel(); - let pending = write_batch( - &mut db, - None, - &blocked, - pending, - vec![Command::Query(json!({}), tx)], - ); - assert!(pending.is_empty()); - assert_eq!(rx.try_recv().unwrap()[0]["line"], "retained"); - assert_eq!( - query_all(&db, None, &json!({})) - .unwrap() - .as_array() - .unwrap() - .len(), - 1 - ); - } - - #[tokio::test] - async fn legacy_wal_and_new_journal_share_filters_order_and_limit() { - let dir = tempfile::tempdir().unwrap(); - let legacy = dir.path().join("state/core/journal.sqlite"); - let mut old = open_database(legacy.clone()).unwrap(); - write_batch( - &mut old, - None, - dir.path(), - vec![], - vec![Command::Write(entry("old match"))], - ); - // Leave the WAL connection open: opening the new journal must not move its files. - let wal = legacy.with_extension("sqlite-wal"); - let before = std::fs::read(&wal).unwrap(); - let new = dir.path().join("logs/core/journal.sqlite"); - let journal = Journal::open_with_legacy(new.clone(), Some(legacy.clone())) - .await - .unwrap(); - journal.write("pid", "test", "core", "new match"); - let entries = tokio::time::timeout( - std::time::Duration::from_secs(5), - journal.query(json!({"pid":"pid","grep":"match","limit":-1})), - ) - .await - .unwrap(); - assert_eq!(entries.as_array().unwrap().len(), 2); - assert_eq!(entries[0]["line"], "old match"); - assert_eq!(entries[1]["line"], "new match"); - assert_eq!( - journal.query(json!({"limit":1})).await[0]["line"], - "new match" - ); - assert_eq!( - journal.query(json!({"until":entries[0]["id"]})).await[0]["line"], - "old match" - ); - assert_eq!( - journal - .query(json!({"since":entries[1]["id"],"plugin":"test"})) - .await[0]["line"], - "new match" - ); - assert_eq!(journal.query(json!({"grep":"absent"})).await, json!([])); - assert_eq!(std::fs::read(wal).unwrap(), before); - assert!(legacy.exists() && new.exists()); - assert_eq!( - query_database(&old, &json!({})) - .unwrap() - .as_array() - .unwrap() - .len(), - 1 - ); - } - - /// PROTOCOL 7: completed processes do not leave warning identities in memory. - #[tokio::test] - async fn completion_forgets_warning_identity() { - let folder = std::env::temp_dir().join(format!("sausage-warning-{}", uuid::Uuid::now_v7())); - crate::world::World::create(&folder).unwrap(); - let host = crate::Host::start_with_cache(&folder, &folder.join("cache")) - .await - .unwrap(); - host.register_native( - "warn", - std::sync::Arc::new(|ctx, _, _| { - Box::pin(async move { - ctx.host.output(&ctx.pid, "not JSON".into()); - Ok(json!(ctx.pid)) - }) - }), - ); - let pid = host.call("warn.once", json!({})).await.unwrap(); - host.call("core.stop", json!({"pid":pid})).await.unwrap(); - let retained = host - .0 - .journal - .warned - .lock() - .unwrap() - .contains(pid.as_str().unwrap()); - host.stop().await; - std::fs::remove_dir_all(folder).unwrap(); - assert!(!retained); - } -} diff --git a/cli/src/lib.rs b/cli/src/lib.rs deleted file mode 100644 index b0d5e25..0000000 --- a/cli/src/lib.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! A host for worlds of JSON-RPC plugins. -mod activation; -pub mod cargo; -mod components; -pub mod console; -mod core; -mod execution; -mod filesystem; -mod graph; -mod host; -mod journal; -mod locks; -mod native; -mod process; -mod restart; -mod routing; -pub mod runtime; -mod services; -mod upgrades; -mod watcher; -mod watches; -pub mod world; -pub use host::Host; -pub use native::{NativeContext, NativeFuture, NativePlugin}; -pub use process::ProcessInfo; -use serde_json::{Value, json}; -/// A JSON result or a JSON-RPC error object. -pub type RpcResult = Result; -fn error(code: i64, text: &str) -> Value { - json!({"code":code,"message":text}) -} diff --git a/cli/src/locks.rs b/cli/src/locks.rs deleted file mode 100644 index 94218f4..0000000 --- a/cli/src/locks.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Advisory FIFO locks and cleanup of ended processes. -use crate::error; -use crate::{Host, RpcResult, host::State}; -use serde_json::json; -use std::collections::VecDeque; -use tokio::sync::oneshot; - -pub(crate) struct Lock { - pub(crate) owner: String, - pub(crate) queue: VecDeque<(String, oneshot::Sender)>, -} - -impl Host { - /// Acquire a FIFO queue position, or report whether the lock is free. - pub(crate) async fn lock(&self, caller: &str, path: &str, wait: bool) -> RpcResult { - let rx = { - let mut state = self.0.state.lock().unwrap(); - if caller != "console" - && caller != "core" - && !state - .processes - .get(caller) - .is_some_and(|p| p.info.state == "running") - { - return Err(error(-32600, "process is stopping or ended")); - } - if let Some(lock) = state.locks.get_mut(path) { - if !wait { - return Ok(json!({"held":false})); - } - let (tx, rx) = oneshot::channel(); - lock.queue.push_back((caller.into(), tx)); - Some(rx) - } else { - state.locks.insert( - path.into(), - Lock { - owner: caller.into(), - queue: VecDeque::new(), - }, - ); - None - } - }; - match rx { - Some(rx) => rx - .await - .unwrap_or_else(|_| Err(error(-32603, "lock canceled"))), - None => Ok(json!({"held":true})), - } - } - /// Release a held lock only when this process owns it. - pub(crate) fn unlock(&self, caller: &str, path: &str) { - let mut state = self.0.state.lock().unwrap(); - if state - .locks - .get(path) - .is_some_and(|lock| lock.owner == caller) - { - grant(&mut state, path); - } - } - - /// Remove queued acquisitions and grant the next owner of each held lock. - pub(crate) fn release_locks(state: &mut State, pid: &str) { - for lock in state.locks.values_mut() { - lock.queue.retain(|(p, _)| p != pid); - } - let paths: Vec<_> = state - .locks - .iter() - .filter(|(_, l)| l.owner == pid) - .map(|(p, _)| p.clone()) - .collect(); - for path in paths { - grant(state, &path); - } - } -} -pub(crate) fn grant(state: &mut State, path: &str) { - if let Some(mut lock) = state.locks.remove(path) { - while let Some((owner, tx)) = lock.queue.pop_front() { - if tx.send(Ok(json!({"held":true}))).is_ok() { - lock.owner = owner; - state.locks.insert(path.into(), lock); - break; - } - } - } -} diff --git a/cli/src/main.rs b/cli/src/main.rs deleted file mode 100644 index 9114bbd..0000000 --- a/cli/src/main.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! Command parsing and the minimal line console. -use anyhow::{Result, bail}; -use clap::{Parser, Subcommand}; -use include_dir::{Dir, include_dir}; -use sausage::{Host, world::World}; -use std::path::{Path, PathBuf}; - -static AGENT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent/src"); -mod test_runner; -#[derive(Parser)] -#[command(version)] -struct Cli { - #[arg(long, global = true)] - headless: bool, - #[arg(long, global = true)] - cache_dir: Option, - #[command(subcommand)] - command: Option, -} -#[derive(Subcommand)] -enum Cmd { - Init { - #[arg(default_value = "world")] - dir: PathBuf, - }, - Run { - #[arg(default_value = "world")] - dir: PathBuf, - }, - /// Run a WASI libtest component in isolated instances. - TestRunner { - wasm: PathBuf, - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - args: Vec, - }, -} -fn headless(value: &str) -> std::result::Result { - match value.to_ascii_lowercase().as_str() { - "1" | "true" => Ok(true), - "" | "0" | "false" => Ok(false), - _ => Err("expected 1, true, 0, false, or an empty value".into()), - } -} - -// Readline can still wait on its detached thread when the host exits. -struct TerminalMode(Option); -impl Drop for TerminalMode { - fn drop(&mut self) { - if let Some(mode) = &self.0 { - let _ = nix::sys::termios::tcsetattr( - std::io::stdin(), - nix::sys::termios::SetArg::TCSANOW, - mode, - ); - } - } -} - -/// Creates a world with editable configuration and builds its embedded agent. -async fn init(dir: &Path, cache: Option<&Path>) -> Result<()> { - if dir.exists() { - bail!("world already exists: {}", dir.display()); - } - let parent = dir - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or(Path::new(".")); - std::fs::create_dir_all(parent)?; - let staging = tempfile::Builder::new() - .prefix(".sausage-init-") - .tempdir_in(parent)?; - let world = World::create(&staging.path().join("world"))?; - std::fs::write( - world.root.join("config/agent.yaml"), - include_str!("agent.yaml"), - )?; - std::fs::write( - world.root.join("config/boot.yaml"), - include_str!("boot.yaml"), - )?; - let cache = cache.map(Path::to_path_buf).unwrap_or_else(|| { - let base = std::env::var_os("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| { - PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache") - }); - base.join("sausage") - }); - let builder = sausage::cargo::Cargo::new(world.clone(), &cache)?; - let source = world.plugin_dir("agent").join("source"); - std::fs::create_dir_all(source.join("src"))?; - AGENT.extract(source.join("src"))?; - std::fs::write( - source.join("Cargo.toml"), - include_str!("../../agent/Cargo.toml"), - )?; - std::fs::write( - source.join("Cargo.lock"), - include_bytes!("../../Cargo.lock"), - )?; - println!("The first agent build takes a few minutes."); - let output = builder.build(&source).await?; - std::fs::copy(output.wasm, world.plugin_dir("agent").join("plugin.wasm"))?; - std::fs::rename(&world.root, dir)?; - println!( - "Edit {} before chatting.", - dir.join("config/agent.yaml").display() - ); - println!("Start the world with: sausage run {}", dir.display()); - Ok(()) -} -#[tokio::main] -async fn main() -> Result { - let cli = Cli::parse(); - let _terminal = TerminalMode(nix::sys::termios::tcgetattr(std::io::stdin()).ok()); - let command = match cli.command { - Some(command) => command, - None => { - let dir = PathBuf::from("world"); - if !dir.join("config/boot.yaml").is_file() { - init(&dir, cli.cache_dir.as_deref()).await?; - } - Cmd::Run { dir } - } - }; - match command { - Cmd::TestRunner { wasm, args } => { - if !test_runner::run(&wasm, args).await? { - return Ok(std::process::ExitCode::FAILURE); - } - } - Cmd::Init { dir } => init(&dir, cli.cache_dir.as_deref()).await?, - Cmd::Run { dir } => { - let headless = cli.headless - || std::env::var("SAUSAGE_HEADLESS") - .ok() - .map(|value| headless(&value).map_err(anyhow::Error::msg)) - .transpose()? - .unwrap_or(false); - let mut term = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; - let mut interrupt = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?; - let start = async { - match cli.cache_dir { - Some(cache) => Host::start_with_cache(&dir, &cache).await, - None => Host::start(&dir).await, - } - }; - let host = tokio::select! { - result=start=>result?, - _=term.recv()=>return Ok(std::process::ExitCode::SUCCESS), - _=interrupt.recv()=>return Ok(std::process::ExitCode::SUCCESS), - }; - let mut notifications = host.subscribe(); - let notification_task = headless.then(|| { - tokio::spawn(async move { - loop { - match notifications.recv().await { - Ok(notification) => println!("{notification}"), - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } - } - }) - }); - let console = sausage::console::run_input(host.clone()); - let mut console_result = Ok(()); - let exit = tokio::select! { - _=host.exit_requested()=>true, - _=term.recv()=>false, - _=interrupt.recv(), if headless=>false, - result = console, if !headless => { - console_result=result; - false - }, - }; - let exit = exit || host.restart_requested(); - host.stop().await; - if let Some(task) = notification_task { - task.abort(); - } - if exit { - return Ok(std::process::ExitCode::from(75)); - } - console_result?; - } - } - Ok(std::process::ExitCode::SUCCESS) -} diff --git a/cli/src/native.rs b/cli/src/native.rs deleted file mode 100644 index fe79854..0000000 --- a/cli/src/native.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Native plugin futures and their process context. -use crate::{Host, RpcResult}; -use serde_json::{Value, json}; -use std::{future::Future, pin::Pin, sync::Arc}; -use uuid::Uuid; -/// The cancellable future of a native process. -pub type NativeFuture = Pin + Send>>; -/// A handler for the methods of one native plugin. -pub type NativePlugin = Arc NativeFuture + Send + Sync>; -#[derive(Clone)] -/// The process identity and host access supplied to a native handler. -pub struct NativeContext { - /// The host that owns this native process. - pub host: Host, - /// The ID assigned to this native process. - pub pid: String, -} -impl NativeContext { - /// Make an outgoing call with this native process as its caller. - pub async fn call(&self, method: &str, params: Value) -> RpcResult { - self.host - .route( - self.pid.clone(), - Some(json!(Uuid::now_v7().to_string())), - method.into(), - params, - false, - ) - .await - } - /// Send progress to this process's caller while its call remains unanswered. - pub fn notify(&self, name: &str, params: Value) { - self.host.notify(&self.pid, name, params); - } -} diff --git a/cli/src/process.rs b/cli/src/process.rs deleted file mode 100644 index bae0dcb..0000000 --- a/cli/src/process.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Process metadata, process lifetime, and attached children. -use crate::{Host, RpcResult}; -use serde_json::{Value, json}; -use tokio::sync::{mpsc, oneshot, watch}; -#[derive(Clone, serde::Serialize)] -/// A snapshot of process metadata for inspection and the VFS. -pub struct ProcessInfo { - /// The plugin that supplied the process code. - pub plugin: String, - /// The SHA-256 of its component, or native for a native plugin. - pub version: String, - /// The method without its plugin prefix. - pub method: String, - /// The object supplied by the caller. - pub params: Value, - /// The attached parent, or no parent for a detached process. - pub parent: Option, - /// Whether this process survives the end of its caller. - pub detached: bool, - /// The start time in milliseconds since the Unix epoch. - pub start_time: u128, - /// The current lifecycle state. - pub state: String, -} -pub(crate) struct Process { - pub(crate) info: ProcessInfo, - pub(crate) memory: sausage_vfs::Memory, - pub(crate) files: sausage_vfs::Process, - pub(crate) store_ended: watch::Sender, - pub(crate) input: Option>, - pub(crate) abort: Option, - pub(crate) caller: String, - pub(crate) id: Option, - pub(crate) answer: Option>, - pub(crate) ended: watch::Sender, - pub(crate) stopped: bool, - pub(crate) superseded: bool, -} -impl Host { - /// Called only after the execution task ends. Wait for the store and file operations before releasing locks. - pub(crate) async fn finish(&self, pid: &str, status: &str) { - let (files, mut store_ended) = { - let mut state = self.0.state.lock().unwrap(); - let Some(p) = state.processes.get_mut(pid) else { - return; - }; - p.info.state = "stopping".into(); - (p.files.clone(), p.store_ended.subscribe()) - }; - let _ = store_ended.wait_for(|ended| *ended).await; - files.idle().await; - let mut state = self.0.state.lock().unwrap(); - let process = state.processes.remove(pid); - if let Some(p) = process { - self.0.journal.write(pid, &p.info.plugin, "core", status); - let logs = p - .answer - .as_ref() - .map(|_| self.0.journal.query_later(json!({"pid":pid,"limit":20}))); - self.0.journal.forget(pid); - Self::release_locks(&mut state, pid); - self.service_ended(&mut state, pid, status, p.stopped, p.superseded); - let status = status.to_string(); - tokio::spawn(async move { - if let Some(answer) = p.answer { - let lines = logs.unwrap().await.unwrap_or_else(|_| json!([])); - let failure = json!({ - "code": -32002, - "message": "process ended without an answer", - "data": { "status": status, "lines": lines } - }); - let _ = answer.send(Err(failure)); - } - p.ended.send_replace(true); - }); - let children: Vec<_> = state - .processes - .iter() - .filter(|(_, p)| p.info.parent.as_deref() == Some(pid)) - .map(|(id, _)| id.clone()) - .collect(); - drop(state); - for child in children { - self.begin_stop(&child); - } - } - } - /// Close stdin and start the same five-second deadline for each attached child. - pub(crate) fn begin_stop(&self, pid: &str) -> Option> { - self.stop_process(pid, false) - } - /// Grace expiry is retirement, not an operator stopping the service. - pub(crate) fn begin_retire(&self, pid: &str) -> Option> { - self.stop_process(pid, true) - } - fn stop_process(&self, pid: &str, retirement: bool) -> Option> { - let mut state = self.0.state.lock().unwrap(); - let p = state.processes.get_mut(pid)?; - if !retirement { - p.superseded = false; - } - let ended = p.ended.subscribe(); - if p.info.state == "stopping" { - return Some(ended); - } - p.info.state = "stopping".into(); - p.stopped = true; - p.input.take(); - self.0.journal.write(pid, &p.info.plugin, "core", "stop"); - for lock in state.locks.values_mut() { - lock.queue.retain(|(queued, _)| queued != pid); - } - let children: Vec<_> = state - .processes - .iter() - .filter(|(_, p)| p.info.parent.as_deref() == Some(pid)) - .map(|(id, _)| id.clone()) - .collect(); - drop(state); - for child in children { - self.begin_stop(&child); - } - let h = self.clone(); - let pid = pid.to_string(); - tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - h.kill_process(&pid, retirement); - }); - Some(ended) - } - /// Abort a process task and perform its ended-state cleanup. - pub(crate) fn kill(&self, pid: &str) { - self.kill_process(pid, false); - } - fn kill_process(&self, pid: &str, retirement: bool) { - let mut state = self.0.state.lock().unwrap(); - for lock in state.locks.values_mut() { - lock.queue.retain(|(queued, _)| queued != pid); - } - if let Some(p) = state.processes.get_mut(pid) { - if !retirement { - p.superseded = false; - } - p.stopped = true; - p.info.state = "stopping".into(); - p.input.take(); - self.0.journal.write(pid, &p.info.plugin, "core", "kill"); - if let Some(a) = &p.abort { - a.abort(); - } - } - } - /// Stop every process in parallel and wait for journal writes to finish. - pub async fn stop(&self) { - { - let _state = self.0.state.lock().unwrap(); - self.0 - .shutdown - .store(true, std::sync::atomic::Ordering::SeqCst); - } - loop { - let ids: Vec<_> = self.processes().into_keys().collect(); - if ids.is_empty() { - break; - } - let mut waits = Vec::new(); - for pid in ids { - if let Some(n) = self.begin_stop(&pid) { - waits.push(n); - } - } - for mut n in waits { - let _ = n.wait_for(|ended| *ended).await; - } - } - self.0.journal.query(json!({"limit":0})).await; - } -} diff --git a/cli/src/restart.rs b/cli/src/restart.rs deleted file mode 100644 index d919afc..0000000 --- a/cli/src/restart.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! Supervisor-gated restart requests; the executable still owns normal shutdown. -use crate::{Host, RpcResult, error}; -use serde_json::json; -use std::{ffi::OsStr, sync::atomic::Ordering, time::Duration}; - -pub(crate) fn enabled(value: Option<&OsStr>) -> bool { - value == Some(OsStr::new("1")) -} - -impl Host { - pub(crate) fn request_restart(&self) -> RpcResult { - if !self.0.loop_supervised { - return Err(error( - -32600, - "restart needs the loop supervisor (SAUSAGE_LOOP not set); start with SAUSAGE_LOOP=1", - )); - } - if !self.0.restart_pending.swap(true, Ordering::SeqCst) { - let host = self.clone(); - tokio::spawn(async move { - // Let the RPC response reach the caller before closing any stdin, - // including that of a detached gateway carrying this response. - tokio::time::sleep(Duration::from_millis(250)).await; - host.0.exit.send_replace(true); - }); - } - Ok(json!({})) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{runtime::Runtime, world::World}; - use std::sync::Arc; - use tokio::time::timeout; - - #[test] - fn only_explicit_loop_marker_enables_restart() { - assert!(!enabled(None)); - for value in ["", "0", "true", "yes", "2"] { - assert!(!enabled(Some(OsStr::new(value)))); - } - assert!(enabled(Some(OsStr::new("1")))); - } - - async fn host(supervised: bool) -> (tempfile::TempDir, Host) { - let dir = tempfile::tempdir().unwrap(); - World::create(&dir.path().join("world")).unwrap(); - let runtime = Runtime::new(&dir.path().join("cache")).unwrap(); - let mut host = Host::test_world(&dir.path().join("world"), runtime) - .await - .unwrap(); - // No global environment mutation: parallel tests have independent gates. - Arc::get_mut(&mut host.0).unwrap().loop_supervised = supervised; - (dir, host) - } - - #[tokio::test] - async fn restart_without_supervisor_never_triggers_exit() { - timeout(Duration::from_secs(10), async { - let (_dir, host) = host(false).await; - for method in ["core.restart", "core.exit"] { - let error = host.call(method, json!({})).await.unwrap_err(); - assert_eq!(error["code"], -32600); - assert!( - error["message"] - .as_str() - .unwrap() - .contains("SAUSAGE_LOOP not set") - ); - let indirect = host - .call("core.call", json!({"method":method})) - .await - .unwrap_err(); - assert_eq!(indirect, error); - } - assert!(!host.0.restart_pending.load(Ordering::SeqCst)); - assert!( - timeout(Duration::from_millis(300), host.exit_requested()) - .await - .is_err() - ); - host.stop().await; - }) - .await - .unwrap(); - } - - #[tokio::test] - async fn restart_and_exit_share_response_grace_and_coalescing() { - timeout(Duration::from_secs(10), async { - for first in ["core.restart", "core.exit"] { - let (_dir, host) = host(true).await; - // Same dispatch as a plugin's core.call, without killing this runner. - assert_eq!( - host.call("core.call", json!({"method":first})) - .await - .unwrap(), - json!({}) - ); - assert!(!host.restart_requested()); - assert!(host.0.restart_pending.load(Ordering::SeqCst)); - assert_eq!( - host.call("core.restart", json!({})).await.unwrap(), - json!({}) - ); - timeout(Duration::from_secs(1), host.exit_requested()) - .await - .unwrap(); - // A duplicate after the trigger must not schedule a second task. - host.0.exit.send_replace(false); - assert_eq!( - host.call("core.restart", json!({})).await.unwrap(), - json!({}) - ); - assert_eq!(host.call("core.exit", json!({})).await.unwrap(), json!({})); - assert!( - timeout(Duration::from_millis(300), host.exit_requested()) - .await - .is_err() - ); - host.stop().await; - } - }) - .await - .unwrap(); - } -} diff --git a/cli/src/routing.rs b/cli/src/routing.rs deleted file mode 100644 index 9489f9a..0000000 --- a/cli/src/routing.rs +++ /dev/null @@ -1,397 +0,0 @@ -//! JSON-RPC routing and responses between callers and processes. -use crate::{ - Host, NativeFuture, RpcResult, error, - process::{Process, ProcessInfo}, -}; -use serde_json::{Value, json}; -use tokio::sync::{mpsc, oneshot, watch}; -use uuid::Uuid; - -fn params_not_object(params: &Value) -> Value { - let kind = match params { - Value::String(_) => "a string", - Value::Array(_) => "an array", - Value::Number(_) => "a number", - Value::Null => "null", - Value::Bool(_) => "a boolean", - Value::Object(_) => "an object", - }; - error( - -32602, - &format!( - "params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string." - ), - ) -} - -impl Host { - /// Queue an incoming response or notification for its destination. - pub(crate) fn send(&self, pid: &str, value: Value) { - if pid == "console" { - let _ = self.0.notifications.send(value); - } else if let Some(tx) = self - .0 - .state - .lock() - .unwrap() - .processes - .get(pid) - .and_then(|p| p.input.as_ref()) - { - let _ = tx.send(value); - } - } - /// Remap progress to the caller's original request ID. - pub(crate) fn notify(&self, pid: &str, name: &str, params: Value) { - let target = self - .0 - .state - .lock() - .unwrap() - .processes - .get(pid) - .and_then(|p| p.answer.as_ref().map(|_| (p.caller.clone(), p.id.clone()))); - if let Some((caller, Some(id))) = target { - self.call_notify(&caller, &id, name, params); - } - } - /// Deliver a notification on an open core call using the same envelope as plugin progress. - pub(crate) fn call_notify(&self, caller: &str, id: &Value, name: &str, params: Value) { - self.send( - caller, - json!({ - "jsonrpc":"2.0", "method":"core.notify", - "params": {"call":id, "name":name, "params":params} - }), - ); - } - /// Validate and route a request, then start its native or WASI process. - pub(crate) fn route( - &self, - caller: String, - id: Option, - method: String, - params: Value, - detached: bool, - ) -> NativeFuture { - self.route_from(caller, id, method, params, detached, None) - } - /// Like `route`, but a WASI process runs the component at `wasm` instead - /// of the current `plugin.wasm` of its plugin. Activation uses this to - /// call `rpc.discover` on a candidate file. - pub(crate) fn route_from( - &self, - caller: String, - id: Option, - method: String, - params: Value, - detached: bool, - wasm: Option, - ) -> NativeFuture { - let host = self.clone(); - Box::pin(async move { - if !params.is_object() { - return Err(params_not_object(¶ms)); - } - if caller != "console" && caller != "core" { - let state = host.0.state.lock().unwrap(); - let Some(process) = state.processes.get(&caller) else { - return Err(error(-32600, "process ended")); - }; - if process.info.state != "running" && method != "core.notify" { - return Err(error(-32600, "process is stopping")); - } - } - let (plugin, method) = method - .split_once('.') - .ok_or_else(|| error(-32601, "method not found"))?; - if plugin == "core" { - return host.core(&caller, id, method, params).await; - } - if plugin.is_empty() - || !plugin - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') - { - return Err(error(-32601, "invalid plugin name")); - } - let path = wasm.unwrap_or_else(|| host.0.world.plugin_dir(plugin).join("plugin.wasm")); - let (native, component, version, admission) = loop { - if host.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) { - return Err(error(-32600, "host is stopping")); - } - let native = host.0.state.lock().unwrap().native.get(plugin).cloned(); - let loaded = if native.is_none() { - Some( - host.0 - .components - .load(path.clone(), host.0.runtime.clone()) - .await?, - ) - } else { - None - }; - let admission = host.0.admission.read().await; - if let Some(loaded) = loaded { - if crate::components::Fingerprint::read(&path).await? != loaded.fingerprint { - drop(admission); - continue; - } - break (native, Some(loaded.prepared), loaded.hash, admission); - } - break (native, None, "native".into(), admission); - }; - let pid = format!("{plugin}-{}", Uuid::now_v7()); - let (tx, rx) = oneshot::channel(); - let (input, incoming) = mpsc::unbounded_channel::(); - let info = ProcessInfo { - plugin: plugin.into(), - version, - method: method.into(), - params: params.clone(), - parent: if detached { None } else { Some(caller.clone()) }, - detached, - start_time: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(), - state: "running".into(), - }; - { - let mut state = host.0.state.lock().unwrap(); - if host.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) { - return Err(error(-32600, "host is stopping")); - } - if caller != "console" - && caller != "core" - && !state - .processes - .get(&caller) - .is_some_and(|p| p.info.state == "running") - { - return Err(error(-32600, "caller stopped or ended")); - } - // Older plugins may still try to start their boot service from - // rpc.retired. The supervisor owns it even while a restart is - // pending, or after it has given up. Do not create a second owner. - if state - .processes - .get(&caller) - .is_some_and(|p| p.info.method == "rpc.retired") - && state - .services - .iter() - .any(|s| s.method == format!("{plugin}.{method}") && s.params == params) - { - return Ok(Value::Null); - } - if caller == "core" - && let Some(service) = state - .services - .iter_mut() - .find(|s| Some(&s.request) == id.as_ref()) - { - if !service.enabled || service.owner.is_some() { - return Err(error(-32600, "service attempt is no longer current")); - } - service.owner = Some(pid.clone()); - service.pending = false; - service.version = Some(info.version.clone()); - } - host.0 - .journal - .write(&pid, plugin, "core", &format!("start {method} {params}")); - state.processes.insert( - pid.clone(), - Process { - info: info.clone(), - memory: sausage_vfs::Memory::default(), - files: sausage_vfs::Process::default(), - store_ended: watch::channel(true).0, - input: Some(input), - abort: None, - caller, - id: id.clone(), - answer: if id.is_some() { Some(tx) } else { None }, - ended: watch::channel(false).0, - stopped: false, - superseded: false, - }, - ); - } - let h = host.clone(); - let p = pid.clone(); - let has_id = id.is_some(); - let (start, ready) = oneshot::channel(); - let task = tokio::spawn(async move { - let _ = ready.await; - h.run_process(p, info, component, native, incoming, has_id) - .await - }); - if let Some(p) = host.0.state.lock().unwrap().processes.get_mut(&pid) { - p.abort = Some(task.abort_handle()); - if p.info.state == "stopping" { - task.abort(); - } - } else { - task.abort(); - } - drop(admission); - let cleanup = host.clone(); - let finished_pid = pid.clone(); - tokio::spawn(async move { - let status = task.await.unwrap_or_else(|error| { - if error.is_cancelled() { - "killed".into() - } else { - format!("host task failed: {error}") - } - }); - cleanup.finish(&finished_pid, &status).await; - }); - let _ = start.send(()); - if id.is_none() { - return Ok(Value::Null); - } - rx.await - .unwrap_or_else(|_| Err(error(-32002, "process ended"))) - }) - } - /// Complete the initial call once and discard late responses. - pub(crate) fn answer(&self, pid: &str, result: RpcResult) { - if let Some(tx) = self - .0 - .state - .lock() - .unwrap() - .processes - .get_mut(pid) - .and_then(|p| p.answer.take()) - { - let _ = tx.send(result); - } - } - /// Classify a stdout line and route requests or the initial response. - pub(crate) fn output(&self, pid: &str, line: String) { - if line.trim().is_empty() { - return; - } - let Ok(v) = serde_json::from_str::(&line) else { - self.discard(pid); - return; - }; - if !v.is_object() || v["jsonrpc"] != "2.0" { - self.discard(pid); - return; - } - if let Some(method) = v["method"].as_str() { - if method == "core.notify" || method == "core.unlock" { - // Deliver progress before a following response removes its destination. - let params = v.get("params").cloned().unwrap_or(json!({})); - let result = if !params.is_object() { - Err(params_not_object(¶ms)) - } else if method == "core.unlock" { - if let Some(path) = params["path"].as_str() { - self.unlock(pid, path); - Ok(Value::Null) - } else { - Err(error(-32602, "path must be a string")) - } - } else if let Some(name) = params["name"].as_str() { - self.notify( - pid, - name, - params.get("params").cloned().unwrap_or(json!({})), - ); - Ok(Value::Null) - } else { - Err(error(-32602, "name must be a string")) - }; - if let Some(id) = v.get("id") { - let response = match result { - Ok(result) => json!({"jsonrpc":"2.0", "id":id, "result":result}), - Err(error) => json!({"jsonrpc":"2.0", "id":id, "error":error}), - }; - self.send(pid, response); - } - return; - } - let h = self.clone(); - let caller = pid.to_string(); - let method = method.to_string(); - tokio::spawn(async move { - let id = v.get("id").cloned(); - let result = h - .route( - caller.clone(), - id.clone(), - method, - v.get("params").cloned().unwrap_or(json!({})), - false, - ) - .await; - if let Some(id) = id { - let value = match result { - Ok(result) => json!({ - "jsonrpc": "2.0", - "id": id, - "result": result - }), - Err(error) => json!({ - "jsonrpc": "2.0", - "id": id, - "error": error - }), - }; - h.send(&caller, value); - } - }); - } else if v["id"] == pid { - self.answer( - pid, - if let Some(e) = v.get("error").filter(|e| !e.is_null()) { - Err(e.clone()) - } else { - Ok(v["result"].clone()) - }, - ); - } - } - pub(crate) fn discard(&self, pid: &str) { - // The journal deduplicates this warning for each process. - if let Some(plugin) = self - .0 - .state - .lock() - .unwrap() - .processes - .get(pid) - .map(|p| p.info.plugin.clone()) - { - self.0.journal.warning(pid, &plugin); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - /// PROTOCOL 6: an unlock from stdout completes before output returns. - #[tokio::test] - async fn stdout_unlock_is_synchronous() { - let folder = std::env::temp_dir().join(format!("sausage-unlock-{}", Uuid::now_v7())); - crate::world::World::create(&folder).unwrap(); - let host = Host::start_with_cache(&folder, &folder.join("cache")) - .await - .unwrap(); - host.lock("core", "ordered", true).await.unwrap(); - host.output( - "core", - r#"{"jsonrpc":"2.0","method":"core.unlock","params":{"path":"ordered"}}"#.into(), - ); - let held = host.lock("console", "ordered", false).await.unwrap(); - host.stop().await; - std::fs::remove_dir_all(folder).unwrap(); - assert_eq!(held, json!({"held":true})); - } -} diff --git a/cli/src/runtime.rs b/cli/src/runtime.rs deleted file mode 100644 index a994ff1..0000000 --- a/cli/src/runtime.rs +++ /dev/null @@ -1,440 +0,0 @@ -//! Wasmtime engine and process instances. -//! -//! An instance is one run of a `wasi:cli/command` component. The host writes -//! NDJSON lines to its stdin and reads NDJSON lines from its stdout. - -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Result; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream}; -use tokio::sync::mpsc; -use tokio::task::JoinHandle; -use wasmtime::component::{Component, Linker, ResourceTable}; -use wasmtime::{Config, Engine, Store}; -use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream}; - -use wasmtime_wasi::p2::bindings::CommandPre; - -/// A component with its imports resolved once for this runtime. -pub(crate) struct Prepared(Result, String>); - -use wasmtime_wasi::{I32Exit, WasiCtx, WasiCtxView, WasiView}; -use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView}; - -/// The HTTP transport hooks supplied to WASI processes. -pub struct HttpHooks { - allow: bool, -} - -impl WasiHttpHooks for HttpHooks { - fn send_request( - &mut self, - request: http::Request, - options: Option, - fut: Box> + Send>, - ) -> Box< - dyn Future< - Output = wasmtime_wasi_http::Result<( - http::Response, - Box> + Send>, - )>, - > + Send, - > { - _ = fut; - let allow = self.allow; - Box::new(async move { - use http_body_util::BodyExt; - if !allow { - return Err(wasmtime_wasi_http::Error::HttpRequestDenied); - } - let (res, io) = wasmtime_wasi_http::default_send_request(request, options).await?; - Ok(( - res.map(BodyExt::boxed_unsync), - Box::new(io) as Box + Send>, - )) - }) - } -} - -/// The WASI and HTTP resources owned by one store. -pub struct ProcessState { - ctx: WasiCtx, - table: ResourceTable, - http: WasiHttpCtx, - hooks: HttpHooks, - fs: sausage_vfs::adapter::Adapter, -} - -impl WasiView for ProcessState { - fn ctx(&mut self) -> WasiCtxView<'_> { - WasiCtxView { - ctx: &mut self.ctx, - table: &mut self.table, - } - } -} - -impl WasiHttpView for ProcessState { - fn http(&mut self) -> WasiHttpCtxView<'_> { - WasiHttpCtxView { - ctx: &mut self.http, - table: &mut self.table, - hooks: &mut self.hooks, - } - } -} - -/// A Wasmtime engine and linker shared by the world. -pub struct Runtime { - engine: Engine, - linker: Linker, -} - -/// How an instance ended. -#[derive(Debug, Clone)] -pub enum Exit { - /// `main` returned or the guest called `exit(0)`. - Clean, - /// The guest exited with a nonzero code. - Code(i32), - /// The guest trapped (panic, invalid memory access, ...). - Trap(String), -} - -impl std::fmt::Display for Exit { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Exit::Clean => write!(f, "exit 0"), - Exit::Code(c) => write!(f, "exit {c}"), - Exit::Trap(t) => write!(f, "trap: {t}"), - } - } -} - -/// The filesystem, network, and environment supplied to one process. -pub struct InstanceConfig { - /// The virtual namespace exposed through the single root preopen. - pub filesystem: sausage_vfs::Mounts, - /// The store completion barrier used by process cleanup. - pub store_ended: tokio::sync::watch::Sender, - /// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`. - pub network: bool, - /// The environment variables supplied to the guest. - pub env: Vec<(String, String)>, -} - -/// A running guest. Dropping it kills the guest. -pub struct Instance { - /// The host end of stdin, removed when the process stops. - pub stdin: Option, - /// Lines that the guest writes to stdout. Closed when the guest ends. - pub stdout: mpsc::Receiver, - /// The stderr lines that the process writes. - pub stderr: mpsc::Receiver, - stderr_task: Option>, - task: Option>, - store_ended: tokio::sync::watch::Sender, -} - -impl Drop for Instance { - fn drop(&mut self) { - let task = self.task.take(); - let stderr = self.stderr_task.take(); - if let Some(task) = &task { - task.abort(); - } - if task.is_some() || stderr.is_some() { - let ended = self.store_ended.clone(); - tokio::spawn(async move { - if let Some(task) = task { - let _ = task.await; - } - if let Some(stderr) = stderr { - let _ = stderr.await; - } - ended.send_replace(true); - }); - } - } -} - -impl Instance { - /// Write one line to the guest's stdin, in one write. Meanwhile, pass the - /// lines that the guest writes to `route`: a guest that blocks on a full - /// stdout would never read its stdin. - pub async fn send_line( - &mut self, - line: &str, - mut route: impl FnMut(String), - ) -> std::io::Result<()> { - let stdin = self.stdin.as_mut().ok_or(std::io::ErrorKind::BrokenPipe)?; - let bytes = format!("{line}\n"); - let write = async { - stdin.write_all(bytes.as_bytes()).await?; - stdin.flush().await - }; - tokio::pin!(write); - let mut open = true; - loop { - tokio::select! { - done = &mut write => return done, - out = self.stdout.recv(), if open => match out { - Some(out) => route(out), - None => open = false, - }, - } - } - } - - /// Wait until the guest ends. - pub async fn wait(&mut self) -> Exit { - let result = self.task.as_mut().unwrap().await; - self.task.take(); - if let Some(stderr) = self.stderr_task.as_mut() { - let _ = stderr.await; - } - self.stderr_task.take(); - self.store_ended.send_replace(true); - match result { - Ok(exit) => exit, - Err(e) if e.is_cancelled() => Exit::Trap("killed".into()), - Err(e) => Exit::Trap(format!("host task failed: {e}")), - } - } -} - -/// The most useful line of a trap: the `wasm trap: ...` reason. -fn short_trap(full: &str) -> String { - let line = full - .lines() - .find_map(|l| l.find("wasm trap:").map(|i| l[i..].to_string())) - .unwrap_or_else(|| full.lines().next().unwrap_or("trap").to_string()); - let mut line = line.trim().to_string(); - let mut end = line.len().min(300); - while !line.is_char_boundary(end) { - end -= 1; - } - line.truncate(end); - line -} - -impl Runtime { - /// Create an engine with disk caching and a ten-millisecond epoch tick. - pub fn new(cache: &Path) -> Result> { - let mut config = Config::new(); - config.epoch_interruption(true); - let disk_cache = (|| -> Result<_> { - let cache = cache.join("wasmtime"); - std::fs::create_dir_all(&cache)?; - let mut cache_config = wasmtime::CacheConfig::new(); - cache_config.with_directory(cache.canonicalize()?); - Ok(wasmtime::Cache::new(cache_config)?) - })(); - match disk_cache { - Ok(cache) => { - config.cache(Some(cache)); - } - Err(error) => eprintln!( - "compile cache {} unavailable: {error}; running without disk cache", - cache.display() - ), - } - let engine = Engine::new(&config)?; - - let mut linker: Linker = Linker::new(&engine); - wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; - sausage_vfs::adapter::add_to_linker(&mut linker, |state| sausage_vfs::adapter::View { - adapter: &mut state.fs, - table: &mut state.table, - })?; - wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; - // Time slices: every 10 ms each running guest yields to the scheduler. - let runtime = Arc::new(Self { engine, linker }); - let ticker = Arc::downgrade(&runtime); - std::thread::spawn(move || { - loop { - std::thread::sleep(Duration::from_millis(10)); - let Some(runtime) = ticker.upgrade() else { - break; - }; - runtime.engine.increment_epoch(); - } - }); - - Ok(runtime) - } - - /// Compile component bytes through Wasmtime's disk cache. - pub fn compile(&self, bytes: &[u8]) -> Result { - Ok(Component::new(&self.engine, bytes)?) - } - - pub(crate) fn prepare(&self, component: &Component) -> Prepared { - // Preserve link failures as process exits, rather than load errors. - Prepared( - self.linker - .instantiate_pre(component) - .and_then(CommandPre::new) - .map_err(|error| error.to_string()), - ) - } - - pub(crate) async fn start_logged( - self: &Arc, - prepared: &Prepared, - cfg: InstanceConfig, - journal: Arc, - pid: String, - plugin: String, - ) -> Result { - self.start_inner(prepared.0.clone(), cfg, &[], Some((journal, pid, plugin))) - .await - } - - /// Start a WASI task with the supplied virtual namespace. - pub async fn start( - self: &Arc, - component: &Component, - cfg: InstanceConfig, - ) -> Result { - self.start_with_args(component, cfg, &[]).await - } - - /// Start a WASI task with explicit CLI arguments, including its program name. - pub async fn start_with_args( - self: &Arc, - component: &Component, - cfg: InstanceConfig, - args: &[String], - ) -> Result { - self.start_inner(self.prepare(component).0, cfg, args, None) - .await - } - - async fn start_inner( - self: &Arc, - prepared: Result, String>, - cfg: InstanceConfig, - args: &[String], - log: Option<(Arc, String, String)>, - ) -> Result { - let (host_in, guest_in) = tokio::io::duplex(64 * 1024); - let (guest_out, host_out) = tokio::io::duplex(64 * 1024); - - let (guest_err, host_err) = tokio::io::duplex(64 * 1024); - let mut b = WasiCtx::builder(); - b.args(args); - if cfg.network { - b.inherit_network() - .allow_tcp(true) - .allow_udp(true) - .allow_ip_name_lookup(true); - } - b.stdin(AsyncStdinStream::new(guest_in)); - b.stdout(AsyncStdoutStream::new(8192, guest_out)); - b.stderr(AsyncStdoutStream::new(8192, guest_err)); - for (k, v) in &cfg.env { - b.env(k, v); - } - - let state = ProcessState { - ctx: b.build(), - table: ResourceTable::new(), - http: WasiHttpCtx::new(), - hooks: HttpHooks { allow: cfg.network }, - fs: sausage_vfs::adapter::Adapter::new(cfg.filesystem), - }; - - let (line_tx, line_rx) = mpsc::channel(1024); - tokio::spawn(async move { - let mut reader = BufReader::new(host_out); - let mut bytes = Vec::new(); - while let Ok(n) = reader.read_until(b'\n', &mut bytes).await { - if n == 0 { - break; - } - // Preserve a non-blank invalid line so routing emits its warning. - let line = String::from_utf8(std::mem::take(&mut bytes)) - .unwrap_or_else(|_| "\u{fffd}".into()); - if line_tx.send(line).await.is_err() { - break; - } - } - }); - - let (err_tx, err_rx) = mpsc::channel(1024); - let trap_log = log.clone(); - let stderr_task = tokio::spawn(async move { - let mut reader = BufReader::new(host_err); - let mut bytes = Vec::new(); - while let Ok(n) = reader.read_until(b'\n', &mut bytes).await { - if n == 0 { - break; - } - let line = String::from_utf8_lossy(&bytes) - .trim_end_matches(['\r', '\n']) - .to_string(); - bytes.clear(); - if let Some((journal, pid, plugin)) = &log { - journal.write(pid, plugin, "stderr", &line); - } else if err_tx.send(line).await.is_err() { - break; - } - } - }); - let rt = self.clone(); - cfg.store_ended.send_replace(false); - let task = tokio::spawn(async move { - let mut store = Store::new(&rt.engine, state); - store.epoch_deadline_async_yield_and_update(1); - let result = async { - let cmd = prepared - .map_err(wasmtime::Error::msg)? - .instantiate_async(&mut store) - .await?; - cmd.wasi_cli_run().call_run(&mut store).await - } - .await; - match result { - Ok(Ok(())) => Exit::Clean, - Ok(Err(())) => Exit::Code(1), - Err(e) => match e.downcast_ref::() { - Some(I32Exit(0)) => Exit::Clean, - Some(I32Exit(c)) => Exit::Code(*c), - None => { - let full = format!("{e:?}"); - if let Some((journal, pid, plugin)) = &trap_log { - journal.write(pid, plugin, "core", &full); - } - Exit::Trap(short_trap(&full)) - } - }, - } - }); - - Ok(Instance { - stdin: Some(host_in), - stdout: line_rx, - stderr: err_rx, - stderr_task: Some(stderr_task), - task: Some(task), - store_ended: cfg.store_ended, - }) - } -} - -#[cfg(test)] -mod trap_text_tests { - use super::*; - #[test] - fn short_trap_display_is_utf8_safe() { - let full = format!("backtrace\nwasm trap: {}\nmore detail", "😀".repeat(1000)); - let status = short_trap(&full); - assert!(status.len() <= 300); - assert!(status.starts_with("wasm trap:")); - assert_eq!(short_trap(""), "trap"); - assert_eq!(short_trap("first line\nsecond"), "first line"); - } -} diff --git a/cli/src/services.rs b/cli/src/services.rs deleted file mode 100644 index a8a7859..0000000 --- a/cli/src/services.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! Ordered boot calls and the single restart owner for each service (PROTOCOL.md section 8). -use crate::{Host, host::State}; -use serde_json::{Value, json}; -use std::{collections::VecDeque, sync::atomic::Ordering, time::Duration}; -use tokio::time::Instant; - -// Systemd-derived values: -// https://www.man7.org/linux/man-pages/man5/systemd-system.conf.5.html. -const CRASH_LIMIT: usize = 5; -const CRASH_WINDOW: Duration = Duration::from_secs(10); - -pub(crate) struct Service { - pub(crate) method: String, - pub(crate) params: Value, - pub(crate) request: Value, - pub(crate) owner: Option, - pub(crate) pending: bool, - pub(crate) version: Option, - pub(crate) enabled: bool, - crashes: VecDeque, -} -impl Service { - fn crashed(&mut self, now: Instant) -> bool { - while self - .crashes - .front() - .is_some_and(|t| now.duration_since(*t) >= CRASH_WINDOW) - { - self.crashes.pop_front(); - } - self.crashes.push_back(now); - self.crashes.len() <= CRASH_LIMIT - } -} -impl Host { - /// Starts discovery in the background, then waits for each boot service in order. - pub(crate) async fn boot(&self) -> anyhow::Result<()> { - self.watch_plugins()?; - let boot: Vec = serde_yaml_ng::from_slice( - &tokio::fs::read(self.0.world.root.join("config/boot.yaml")).await?, - )?; - for service in boot { - self.start_service( - service["method"].as_str().unwrap_or("").into(), - service["params"].clone(), - ) - .await; - } - Ok(()) - } - /// Register once, before admission or readiness. Restarts reuse this registration. - pub(crate) async fn start_service(&self, method: String, params: Value) { - let request = json!(uuid::Uuid::now_v7().to_string()); - { - let mut state = self.0.state.lock().unwrap(); - if self.0.shutdown.load(Ordering::SeqCst) { - return; - } - state.services.push(Service { - method, - params, - request: request.clone(), - owner: None, - enabled: true, - pending: true, - version: None, - crashes: VecDeque::new(), - }); - } - self.run_service(request).await; - } - /// Await readiness only; process cleanup, not this response, decides whether to restart. - async fn run_service(&self, request: Value) { - let (method, params) = { - let state = self.0.state.lock().unwrap(); - if self.0.shutdown.load(Ordering::SeqCst) { - return; - } - let Some(service) = state - .services - .iter() - .find(|s| s.request == request && s.enabled) - else { - return; - }; - (service.method.clone(), service.params.clone()) - }; - self.0 - .journal - .write("core", "core", "core", &format!("service start {method}")); - let result = self - .route("core".into(), Some(request.clone()), method, params, true) - .await; - { - let mut state = self.0.state.lock().unwrap(); - if let Some(service) = state.services.iter_mut().find(|s| s.request == request) { - service.pending = false; - } - } - if let Err(error) = result { - // Admission failures have no process lifetime to supervise. A process crash - // before readiness is handled by finish just like one after readiness. - self.0 - .journal - .write("core", "core", "core", &format!("service failed {error}")); - } - } - /// Called under the state lock after the old owner and its files/locks are gone. - pub(crate) fn service_ended( - &self, - state: &mut State, - pid: &str, - status: &str, - stopped: bool, - superseded: bool, - ) { - let Some(service) = state - .services - .iter_mut() - .find(|s| s.owner.as_deref() == Some(pid)) - else { - return; - }; - service.owner = None; - if self.0.shutdown.load(Ordering::SeqCst) || !service.enabled { - return; - } - if !superseded { - if stopped { - service.enabled = false; - return; - } - if status == "exit 0" { - return; - } - if !service.crashed(Instant::now()) { - service.enabled = false; - self.0.journal.write( - "core", - "core", - "core", - &format!( - "service gave up {}: more than {CRASH_LIMIT} crashes within {} seconds", - service.method, - CRASH_WINDOW.as_secs(), - ), - ); - return; - } - } - self.queue_service(service); - } - /// Reserve the next attempt before spawning it; upgrades and crashes share this owner. - pub(crate) fn queue_service(&self, service: &mut Service) { - service.request = json!(uuid::Uuid::now_v7().to_string()); - service.pending = true; - let request = service.request.clone(); - let host = self.clone(); - tokio::spawn(async move { - host.run_service(request).await; - }); - } -} - -#[cfg(test)] -mod tests; diff --git a/cli/src/services/tests.rs b/cli/src/services/tests.rs deleted file mode 100644 index 2d38c86..0000000 --- a/cli/src/services/tests.rs +++ /dev/null @@ -1,356 +0,0 @@ -//! Deterministic service supervision tests: native tasks stand in for guest lifetimes. -use super::*; -use crate::{NativePlugin, runtime::Runtime}; -use std::sync::Arc; -use tokio::sync::{mpsc, oneshot, watch}; - -struct Rig { - host: Host, - starts: mpsc::UnboundedReceiver<(String, oneshot::Sender<()>)>, - _world: tempfile::TempDir, -} -impl Rig { - async fn new() -> Self { - let world = tempfile::tempdir().unwrap(); - let runtime = Runtime::new(world.path()).unwrap(); - let host = Host::test_world(world.path(), runtime).await.unwrap(); - let (tx, starts) = mpsc::unbounded_channel(); - let plugin: NativePlugin = Arc::new(move |ctx, method, _| { - let tx = tx.clone(); - Box::pin(async move { - if method != "serve" { - return Ok(Value::Null); - } - let (end, ended) = oneshot::channel(); - tx.send((ctx.pid, end)).unwrap(); - let _ = ended.await; - Ok(Value::Null) - }) - }); - host.register_native("demo", plugin.clone()); - host.register_native("other", plugin); - Self { - host, - starts, - _world: world, - } - } - async fn start(&mut self, method: &str) -> (String, oneshot::Sender<()>) { - let host = self.host.clone(); - let method = method.to_string(); - tokio::spawn(async move { - host.start_service(method, json!({"kept":true})).await; - }); - self.next().await - } - async fn next(&mut self) -> (String, oneshot::Sender<()>) { - // Reuse the host integration call bound (activation.rs::call). - tokio::time::timeout(Duration::from_secs(60), self.starts.recv()) - .await - .unwrap() - .unwrap() - } - fn ended(&self, pid: &str) -> watch::Receiver { - self.host.0.state.lock().unwrap().processes[pid] - .ended - .subscribe() - } - async fn crash(&self, pid: &str) { - let mut ended = self.ended(pid); - // Abort execution without asking the supervisor for an intentional stop. - self.host.0.state.lock().unwrap().processes[pid] - .abort - .as_ref() - .unwrap() - .abort(); - ended.wait_for(|done| *done).await.unwrap(); - } - async fn clean(&self, pid: &str, end: oneshot::Sender<()>) { - let mut ended = self.ended(pid); - end.send(()).unwrap(); - ended.wait_for(|done| *done).await.unwrap(); - } - fn assert_single(&self, pid: &str, registrations: usize) { - let state = self.host.0.state.lock().unwrap(); - assert_eq!(state.services.len(), registrations); - let p = &state.processes[pid]; - assert_eq!(p.info.params, json!({"kept":true})); - assert!(p.info.detached); - assert_eq!(p.caller, "core"); - assert_eq!( - state - .services - .iter() - .filter(|s| s.owner.as_deref() == Some(pid)) - .count(), - 1 - ); - } - fn no_start(&mut self) { - assert!(self.starts.try_recv().is_err()); - } -} - -#[tokio::test(start_paused = true)] -async fn crash_before_and_after_readiness_observes_process_not_response() { - let mut rig = Rig::new().await; - let (pid, _end) = rig.start("demo.serve").await; - rig.assert_single(&pid, 1); - rig.crash(&pid).await; // No ready RPC response was sent. - let (pid, end) = rig.next().await; - rig.assert_single(&pid, 1); - rig.host.answer(&pid, Ok(json!({"ready":true}))); - tokio::task::yield_now().await; - rig.no_start(); // Ready does not end the service lifetime. - rig.crash(&pid).await; - drop(end); - let (pid, end) = rig.next().await; - rig.assert_single(&pid, 1); - rig.clean(&pid, end).await; - rig.no_start(); // A clean, intentional return is not a crash. - rig.host.stop().await; -} - -#[tokio::test(start_paused = true)] -async fn fifth_restarts_sixth_gives_up_and_journals_once() { - let mut rig = Rig::new().await; - let (mut pid, mut end) = rig.start("demo.serve").await; - for crash in 1..=CRASH_LIMIT { - rig.crash(&pid).await; - drop(end); - (pid, end) = rig.next().await; - rig.assert_single(&pid, 1); - assert_eq!( - rig.host.0.state.lock().unwrap().services[0].crashes.len(), - crash - ); - } - rig.crash(&pid).await; - drop(end); - rig.no_start(); - let logs = rig - .host - .call("core.log", json!({"grep":"service gave up"})) - .await - .unwrap(); - assert_eq!(logs.as_array().unwrap().len(), 1); - assert!( - logs[0]["line"] - .as_str() - .unwrap() - .contains("more than 5 crashes within 10 seconds") - ); - assert!(!rig.host.0.state.lock().unwrap().services[0].enabled); - // A retired hook cannot bypass give-up while its supervisor has no owner. - rig.host.register_native( - "fallback", - Arc::new(move |ctx, _, _| { - Box::pin(async move { - ctx.call( - "core.call", - json!({"method":"demo.serve","params":{"kept":true},"detach":true}), - ) - .await - }) - }), - ); - assert_eq!( - rig.host - .call("fallback.rpc.retired", json!({})) - .await - .unwrap(), - Value::Null - ); - rig.no_start(); - // Neither time passing nor an upgrade re-arms a given-up service. - tokio::time::advance(CRASH_WINDOW).await; - let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new"); - rig.host.begin_upgrade(upgrade); - tokio::task::yield_now().await; - rig.no_start(); - rig.host.stop().await; -} - -#[test] -fn sliding_window_boundary_and_progress() { - let now = Instant::now(); - let service = || Service { - method: "demo.serve".into(), - params: json!({}), - request: json!("request"), - owner: None, - enabled: true, - pending: false, - version: None, - crashes: VecDeque::new(), - }; - let mut before = service(); - let mut exact = service(); - let mut after = service(); - for _ in 0..CRASH_LIMIT { - assert!(before.crashed(now)); - assert!(exact.crashed(now)); - assert!(after.crashed(now)); - } - assert!(!before.crashed(now + CRASH_WINDOW - Duration::from_nanos(1))); - assert!(exact.crashed(now + CRASH_WINDOW)); - assert_eq!(exact.crashes.len(), 1); - assert!(after.crashed(now + CRASH_WINDOW + Duration::from_nanos(1))); - // Repeated progress beyond the window never accumulates old failures. - for step in 2..=CRASH_LIMIT as u32 + 1 { - assert!(exact.crashed(now + CRASH_WINDOW * step)); - assert_eq!(exact.crashes.len(), 1); - } -} - -#[tokio::test(start_paused = true)] -async fn services_have_independent_crash_budgets() { - let mut rig = Rig::new().await; - let (mut a, mut a_end) = rig.start("demo.serve").await; - let (b, b_end) = rig.start("other.serve").await; - for _ in 0..CRASH_LIMIT { - rig.crash(&a).await; - drop(a_end); - (a, a_end) = rig.next().await; - } - rig.crash(&a).await; - drop(a_end); - rig.crash(&b).await; - drop(b_end); - let (b, b_end) = rig.next().await; - rig.assert_single(&b, 2); - rig.clean(&b, b_end).await; - rig.no_start(); - rig.host.stop().await; -} - -#[tokio::test(start_paused = true)] -async fn upgrade_crash_race_has_one_restart_and_retired_fallback_cannot_duplicate_it() { - let mut rig = Rig::new().await; - let (pid, end) = rig.start("demo.serve").await; - let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new"); - rig.crash(&pid).await; // Exits between snapshot and begin_upgrade. - drop(end); - rig.host.begin_upgrade(upgrade); - let (pid, end) = rig.next().await; - rig.assert_single(&pid, 1); - assert!( - rig.host.0.state.lock().unwrap().services[0] - .crashes - .is_empty() - ); - // A native retired hook exercises the same routing path as a guest fallback. - rig.host.register_native( - "fallback", - Arc::new(move |ctx, _, _| { - Box::pin(async move { - ctx.call( - "core.call", - json!({"method":"demo.serve","params":{"kept":true},"detach":true}), - ) - .await - }) - }), - ); - assert_eq!( - rig.host - .call("fallback.rpc.retired", json!({})) - .await - .unwrap(), - Value::Null - ); - rig.no_start(); - rig.clean(&pid, end).await; - rig.host.stop().await; -} - -#[tokio::test(start_paused = true)] -async fn crash_before_upgrade_snapshot_also_has_one_restart() { - let mut rig = Rig::new().await; - let (pid, end) = rig.start("demo.serve").await; - rig.crash(&pid).await; - drop(end); - // Restart may be pending admission or already admitted: snapshot handles both. - let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "native"); - rig.host.begin_upgrade(upgrade); - let (pid, end) = rig.next().await; - rig.assert_single(&pid, 1); - rig.no_start(); - rig.clean(&pid, end).await; - rig.host.stop().await; -} - -#[tokio::test(start_paused = true)] -async fn pending_admission_is_single_owned_across_upgrade() { - let mut rig = Rig::new().await; - let (pid, end) = rig.start("demo.serve").await; - let host = rig.host.clone(); - let admission = host.0.admission.write().await; - rig.crash(&pid).await; - drop(end); - let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "native"); - rig.host.begin_upgrade(upgrade); - assert!(rig.host.0.state.lock().unwrap().services[0].pending); - drop(admission); - let (pid, end) = rig.next().await; - rig.assert_single(&pid, 1); - rig.no_start(); - rig.clean(&pid, end).await; - rig.host.stop().await; -} - -#[tokio::test(start_paused = true)] -async fn shutdown_rejects_restart_already_waiting_for_admission() { - let mut rig = Rig::new().await; - let (pid, end) = rig.start("demo.serve").await; - let host = rig.host.clone(); - let admission = host.0.admission.write().await; - rig.crash(&pid).await; - drop(end); - rig.host.stop().await; - drop(admission); - tokio::task::yield_now().await; - rig.no_start(); - assert!(rig.host.processes().is_empty()); -} - -#[tokio::test(start_paused = true)] -async fn clean_service_stays_dormant_until_upgrade_and_explicit_stop_stays_disabled() { - let mut rig = Rig::new().await; - let (pid, end) = rig.start("demo.serve").await; - rig.clean(&pid, end).await; - rig.no_start(); - let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new"); - rig.host.begin_upgrade(upgrade); - let (pid, end) = rig.next().await; - rig.assert_single(&pid, 1); - // An explicit stop stays disabled even if the task subsequently exits cleanly. - let mut ended = rig.host.begin_stop(&pid).unwrap(); - end.send(()).unwrap(); - ended.wait_for(|done| *done).await.unwrap(); - let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "newer"); - rig.host.begin_upgrade(upgrade); - tokio::task::yield_now().await; - rig.no_start(); - rig.host.stop().await; -} - -#[tokio::test(start_paused = true)] -async fn intentional_stop_and_shutdown_never_restart_even_during_upgrade() { - let mut rig = Rig::new().await; - let (pid, end) = rig.start("demo.serve").await; - let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new"); - // An explicit kill after supersession overrides the automatic upgrade restart. - rig.host - .call("core.kill", json!({"pid":pid})) - .await - .unwrap(); - drop(end); - rig.host.begin_upgrade(upgrade); - tokio::task::yield_now().await; - rig.no_start(); - let (_pid, end) = rig.start("other.serve").await; - rig.host.stop().await; - drop(end); - rig.no_start(); -} diff --git a/cli/src/test_runner.rs b/cli/src/test_runner.rs deleted file mode 100644 index 2384f9a..0000000 --- a/cli/src/test_runner.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! Cargo's WASI test runner: one compiled component, isolated instances, and a small core transport. -use anyhow::{Result, bail}; -use sausage::{ - Host, - runtime::{Exit, InstanceConfig, Runtime}, -}; -use serde_json::{Value, json}; -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; -use wasmtime::component::Component; - -/// A temporary world remains alive through the end of its WASI store. -struct TestWorld(PathBuf); -impl Drop for TestWorld { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} -struct Run { - exit: Exit, - output: String, -} -/// Runs one instance and separates JSON-RPC requests from ordinary test output. -async fn instance( - runtime: Arc, - component: Component, - args: Vec, - package: PathBuf, -) -> Result { - let path = std::env::temp_dir().join(format!("sausage-test-{}", uuid::Uuid::now_v7())); - let world = TestWorld(path); - let host = Host::test_world(&world.0, runtime.clone()).await?; - let tracker = sausage_vfs::Process::default(); - let mut filesystem = host.filesystem().await?; - // Virtual ancestors keep directory entries of the absolute package mount traversable. - for ancestor in package - .ancestors() - .skip(1) - .filter(|path| *path != Path::new("/")) - { - filesystem.mount( - ancestor.to_str().unwrap(), - Arc::new(sausage_vfs::Memory::default()), - false, - ); - } - filesystem.mount( - package.to_str().unwrap(), - Arc::new(sausage_vfs::Host::new(&package, tracker.clone())?), - false, - ); - let mut env: Vec<_> = std::env::vars_os() - .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?))) - .filter(|(key, _)| { - ["CARGO_", "RUST_", "SAUSAGE_"] - .iter() - .any(|prefix| key.starts_with(prefix)) - }) - .collect(); - env.extend([ - ("SAUSAGE_PID".into(), "test".into()), - ("SAUSAGE_PLUGIN".into(), "test".into()), - ("SAUSAGE_PARENT".into(), "core".into()), - ]); - let timeout_secs = std::env::var("SAUSAGE_TEST_TIMEOUT_SECS") - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|value| *value >= 1) - .unwrap_or(sausage::cargo::DEFAULT_TEST_TIMEOUT_SECS); - let deadline = tokio::time::Instant::now() - .checked_add(std::time::Duration::from_secs(timeout_secs)) - .ok_or_else(|| anyhow::anyhow!("test_timeout_secs is too large"))?; - let (ended, mut ended_rx) = tokio::sync::watch::channel(true); - let mut guest = runtime - .start_with_args( - &component, - InstanceConfig { - filesystem, - store_ended: ended, - network: true, - env, - }, - &args, - ) - .await?; - let mut pending = std::collections::VecDeque::new(); - let mut stderr = std::mem::replace(&mut guest.stderr, tokio::sync::mpsc::channel(1).1); - let (errors, mut stderr_lines) = tokio::sync::mpsc::unbounded_channel(); - let stderr_task = tokio::spawn(async move { - while let Some(line) = stderr.recv().await { - let _ = errors.send(line); - } - }); - let mut output = String::new(); - let mut stdout_open = true; - let mut stderr_open = true; - let mut handlers = tokio::task::JoinSet::new(); - let result = tokio::time::timeout_at(deadline, async { - while stdout_open || stderr_open || !pending.is_empty() { - tokio::select! { - line=async { if let Some(line) = pending.pop_front() { Some(line) } else { guest.stdout.recv().await } }, if stdout_open || !pending.is_empty()=>match line { - Some(line)=> { - // Libtest can prefix its status line before SDK output. The SDK starts JSON on a fresh line. - if let Ok(request)=serde_json::from_str::(&line) && request["jsonrpc"]=="2.0" && request["method"].is_string() { - let method = request["method"].as_str().unwrap(); - if !matches!(method, "core.lock" | "core.log") { - let params = request.get("params").cloned().unwrap_or(json!({})); - let result = match method { - "core.unlock" => host.call(method, params).await, - "core.notify" => Ok(Value::Null), - _ => Err(json!({"code":-32601,"message":"a test has no other plugins"})), - }; - if let Some(id) = request.get("id") { - let response = match result { Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}), Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error}) }; - if guest.send_line(&response.to_string(), |line| pending.push_back(line)).await.is_err() { guest.stdin.take(); } - } - continue; - } - let host = host.clone(); - handlers.spawn(async move { - let method=request["method"].as_str().unwrap(); - let params=request.get("params").cloned().unwrap_or(json!({})); - let result=match method { - "core.lock"|"core.unlock"|"core.log"=>host.call(method,params).await, - "core.notify"=>Ok(Value::Null), - _=>Err(json!({"code":-32601,"message":"a test has no other plugins"})), - }; - request.get("id").map(|id|match result {Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}),Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error})}) - }); - } else {output.push_str(line.trim_end_matches(['\r', '\n']));output.push('\n');} - },None=>stdout_open=false, - }, - line=stderr_lines.recv(), if stderr_open=>match line {Some(line)=>{output.push_str(&line);output.push('\n');},None=>stderr_open=false}, - response=handlers.join_next(), if !handlers.is_empty()=> { - if let Some(response) = response.transpose()?.flatten() - && guest.send_line(&response.to_string(), |line| pending.push_back(line)).await.is_err() { guest.stdin.take(); } - }, - } - } - Ok::<_, anyhow::Error>(guest.wait().await) - }).await; - handlers.abort_all(); - // Dropping the instance aborts even a CPU-bound WASI loop. Wait for the - // store and outstanding filesystem work before removing its test world. - drop(guest); - let _ = ended_rx.wait_for(|ended| *ended).await; - let _ = stderr_task.await; - tracker.idle().await; - host.stop().await; - let exit = - result.map_err(|_| anyhow::anyhow!("timed out after {timeout_secs} s\n{output}"))??; - Ok(Run { exit, output }) -} -/// Extracts libtest names from its stable list format. -fn names(output: &str) -> Vec { - output - .lines() - .filter_map(|line| line.strip_suffix(": test").map(str::to_owned)) - .collect() -} -/// Lists tests, selects libtest filters, and runs each selected test on the core pool. -pub async fn run(wasm: &Path, args: Vec) -> Result { - let package = std::env::current_dir()?; - let cache = std::env::var_os("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| { - PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache") - }) - .join("sausage"); - let compile_path = wasm.to_path_buf(); - let (runtime, component) = tokio::task::spawn_blocking(move || -> Result<_> { - let runtime = Runtime::new(&cache)?; - let component = runtime.compile(&std::fs::read(compile_path)?)?; - Ok((runtime, component)) - }) - .await??; - let mut listing_args = vec![wasm.display().to_string(), "--list".into()]; - listing_args.extend(args.iter().filter(|arg| arg.as_str() != "--list").cloned()); - let listing = instance( - runtime.clone(), - component.clone(), - listing_args.clone(), - package.clone(), - ) - .await?; - if !matches!(listing.exit, Exit::Clean) { - bail!( - "cannot list WASI tests: {}\n{}", - listing.exit, - listing.output - ); - } - if args.iter().any(|arg| arg == "--list") { - print!("{}", listing.output); - return Ok(true); - } - let selected = names(&listing.output); - listing_args.push("--ignored".into()); - let ignored = if args - .iter() - .any(|arg| matches!(arg.as_str(), "--ignored" | "--include-ignored")) - { - Vec::new() - } else { - names( - &instance( - runtime.clone(), - component.clone(), - listing_args, - package.clone(), - ) - .await? - .output, - ) - }; - println!("running {} tests", selected.len()); - let cores = std::thread::available_parallelism() - .map(usize::from) - .unwrap_or(1); - let semaphore = Arc::new(tokio::sync::Semaphore::new(cores)); - let mut tasks = tokio::task::JoinSet::new(); - let mut skipped = 0; - for name in selected { - if ignored.contains(&name) { - println!("test {name} ... ignored"); - skipped += 1; - continue; - } - let runtime = runtime.clone(); - let component = component.clone(); - let package = package.clone(); - let semaphore = semaphore.clone(); - let arguments = vec![ - wasm.display().to_string(), - "--exact".into(), - name.clone(), - "--nocapture".into(), - "--test-threads=1".into(), - "--include-ignored".into(), - ]; - tasks.spawn(async move { - let _permit = semaphore.acquire_owned().await?; - let result = instance(runtime, component, arguments, package).await; - Ok::<_, anyhow::Error>((name, result)) - }); - } - let mut passed = 0; - let mut failed = 0; - while let Some(result) = tasks.join_next().await { - let (name, result) = result??; - let success = result - .as_ref() - .is_ok_and(|run| matches!(run.exit, Exit::Clean)); - if success { - passed += 1; - } else { - failed += 1; - } - println!("test {name} ... {}", if success { "ok" } else { "FAILED" }); - match result { - Ok(run) => { - for line in run - .output - .lines() - .filter(|line| { - !line.trim().is_empty() - && !line.starts_with("test result:") - && !matches!(*line, "running 1 test" | "running 0 tests") - && !matches!(*line, "ok" | "FAILED") - }) - .map(|line| { - line.strip_prefix(&format!("test {name} ... ")) - .unwrap_or(line) - }) - .filter(|line| { - !matches!(*line, "ok" | "FAILED" | "ignored") && !line.is_empty() - }) - { - // Indentation keeps ordinary test output separate from our libtest status and totals. - println!(" {line}"); - } - } - Err(error) => println!("{error:#}"), - } - } - println!( - "\ntest result: {}. {passed} passed; {failed} failed; {skipped} ignored; 0 measured; 0 filtered out", - if failed == 0 { "ok" } else { "FAILED" } - ); - Ok(failed == 0) -} diff --git a/cli/src/upgrades.rs b/cli/src/upgrades.rs deleted file mode 100644 index d3c8d09..0000000 --- a/cli/src/upgrades.rs +++ /dev/null @@ -1,192 +0,0 @@ -//! Grace periods, service restarts, and retirement of replaced versions (PROTOCOL.md section 3.7). -use crate::Host; -use serde_json::json; -use std::{collections::BTreeMap, sync::atomic::Ordering, time::Duration}; -use tokio::sync::watch; - -/// Uniform host policy (PROTOCOL 3.7), independent of discovery metadata. -const UPGRADE_GRACE: Duration = Duration::from_secs(5); - -#[cfg(test)] -mod tests; - -pub(crate) struct OldProcess { - pid: String, - version: String, - ended: watch::Receiver, -} -pub(crate) struct Upgrade { - plugin: String, - previous: String, - current: String, - processes: Vec, -} -impl Host { - /// Snapshots old admissions while activation holds the admission write guard. - pub(crate) fn snapshot_upgrade( - &self, - plugin: &str, - previous: String, - current: &str, - ) -> Upgrade { - let mut state = self.0.state.lock().unwrap(); - let processes = state - .processes - .iter_mut() - .filter(|(_, process)| { - process.info.plugin == plugin - && process.info.version != current - && process.info.method != "rpc.discover" - }) - .map(|(pid, process)| { - // The service supervisor owns the restart, including an exit on - // the superseded notice before the grace timer is installed. - process.superseded = !process.stopped; - OldProcess { - pid: pid.clone(), - version: process.info.version.clone(), - ended: process.ended.subscribe(), - } - }) - .collect(); - // A cleanly returned service is dormant until an upgrade. A pending - // admission will choose the current version itself. Disabled services - // (explicit stop or crash-limit give-up) must never be re-armed here. - for service in &mut state.services { - if service.enabled - && !service.pending - && service.owner.is_none() - && service.version.as_deref() != Some(current) - && service - .method - .split_once('.') - .is_some_and(|(name, _)| name == plugin) - { - self.queue_service(service); - } - } - Upgrade { - plugin: plugin.into(), - previous, - current: current.into(), - processes, - } - } - /// Lets old calls finish, stops them after grace, and starts each lifecycle hook once. - pub(crate) fn begin_upgrade(&self, upgrade: Upgrade) { - // Queue the notice before returning from activation, without waiting on guest code. - for process in &upgrade.processes { - self.send( - &process.pid, - json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":upgrade.current}}), - ); - } - let host = self.clone(); - tokio::spawn(async move { - if host.0.shutdown.load(Ordering::SeqCst) { - return; - } - host.0.journal.write( - "core", - &upgrade.plugin, - "core", - &format!("upgrade grace {} seconds", UPGRADE_GRACE.as_secs()), - ); - let mut versions: BTreeMap>> = BTreeMap::new(); - versions.entry(upgrade.previous).or_default(); - for process in upgrade.processes { - versions - .entry(process.version) - .or_default() - .push(process.ended.clone()); - let host = host.clone(); - tokio::spawn(async move { - let mut ended = process.ended; - tokio::select! { - _ = ended.wait_for(|ended|*ended) => (), - _ = tokio::time::sleep(UPGRADE_GRACE) => { host.begin_retire(&process.pid); } - } - }); - } - for (version, processes) in versions { - let host = host.clone(); - let plugin = upgrade.plugin.clone(); - let retirement = (plugin.clone(), version.clone()); - if !host - .0 - .retirements - .lock() - .unwrap() - .insert(retirement.clone()) - { - continue; - } - tokio::spawn(async move { - for mut ended in processes { - let _ = ended.wait_for(|ended| *ended).await; - } - loop { - if host.0.shutdown.load(Ordering::SeqCst) { - host.0.retirements.lock().unwrap().remove(&retirement); - return; - } - let live: Vec<_> = host - .0 - .state - .lock() - .unwrap() - .processes - .values() - .filter(|process| { - process.info.plugin == plugin - && process.info.version == version - && process.info.method != "rpc.discover" - }) - .map(|process| process.ended.subscribe()) - .collect(); - if live.is_empty() { - break; - } - for mut ended in live { - let _ = ended.wait_for(|ended| *ended).await; - } - } - if host - .0 - .graph - .lock() - .unwrap() - .plugins - .get(&plugin) - .is_some_and(|current| current.hash == version) - { - host.0.retirements.lock().unwrap().remove(&retirement); - return; - } - host.0.journal.write( - "core", - &plugin, - "core", - &format!("retired start {version}"), - ); - let result = host - .route( - "core".into(), - Some(json!(uuid::Uuid::now_v7().to_string())), - format!("{plugin}.rpc.retired"), - json!({"previous":version}), - true, - ) - .await; - host.0.journal.write( - "core", - &plugin, - "core", - &format!("retired result {result:?}"), - ); - host.0.retirements.lock().unwrap().remove(&retirement); - }); - } - }); - } -} diff --git a/cli/src/upgrades/tests.rs b/cli/src/upgrades/tests.rs deleted file mode 100644 index 3bf71d7..0000000 --- a/cli/src/upgrades/tests.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Controlled-clock coverage of both upgrade windows and discovery exclusion. -use super::*; -use crate::{ - process::{Process, ProcessInfo}, - runtime::Runtime, - world::World, -}; -use tokio::{ - sync::mpsc, - task::JoinHandle, - time::{Instant, advance}, -}; - -/// A process with active work that ignores both superseded and EOF. -fn process( - host: &Host, - pid: &str, - method: &str, -) -> (mpsc::UnboundedReceiver, JoinHandle<()>) { - let (input, incoming) = mpsc::unbounded_channel(); - let task = tokio::spawn(std::future::pending()); - host.0.state.lock().unwrap().processes.insert( - pid.into(), - Process { - info: ProcessInfo { - plugin: "notes".into(), - version: "old".into(), - method: method.into(), - params: json!({}), - parent: None, - detached: true, - start_time: 0, - state: "running".into(), - }, - memory: sausage_vfs::Memory::default(), - files: sausage_vfs::Process::default(), - store_ended: watch::channel(true).0, - input: Some(input), - abort: Some(task.abort_handle()), - caller: "console".into(), - id: None, - answer: None, - ended: watch::channel(false).0, - stopped: false, - superseded: false, - }, - ); - (incoming, task) -} - -/// PROTOCOL 3.7/3.6: five seconds running, then five seconds stopping; discovery is untouched. -#[tokio::test] -async fn two_five_second_windows_and_discovery_exclusion() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("world"); - World::create(&root).unwrap(); - let host = Host::test_world(&root, Runtime::new(&dir.path().join("cache")).unwrap()) - .await - .unwrap(); - let (mut input, work) = process(&host, "old-work", "work"); - let (mut discovery_input, discovery) = process(&host, "old-discovery", "rpc.discover"); - let upgrade = host.snapshot_upgrade("notes", "old".into(), "new"); - assert_eq!(upgrade.processes.len(), 1); - tokio::time::pause(); - let start = Instant::now(); - host.begin_upgrade(upgrade); - assert_eq!( - input.recv().await.unwrap(), - json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":"new"}}) - ); - // Run the upgrade dispatcher, then its per-process task, so the deadline is armed. - tokio::task::yield_now().await; - tokio::task::yield_now().await; - let window = Duration::from_secs(5); - assert_eq!(UPGRADE_GRACE, window); - // tokio::time::sleep documents millisecond granularity; the paused driver - // may round a deadline up by one tick. Check each window independently. - let tick = Duration::from_millis(1); - let before_deadline = window - tick; - advance(before_deadline).await; - tokio::task::yield_now().await; - assert_eq!(host.processes()["old-work"].state, "running"); - assert!(!work.is_finished()); - advance(tick).await; - assert!( - input.recv().await.is_none(), - "EOF at grace, even with active work" - ); - let stopped = Instant::now(); - assert!((window..=window + tick).contains(&(stopped - start))); - assert_eq!(host.processes()["old-work"].state, "stopping"); - // begin_retire schedules the independent PROTOCOL 3.6 deadline. - tokio::task::yield_now().await; - advance(before_deadline).await; - tokio::task::yield_now().await; - assert!( - !work.is_finished(), - "on_stop still has its full five seconds" - ); - advance(tick).await; - assert!(work.await.unwrap_err().is_cancelled()); - assert!((window..=window + tick).contains(&(Instant::now() - stopped))); - assert_eq!(host.processes()["old-discovery"].state, "running"); - assert!(matches!( - discovery_input.try_recv(), - Err(mpsc::error::TryRecvError::Empty) - )); - assert!(!discovery.is_finished()); - discovery.abort(); - let _ = discovery.await; - // Synthetic processes have no execution cleanup task; discard only this test table. - host.0.shutdown.store(true, Ordering::SeqCst); - host.0.state.lock().unwrap().processes.clear(); - host.stop().await; -} diff --git a/cli/src/watcher.rs b/cli/src/watcher.rs deleted file mode 100644 index d7229e6..0000000 --- a/cli/src/watcher.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Background discovery and nonrecursive notifications for installed plugin versions. -use crate::Host; -use notify::{RecursiveMode, Watcher}; -impl Host { - /// Watches only the plugin root and its immediate folders. Discovery survives watcher failure. - pub(crate) fn watch_plugins(&self) -> anyhow::Result<()> { - let root = self.0.world.root.join("plugins"); - let watched_root = root.clone(); - let (events, mut incoming) = tokio::sync::mpsc::unbounded_channel(); - let setup = (|| -> anyhow::Result<_> { - let mut watcher = - notify::recommended_watcher(move |event: notify::Result| { - if let Ok(event) = event - && !matches!(event.kind, notify::EventKind::Access(_)) - && event.paths.iter().any(|path| { - path.parent() == Some(&watched_root) - || path.file_name().is_some_and(|name| name == "plugin.wasm") - }) - { - let _ = events.send(()); - } - })?; - watcher.watch(&root, RecursiveMode::NonRecursive)?; - for entry in std::fs::read_dir(&root)? { - let entry = entry?; - if entry.file_type()?.is_dir() { - watcher.watch(&entry.path(), RecursiveMode::NonRecursive)?; - } - } - Ok(watcher) - })(); - match setup { - Ok(watcher) => *self.0.watcher.lock().unwrap() = Some(watcher), - Err(error) => { - self.0 - .journal - .write("core", "core", "core", &format!("watcher failed {error}")) - } - } - let weak = std::sync::Arc::downgrade(&self.0); - tokio::spawn(async move { - loop { - let Some(inner) = weak.upgrade() else { - break; - }; - let host = Host(inner); - // Root events also cover plugin folders created after startup. - if let Some(watcher) = host.0.watcher.lock().unwrap().as_mut() - && let Ok(entries) = std::fs::read_dir(&root) - { - for entry in entries.flatten() { - if entry.file_type().is_ok_and(|kind| kind.is_dir()) - && let Err(error) = - watcher.watch(&entry.path(), RecursiveMode::NonRecursive) - { - host.0.journal.write( - "core", - "core", - "core", - &format!("watcher failed {error}"), - ); - } - } - } - let _activation = host.0.activations.lock().await; - if let Err(error) = host.refresh_graph().await { - host.0.journal.write( - "core", - "core", - "core", - &format!("discovery failed {error}"), - ); - } - drop(_activation); - drop(host); - if incoming.recv().await.is_none() { - break; - } - while incoming.try_recv().is_ok() {} - } - }); - Ok(()) - } -} diff --git a/cli/src/watches.rs b/cli/src/watches.rs deleted file mode 100644 index b6a1ab6..0000000 --- a/cli/src/watches.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Armed plugin-file watches. A Notify permit coalesces changes without losing a later change. -use crate::{Host, RpcResult, error}; -use sausage_vfs::observe::under; -use serde_json::{Value, json}; -use std::{ - collections::HashMap, - sync::{Arc, Mutex}, -}; -use tokio::sync::Notify; -use uuid::Uuid; - -pub(crate) struct Watch { - path: String, - changed: Arc, -} -pub(crate) type Watches = Arc>>; -pub(crate) fn changed(watches: &Watches, path: &str) { - let targets: Vec<_> = watches - .lock() - .unwrap() - .values() - // Removing/moving an ancestor also invalidates a watch inside that subtree. - .filter(|w| under(path, &w.path) || under(&w.path, path)) - .map(|w| w.changed.clone()) - .collect(); - for changed in targets { - changed.notify_one(); - } -} -struct Registration { - watches: Watches, - key: Uuid, -} -impl Drop for Registration { - fn drop(&mut self) { - self.watches.lock().unwrap().remove(&self.key); - } -} -fn path(caller: &str, path: &str) -> String { - let path = sausage_vfs::resolve("/", path); - let path = path.trim_end_matches('/'); - if path.is_empty() { - return "/".into(); - } - if under(path, "/self") { - format!("/proc/{caller}{}", &path[5..]) - } else { - path.into() - } -} -impl Host { - pub(crate) async fn watch(&self, caller: &str, id: Option, target: &str) -> RpcResult { - let id = id.ok_or_else(|| error(-32600, "watch needs a request ID"))?; - let mut ended = self - .0 - .state - .lock() - .unwrap() - .processes - .get(caller) - .filter(|p| p.info.state == "running") - .map(|p| p.ended.subscribe()) - .ok_or_else(|| error(-32600, "watch needs a running caller process"))?; - let changed = Arc::new(Notify::new()); - let registration = Registration { - watches: self.0.watches.clone(), - key: Uuid::now_v7(), - }; - self.0.watches.lock().unwrap().insert( - registration.key, - Watch { - path: path(caller, target), - changed: changed.clone(), - }, - ); - self.call_notify(caller, &id, "armed", json!({})); - loop { - tokio::select! { - biased; - _ = ended.wait_for(|ended| *ended) => return Ok(Value::Null), - _ = changed.notified() => self.call_notify(caller, &id, "changed", json!({})), - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - /// Pending changes coalesce; a change after consuming the last permit is retained. - #[tokio::test] - async fn coalescing_and_cleanup() { - let watches = Watches::default(); - let n = Arc::new(Notify::new()); - let r = Registration { - watches: watches.clone(), - key: Uuid::now_v7(), - }; - watches.lock().unwrap().insert( - r.key, - Watch { - path: "/mailbox".into(), - changed: n.clone(), - }, - ); - changed(&watches, "/mailboxes/a"); - assert!( - tokio::time::timeout(std::time::Duration::ZERO, n.notified()) - .await - .is_err() - ); - changed(&watches, "/mailbox/a"); - changed(&watches, "/mailbox/b"); - n.notified().await; - assert!( - tokio::time::timeout(std::time::Duration::ZERO, n.notified()) - .await - .is_err() - ); - changed(&watches, "/mailbox/c"); - n.notified().await; - drop(r); - assert!(watches.lock().unwrap().is_empty()); - changed(&watches, "/mailbox/d"); - assert!( - tokio::time::timeout(std::time::Duration::ZERO, n.notified()) - .await - .is_err() - ); - } - #[test] - fn self_alias_and_normalization() { - assert_eq!(path("writer", "/self/../self/a/"), "/proc/writer/a"); - assert_eq!(path("writer", "/selfish/a"), "/selfish/a"); - assert_eq!(path("writer", "/"), "/"); - } -} - -#[cfg(test)] -mod lifetime_tests { - use super::*; - /// The real caller's ended event releases the registration, without a cancellation RPC. - #[tokio::test] - async fn caller_death_removes_watch() { - let temp = tempfile::tempdir().unwrap(); - let runtime = crate::runtime::Runtime::new(&temp.path().join("cache")).unwrap(); - let host = Host::test_world(&temp.path().join("world"), runtime) - .await - .unwrap(); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - host.register_native( - "holder", - Arc::new(move |ctx, _, _| { - let tx = tx.clone(); - Box::pin(async move { - tx.send(ctx.pid.clone()).unwrap(); - std::future::pending().await - }) - }), - ); - let h = host.clone(); - let task = tokio::spawn(async move { h.call("holder.hold", json!({})).await }); - let pid = rx.recv().await.unwrap(); - let mut watch = Box::pin(host.watch(&pid, Some(json!("watch")), "/mailbox")); - assert!( - watch - .as_mut() - .poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) - .is_pending() - ); - assert_eq!(host.0.watches.lock().unwrap().len(), 1); - host.call("core.kill", json!({"pid":pid})).await.unwrap(); - watch.await.unwrap(); - assert!(host.0.watches.lock().unwrap().is_empty()); - assert!(task.await.unwrap().is_err()); - host.stop().await; - } -} diff --git a/cli/src/world.rs b/cli/src/world.rs deleted file mode 100644 index 6c707f8..0000000 --- a/cli/src/world.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! World folders and atomic build output helpers. -use anyhow::{Result, bail}; -use std::path::{Path, PathBuf}; -#[derive(Clone)] -/// The absolute host folder shared by all processes. -pub struct World { - /// The absolute path of the world on the host. - pub root: PathBuf, -} -impl World { - /// Validate reserved root names and resolve the world path. - pub fn open(path: &Path) -> Result { - for name in ["proc", "run", "self"] { - if path.join(name).symlink_metadata().is_ok() { - bail!("reserved world path: {name}"); - } - } - Ok(Self { - root: path.canonicalize()?, - }) - } - /// Create the folders and empty boot configuration of a new world. - pub fn create(path: &Path) -> Result { - if path.exists() { - bail!("world already exists: {}", path.display()); - } - for dir in ["plugins", "state/core", "config"] { - std::fs::create_dir_all(path.join(dir))?; - } - std::fs::write(path.join("config/boot.yaml"), "[]\n")?; - Self::open(path) - } - /// Return the folder that contains a plugin component. - pub fn plugin_dir(&self, name: &str) -> PathBuf { - self.root.join("plugins").join(name) - } - /// Return the persistent state folder of a plugin. - pub fn state_dir(&self, name: &str) -> PathBuf { - self.root.join("state").join(name) - } -} -/// Write a temporary sibling and rename it over the destination. -pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { - std::fs::create_dir_all(path.parent().unwrap())?; - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, bytes)?; - std::fs::rename(tmp, path)?; - Ok(()) -} -/// Replace a build copy with a source tree, without target folders. -pub fn replace_source(from: &Path, to: &Path) -> Result<()> { - if to.exists() { - std::fs::remove_dir_all(to)?; - } - copy_tree(from, to) -} -fn copy_tree(from: &Path, to: &Path) -> Result<()> { - std::fs::create_dir_all(to)?; - for entry in std::fs::read_dir(from)? { - let entry = entry?; - if entry.file_name() == "target" { - continue; - } - if entry.file_type()?.is_dir() { - copy_tree(&entry.path(), &to.join(entry.file_name()))?; - } else { - std::fs::copy(entry.path(), to.join(entry.file_name()))?; - } - } - Ok(()) -} diff --git a/cli/tests/crates/cargo-c/Cargo.toml b/cli/tests/crates/cargo-c/Cargo.toml deleted file mode 100644 index aff3e8d..0000000 --- a/cli/tests/crates/cargo-c/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "cargo-c" -version = "0.1.0" -edition = "2024" - -[dependencies] -sausage-plugin = "0.1" - -[build-dependencies] -cc = "1" - -[workspace] diff --git a/cli/tests/crates/cargo-c/build.rs b/cli/tests/crates/cargo-c/build.rs deleted file mode 100644 index c4a89ae..0000000 --- a/cli/tests/crates/cargo-c/build.rs +++ /dev/null @@ -1,5 +0,0 @@ -// Compile C with the `cc` crate: natively for the unit test, and with the -// WASI clang (`CC_wasm32_wasip2`) for the actor. -fn main() { - cc::Build::new().file("src/add.c").compile("add"); -} diff --git a/cli/tests/crates/cargo-c/src/add.c b/cli/tests/crates/cargo-c/src/add.c deleted file mode 100644 index f83c3dc..0000000 --- a/cli/tests/crates/cargo-c/src/add.c +++ /dev/null @@ -1 +0,0 @@ -int c_add(int a, int b) { return a + b; } diff --git a/cli/tests/crates/cargo-c/src/main.rs b/cli/tests/crates/cargo-c/src/main.rs deleted file mode 100644 index 6d830cb..0000000 --- a/cli/tests/crates/cargo-c/src/main.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! SDK plugin linked to a WASI C function. -unsafe extern "C" { - fn c_add(a: i32, b: i32) -> i32; -} -fn add(a: i32, b: i32) -> i32 { - unsafe { c_add(a, b) } -} -struct Plugin; -#[sausage_plugin::rpc] -impl Plugin { - /// Add two fixed numbers through C. - fn sum(&self) -> sausage_plugin::Result { - Ok(add(2, 3)) - } -} -fn main() { - sausage_plugin::run(Plugin); -} -#[cfg(test)] -mod tests { - #[test] - fn c_code_runs_under_wasi() { - assert_eq!(super::add(2, 3), 5); - } -} diff --git a/cli/tests/crates/cargo-fail/Cargo.toml b/cli/tests/crates/cargo-fail/Cargo.toml deleted file mode 100644 index 6403b77..0000000 --- a/cli/tests/crates/cargo-fail/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "cargo-fail" -version = "0.1.0" -edition = "2024" - -[workspace] diff --git a/cli/tests/crates/cargo-fail/src/main.rs b/cli/tests/crates/cargo-fail/src/main.rs deleted file mode 100644 index 57d1382..0000000 --- a/cli/tests/crates/cargo-fail/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - missing_function(); -} diff --git a/cli/tests/crates/cargo-hang/Cargo.toml b/cli/tests/crates/cargo-hang/Cargo.toml deleted file mode 100644 index 215006f..0000000 --- a/cli/tests/crates/cargo-hang/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "cargo-hang" -version = "0.1.0" -edition = "2024" - -[workspace] diff --git a/cli/tests/crates/cargo-hang/build.rs b/cli/tests/crates/cargo-hang/build.rs deleted file mode 100644 index 4c77e75..0000000 --- a/cli/tests/crates/cargo-hang/build.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! A build script descendant that stays alive until the native call ends. -fn main() { - std::process::Command::new("sh") - .args(["-c", "echo $$ > child.pid; exec sleep 1000"]) - .status() - .unwrap(); -} diff --git a/cli/tests/crates/cargo-hang/src/main.rs b/cli/tests/crates/cargo-hang/src/main.rs deleted file mode 100644 index f328e4d..0000000 --- a/cli/tests/crates/cargo-hang/src/main.rs +++ /dev/null @@ -1 +0,0 @@ -fn main() {} diff --git a/cli/tests/crates/cargo-test-fail/Cargo.toml b/cli/tests/crates/cargo-test-fail/Cargo.toml deleted file mode 100644 index a0be58d..0000000 --- a/cli/tests/crates/cargo-test-fail/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "cargo-test-fail" -version = "0.1.0" -edition = "2024" - -[workspace] diff --git a/cli/tests/crates/cargo-test-fail/src/main.rs b/cli/tests/crates/cargo-test-fail/src/main.rs deleted file mode 100644 index 3ce03e9..0000000 --- a/cli/tests/crates/cargo-test-fail/src/main.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! A panic must not prevent the runner from executing another test. -fn main() {} -#[cfg(test)] -mod tests { - #[test] - fn a_panics() { - panic!("deliberate WASI panic"); - } - #[test] - fn z_survives() { - assert_eq!(2 + 2, 4); - } -} diff --git a/cli/tests/crates/cargo-valid/Cargo.toml b/cli/tests/crates/cargo-valid/Cargo.toml deleted file mode 100644 index 0cbbca2..0000000 --- a/cli/tests/crates/cargo-valid/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "cargo-valid" -version = "0.1.0" -edition.workspace = true - -[target.'cfg(target_arch = "wasm32")'.dependencies] -sausage_plugin = { package = "sausage-plugin", version = "0.1", optional = true } - -[features] -default = ["dep:sausage_plugin"] - -[workspace] - -[workspace.package] -edition = "2024" diff --git a/cli/tests/crates/cargo-valid/build.rs b/cli/tests/crates/cargo-valid/build.rs deleted file mode 100644 index 7bdd766..0000000 --- a/cli/tests/crates/cargo-valid/build.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Produces a warning so the build response can retain diagnostics. -fn main() { - println!("cargo:warning=fixture build warning"); -} diff --git a/cli/tests/crates/cargo-valid/fixture.txt b/cli/tests/crates/cargo-valid/fixture.txt deleted file mode 100644 index ee8c1ee..0000000 --- a/cli/tests/crates/cargo-valid/fixture.txt +++ /dev/null @@ -1 +0,0 @@ -fixture diff --git a/cli/tests/crates/cargo-valid/src/main.rs b/cli/tests/crates/cargo-valid/src/main.rs deleted file mode 100644 index 19f1125..0000000 --- a/cli/tests/crates/cargo-valid/src/main.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! SDK plugin and tests for the native build pipeline. -struct Plugin; -#[sausage_plugin::rpc] -impl Plugin { - /// Return a greeting. - fn hello(&self) -> sausage_plugin::Result { - Ok("hello".into()) - } -} -fn main() { - sausage_plugin::run(Plugin); -} -#[cfg(test)] -mod tests { - #[test] - fn arithmetic() { - assert_eq!(2 + 2, 4); - } - #[test] - fn fixture_at_manifest_path() { - assert_eq!( - std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/fixture.txt")).unwrap(), - "fixture\n" - ); - } - #[test] - fn current_directory() { - println!( - "WASI_CURRENT_DIR={}", - std::env::current_dir().unwrap().display() - ); - } - #[sausage_plugin::test] - async fn sdk_lock() { - let guard = sausage_plugin::lock("/state/lock").await.unwrap(); - assert!(guard.held()); - let locks: sausage_plugin::serde_json::Value = sausage_plugin::serde_json::from_str( - &std::fs::read_to_string("/proc/core/locks.json").unwrap(), - ) - .unwrap(); - assert_eq!(locks.as_object().unwrap().len(), 1); - assert!(locks.get("/state/lock").is_some()); - let mut guard = Some(guard); - let mut waiting = Box::pin(std::future::IntoFuture::into_future(sausage_plugin::lock( - "/state/lock", - ))); - let acquired = std::future::poll_fn(|context| { - let result = std::future::Future::poll(waiting.as_mut(), context); - if result.is_pending() { - drop(guard.take()); - } - result - }) - .await - .unwrap(); - assert!(acquired.held()); - let logs = sausage_plugin::call("core.log", sausage_plugin::serde_json::json!({})) - .await - .unwrap(); - assert!(logs.is_array()); - sausage_plugin::call( - "core.notify", - sausage_plugin::serde_json::json!({"name":"progress","params":{}}), - ) - .await - .unwrap(); - } - #[sausage_plugin::test] - async fn other_plugin_fails() { - let error = sausage_plugin::call("missing.method", sausage_plugin::serde_json::json!({})) - .await - .unwrap_err(); - assert!(error.to_string().contains("a test has no other plugins")); - } - #[sausage_plugin::test] - async fn unlock_order() { - for iteration in 0..300 { - let guard = sausage_plugin::lock("/state/order").await.unwrap(); - assert!(guard.held()); - drop(guard); - let guard = sausage_plugin::lock("/state/order") - .try_now() - .await - .unwrap(); - assert!(guard.held(), "iteration {iteration}"); - } - } - #[test] - fn empty_world() { - for path in [ - "/plugins", - "/config", - "/state", - "/state/core/journal.sqlite", - "/logs/core/journal.sqlite", - ] { - assert!(!std::path::Path::new(path).exists(), "{path}"); - } - let names: Vec<_> = std::fs::read_dir("/") - .unwrap() - .map(|entry| entry.unwrap().file_name()) - .collect(); - // The runner mounts the package folder at its host path, so the first - // folder of that path also appears in `/` (for example `tmp` or `build`). - let package = env!("CARGO_MANIFEST_DIR") - .trim_start_matches('/') - .split('/') - .next() - .unwrap(); - assert!( - names.iter().all(|name| { - ["proc", "self", "run", package] - .iter() - .any(|allowed| name == allowed) - }), - "{names:?}" - ); - for path in ["/proc", "/self", "/run"] { - assert!(std::path::Path::new(path).is_dir()); - } - assert!(std::fs::write("/proc/forbidden", "no").is_err()); - } - fn isolated() { - assert!(!std::path::Path::new("/self/own").exists()); - assert!(!std::path::Path::new("/run/own").exists()); - std::fs::write("/self/own", "self").unwrap(); - std::fs::write("/run/own", "run").unwrap(); - assert_eq!(std::fs::read_to_string("/self/own").unwrap(), "self"); - } - #[test] - fn isolation_one() { - isolated(); - } - #[test] - fn isolation_two() { - isolated(); - } - #[test] - fn environment() { - for (key, _) in std::env::vars() { - assert!( - ["CARGO_", "RUST_", "SAUSAGE_"] - .iter() - .any(|prefix| key.starts_with(prefix)), - "{key}" - ); - } - assert!(std::env::var("PWD").is_err()); - } - #[test] - fn late_response() { - use std::io::Write; - for id in 0..100 { - println!( - "\n{{\"jsonrpc\":\"2.0\",\"id\":{id},\"method\":\"core.log\",\"params\":{{}}}}" - ); - } - std::io::stdout().flush().unwrap(); - } - #[test] - #[ignore] - fn ignored_case() { - assert_eq!(1, 1); - } -} diff --git a/cli/tests/crates/cargo-valid/tests/integration.rs b/cli/tests/crates/cargo-valid/tests/integration.rs deleted file mode 100644 index 1b8cc50..0000000 --- a/cli/tests/crates/cargo-valid/tests/integration.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Checks that cargo invokes integration test binaries through the WASI runner. -#[test] -fn integration_case() { - println!("INTEGRATION_CASE"); -} diff --git a/cli/tests/host/activation.rs b/cli/tests/host/activation.rs deleted file mode 100644 index ffb7bc8..0000000 --- a/cli/tests/host/activation.rs +++ /dev/null @@ -1,1262 +0,0 @@ -//! Activation, graph, and upgrade integration tests with raw WASI guests. -use sausage::{Host, world::World}; -use serde_json::{Value, json}; -use std::{ - path::{Path, PathBuf}, - sync::OnceLock, - time::Duration, -}; - -/// Builds genuine guest versions in this executor's target folder. -fn fixture(version: usize) -> PathBuf { - static FILES: OnceLock> = OnceLock::new(); - FILES.get_or_init(|| { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - let target = PathBuf::from(concat!(env!("CARGO_TARGET_TMPDIR"), "/activation-guests")); - (1..=3) - .map(|version| { - let target = target.join(version.to_string()); - let status = std::process::Command::new("cargo") - .env("FIXTURE_VERSION", version.to_string()) - .args([ - "build", - "--offline", - "--target", - "wasm32-wasip2", - "--manifest-path", - ]) - .arg(root.join("cli/tests/plugins/activation/Cargo.toml")) - .arg("--target-dir") - .arg(&target) - .status() - .unwrap(); - assert!(status.success()); - let copy = target.join(format!("v{version}.wasm")); - std::fs::copy( - target.join("wasm32-wasip2/debug/activation-guest.wasm"), - ©, - ) - .unwrap(); - copy - }) - .collect() - })[version - 1] - .clone() -} -fn fixture_hash(version: usize) -> String { - use sha2::{Digest, Sha256}; - format!( - "{:x}", - Sha256::digest(std::fs::read(fixture(version)).unwrap()) - ) -} - -/// PROTOCOL 10.3: summaries omit even large schemas and report sorted main-method diffs. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn summarizes_method_changes() { - let mut old = document("unchanged"); - old["methods"] = json!([ - {"name":"z_removed"}, {"name":"a_removed"}, {"name":"direct", "params":[]}, - {"name":"referenced", "result":{"schema":{"$ref":"#/components/schemas/Node"}}}, - {"name":"unrelated", "result":{"schema":{"type":"string"}}} - ]); - old["components"]["schemas"] = json!({ - "Node":{"properties":{"next":{"$ref":"#/components/schemas/Node"}, "value":{"$ref":"#/components/schemas/Value"}}}, - "Value":{"type":"string"}, "Unused":{"type":"string"} - }); - let mut next = old.clone(); - next["methods"].as_array_mut().unwrap().drain(..2); - next["methods"][0]["params"] = json!([{"name":"limit","schema":{"type":"integer"}}]); - next["methods"].as_array_mut().unwrap().extend( - json!([ - {"name":"z_added"}, {"name":"a_added"} - ]) - .as_array() - .unwrap() - .iter() - .cloned(), - ); - next["components"]["schemas"]["Value"]["type"] = json!("integer"); - next["components"]["schemas"]["Unused"]["description"] = json!("x".repeat(220_000)); - let world = TestWorld::new(); - world.plugin("notes", old, next.clone()); - let host = world.start().await; - let result = activate(&host, "notes").await.unwrap(); - assert_eq!(result["notes"]["method_count"], 5); - assert_eq!(result["notes"]["added"], json!(["a_added", "z_added"])); - assert_eq!( - result["notes"]["removed"], - json!(["a_removed", "z_removed"]) - ); - assert_eq!(result["notes"]["changed"], json!(["direct", "referenced"])); - assert!(result.to_string().len() < 1024); - assert_eq!(call(&host, "notes.rpc.discover", json!({})).await, next); - assert_eq!( - call( - &host, - "notes.read", - json!({"path":"/proc/core/plugins/notes/discover.json"}) - ) - .await, - next - ); - host.stop().await; -} - -/// PROTOCOL 10.3: first installation has no previous build; a no-op has no method changes. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn summarizes_first_install_and_noop() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - std::fs::remove_file(world.0.join("plugins/notes/plugin.wasm")).unwrap(); - let host = world.start().await; - let result = activate(&host, "notes").await.unwrap(); - assert_eq!(result["notes"]["previous"], Value::Null); - assert_eq!(result["notes"]["added"], json!(["add"])); - assert_eq!(result["notes"]["hash"], fixture_hash(2)); - let result = activate(&host, "notes").await.unwrap(); - assert_eq!(result["notes"]["previous"], fixture_hash(2)); - for field in ["added", "removed", "changed"] { - assert_eq!(result["notes"][field], json!([])); - } - assert!(!world.0.join("plugins/notes/plugin.prev.wasm").exists()); - host.stop().await; -} - -struct TestWorld(PathBuf); -impl TestWorld { - /// Creates an isolated world with two version documents per plugin. - fn new() -> Self { - let path = - std::env::temp_dir().join(format!("sausage-activation-{}", uuid::Uuid::now_v7())); - World::create(&path).unwrap(); - Self(path) - } - /// Installs version one and stages version two under the world state. - fn plugin(&self, name: &str, old: Value, new: Value) { - std::fs::create_dir_all(self.0.join(format!("plugins/{name}"))).unwrap(); - std::fs::create_dir_all(self.0.join(format!("state/{name}"))).unwrap(); - std::fs::copy( - fixture(1), - self.0.join(format!("plugins/{name}/plugin.wasm")), - ) - .unwrap(); - std::fs::copy(fixture(2), self.0.join(format!("state/{name}/next.wasm"))).unwrap(); - std::fs::write(self.0.join(format!("state/{name}/1.json")), old.to_string()).unwrap(); - std::fs::write(self.0.join(format!("state/{name}/2.json")), new.to_string()).unwrap(); - } - /// Starts a host with a persistent compile cache shared by the test suites. - async fn start(&self) -> Host { - Host::start_with_cache( - &self.0, - Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")), - ) - .await - .unwrap() - } -} -impl Drop for TestWorld { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} -/// Builds a method-only document; all plugins use the host's five-second grace. -fn document(name: &str) -> Value { - json!({"methods":[{"name":name,"params":[],"result":{"schema":{"type":"string"}}}]}) -} -/// Adds an import of a provider's method list. -fn consumer(provider: &str, expected: Value) -> Value { - let mut document = document("consume"); - document["x-sausage-imports"] = json!({provider:expected}); - document -} -/// Performs a bounded test call without imposing a host execution limit. -async fn call(host: &Host, method: &str, params: Value) -> Value { - tokio::time::timeout(Duration::from_secs(60), host.call(method, params)) - .await - .unwrap() - .unwrap() -} -/// Activates the staged candidate. -async fn activate(host: &Host, name: &str) -> Result { - tokio::time::timeout( - Duration::from_secs(60), - host.call( - "core.activate", - json!({"plugin":name,"wasm":format!("/state/{name}/next.wasm")}), - ), - ) - .await - .unwrap() -} -/// Waits for a lifecycle marker without a fixed delay. -async fn marker(path: PathBuf, expected: &str) { - tokio::time::timeout(Duration::from_secs(30), async { - loop { - if std::fs::read_to_string(&path).ok().as_deref() == Some(expected) { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); -} -/// Reads the cached graph through the actual proc adapter. -async fn graph(host: &Host) -> Value { - call(host, "notes.read", json!({"path":"/proc/core/graph.json"})).await -} - -/// H1/H2: broken providers and stray files do not block repair, revert, or force. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn review_repairs_with_broken_entries() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - world.plugin("broken", document("bad"), document("bad")); - std::fs::write(world.0.join("plugins/broken/plugin.wasm"), b"broken").unwrap(); - let host = world.start().await; - activate(&host, "notes").await.unwrap(); - assert!(!graph(&host).await["plugins"]["broken"]["error"].is_null()); - std::fs::copy(fixture(1), world.0.join("plugins/broken/plugin.prev.wasm")).unwrap(); - call(&host, "core.revert", json!({"plugin":"broken"})).await; - std::fs::write(world.0.join("plugins/broken/plugin.wasm"), b"broken again").unwrap(); - activate(&host, "broken").await.unwrap(); - std::fs::write(world.0.join("plugins/broken/plugin.wasm"), b"broken again").unwrap(); - std::fs::write( - world.0.join("state/notes/2.json"), - consumer("broken", document("bad")).to_string(), - ) - .unwrap(); - call( - &host, - "core.activate", - json!({"plugin":"notes","wasm":"/state/notes/next.wasm","force":true}), - ) - .await; - assert_eq!( - graph(&host).await["plugins"]["notes"]["imports"]["broken"]["status"], - "unavailable" - ); - host.stop().await; -} -/// H2: a stray file under plugins does not interrupt discovery or activation. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn review_stray_file() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - std::fs::write(world.0.join("plugins/notes.md"), "a note").unwrap(); - let host = world.start().await; - activate(&host, "notes").await.unwrap(); - assert!(graph(&host).await["plugins"]["notes"]["error"].is_null()); - host.stop().await; -} -/// M4: an answered discovery receives EOF instead of leaking a detached process. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn review_discovery_answer_stops() { - let world = TestWorld::new(); - let mut next = document("add"); - next["x-test-linger"] = json!(true); - world.plugin("notes", document("add"), next); - let host = world.start().await; - activate(&host, "notes").await.unwrap(); - marker(world.0.join("state/notes/discovery-ended-2"), "ended").await; - host.stop().await; -} -/// Checks that a rejected discovery never receives a retirement hook. -async fn review_candidate_is_not_old_version() { - use sha2::{Digest, Sha256}; - let world = TestWorld::new(); - let mut rejected = consumer("missing", document("add")); - rejected["x-test-linger"] = json!(true); - rejected["x-test-stubborn"] = json!(true); - world.plugin("notes", document("add"), rejected); - std::fs::write( - world.0.join("state/notes/3.json"), - document("add").to_string(), - ) - .unwrap(); - std::fs::copy(fixture(3), world.0.join("state/notes/third.wasm")).unwrap(); - let host = world.start().await; - assert!(activate(&host, "notes").await.is_err()); - let candidate = host - .processes() - .into_iter() - .find(|(_, process)| process.method == "rpc.discover") - .unwrap() - .0; - call( - &host, - "core.activate", - json!({"plugin":"notes","wasm":"/state/notes/third.wasm"}), - ) - .await; - call(&host, "core.kill", json!({"pid":candidate})).await; - let old = format!("{:x}", Sha256::digest(std::fs::read(fixture(1)).unwrap())); - marker(world.0.join("state/notes/retired"), &old).await; - let rejected_hash = format!("{:x}", Sha256::digest(std::fs::read(fixture(2)).unwrap())); - assert!( - tokio::time::timeout( - Duration::from_millis(300), - marker(world.0.join("state/notes/retired"), &rejected_hash) - ) - .await - .is_err() - ); - host.stop().await; -} -/// M6: shutdown suppresses service restarts and retirement hooks from pending upgrades. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn review_shutdown_suppresses_hooks() { - let world = TestWorld::new(); - let mut old = document("serve"); - old["x-sausage-grace"] = json!(60); - world.plugin("notes", old, document("serve")); - std::fs::write( - world.0.join("config/boot.yaml"), - json!([{"method":"notes.serve","params":{}}]).to_string(), - ) - .unwrap(); - let host = world.start().await; - marker(world.0.join("state/notes/service"), "1").await; - activate(&host, "notes").await.unwrap(); - host.stop().await; - assert!(host.processes().is_empty()); - assert!(!world.0.join("state/notes/retired").exists()); - assert_eq!( - std::fs::read_to_string(world.0.join("state/notes/service")).unwrap(), - "1" - ); - assert!( - call(&host, "core.log", json!({"grep":"retired start"})) - .await - .as_array() - .unwrap() - .is_empty() - ); - assert_eq!( - call(&host, "core.log", json!({"grep":"service start"})) - .await - .as_array() - .unwrap() - .len(), - 1 - ); -} -/// Checks retirement against live admissions after a revert. -async fn review_retirement_checks_live_version() { - let world = TestWorld::new(); - let mut old = document("hold"); - old["x-sausage-grace"] = json!(60); - world.plugin("notes", old.clone(), old); - let host = world.start().await; - call(&host, "notes.hold", json!({})).await; - let first = host - .processes() - .into_iter() - .find(|(_, p)| p.method == "hold") - .unwrap() - .0; - activate(&host, "notes").await.unwrap(); - call(&host, "core.revert", json!({"plugin":"notes"})).await; - call(&host, "notes.hold", json!({})).await; - let second = host - .processes() - .into_iter() - .find(|(pid, p)| p.method == "hold" && *pid != first) - .unwrap() - .0; - activate(&host, "notes").await.unwrap(); - // The v2 retirement from the revert is unrelated to the pending v1 retirement. - let previous = { - use sha2::{Digest, Sha256}; - format!("{:x}", Sha256::digest(std::fs::read(fixture(1)).unwrap())) - }; - call(&host, "core.stop", json!({"pid":first})).await; - assert!( - tokio::time::timeout( - Duration::from_millis(300), - marker(world.0.join("state/notes/retired"), &previous) - ) - .await - .is_err() - ); - call(&host, "core.stop", json!({"pid":second})).await; - marker(world.0.join("state/notes/retired"), &previous).await; - host.stop().await; -} -/// T23: an unwritable compile cache does not prevent a world from running. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unwritable_compile_cache_is_nonfatal() { - use std::os::unix::fs::PermissionsExt; - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - let parent = world.0.join("unwritable"); - std::fs::create_dir(&parent).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap(); - let result = Host::start_with_cache(&world.0, &parent.join("cache")).await; - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); - let host = result.unwrap(); - assert_eq!( - call(&host, "notes.version", json!({})).await["version"], - "1" - ); - host.stop().await; -} -/// L13: source subtrees require no watches, even when the host cannot read them. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn review_watcher_is_shallow() { - use std::os::unix::fs::PermissionsExt; - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - let source = world.0.join("plugins/notes/source"); - std::fs::create_dir_all(&source).unwrap(); - std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o0)).unwrap(); - let result = Host::start_with_cache( - &world.0, - Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")), - ) - .await; - std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o700)).unwrap(); - let host = result.unwrap(); - activate(&host, "notes").await.unwrap(); - host.stop().await; -} -/// L13: a watcher startup error reaches the journal without preventing host startup. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn review_watcher_failure_is_nonfatal() { - use std::os::unix::fs::PermissionsExt; - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - let inaccessible = world.0.join("plugins/inaccessible"); - std::fs::create_dir_all(&inaccessible).unwrap(); - std::fs::set_permissions(&inaccessible, std::fs::Permissions::from_mode(0o0)).unwrap(); - let result = Host::start_with_cache( - &world.0, - Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")), - ) - .await; - std::fs::set_permissions(&inaccessible, std::fs::Permissions::from_mode(0o700)).unwrap(); - let host = result.unwrap(); - assert!( - !call(&host, "core.log", json!({"grep":"watcher failed"})) - .await - .as_array() - .unwrap() - .is_empty() - ); - activate(&host, "notes").await.unwrap(); - host.stop().await; -} -/// H2: a plugin read error belongs to that entry, not the entire graph. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn review_read_error_is_local() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - std::fs::create_dir_all(world.0.join("plugins/broken/plugin.wasm")).unwrap(); - let host = world.start().await; - activate(&host, "notes").await.unwrap(); - assert!(!graph(&host).await["plugins"]["broken"]["error"].is_null()); - host.stop().await; -} -/// L3: cancellation of the caller cannot cancel an activation body. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn review_dropped_activation_completes() { - let world = TestWorld::new(); - let mut next = document("add"); - next["x-test-wait"] = json!(true); - world.plugin("notes", document("add"), next); - let host = world.start().await; - let caller = host.clone(); - let task = tokio::spawn(async move { activate(&caller, "notes").await }); - marker(world.0.join("state/notes/discover-started"), "ready").await; - task.abort(); - let _ = task.await; - std::fs::write(world.0.join("state/notes/discover-release"), "ready").unwrap(); - tokio::time::timeout(Duration::from_secs(30), async { - while std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap() - != std::fs::read(fixture(2)).unwrap() - { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - host.stop().await; -} -/// PROTOCOL 10.1: discovery fails after its fixed ten-second deadline and receives EOF. -/// PROTOCOL 3.7: old processes get five seconds regardless of declarations; new calls use new code. -/// PROTOCOL 3.7: retirement waits for all live admissions, including reverted versions. -/// PROTOCOL 3.7: rejected discovery candidates never receive retirement hooks. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn upgrade_and_discovery_deadlines() { - tokio::join!( - review_discovery_deadline_and_stop(), - old_process_grace_and_new_calls(), - review_retirement_checks_live_version(), - review_candidate_is_not_old_version(), - ); -} - -/// Checks the fixed discovery deadline and subsequent process stop. -async fn review_discovery_deadline_and_stop() { - let world = TestWorld::new(); - let mut next = document("add"); - next["x-test-no-answer"] = json!(true); - world.plugin("notes", document("add"), next); - let host = world.start().await; - let before = std::time::Instant::now(); - let result = tokio::time::timeout(Duration::from_secs(15), activate(&host, "notes")).await; - assert!( - result.unwrap().unwrap_err()["message"] - .as_str() - .unwrap() - .contains("discovery") - ); - assert!(before.elapsed() >= Duration::from_millis(9900)); - assert!( - !host - .processes() - .values() - .any(|process| process.method == "rpc.discover" && process.state == "running") - ); - host.stop().await; -} - -/// PROTOCOL 10.4: proc graph files contain valid documents before discovery completes. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn graph_is_valid_immediately() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - let host = world.start().await; - assert!(graph(&host).await["plugins"].is_object()); - let dot = call(&host, "notes.read", json!({"path":"/proc/core/graph.dot"})).await; - assert!(dot.as_str().unwrap().starts_with("digraph plugins")); - host.stop().await; -} -/// PROTOCOL 10.3: a rejected group leaves every installed file unchanged. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rejects_group_without_changes() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("other")); - world.plugin( - "agent", - consumer("notes", document("add")), - consumer("notes", document("missing")), - ); - let host = world.start().await; - let result = host.call("core.activate",json!({"plugins":[{"plugin":"notes","wasm":"/state/notes/next.wasm"},{"plugin":"agent","wasm":"/state/agent/next.wasm"}]})).await; - assert_eq!(result.unwrap_err()["code"], -32003); - for name in ["notes", "agent"] { - assert_eq!( - std::fs::read(world.0.join(format!("plugins/{name}/plugin.wasm"))).unwrap(), - std::fs::read(fixture(1)).unwrap() - ); - } - host.stop().await; -} -/// PROTOCOL 10.3: activation discovers and installs a copied candidate. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn activates_copy() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - let host = world.start().await; - let result = activate(&host, "notes").await.unwrap(); - assert_eq!( - result, - json!({"notes": { - "plugin":"notes", "wasm":"/plugins/notes/plugin.wasm", - "hash":fixture_hash(2), "previous":fixture_hash(1), - "method_count":1, "added":[], "removed":[], "changed":[], - "interface_check":"passed" - }}) - ); - assert_eq!( - call(&host, "notes.rpc.discover", json!({})).await, - document("add") - ); - assert_eq!( - call(&host, "notes.version", json!({})).await["version"], - "2" - ); - assert_eq!( - std::fs::read(world.0.join("plugins/notes/plugin.prev.wasm")).unwrap(), - std::fs::read(fixture(1)).unwrap() - ); - host.stop().await; -} -/// PROTOCOL 10.3: changes to the original after discovery starts cannot change the installed copy. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn source_changes_do_not_change_candidate() { - let world = TestWorld::new(); - let mut next = document("add"); - next["x-test-wait"] = json!(true); - world.plugin("notes", document("add"), next); - let host = world.start().await; - let activation_host = host.clone(); - let activation = tokio::spawn(async move { activate(&activation_host, "notes").await }); - marker(world.0.join("state/notes/discover-started"), "ready").await; - std::fs::copy(fixture(1), world.0.join("state/notes/next.wasm")).unwrap(); - std::fs::write(world.0.join("state/notes/discover-release"), "ready").unwrap(); - activation.await.unwrap().unwrap(); - assert_eq!( - std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap(), - std::fs::read(fixture(2)).unwrap() - ); - host.stop().await; -} -/// PROTOCOL 3.7: missing metadata also retains old calls until the uniform deadline. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn default_grace_retains_old_process() { - let world = TestWorld::new(); - let mut old = document("hold"); - old.as_object_mut().unwrap().remove("x-sausage-grace"); - world.plugin("notes", old, document("hold")); - let host = world.start().await; - call(&host, "notes.hold", json!({})).await; - let pid = host - .processes() - .into_iter() - .find(|(_, process)| process.method == "hold") - .unwrap() - .0; - activate(&host, "notes").await.unwrap(); - assert_eq!(host.processes()[&pid].state, "running"); - host.stop().await; -} -/// PROTOCOL 3.7: metadata cannot shorten or extend the actual grace for active requests. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn uniform_grace_ignores_discovery_declarations() { - let world = TestWorld::new(); - let declarations = [ - ("missing", None), - ("default", Some(json!(60))), - ("long", Some(json!(3600))), - ("zero", Some(json!(0))), - ("negative", Some(json!(-1))), - ("invalid", Some(json!("long"))), - ("huge", Some(json!(1e300))), - ]; - for (name, grace) in &declarations { - let mut old = document("work"); - if let Some(grace) = grace { - old["x-sausage-grace"] = grace.clone(); - } - world.plugin(name, old, document("work")); - } - let host = world.start().await; - let mut ready = host.subscribe(); - let mut calls = Vec::new(); - for (name, _) in &declarations { - let host = host.clone(); - let method = format!("{name}.work"); - calls.push(tokio::spawn(async move { - let result = host.call(&method, json!({})).await; - (result, std::time::Instant::now()) - })); - } - // Guest notifications are events, not process/file polling. - for _ in &declarations { - let event = tokio::time::timeout(Duration::from_secs(30), ready.recv()) - .await - .unwrap() - .unwrap(); - assert_eq!(event["params"]["name"], "ready"); - } - let before = std::time::Instant::now(); - let plugins: Vec<_> = declarations - .iter() - .map(|(name, _)| json!({"plugin":name,"wasm":format!("/state/{name}/next.wasm")})) - .collect(); - call(&host, "core.activate", json!({"plugins":plugins})).await; - // Use the existing discovery+stop test budget, not a new execution limit. - tokio::time::timeout(Duration::from_secs(15), async { - for task in calls { - let (result, ended) = task.await.unwrap(); - assert_eq!(result.unwrap_err()["code"], -32002); - assert!( - ended.duration_since(before) >= Duration::from_secs(5), - "every declaration gets the full grace" - ); - } - }) - .await - .unwrap(); - host.stop().await; -} - -/// PROTOCOL 10.4/3.7: a watcher refresh uses the same policy as explicit activation. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn manual_copy_uses_uniform_grace() { - let world = TestWorld::new(); - let mut old = document("work"); - old["x-sausage-grace"] = json!(3600); - world.plugin("notes", old, document("work")); - let host = world.start().await; - // Wait for initial discovery without changing the installed version. - call( - &host, - "core.activate", - json!({"plugin":"notes","wasm":"/plugins/notes/plugin.wasm"}), - ) - .await; - let mut ready = host.subscribe(); - let work_host = host.clone(); - let work = tokio::spawn(async move { work_host.call("notes.work", json!({})).await }); - let event = tokio::time::timeout(Duration::from_secs(30), ready.recv()) - .await - .unwrap() - .unwrap(); - assert_eq!(event["params"]["name"], "ready"); - let before = std::time::Instant::now(); - std::fs::copy(fixture(2), world.0.join("plugins/notes/plugin.wasm")).unwrap(); - assert_eq!( - tokio::time::timeout(Duration::from_secs(15), work) - .await - .unwrap() - .unwrap() - .unwrap_err()["code"], - -32002 - ); - assert!(before.elapsed() >= Duration::from_secs(5)); - host.stop().await; -} - -/// PROTOCOL 10.3: candidate imports must fit installed providers. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn checks_candidate_imports() { - let world = TestWorld::new(); - world.plugin( - "notes", - document("add"), - consumer("absent", document("add")), - ); - let host = world.start().await; - let failure = activate(&host, "notes").await.unwrap_err(); - assert_eq!(failure["code"], -32003); - assert_eq!(failure["data"][0]["status"], "missing"); - host.stop().await; -} -/// PROTOCOL 10.3: current consumers block an incompatible provider upgrade. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn checks_current_consumers() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("other")); - world.plugin( - "agent", - consumer("notes", document("add")), - document("consume"), - ); - let host = world.start().await; - let failure = activate(&host, "notes").await.unwrap_err(); - assert_eq!(failure["code"], -32003); - assert_eq!(failure["data"][0]["method"], "add"); - assert_eq!( - std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap(), - std::fs::read(fixture(1)).unwrap() - ); - host.stop().await; -} -/// PROTOCOL 10.3: a group checks the proposed set rather than intermediate versions. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn activates_group() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("other")); - world.plugin( - "agent", - consumer("notes", document("add")), - consumer("notes", document("other")), - ); - let host = world.start().await; - let result = call(&host,"core.activate",json!({"plugins":[{"plugin":"notes","wasm":"/state/notes/next.wasm"},{"plugin":"agent","wasm":"/state/agent/next.wasm"}]})).await; - assert_eq!(result.as_object().unwrap().len(), 2); - assert_eq!(result["notes"]["added"], json!(["other"])); - assert_eq!(result["notes"]["removed"], json!(["add"])); - assert_eq!(result["agent"]["method_count"], 1); - assert_eq!(result["agent"]["interface_check"], "passed"); - assert_eq!( - call(&host, "agent.version", json!({})).await["version"], - "2" - ); - host.stop().await; -} -/// PROTOCOL 10.3: force skips compatibility checks. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn force_skips_checks() { - let world = TestWorld::new(); - world.plugin( - "notes", - document("add"), - consumer("absent", document("add")), - ); - let host = world.start().await; - let result = call( - &host, - "core.activate", - json!({"plugin":"notes","wasm":"/state/notes/next.wasm","force":true}), - ) - .await; - assert_eq!(result["notes"]["interface_check"], "skipped"); - host.stop().await; -} -/// PROTOCOL 10.3: revert activates the previous copy and preserves the replaced version. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn reverts_previous_version() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - let host = world.start().await; - activate(&host, "notes").await.unwrap(); - let result = call(&host, "core.revert", json!({"plugin":"notes"})).await; - assert_eq!(result["notes"]["hash"], fixture_hash(1)); - assert_eq!(result["notes"]["previous"], fixture_hash(2)); - assert_eq!(result["notes"]["interface_check"], "passed"); - assert!(result["notes"].get("methods").is_none()); - assert_eq!( - call(&host, "notes.version", json!({})).await["version"], - "1" - ); - assert_eq!( - std::fs::read(world.0.join("plugins/notes/plugin.prev.wasm")).unwrap(), - std::fs::read(fixture(2)).unwrap() - ); - host.stop().await; -} -/// PROTOCOL 10.3: concurrent activations serialize their installation and revert state. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn serializes_activations() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - let host = world.start().await; - let (first, second) = tokio::join!(activate(&host, "notes"), activate(&host, "notes")); - first.unwrap(); - second.unwrap(); - assert_eq!( - std::fs::read(world.0.join("plugins/notes/plugin.prev.wasm")).unwrap(), - std::fs::read(fixture(1)).unwrap() - ); - host.stop().await; -} -/// PROTOCOL 10.4: startup fetches every installed document before activation checks consumers. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn discovers_all_before_checks() { - checks_current_consumers_body().await; -} -/// Exercises a consumer whose discovery must finish before the provider can change. -async fn checks_current_consumers_body() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("other")); - world.plugin( - "agent", - consumer("notes", document("add")), - document("consume"), - ); - let host = world.start().await; - assert_eq!(activate(&host, "notes").await.unwrap_err()["code"], -32003); - host.stop().await; -} -/// PROTOCOL 10.4: manual copies update cached hashes and expose broken imports in the journal. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn watches_manual_copy() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("other")); - world.plugin( - "agent", - consumer("notes", document("add")), - document("consume"), - ); - let host = world.start().await; - // A same-version activation waits for initial discovery without changing the provider. - call( - &host, - "core.activate", - json!({"plugin":"notes","wasm":"/plugins/notes/plugin.wasm"}), - ) - .await; - std::fs::copy(fixture(2), world.0.join("plugins/notes/plugin.wasm")).unwrap(); - tokio::time::timeout(Duration::from_secs(30), async { - loop { - let graph = graph(&host).await; - if graph["plugins"]["agent"]["imports"]["notes"]["status"] == "broken" { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert!( - !call(&host, "core.log", json!({"grep":"broken import"})) - .await - .as_array() - .unwrap() - .is_empty() - ); - host.stop().await; -} -/// PROTOCOL 10.4: nonexistent providers appear as missing imports. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn graph_missing_import() { - let world = TestWorld::new(); - world.plugin( - "notes", - consumer("absent", document("add")), - document("add"), - ); - let host = world.start().await; - call( - &host, - "core.activate", - json!({"plugin":"notes","wasm":"/plugins/notes/plugin.wasm","force":true}), - ) - .await; - assert_eq!( - graph(&host).await["plugins"]["notes"]["imports"]["absent"]["status"], - "missing" - ); - let dot = call(&host, "notes.read", json!({"path":"/proc/core/graph.dot"})).await; - assert!(dot.as_str().unwrap().contains("absent")); - host.stop().await; -} -/// Checks that an explicit long declaration cannot extend grace; new calls use the new version. -async fn old_process_grace_and_new_calls() { - let world = TestWorld::new(); - let mut old = document("add"); - old["x-sausage-grace"] = json!(3600); - world.plugin("notes", old, document("add")); - let host = world.start().await; - call(&host, "notes.hold", json!({})).await; - let pid = host - .processes() - .into_iter() - .find(|(_, process)| process.method == "hold") - .unwrap() - .0; - activate(&host, "notes").await.unwrap(); - assert!(host.processes().contains_key(&pid)); - assert_eq!( - call(&host, "notes.version", json!({})).await["version"], - "2" - ); - marker(world.0.join("state/notes/ended-1"), "ended").await; - let notice: Value = serde_json::from_slice( - &std::fs::read(world.0.join(format!("state/notes/superseded-{pid}"))).unwrap(), - ) - .unwrap(); - assert_eq!(notice["params"]["current"], fixture_hash(2)); - host.stop().await; -} -/// PROTOCOL 3.7: every old process gets the new hash while still running, before grace ends. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn superseded_notifies_each_old_process() { - let world = TestWorld::new(); - let mut old = document("hold"); - old["x-sausage-grace"] = json!(60); - world.plugin("notes", old, document("hold")); - let host = world.start().await; - call(&host, "notes.hold", json!({})).await; - call(&host, "notes.hold", json!({})).await; - let pids: Vec<_> = host - .processes() - .into_iter() - .filter(|(_, process)| process.method == "hold") - .map(|(pid, _)| pid) - .collect(); - assert_eq!(pids.len(), 2); - activate(&host, "notes").await.unwrap(); - let expected = - json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":fixture_hash(2)}}) - .to_string(); - for pid in pids { - // The notice arrives while the old process is still in its five-second window. - marker( - world.0.join(format!("state/notes/superseded-{pid}")), - &expected, - ) - .await; - assert_eq!(host.processes()[&pid].state, "running"); - } - assert_eq!( - call(&host, "notes.version", json!({})).await["version"], - "2" - ); - host.stop().await; -} -/// PROTOCOL 3.7: a service that exits on the notice restarts before its grace deadline. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn superseded_service_exits_early() { - let world = TestWorld::new(); - let mut old = document("serve"); - old["x-sausage-grace"] = json!(60); - world.plugin("notes", old, document("serve")); - std::fs::write( - world.0.join("config/boot.yaml"), - json!([{"method":"notes.serve","params":{"exit_on_superseded":true}}]).to_string(), - ) - .unwrap(); - let host = world.start().await; - marker(world.0.join("state/notes/service"), "1").await; - let pid = host - .processes() - .into_iter() - .find(|(_, p)| p.method == "serve") - .unwrap() - .0; - activate(&host, "notes").await.unwrap(); - marker(world.0.join("state/notes/service"), "2").await; - assert!(!host.processes().contains_key(&pid)); - assert!( - !world.0.join("state/notes/ended-1").exists(), - "old service must exit on the notice, not EOF" - ); - host.stop().await; -} -/// PROTOCOL 3.7: retirement receives the previous hash only after old processes end. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn retires_old_version() { - use sha2::{Digest, Sha256}; - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - let host = world.start().await; - call(&host, "notes.hold", json!({})).await; - activate(&host, "notes").await.unwrap(); - let previous = format!("{:x}", Sha256::digest(std::fs::read(fixture(1)).unwrap())); - marker(world.0.join("state/notes/retired"), &previous).await; - marker(world.0.join("state/notes/ended-1"), "ended").await; - assert!( - !call(&host, "core.log", json!({"grep":"retired start"})) - .await - .as_array() - .unwrap() - .is_empty() - ); - host.stop().await; -} -/// PROTOCOL 8 and 3.7: services restart after their old process ends. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn restarts_service() { - let world = TestWorld::new(); - world.plugin("notes", document("serve"), document("serve")); - std::fs::write( - world.0.join("config/boot.yaml"), - json!([{"method":"notes.serve","params":{}}]).to_string(), - ) - .unwrap(); - let host = world.start().await; - marker(world.0.join("state/notes/service"), "1").await; - activate(&host, "notes").await.unwrap(); - marker(world.0.join("state/notes/service"), "2").await; - host.stop().await; -} -/// PROTOCOL 8: ordinary detached processes do not become services after an upgrade. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn does_not_restart_detached_call() { - let world = TestWorld::new(); - world.plugin("notes", document("hold"), document("hold")); - let host = world.start().await; - call( - &host, - "core.call", - json!({"method":"notes.hold","params":{},"detach":true}), - ) - .await; - activate(&host, "notes").await.unwrap(); - marker(world.0.join("state/notes/ended-1"), "ended").await; - assert!( - !host - .processes() - .values() - .any(|process| process.method == "hold" && process.state == "running") - ); - host.stop().await; -} - -/// PROTOCOL 7: activation records the plugin's upgrade grace period in the journal. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn upgrades_enter_the_journal() { - let world = TestWorld::new(); - let mut old = document("hold"); - old.as_object_mut().unwrap().remove("x-sausage-grace"); - world.plugin("notes", old, document("hold")); - let host = world.start().await; - call(&host, "notes.hold", json!({})).await; - activate(&host, "notes").await.unwrap(); - let entries = tokio::time::timeout(Duration::from_secs(10), async { - loop { - let entries = call( - &host, - "core.log", - json!({"plugin":"notes","grep":"upgrade grace"}), - ) - .await; - if entries - .as_array() - .unwrap() - .iter() - .any(|e| e["line"] == "upgrade grace 5 seconds") - { - break entries; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("the upgrade journal entry must arrive within 10 seconds"); - assert!( - entries - .as_array() - .unwrap() - .iter() - .all(|e| e["stream"] == "core") - ); - host.stop().await; -} - -/// PROTOCOL 10.2 and 10.3: breaking endpoint methods reject activation with counterexamples where possible. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn endpoint_contracts_and_activation_counterexamples() { - let method = json!({"name":"stats","params":[{"name":"value","required":true,"schema":{"type":"integer","minimum":0}}],"result":{"schema":{"type":"string"}}}); - let expected = json!({"methods":[],"x-sausage-endpoints":{"control":{"methods":[method]}}}); - for case in ["missing", "params", "result", "unsupported"] { - let world = TestWorld::new(); - let mut next = expected.clone(); - match case { - "missing" => next["x-sausage-endpoints"]["control"]["methods"] = json!([]), - "params" => { - next["x-sausage-endpoints"]["control"]["methods"][0]["params"][0]["schema"]["minimum"] = - json!(1) - } - "result" => { - next["x-sausage-endpoints"]["control"]["methods"][0]["result"]["schema"] = - json!({"type":"integer"}) - } - _ => { - next["x-sausage-endpoints"]["control"]["methods"][0]["result"]["schema"]["pattern"] = - json!("[a-z]+") - } - } - world.plugin("notes", expected.clone(), next); - world.plugin( - "agent", - consumer("notes", expected.clone()), - document("consume"), - ); - let host = world.start().await; - let error = activate(&host, "notes").await.unwrap_err(); - assert_eq!(error["code"], -32003, "{case}: {error}"); - assert!(!error["data"].as_array().unwrap().is_empty()); - if ["params", "result"].contains(&case) { - assert!( - error["data"] - .as_array() - .unwrap() - .iter() - .any(|p| !p["counterexample"].is_null()), - "{error}" - ); - } - if case == "unsupported" { - assert!(error["data"].to_string().contains("unsupported"), "{error}"); - } - assert_eq!( - std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap(), - std::fs::read(fixture(1)).unwrap() - ); - host.stop().await; - } -} - -/// PROTOCOL 10.2: providers can add methods, optional params, and result fields without breaking consumers. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn additive_contract_changes_activate() { - let old = json!({"methods":[{"name":"read","params":[],"result":{"schema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}}}]}); - let mut next = old.clone(); - next["methods"][0]["params"] = - json!([{"name":"limit","required":false,"schema":{"type":"integer"}}]); - next["methods"][0]["result"]["schema"]["properties"]["extra"] = json!({"type":"boolean"}); - next["methods"] - .as_array_mut() - .unwrap() - .push(json!({"name":"new","params":[],"result":{"schema":{"type":"null"}}})); - let world = TestWorld::new(); - world.plugin("notes", old.clone(), next); - world.plugin("agent", consumer("notes", old), document("consume")); - let host = world.start().await; - activate(&host, "notes").await.unwrap(); - host.stop().await; -} - -/// PROTOCOL 3.7: an old consumer can still send its old import during grace, even after the provider removes it. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn old_consumer_can_call_removed_provider_method() { - let world = TestWorld::new(); - world.plugin("notes", document("legacy"), document("new")); - let mut old = consumer("notes", document("legacy")); - old["x-sausage-grace"] = json!(60); - world.plugin("agent", old, document("consume")); - let host = world.start().await; - let pid = call(&host, "agent.deferred", json!({})).await; - activate(&host, "agent").await.unwrap(); - activate(&host, "notes").await.unwrap(); - assert_eq!(host.processes()[pid.as_str().unwrap()].state, "running"); - std::fs::write(world.0.join("state/agent/release-call"), "release").unwrap(); - let response: Value = tokio::time::timeout(Duration::from_secs(10), async { - loop { - if let Ok(bytes) = std::fs::read(world.0.join("state/agent/call-result")) - && let Ok(value) = serde_json::from_slice(&bytes) - { - break value; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert_eq!(response["error"]["code"], -32601); - assert_eq!(response["error"]["data"]["version"], "2"); - host.stop().await; -} - -/// PROTOCOL 8: supervise actual WASI exits on either side of the readiness response. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn crashed_boot_service_restarts_before_and_after_ready() { - for when in ["before", "after"] { - let world = TestWorld::new(); - world.plugin("notes", document("serve"), document("serve")); - std::fs::write( - world.0.join("config/boot.yaml"), - json!([{"method":"notes.serve","params":{"crash_once":when}}]).to_string(), - ) - .unwrap(); - let host = world.start().await; - marker(world.0.join("state/notes/service"), "1").await; - let entries = call( - &host, - "core.log", - json!({"grep":"service start notes.serve"}), - ) - .await; - assert_eq!(entries.as_array().unwrap().len(), 2); - let processes = host.processes(); - let services: Vec<_> = processes.values().filter(|p| p.method == "serve").collect(); - assert_eq!(services.len(), 1); - assert_eq!(services[0].params, json!({"crash_once":when})); - let exits = call(&host, "core.log", json!({"plugin":"notes","grep":"exit 1"})).await; - assert_eq!(exits.as_array().unwrap().len(), 1); - host.stop().await; - } -} - -/// PROTOCOL 10.2: activation and revert check interfaces but leave persistent state unchanged. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn state_is_outside_interface_checks() { - let world = TestWorld::new(); - world.plugin("notes", document("add"), document("add")); - let state = world.0.join("state/notes/user-state"); - let contents = b"a state format neither version understands\0"; - std::fs::write(&state, contents).unwrap(); - let host = world.start().await; - activate(&host, "notes").await.unwrap(); - assert_eq!(std::fs::read(&state).unwrap(), contents); - call(&host, "core.revert", json!({"plugin":"notes"})).await; - assert_eq!(std::fs::read(state).unwrap(), contents); - host.stop().await; -} diff --git a/cli/tests/host/agent.rs b/cli/tests/host/agent.rs deleted file mode 100644 index c532869..0000000 --- a/cli/tests/host/agent.rs +++ /dev/null @@ -1,669 +0,0 @@ -//! Run the agent in the real core against scripted OpenAI and Anthropic HTTP servers. -use bytes::Bytes; -use http_body_util::{BodyExt, Full}; -use hyper::{Response, server::conn::http1, service::service_fn}; -use hyper_util::rt::TokioIo; -use sausage::{Host, world::World}; -use sausage_vfs::Layer; -use serde_json::{Value, json}; -use std::{convert::Infallible, path::PathBuf, sync::OnceLock, time::Duration}; -use tokio::{ - net::TcpListener, - sync::{mpsc, oneshot}, -}; - -/// The two HTTP contracts supported by the agent. -const APIS: [&str; 3] = [ - "openai-responses", - "anthropic-messages", - "openai-chat-completions", -]; -/// A captured request and the script's delayed HTTP response. -type Exchange = (String, Value, oneshot::Sender<(u16, Value)>); -/// The script controls each response, including concurrent requests. -pub(super) struct Llm { - pub(super) url: String, - api: &'static str, - requests: mpsc::UnboundedReceiver, - task: tokio::task::JoinHandle<()>, -} -impl Llm { - pub(super) async fn new(api: &'static str) -> Self { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let (tx, requests) = mpsc::unbounded_channel::(); - let task = tokio::spawn(async move { - loop { - let (stream, _) = listener.accept().await.unwrap(); - let tx = tx.clone(); - tokio::spawn(async move { - let service = - service_fn(move |request: hyper::Request| { - let tx = tx.clone(); - async move { - assert_eq!(request.method(), "POST"); - // The local Chat Completions fixture explicitly disables authentication. - if api == APIS[2] { - assert!(!request.headers().contains_key("authorization")); - } - let path = request.uri().path().to_string(); - let bytes = request.into_body().collect().await.unwrap().to_bytes(); - let body = serde_json::from_slice(&bytes).unwrap(); - let (reply, answer) = oneshot::channel(); - tx.send((path, body, reply)).unwrap(); - let (status, body) = answer.await.unwrap(); - Ok::<_, Infallible>( - Response::builder() - .status(status) - .header("content-type", "application/json") - .body(Full::new(Bytes::from(body.to_string()))) - .unwrap(), - ) - } - }); - http1::Builder::new() - .serve_connection(TokioIo::new(stream), service) - .await - .unwrap(); - }); - } - }); - Self { - url, - api, - requests, - task, - } - } - pub(super) async fn next(&mut self) -> (Value, oneshot::Sender<(u16, Value)>) { - let (path, body, reply) = - tokio::time::timeout(Duration::from_secs(60), self.requests.recv()) - .await - .unwrap() - .unwrap(); - assert_eq!( - path, - if self.api == APIS[0] { - "/v1/responses" - } else if self.api == APIS[2] { - "/v1/chat/completions" - } else { - "/v1/messages" - } - ); - assert_eq!(body["model"], "scripted-test"); - (body, reply) - } - pub(super) fn answer(&self, text: &str, calls: &[(&str, Value)]) -> Value { - if self.api == APIS[2] { - let calls: Vec = calls.iter().enumerate().map(|(n,(name,args))| json!({"id":format!("tool-{n}"),"type":"function","function":{"name":name,"arguments":args.to_string()}})).collect(); - json!({"choices":[{"message":{"role":"assistant","content":text,"tool_calls":calls}}]}) - } else if self.api == APIS[0] { - let mut output = - vec![json!({"type":"message","content":[{"type":"output_text","text":text}]})]; - for (n, (name, args)) in calls.iter().enumerate() { - output.push(json!({"type":"function_call","call_id":format!("tool-{n}"),"name":name,"arguments":args.to_string()})); - } - json!({"status":"completed","output":output}) - } else { - let mut content = vec![json!({"type":"text","text":text})]; - for (n, (name, args)) in calls.iter().enumerate() { - content.push( - json!({"type":"tool_use","id":format!("tool-{n}"),"name":name,"input":args}), - ); - } - json!({"content":content,"stop_reason":if calls.is_empty(){"end_turn"}else{"tool_use"}}) - } - } -} -impl Drop for Llm { - fn drop(&mut self) { - self.task.abort(); - } -} - -fn fixtures() -> &'static PathBuf { - static TARGET: OnceLock = OnceLock::new(); - TARGET.get_or_init(|| { - super::common::build("agent", "agent"); - super::common::sdk_probe().parent().unwrap().to_owned() - }) -} -struct TestWorld(PathBuf); -impl TestWorld { - fn new(server: &Llm) -> Self { - let path = std::env::temp_dir().join(format!("sausage-agent-{}", uuid::Uuid::now_v7())); - let world = World::create(&path).unwrap(); - for (plugin, binary) in [("agent", "agent"), ("probe", "sdk-probe")] { - std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap(); - std::fs::copy( - fixtures().join(format!("{binary}.wasm")), - world.plugin_dir(plugin).join("plugin.wasm"), - ) - .unwrap(); - } - std::fs::write(path.join("config/agent.yaml"), serde_yaml_ng::to_string(&json!({"api":server.api,"base_url":if server.api == APIS[1] {server.url.clone()} else {format!("{}/v1",server.url)},"model":"scripted-test","token":if server.api == APIS[2] {""} else {"fake-token"},"allow_no_token":server.api == APIS[2]})).unwrap()).unwrap(); - Self(path) - } - async fn start(&self) -> Host { - Host::start_with_cache( - &self.0, - std::path::Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")), - ) - .await - .unwrap() - } - fn history(&self) -> String { - std::fs::read_to_string(self.0.join("state/agent/history.jsonl")).unwrap() - } -} -impl Drop for TestWorld { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} -fn chat(host: &Host, text: &str) -> tokio::task::JoinHandle { - let host = host.clone(); - let text = text.to_owned(); - tokio::spawn(async move { - tokio::time::timeout( - Duration::from_secs(90), - host.call("agent.chat", json!({"text":text})), - ) - .await - .unwrap() - .unwrap() - }) -} -fn input(body: &Value, api: &str) -> Value { - body[if api == APIS[0] { "input" } else { "messages" }].clone() -} -fn outputs(body: &Value, api: &str) -> Vec { - if api == APIS[2] { - body["messages"] - .as_array() - .unwrap() - .iter() - .filter(|e| e["role"] == "tool") - .map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap()) - .collect() - } else if api == APIS[0] { - body["input"] - .as_array() - .unwrap() - .iter() - .filter(|e| e["type"] == "function_call_output") - .map(|e| serde_json::from_str(e["output"].as_str().unwrap()).unwrap()) - .collect() - } else { - body["messages"] - .as_array() - .unwrap() - .iter() - .flat_map(|e| e["content"].as_array().unwrap()) - .filter(|e| e["type"] == "tool_result") - .map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap()) - .collect() - } -} - -/// Invalid configuration names the file and the field before any inference request. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unconfigured_chat_names_the_configuration() { - let server = Llm::new(APIS[0]).await; - let world = TestWorld::new(&server); - let path = world.0.join("config/agent.yaml"); - std::fs::remove_file(&path).unwrap(); - let host = world.start().await; - let error = host - .call("agent.chat", json!({"text":"hello"})) - .await - .unwrap_err(); - assert!( - error["message"] - .as_str() - .unwrap() - .contains("/config/agent.yaml") - ); - for (config, field) in [ - ("", "api"), - ("api: unknown", "api"), - ("api: openai-responses", "base_url"), - ( - "api: openai-responses\nbase_url: http://localhost/v1", - "model", - ), - ( - "api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: ''", - "token", - ), - ( - "api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: CHANGE_ME", - "token", - ), - ( - "api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: key\nthinking: hihg", - "thinking", - ), - ] { - std::fs::write(&path, config).unwrap(); - let error = host - .call("agent.chat", json!({"text":"hello"})) - .await - .unwrap_err(); - let message = error["message"].as_str().unwrap(); - assert!(message.contains("/config/agent.yaml"), "{message}"); - assert!(message.contains(field), "{message}"); - } - host.stop().await; -} - -/// All API shapes return final text and retain the complete history after a core restart. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn final_answers_and_history_survive_restart() { - for api in APIS { - let mut server = Llm::new(api).await; - let world = TestWorld::new(&server); - let host = world.start().await; - let turn = chat(&host, "first request alpha"); - let (body, reply) = server.next().await; - assert!( - input(&body, api) - .to_string() - .contains("first request alpha") - ); - assert_eq!(body["tools"].as_array().unwrap().len(), 5); - reply - .send((200, server.answer("first answer alpha", &[]))) - .unwrap(); - assert_eq!(turn.await.unwrap(), "first answer alpha"); - host.stop().await; - let prefix = world.history(); - drop(host); - let host = world.start().await; - let turn = chat(&host, "second request beta"); - let (body, reply) = server.next().await; - let text = input(&body, api).to_string(); - for expected in [ - "first request alpha", - "first answer alpha", - "second request beta", - ] { - assert!(text.contains(expected)); - } - reply - .send((200, server.answer("second answer beta", &[]))) - .unwrap(); - assert_eq!(turn.await.unwrap(), "second answer beta"); - assert!(world.history().starts_with(&prefix)); - let entries: Vec = world - .history() - .lines() - .map(|line| serde_json::from_str(line).unwrap()) - .collect(); - assert_eq!( - entries - .iter() - .map(|e| e["kind"].as_str().unwrap()) - .collect::>(), - ["user", "assistant", "user", "assistant"] - ); - let doc = host.call("agent.rpc.discover", json!({})).await.unwrap(); - assert_eq!(doc["x-sausage-grace"], 600); - host.stop().await; - } -} - -/// Both API shapes execute every tool, return tool errors, and deliver interim text. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tools_and_interim_notifications() { - for api in APIS { - let mut server = Llm::new(api).await; - let world = TestWorld::new(&server); - let host = world.start().await; - let mut events = host.subscribe(); - let turn = chat(&host, "exercise the tools"); - let (_, reply) = server.next().await; - let calls = [ - ( - "call", - json!({"method":"probe.add","params":{"a":4,"b":5},"detach":true}), - ), - ("read_file", json!({"path":"/proc/core/PROTOCOL.md"})), - ( - "write_file", - json!({"path":"/state/work/source/note.txt","content":"saved"}), - ), - ("list_dir", json!({"path":"/state/work/source"})), - ( - "copy_dir", - json!({"from":"/state/work/source","to":"/state/work/copied"}), - ), - ("read_file", json!({"path":"/state/work/missing"})), - ("call", json!({"method":"absent.run","params":{}})), - ("call", json!({"method":"probe.fail","params":{}})), - ]; - reply - .send((200, server.answer("I will inspect the world.", &calls))) - .unwrap(); - let (body, reply) = server.next().await; - let results = outputs(&body, api); - assert_eq!(results.len(), 8); - assert_eq!(results[0], 9); - assert!(results[1]["content"].as_str().unwrap().contains("JSON-RPC")); - assert_eq!(results[2]["written"], "/state/work/source/note.txt"); - assert_eq!(results[3]["entries"][0]["name"], "note.txt"); - assert_eq!(results[4]["copied_files"], 1); - assert!(results[5]["error"]["message"].is_string()); - assert!(results[6]["error"]["code"].is_number()); - assert_eq!( - results[7]["error"], - json!({"code":-32010,"message":"deliberate","data":{"diagnostic":"probe error details"}}) - ); - assert_eq!( - std::fs::read_to_string(world.0.join("state/work/copied/note.txt")).unwrap(), - "saved" - ); - let event = tokio::time::timeout(Duration::from_secs(5), events.recv()) - .await - .unwrap() - .unwrap(); - assert_eq!(event["params"]["name"], "text"); - assert_eq!( - event["params"]["params"]["text"], - "I will inspect the world." - ); - reply - .send((200, server.answer("The tools finished.", &[]))) - .unwrap(); - assert_eq!(turn.await.unwrap(), "The tools finished."); - host.stop().await; - } -} - -/// The history lock holds the second chat until the complete first turn ends. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn concurrent_chats_serialize_complete_turns() { - for api in APIS { - let mut server = Llm::new(api).await; - let world = TestWorld::new(&server); - let host = world.start().await; - let first = chat(&host, "first concurrent request"); - let (_, reply) = server.next().await; - let second = chat(&host, "second concurrent request"); - let view = host.filesystem().await.unwrap(); - tokio::time::timeout(Duration::from_secs(10), async { - loop { - let file = view - .open("/proc/core/locks.json", sausage_vfs::Open::default()) - .await - .unwrap(); - let locks: Value = - serde_json::from_slice(&file.read(0, 65536).await.unwrap()).unwrap(); - if locks["/state/agent/history.jsonl"]["waiting"] - .as_array() - .is_some_and(|waiting| waiting.len() == 1) - { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert!(server.requests.try_recv().is_err()); - reply - .send((200, server.answer("first concurrent answer", &[]))) - .unwrap(); - assert_eq!(first.await.unwrap(), "first concurrent answer"); - let (body, reply) = server.next().await; - let text = input(&body, api).to_string(); - assert!(text.contains("first concurrent answer")); - assert!(text.contains("second concurrent request")); - reply - .send((200, server.answer("second concurrent answer", &[]))) - .unwrap(); - assert_eq!(second.await.unwrap(), "second concurrent answer"); - host.stop().await; - } -} - -/// Context-size failures discard old complete turns without changing the append-only history. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn context_window_advances_by_complete_turns() { - for api in APIS { - let mut server = Llm::new(api).await; - let world = TestWorld::new(&server); - let host = world.start().await; - let first = chat(&host, "old turn"); - let (_, reply) = server.next().await; - reply.send((200, server.answer("old answer", &[]))).unwrap(); - first.await.unwrap(); - let second = chat(&host, "current turn"); - let (_, reply) = server.next().await; - reply - .send((400, json!({"error":"context_length_exceeded"}))) - .unwrap(); - let (body, reply) = server.next().await; - let text = input(&body, api).to_string(); - assert!(!text.contains("old turn")); - assert!(!text.contains("old answer")); - assert!(text.contains("current turn")); - reply - .send((200, server.answer("current answer", &[]))) - .unwrap(); - assert_eq!(second.await.unwrap(), "current answer"); - assert_eq!(world.history().lines().count(), 4); - host.stop().await; - } -} - -/// Oversized tool outputs are bounded, and failed retries do not commit a new history cursor. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn oversized_current_turn_preserves_the_previous_cursor() { - for api in APIS { - let mut server = Llm::new(api).await; - let world = TestWorld::new(&server); - std::fs::write(world.0.join("large.txt"), "x".repeat(200_000)).unwrap(); - let host = world.start().await; - let first = chat(&host, "old retained turn"); - let (_, reply) = server.next().await; - reply - .send((200, server.answer("old retained answer", &[]))) - .unwrap(); - first.await.unwrap(); - let skip = world.0.join("state/agent/history.skip"); - std::fs::write(&skip, "0").unwrap(); - let caller = host.clone(); - let turn = tokio::spawn(async move { - caller - .call("agent.chat", json!({"text":"read a large tool result"})) - .await - }); - let (_, reply) = server.next().await; - reply - .send(( - 200, - server.answer("", &[("read_file", json!({"path":"/large.txt"}))]), - )) - .unwrap(); - let (body, reply) = server.next().await; - let serialized = input(&body, api).to_string(); - assert!(serialized.contains("Full output: /logs/agent/")); - let history = world.history(); - let result: Value = history - .lines() - .map(|line| serde_json::from_str::(line).unwrap()) - .find(|entry| { - entry["output"] - .as_str() - .is_some_and(|out| out.contains("Full output: ")) - }) - .unwrap(); - let output = result["output"].as_str().unwrap(); - let path = output - .split("Full output: ") - .nth(1) - .unwrap() - .split(". ") - .next() - .unwrap(); - let archive = std::fs::read_to_string(world.0.join(path.trim_start_matches('/'))).unwrap(); - assert!(archive.contains(&"x".repeat(200_000))); - assert!(output.len() <= 50 * 1024); - assert!(serialized.len() < 70_000); - reply - .send((413, json!({"type":"request_too_large"}))) - .unwrap(); - let (body, reply) = server.next().await; - assert!(!input(&body, api).to_string().contains("old retained turn")); - reply - .send((413, json!({"type":"request_too_large"}))) - .unwrap(); - assert!(turn.await.unwrap().is_err()); - assert_eq!(std::fs::read_to_string(&skip).unwrap(), "0"); - host.stop().await; - } -} - -/// Provider-private items are replayed only for their original API and model. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn private_items_are_scoped_to_the_model() { - for api in [APIS[0], APIS[1]] { - let mut server = Llm::new(api).await; - let world = TestWorld::new(&server); - // Without a thinking level, newer models think by default, so their items return. - std::fs::create_dir_all(world.0.join("state/agent")).unwrap(); - let mut entries = vec![json!({"kind":"user","text":"old turn"})]; - for (model, marker) in [ - ("another-model", "old-private-marker"), - ("scripted-test", "current-private-marker"), - ] { - let item = if api == APIS[0] { - json!({"type":"reasoning","encrypted_content":marker}) - } else { - json!({"type":"thinking","thinking":"private","signature":marker}) - }; - entries.push(json!({"kind":"opaque","api":api,"model":model,"item":item})); - } - entries.push(json!({"kind":"assistant","text":"old answer"})); - std::fs::write( - world.0.join("state/agent/history.jsonl"), - entries - .iter() - .map(|entry| format!("{entry}\n")) - .collect::(), - ) - .unwrap(); - let host = world.start().await; - let turn = chat(&host, "next turn"); - let (body, reply) = server.next().await; - let text = input(&body, api).to_string(); - assert!(!text.contains("old-private-marker")); - assert!(text.contains("current-private-marker")); - reply.send((200, server.answer("done", &[]))).unwrap(); - assert_eq!(turn.await.unwrap(), "done"); - host.stop().await; - } -} - -/// Each API gets the configured thinking level. A level in the call applies to that turn only. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn thinking_levels_reach_each_api() { - for api in APIS { - let mut server = Llm::new(api).await; - let world = TestWorld::new(&server); - let config_path = world.0.join("config/agent.yaml"); - let mut config: Value = - serde_yaml_ng::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); - config["thinking"] = json!("high"); - std::fs::write(config_path, serde_yaml_ng::to_string(&config).unwrap()).unwrap(); - let host = world.start().await; - // Off is `none` for OpenAI, and disabled thinking without an effort for Anthropic. - let off = if api == APIS[1] { None } else { Some("none") }; - for (thinking, effort) in [ - (None, Some("high")), - (Some("low"), Some("low")), - (Some("off"), off), - (None, Some("high")), - ] { - let mut params = json!({"text":"hello"}); - if let Some(thinking) = thinking { - params["thinking"] = json!(thinking); - } - let caller = host.clone(); - let turn = - tokio::spawn(async move { caller.call("agent.chat", params).await.unwrap() }); - let (body, reply) = server.next().await; - let sent = if api == APIS[0] { - &body["reasoning"]["effort"] - } else if api == APIS[2] { - &body["reasoning_effort"] - } else { - let mode = if thinking == Some("off") { - "disabled" - } else { - "adaptive" - }; - assert_eq!(body["thinking"]["type"], mode, "{body}"); - &body["output_config"]["effort"] - }; - assert_eq!(sent.as_str(), effort, "{api} {thinking:?}"); - reply.send((200, server.answer("done", &[]))).unwrap(); - assert_eq!(turn.await.unwrap(), "done"); - } - let error = host - .call("agent.chat", json!({"text":"hello","thinking":"hihg"})) - .await - .unwrap_err(); - assert_eq!(error["code"], -32602, "{error}"); - host.stop().await; - } -} - -/// Separate assistant messages retain a separator in the final answer. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn final_texts_have_a_separator() { - for api in [APIS[0], APIS[1]] { - let mut server = Llm::new(api).await; - let world = TestWorld::new(&server); - let host = world.start().await; - let turn = chat(&host, "two paragraphs"); - let (_, reply) = server.next().await; - let mut answer = server.answer("first", &[]); - if api == APIS[0] { - answer["output"] - .as_array_mut() - .unwrap() - .push(json!({"type":"message","content":[{"type":"output_text","text":"second"}]})); - } else { - answer["content"] - .as_array_mut() - .unwrap() - .push(json!({"type":"text","text":"second"})); - } - reply.send((200, answer)).unwrap(); - assert_eq!(turn.await.unwrap(), "first\n\nsecond"); - host.stop().await; - } -} - -/// An incomplete response containing reasoning but no text is an error. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn incomplete_without_text_is_an_error() { - let mut server = Llm::new(APIS[0]).await; - let world = TestWorld::new(&server); - let host = world.start().await; - let caller = host.clone(); - let turn = - tokio::spawn(async move { caller.call("agent.chat", json!({"text":"answer"})).await }); - let (_, reply) = server.next().await; - reply.send((200, json!({"status":"incomplete","output":[{"type":"reasoning","encrypted_content":"private"}]}))).unwrap(); - assert!( - turn.await.unwrap().unwrap_err()["message"] - .as_str() - .unwrap() - .contains("incomplete response") - ); - host.stop().await; -} diff --git a/cli/tests/host/cargo.rs b/cli/tests/host/cargo.rs deleted file mode 100644 index a381f46..0000000 --- a/cli/tests/host/cargo.rs +++ /dev/null @@ -1,1107 +0,0 @@ -//! Native build and WASI libtest integration checks under PROTOCOL.md section 11. -use super::common::CACHE; -use sausage::{Host, world::World}; -use serde_json::{Value, json}; -use std::path::{Path, PathBuf}; -use tokio::sync::OnceCell; -struct TestWorld(PathBuf); -impl TestWorld { - /// Creates a fresh native build world. - fn new() -> Self { - let path = std::env::temp_dir().join(format!("sausage-cargo-{}", uuid::Uuid::now_v7())); - World::create(&path).unwrap(); - Self(path) - } - /// Short-deadline tests must not queue on another test's Cargo target lock. - async fn timeout_host(&self, name: &str) -> Host { - Host::start_with_cache(&self.0, &Path::new(CACHE).join("timeouts").join(name)) - .await - .unwrap() - } - /// Creates a dependency-free plugin fixture. - fn simple(&self, name: &str, text: &str) { - let root = self.0.join(format!("plugins/{name}/source")); - std::fs::create_dir_all(root.join("src")).unwrap(); - std::fs::write( - root.join("Cargo.toml"), - format!("[package]\nname='{name}'\nversion='0.1.0'\nedition='2024'\n"), - ) - .unwrap(); - std::fs::write(root.join("src/main.rs"), text).unwrap(); - } - /// Copies a fixture into its plugin source folder. - fn source(&self, name: &str, source: &Path) { - copy(source, &self.0.join(format!("plugins/{name}/source"))); - } -} -impl Drop for TestWorld { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} -/// Copies fixtures without their build outputs or git metadata. -fn copy(source: &Path, destination: &Path) { - std::fs::create_dir_all(destination).unwrap(); - for entry in std::fs::read_dir(source).unwrap() { - let entry = entry.unwrap(); - if matches!(entry.file_name().to_str(), Some("target" | ".git")) { - continue; - } - let destination = destination.join(entry.file_name()); - if entry.file_type().unwrap().is_dir() { - copy(&entry.path(), &destination); - } else { - std::fs::copy(entry.path(), destination).unwrap(); - } - } -} -/// Finds a checked-in build fixture. -fn fixture(name: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/crates") - .join(name) -} -/// Builds through the real registered native plugin. -async fn build(world: &TestWorld, name: &str) -> Result { - let host = Host::start_with_cache(&world.0, Path::new(CACHE)) - .await - .unwrap(); - let runner = world.0.join("state/runner with spaces"); - std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_sausage"), &runner).unwrap(); - sausage::cargo::Cargo::with_runner(World::open(&world.0).unwrap(), runner, Path::new(CACHE)) - .unwrap() - .register(&host); - let result = host.call("cargo.build", json!({"plugin":name})).await; - host.stop().await; - let logs = match &result { - Ok(output) => &output["logs"], - Err(error) => &error["data"]["logs"], - }; - check_logs(world, logs); - result -} -/// RPC paths point at full raw streams, outside pruned build folders. -fn check_logs(world: &TestWorld, logs: &Value) { - let mut total = 0; - for stream in ["stdout", "stderr"] { - let path = logs[stream]["path"].as_str().expect("log path"); - assert!(path.starts_with("/logs/cargo/")); - assert!(path.ends_with(&format!(".{stream}.log"))); - let bytes = std::fs::metadata(world.0.join(path.trim_start_matches('/'))) - .unwrap() - .len(); - assert_eq!(logs[stream]["bytes"], bytes); - total += bytes; - } - assert_eq!(logs["total_bytes"], total); -} -struct Valid { - world: TestWorld, - result: Value, - test: PathBuf, -} -/// Shares one successful SDK build while runner invocations retain isolated worlds. -async fn valid() -> &'static Valid { - static VALID: OnceCell = OnceCell::const_new(); - VALID - .get_or_init(|| async { - let world = TestWorld::new(); - world.source("valid", &fixture("cargo-valid")); - std::fs::create_dir_all(world.0.join("plugins/valid/source/target")).unwrap(); - std::fs::write( - world.0.join("plugins/valid/source/target/excluded"), - "exclude", - ) - .unwrap(); - let nested = world.0.join("plugins/valid/source/src/target"); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::write(nested.join("data"), "nested target").unwrap(); - let git = world.0.join("plugins/valid/source/src/.git"); - std::fs::create_dir_all(&git).unwrap(); - std::fs::write(git.join("data"), "nested git").unwrap(); - for number in 1..=20 { - std::fs::create_dir_all(world.0.join(format!("state/cargo/builds/{number}"))) - .unwrap(); - } - std::fs::create_dir_all(world.0.join("state/cargo/tmp/stale")).unwrap(); - std::fs::write(world.0.join("state/cargo/tmp/stale/file"), "stale").unwrap(); - let result = build(&world, "valid") - .await - .unwrap_or_else(|error| panic!("build failed: {error}")); - // Integration tests also build a normal binary. Cargo identifies the libtest artifact. - let artifacts = tokio::process::Command::new("cargo") - .args([ - "test", - "--no-run", - "--release", - "--message-format=json", - "--bin", - "cargo-valid", - "--target", - "wasm32-wasip2", - "--target-dir", - ]) - .arg(Path::new(CACHE).join("cargo-target")) - .current_dir(world.0.join("state/cargo/work")) - .stdin(std::process::Stdio::null()) - .output() - .await - .unwrap(); - assert!( - artifacts.status.success(), - "{}", - String::from_utf8_lossy(&artifacts.stderr) - ); - let mut tests: Vec = String::from_utf8_lossy(&artifacts.stdout) - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .filter(|message| { - message["reason"] == "compiler-artifact" - && message["profile"]["test"] == true - && message["target"]["kind"] - .as_array() - .is_some_and(|kinds| kinds.iter().any(|kind| kind == "bin")) - }) - .filter_map(|message| message["executable"].as_str().map(PathBuf::from)) - .collect(); - assert_eq!( - tests.len(), - 1, - "expected exactly one libtest binary: {tests:?}" - ); - let test = tests.pop().unwrap(); - Valid { - world, - result, - test, - } - }) - .await -} -/// Invokes the same runner subcommand that cargo uses. -async fn runner(valid: &Valid, args: &[&str]) -> std::process::Output { - let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_sausage")); - command - .arg("test-runner") - .arg(&valid.test) - .args(args) - .current_dir(valid.world.0.join("state/cargo/work")) - .kill_on_drop(true); - tokio::time::timeout(std::time::Duration::from_secs(120), command.output()) - .await - .expect("test runner exceeded 120 seconds") - .unwrap() -} - -/// T23: unlocks stay ordered before the next lock over 300 acquisitions. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unlock_order() { - assert!( - runner(valid().await, &["unlock_order"]) - .await - .status - .success() - ); -} -/// T23: each runner world is empty and has independent proc, self, and run mounts. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn empty_and_isolated_worlds() { - for name in ["empty_world", "isolation_", "environment", "late_response"] { - let output = runner(valid().await, &[name]).await; - assert!( - output.status.success(), - "{name}: {} {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } -} -/// T23: successful builds preserve warnings and integration output without cargo progress noise. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn build_output_and_warnings() { - let valid = valid().await; - assert!( - valid.result["warnings"] - .as_str() - .unwrap() - .contains("fixture build warning") - ); - let logs = &valid.result["logs"]; - let raw = |stream: &str| { - std::fs::read_to_string( - valid.world.0.join( - logs[stream]["path"] - .as_str() - .unwrap() - .trim_start_matches('/'), - ), - ) - .unwrap() - }; - assert!(raw("stdout").contains("\"reason\":\"compiler-artifact\"")); - assert!(raw("stderr").contains("Finished ")); - let output = valid.result["tests"]["output"].as_str().unwrap(); - assert!(output.contains("INTEGRATION_CASE")); - assert!(!output.contains("Compiling ")); - assert!(!output.contains(" running 1 test")); - assert!( - !output - .lines() - .any(|line| line.starts_with(" ") && line.trim().is_empty()) - ); -} -/// T23: publication retains ten builds and the next build removes abandoned test worlds. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn build_retention_and_temporary_cleanup() { - let valid = valid().await; - assert_eq!( - std::fs::read_dir(valid.world.0.join("state/cargo/builds")) - .unwrap() - .count(), - 10 - ); - assert!(!valid.world.0.join("state/cargo/tmp/stale").exists()); - assert_eq!( - std::fs::read_dir(valid.world.0.join("state/cargo/tmp")) - .unwrap() - .count(), - 0 - ); -} -/// T23: manifest preparation preserves workspaces, renamed target dependencies, and optional flags. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn manifest_and_nested_sources() { - let valid = valid().await; - let root = valid.world.0.join("state/cargo/work"); - let manifest = std::fs::read_to_string(root.join("Cargo.toml")).unwrap(); - assert!(manifest.contains("workspace.package")); - assert!(manifest.contains("optional = true")); - let document: toml_edit::DocumentMut = manifest.parse().unwrap(); - let sdk = Path::new(document["target"]["cfg(target_arch = \"wasm32\")"]["dependencies"]["sausage_plugin"]["path"].as_str().unwrap()); - assert_eq!(sdk, valid.world.0.join("plugins/cargo/sdk")); - assert_eq!( - std::fs::read_to_string(root.join("src/target/data")).unwrap(), - "nested target" - ); - assert_eq!( - std::fs::read_to_string(root.join("src/.git/data")).unwrap(), - "nested git" - ); -} -/// T23: discovery describes the native build method and preparation errors name their paths. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn native_discovery_and_error_paths() { - let world = TestWorld::new(); - let host = Host::start_with_cache(&world.0, Path::new(CACHE)) - .await - .unwrap(); - assert_eq!( - host.call("cargo.rpc.discover", json!({})).await.unwrap()["methods"][0]["name"], - "build" - ); - let error = host - .call("cargo.build", json!({"plugin":"missing"})) - .await - .unwrap_err(); - assert_eq!(error["code"], -32004); - check_logs(&world, &error["data"]["logs"]); - assert!( - error["data"]["output"] - .as_str() - .unwrap() - .contains("plugins/missing/source") - ); - host.stop().await; -} -/// Rework 3: an unavailable cache prevents builds, not host startup or SDK installation. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cache_failure_is_deferred_until_build() { - let world = TestWorld::new(); - world.source("simple", &fixture("cargo-test-fail")); - // A file blocks directory creation even when the test runs as root. - let cache = world.0.join("blocked-cache"); - std::fs::write(&cache, "not a directory").unwrap(); - let host = Host::start_with_cache(&world.0, &cache).await.unwrap(); - assert!(world.0.join("plugins/cargo/sdk/src/lib.rs").is_file()); - let error = host - .call("cargo.build", json!({"plugin":"simple"})) - .await - .unwrap_err(); - assert_eq!(error["code"], -32004); - let output = error["data"]["output"].as_str().unwrap(); - assert!(output.contains(cache.to_str().unwrap()), "{output}"); - assert!(output.contains("create"), "{output}"); - host.stop().await; - - let standalone = TestWorld::new(); - standalone.source("simple", &fixture("cargo-test-fail")); - let builder = sausage::cargo::Cargo::with_runner( - World::open(&standalone.0).unwrap(), - PathBuf::from(env!("CARGO_BIN_EXE_sausage")), - &cache, - ) - .unwrap(); - assert!(standalone.0.join("plugins/cargo/sdk/src/lib.rs").is_file()); - let error = builder - .build(&standalone.0.join("plugins/simple/source")) - .await - .unwrap_err(); - assert!(error.output.contains(cache.to_str().unwrap()), "{error}"); - assert!(cache.is_file()); -} -/// T23: release-only failures use data.output and retain successful WASI totals. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn release_only_failure() { - let world = TestWorld::new(); - let root = world.0.join("plugins/release/source/src"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write( - root.parent().unwrap().join("Cargo.toml"), - "[package]\nname='release-only'\nversion='0.1.0'\nedition='2024'\n", - ) - .unwrap(); - std::fs::write(root.join("main.rs"), "#[cfg(not(test))] compile_error!(\"release-only error\"); fn main() {} #[test] fn passes() {}\n").unwrap(); - let error = build(&world, "release").await.unwrap_err(); - assert!( - error["data"]["output"] - .as_str() - .unwrap() - .contains("release-only error") - ); - assert_eq!(error["data"]["tests"]["passed"], 1); -} -/// T23: cancellation retains serialization until a blocking source copy completes. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cancelled_preparation_is_serialized() { - let world = TestWorld::new(); - let first = world.0.join("first"); - let second = world.0.join("second"); - for (root, name) in [(&first, "first"), (&second, "second")] { - std::fs::create_dir_all(root.join("src")).unwrap(); - std::fs::write( - root.join("Cargo.toml"), - format!("[package]\nname='{name}'\nversion='0.1.0'\nedition='2024'\n"), - ) - .unwrap(); - std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); - } - for number in 0..10000 { - std::fs::write(first.join(format!("old-{number}")), "copy").unwrap(); - } - let builder = std::sync::Arc::new( - sausage::cargo::Cargo::with_runner( - World::open(&world.0).unwrap(), - PathBuf::from(env!("CARGO_BIN_EXE_sausage")), - Path::new(CACHE), - ) - .unwrap(), - ); - let copying = builder.clone(); - let task = tokio::spawn(async move { copying.build(&first).await }); - tokio::time::timeout(std::time::Duration::from_secs(30), async { - loop { - if std::fs::read_dir(world.0.join("state/cargo/work")) - .is_ok_and(|entries| entries.count() > 10) - { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - task.abort(); - let _ = task.await; - builder.build(&second).await.unwrap(); - assert!( - !std::fs::read_dir(world.0.join("state/cargo/work")) - .unwrap() - .any(|entry| entry - .unwrap() - .file_name() - .to_string_lossy() - .starts_with("old-")) - ); -} -/// T23: multiple binary artifacts fail clearly instead of publishing an arbitrary binary. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn multiple_binaries() { - let world = TestWorld::new(); - let root = world.0.join("plugins/multi/source/src/bin"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write( - root.parent().unwrap().parent().unwrap().join("Cargo.toml"), - "[package]\nname='multiple'\nversion='0.1.0'\nedition='2024'\n", - ) - .unwrap(); - for name in ["one", "two"] { - std::fs::write(root.join(format!("{name}.rs")), "fn main() {}\n").unwrap(); - } - let error = build(&world, "multi").await.unwrap_err(); - assert!( - error["data"]["output"] - .as_str() - .unwrap() - .contains("exactly one binary") - ); -} -/// PROTOCOL 11: a valid SDK plugin builds and publishes a release component without activation. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn valid_build() { - let valid = valid().await; - let wasm = valid.result["wasm"].as_str().unwrap(); - assert!(wasm.starts_with("/state/cargo/builds/")); - assert!(valid.world.0.join(wasm.trim_start_matches('/')).is_file()); - assert_eq!(valid.result["tests"]["passed"], 12); - assert_eq!(valid.result["tests"]["failed"], 0); - assert!(!valid.world.0.join("plugins/valid/plugin.wasm").exists()); - assert!(!valid.world.0.join("state/cargo/target").exists()); - assert!( - Path::new(CACHE) - .join("cargo-target/wasm32-wasip2/release") - .is_dir() - ); -} -/// PROTOCOL 11: tests and compilation share one source copy without target metadata. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn source_copy() { - let valid = valid().await; - assert!( - !valid - .world - .0 - .join("state/cargo/work/target/excluded") - .exists() - ); - assert!( - std::fs::read_to_string(valid.world.0.join("state/cargo/work/Cargo.toml")) - .unwrap() - .contains(valid.world.0.join("plugins/cargo/sdk").to_str().unwrap()) - ); -} -/// PROTOCOL 11: compiler failures return compiler diagnostics. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn compile_error() { - let world = TestWorld::new(); - world.source("bad", &fixture("cargo-fail")); - let error = build(&world, "bad").await.unwrap_err(); - assert!( - error["data"]["tests"]["output"] - .as_str() - .unwrap() - .contains("error") - ); -} -/// PROTOCOL 11: a failing test rejects the build with its test output. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn failing_test() { - let world = TestWorld::new(); - world.source("bad", &fixture("cargo-test-fail")); - let error = build(&world, "bad").await.unwrap_err(); - assert_eq!(error["data"]["tests"]["failed"], 1); - assert!( - error["data"]["tests"]["output"] - .as_str() - .unwrap() - .contains("deliberate WASI panic") - ); -} -/// PROTOCOL 11: one panic does not prevent another test instance from passing. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn panic_is_isolated() { - let world = TestWorld::new(); - world.source("bad", &fixture("cargo-test-fail")); - let error = build(&world, "bad").await.unwrap_err(); - assert_eq!(error["data"]["tests"]["passed"], 1); -} -/// PROTOCOL 11: build scripts can compile and link WASI C code. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn c_build() { - let world = TestWorld::new(); - world.source("c", &fixture("cargo-c")); - let result = build(&world, "c") - .await - .unwrap_or_else(|error| panic!("{error}")); - assert_eq!(result["tests"]["passed"], 1); -} -/// PROTOCOL 11: libtest name filters select only matching tests. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn name_filter() { - let output = runner(valid().await, &["arithmetic"]).await; - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed")); -} -/// PROTOCOL 11: --ignored selects ignored tests instead of normal tests. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn ignored_filter() { - let output = runner(valid().await, &["--ignored"]).await; - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(String::from_utf8_lossy(&output.stdout).contains("test tests::ignored_case ... ok")); -} -/// PROTOCOL 11: CARGO_MANIFEST_DIR fixture reads work at the package's absolute host path. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn manifest_fixture() { - let output = runner(valid().await, &["fixture_at_manifest_path"]).await; - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); -} -/// PROTOCOL 11: WASI Preview 2 keeps cwd at / despite PWD. Absolute manifest paths reach fixtures. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn current_folder() { - let valid = valid().await; - let output = runner(valid, &["current_directory"]).await; - let text = String::from_utf8_lossy(&output.stdout); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - text.lines() - .any(|line| line.ends_with("WASI_CURRENT_DIR=/")), - "{text}" - ); -} -/// PROTOCOL 11: the SDK can acquire and release locks through the runner's core transport. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn sdk_lock() { - let output = runner(valid().await, &["sdk_lock"]).await; - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); -} -/// PROTOCOL 11: calls to other plugins fail with a clear runner error. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unavailable_plugin() { - let output = runner(valid().await, &["other_plugin_fails"]).await; - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); -} -/// PROTOCOL 11: the notes example tests run in isolated worlds under the same runner. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn notes_example() { - let world = TestWorld::new(); - world.source( - "notes", - &Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join("examples/notes"), - ); - let result = build(&world, "notes") - .await - .unwrap_or_else(|error| panic!("{error}")); - assert!(result["tests"]["passed"].as_u64().unwrap() > 0); -} -/// PROTOCOL 11: SDK installation leaves unchanged files and manifests untouched. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unchanged_sdk() { - let world = TestWorld::new(); - let setup = || { - sausage::cargo::Cargo::with_runner( - World::open(&world.0).unwrap(), - PathBuf::from(env!("CARGO_BIN_EXE_sausage")), - Path::new(CACHE), - ) - .unwrap() - }; - setup(); - let manifest = world.0.join("plugins/cargo/sdk/Cargo.toml"); - let modified = std::fs::metadata(&manifest).unwrap().modified().unwrap(); - setup(); - assert_eq!( - std::fs::metadata(manifest).unwrap().modified().unwrap(), - modified - ); -} -/// PROTOCOL 11: an SDK edit is used by the next build on the same running host. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn world_sdk_edits_apply_without_restart() { - tokio::time::timeout(std::time::Duration::from_secs(180), async { - let world = TestWorld::new(); - world.simple("sdk-probe", "fn main() {}\n#[test]\nfn world_sdk_probe() { assert_eq!(sausage_plugin::world_sdk_probe(), 1); }\n"); - let manifest = world.0.join("plugins/sdk-probe/source/Cargo.toml"); - let mut text = std::fs::read_to_string(&manifest).unwrap(); - text.push_str("\n[dependencies]\nsausage-plugin = '0.1'\n"); - std::fs::write(manifest, text).unwrap(); - let host = Host::start_with_cache(&world.0, Path::new(CACHE)).await.unwrap(); - let library = world.0.join("plugins/cargo/sdk/src/lib.rs"); - let original = std::fs::read_to_string(&library).unwrap(); - std::fs::write(&library, format!("{original}\npub fn world_sdk_probe() -> u8 {{ 1 }}\n")).unwrap(); - let params = json!({"plugin":"sdk-probe", "timeout_secs":120, "test_timeout_secs":10}); - let first = host.call("cargo.build", params.clone()).await.unwrap(); - assert_eq!(first["tests"]["passed"], 1); - // Only the SDK changes: Cargo must not reuse its old compiled version. - std::fs::write(&library, format!("{original}\npub fn world_sdk_probe() -> u8 {{ 2 }}\n")).unwrap(); - let second = host.call("cargo.build", params).await.unwrap_err(); - assert_eq!(second["code"], -32004); - assert_eq!(second["data"]["tests"]["failed"], 1); - assert!(second["data"]["tests"]["output"].as_str().unwrap().contains("world_sdk_probe")); - host.stop().await; - }).await.expect("SDK edit regression timed out"); -} -/// PROTOCOL 11: startup seeds a missing SDK, but never repairs or overwrites an existing one. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn world_sdk_survives_restart_and_missing_sdk_is_reseeded() { - tokio::time::timeout(std::time::Duration::from_secs(30), async { - let world = TestWorld::new(); - let sdk = world.0.join("plugins/cargo/sdk"); - let host = Host::start_with_cache(&world.0, Path::new(CACHE)) - .await - .unwrap(); - let library = sdk.join("src/lib.rs"); - let original = std::fs::read(&library).unwrap(); - let manifest = sdk.join("Cargo.toml"); - let edited = b"// world SDK edit must survive restart\n"; - std::fs::write(&library, edited).unwrap(); - std::fs::write(&manifest, "# edited manifest\n").unwrap(); - let removed = sdk.join("macros/src/lib.rs"); - std::fs::remove_file(&removed).unwrap(); - host.stop().await; - let host = Host::start_with_cache(&world.0, Path::new(CACHE)) - .await - .unwrap(); - assert_eq!(std::fs::read(&library).unwrap(), edited); - assert_eq!( - std::fs::read_to_string(&manifest).unwrap(), - "# edited manifest\n" - ); - assert!(!removed.exists(), "existing SDK must not be repaired"); - host.stop().await; - std::fs::remove_dir_all(&sdk).unwrap(); - let host = Host::start_with_cache(&world.0, Path::new(CACHE)) - .await - .unwrap(); - assert_eq!(std::fs::read(&library).unwrap(), original); - assert!(removed.is_file()); - assert!(sdk.join("interface/src/lib.rs").is_file()); - host.stop().await; - }) - .await - .expect("SDK restart regression timed out"); -} -/// PROTOCOL 11: removing the SDK while the host is running re-seeds it on the next build. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn world_sdk_missing_at_build_is_reseeded() { - tokio::time::timeout(std::time::Duration::from_secs(180), async { - let world = TestWorld::new(); - world.simple("reseed-probe", "fn main() {}\n"); - let host = Host::start_with_cache(&world.0, Path::new(CACHE)) - .await - .unwrap(); - let sdk = world.0.join("plugins/cargo/sdk"); - let original = std::fs::read(sdk.join("src/lib.rs")).unwrap(); - std::fs::remove_dir_all(&sdk).unwrap(); - host.call( - "cargo.build", - json!({"plugin":"reseed-probe", "timeout_secs":120, "test_timeout_secs":10}), - ) - .await - .unwrap(); - assert_eq!(std::fs::read(sdk.join("src/lib.rs")).unwrap(), original); - host.stop().await; - }) - .await - .expect("SDK re-seed build regression timed out"); -} -/// PROTOCOL 11: init can build a plugin without a running host. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn standalone_builder() { - let world = TestWorld::new(); - world.source("simple", &fixture("cargo-test-fail")); - let builder = sausage::cargo::Cargo::with_runner( - World::open(&world.0).unwrap(), - PathBuf::from(env!("CARGO_BIN_EXE_sausage")), - Path::new(CACHE), - ) - .unwrap(); - assert_eq!( - builder - .build(&world.0.join("plugins/simple/source")) - .await - .unwrap_err() - .tests - .failed, - 1 - ); -} -/// a named CPU-bound WASI test times out, while other instances still run. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn named_test_timeout_and_mutex_release() { - let world = TestWorld::new(); - world.simple("test-timeout", "fn main() {} #[test] fn a_hung() { loop { std::hint::black_box(1); } } #[test] fn z_remaining() { eprintln!(\"REMAINING_TEST_RAN\"); }"); - world.simple("next", "fn main() {} #[test] fn passes() {}"); - let integration = world.0.join("plugins/test-timeout/source/tests"); - std::fs::create_dir_all(&integration).unwrap(); - std::fs::write( - integration.join("remaining.rs"), - "#[test] fn integration_remaining() { eprintln!(\"REMAINING_BINARY_RAN\"); }", - ) - .unwrap(); - let host = world.timeout_host("named").await; - let error = tokio::time::timeout( - std::time::Duration::from_secs(60), - host.call( - "cargo.build", - json!({"plugin":"test-timeout", "test_timeout_secs":1, "timeout_secs":45}), - ), - ) - .await - .unwrap() - .unwrap_err(); - assert_eq!(error["code"], -32004); - assert_eq!(error["message"], "WASI tests failed"); - assert_eq!(error["data"]["tests"]["failed"], 1); - assert_eq!(error["data"]["tests"]["passed"], 2); - let output = error["data"]["output"].as_str().unwrap(); - assert!(output.contains("test a_hung ... FAILED"), "{output}"); - assert!(output.contains("timed out after 1 s"), "{output}"); - assert!(output.contains("REMAINING_TEST_RAN"), "{output}"); - assert!(output.contains("REMAINING_BINARY_RAN"), "{output}"); - let next = tokio::time::timeout( - std::time::Duration::from_secs(30), - host.call("cargo.build", json!({"plugin":"next"})), - ) - .await - .unwrap() - .unwrap(); - assert_eq!(next["tests"]["passed"], 1); - host.stop().await; -} - -/// Wait for a build-script child without using timing sleeps. -async fn script_child(world: &TestWorld) -> u32 { - tokio::time::timeout(std::time::Duration::from_secs(60), async { - loop { - if let Ok(text) = std::fs::read_to_string(world.0.join("state/cargo/work/child.pid")) - && let Ok(pid) = text.trim().parse() - { - break pid; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("build script never started") -} -/// A killed descendant may briefly remain as a zombie, but cannot keep executing. -async fn child_ended(child: u32) { - tokio::time::timeout(std::time::Duration::from_secs(10), async { - loop { - match std::fs::read_to_string(format!("/proc/{child}/stat")) { - Ok(status) if status.split_whitespace().nth(2) == Some("Z") => break, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, - _ => tokio::task::yield_now().await, - } - } - }) - .await - .expect("cargo descendant survived cancellation/timeout"); -} - -/// an explicit small total budget kills build.rs and unblocks a queued build. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn build_timeout_and_queued_build() { - let world = TestWorld::new(); - world.source("hang", &fixture("cargo-hang")); - world.simple("next", "fn main() {} #[test] fn passes() {}"); - let host = world.timeout_host("queued").await; - let building = host.clone(); - let task = tokio::spawn(async move { - building - .call("cargo.build", json!({"plugin":"hang", "timeout_secs":5})) - .await - }); - let child = script_child(&world).await; - let queued_host = host.clone(); - let queued = tokio::spawn(async move { - queued_host - .call("cargo.build", json!({"plugin":"next", "timeout_secs":3})) - .await - }); - let error = task.await.unwrap().unwrap_err(); - assert_eq!(error["code"], -32004); - assert_eq!(error["message"], "timeout"); - assert_eq!(error["data"]["reason"], "timeout"); - check_logs(&world, &error["data"]["logs"]); - assert_eq!(error["data"]["step"], "tests"); - assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 5.0); - assert!(!error["data"]["output"].as_str().unwrap().is_empty()); - child_ended(child).await; - let next = tokio::time::timeout(std::time::Duration::from_secs(20), queued) - .await - .unwrap() - .unwrap() - .unwrap(); - assert_eq!(next["tests"]["passed"], 1); - host.stop().await; -} - -/// the same total deadline covers release compilation and retains test results. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn release_build_timeout_retains_tests() { - let world = TestWorld::new(); - world.simple( - "release-timeout", - r#"fn main() {} -#[test] fn prepare_release() { - std::fs::write(concat!(env!("CARGO_MANIFEST_DIR"), "/release-marker"), "ready").unwrap(); -}"#, - ); - std::fs::write( - world.0.join("plugins/release-timeout/source/build.rs"), - r#" -fn main() { - println!("cargo:rerun-if-changed=release-marker"); - if std::path::Path::new("release-marker").exists() { - std::process::Command::new("sh") - .args(["-c", "echo $$ > child.pid; exec sleep 1000"]) - .status().unwrap(); - } -}"#, - ) - .unwrap(); - let host = world.timeout_host("release").await; - let building = host.clone(); - let task = tokio::spawn(async move { - building - .call( - "cargo.build", - json!({"plugin":"release-timeout", "timeout_secs":5}), - ) - .await - }); - let child = script_child(&world).await; - let error = tokio::time::timeout(std::time::Duration::from_secs(10), task) - .await - .unwrap() - .unwrap() - .unwrap_err(); - assert_eq!(error["code"], -32004); - assert_eq!(error["data"]["reason"], "timeout"); - check_logs(&world, &error["data"]["logs"]); - assert_eq!(error["data"]["step"], "release build"); - assert_eq!(error["data"]["tests"]["passed"], 1); - assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 5.0); - child_ended(child).await; - host.stop().await; -} - -/// omission selects the real 900-second deadline, without a 15-minute test. -#[tokio::test] -async fn omitted_build_timeout_uses_default() { - let world = TestWorld::new(); - world.source("hang", &fixture("cargo-hang")); - let host = world.timeout_host("default").await; - let building = host.clone(); - let task = - tokio::spawn(async move { building.call("cargo.build", json!({"plugin":"hang"})).await }); - let child = script_child(&world).await; - tokio::time::pause(); - tokio::time::advance(std::time::Duration::from_secs( - sausage::cargo::DEFAULT_TIMEOUT_SECS, - )) - .await; - // Let the awakened deadline observe the advanced clock before resuming it. - tokio::task::yield_now().await; - let error = task.await.unwrap().unwrap_err(); - tokio::time::resume(); - assert_eq!(error["data"]["reason"], "timeout"); - assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 900.0); - child_ended(child).await; - host.stop().await; -} - -/// a caller ending stops its attached native build and releases the mutex. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn ended_caller_releases_build_mutex() { - let world = TestWorld::new(); - world.source("hang", &fixture("cargo-hang")); - world.simple("next", "fn main() {} #[test] fn passes() {}"); - let host = world.timeout_host("caller").await; - let (finish, finished) = tokio::sync::watch::channel(false); - host.register_native( - "caller", - std::sync::Arc::new(move |context, _, _| { - let mut finished = finished.clone(); - Box::pin(async move { - tokio::spawn( - async move { context.call("cargo.build", json!({"plugin":"hang"})).await }, - ); - let _ = finished.wait_for(|done| *done).await; - Ok(json!({})) - }) - }), - ); - let calling = host.clone(); - let caller = tokio::spawn(async move { calling.call("caller.run", json!({})).await }); - let child = script_child(&world).await; - let queued_host = host.clone(); - let queued = tokio::spawn(async move { - queued_host - .call("cargo.build", json!({"plugin":"next"})) - .await - }); - finish.send_replace(true); - caller.await.unwrap().unwrap(); - let next = tokio::time::timeout(std::time::Duration::from_secs(12), queued) - .await - .expect("ended caller retained mutex") - .unwrap() - .unwrap(); - assert_eq!(next["tests"]["passed"], 1); - child_ended(child).await; - host.stop().await; -} - -/// discovery exposes non-null defaults; invalid budgets cannot disable limits. -#[tokio::test] -async fn timeout_params_discovery_and_validation() { - let world = TestWorld::new(); - let host = Host::start_with_cache(&world.0, Path::new(CACHE)) - .await - .unwrap(); - let doc = host.call("cargo.rpc.discover", json!({})).await.unwrap(); - let params = doc["methods"][0]["params"].as_array().unwrap(); - for (name, default) in [("timeout_secs", 900), ("test_timeout_secs", 120)] { - let param = params.iter().find(|param| param["name"] == name).unwrap(); - assert_eq!(param["schema"]["default"], default); - assert_eq!(param["schema"]["minimum"], 1); - assert!(!param["description"].as_str().unwrap().is_empty()); - for value in [json!(0), json!(-1), json!(1.5), json!("1"), json!(null)] { - let mut args = json!({"plugin":"missing"}); - args[name] = value; - let error = host.call("cargo.build", args).await.unwrap_err(); - assert_eq!(error["code"], -32602, "{error}"); - } - } - host.stop().await; -} - -/// PROTOCOL 11: killing a native build also kills its build-script descendants. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn kill_process_group() { - assert!( - Path::new("/proc/self/stat").is_file(), - "process-group checks require /proc" - ); - let world = TestWorld::new(); - world.source("hang", &fixture("cargo-hang")); - let host = world.timeout_host("kill").await; - let building = host.clone(); - let task = - tokio::spawn(async move { building.call("cargo.build", json!({"plugin":"hang"})).await }); - let child = tokio::time::timeout(std::time::Duration::from_secs(120), async { - loop { - if let Ok(text) = std::fs::read_to_string(world.0.join("state/cargo/work/child.pid")) - && let Ok(pid) = text.trim().parse::() - { - break pid; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - let mut ancestor = child; - loop { - let command = std::fs::read(format!("/proc/{ancestor}/cmdline")).unwrap(); - let arguments: Vec<_> = command - .split(|byte| *byte == 0) - .map(|part| String::from_utf8_lossy(part).to_string()) - .collect(); - if arguments[0].ends_with("/cargo") || arguments[0] == "cargo" { - assert!(!arguments.iter().any(|arg| arg == "--offline")); - assert!( - arguments - .iter() - .any(|arg| arg.starts_with("target.wasm32-wasip2.runner=[")) - ); - assert_eq!( - std::fs::read_link(format!("/proc/{ancestor}/fd/0")).unwrap(), - PathBuf::from("/dev/null") - ); - break; - } - let status = std::fs::read_to_string(format!("/proc/{ancestor}/status")).unwrap(); - ancestor = status - .lines() - .find_map(|line| line.strip_prefix("PPid:")) - .unwrap() - .trim() - .parse() - .unwrap(); - assert!(ancestor > 1, "cargo ancestor not found"); - } - let pid = host - .processes() - .into_iter() - .find(|(_, process)| process.plugin == "cargo") - .unwrap() - .0; - let fs = host.filesystem().await.unwrap(); - use sausage_vfs::Layer; - assert!( - fs.stat(&format!("/proc/{pid}/info.json"), true) - .await - .is_ok() - ); - assert!( - !host - .call("core.log", json!({"pid":pid,"grep":"start build"})) - .await - .unwrap() - .as_array() - .unwrap() - .is_empty() - ); - // Queue a build on the SAME builder before cancelling the lock holder. - world.simple("next", "fn main() {} #[test] fn passes() {}"); - let queued_host = host.clone(); - let queued = tokio::spawn(async move { - queued_host - .call("cargo.build", json!({"plugin":"next"})) - .await - }); - host.call("core.kill", json!({"pid":pid})).await.unwrap(); - assert!(task.await.unwrap().is_err()); - child_ended(ancestor).await; - let next = tokio::time::timeout(std::time::Duration::from_secs(10), queued) - .await - .expect("cancelled build retained mutex") - .unwrap() - .unwrap(); - assert_eq!(next["tests"]["passed"], 1); - tokio::time::timeout(std::time::Duration::from_secs(10), async { - loop { - let status = std::fs::read_to_string(format!("/proc/{child}/stat")); - if match status { - Ok(status) => status.split_whitespace().nth(2) == Some("Z"), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, - Err(error) => panic!("read /proc/{child}/stat: {error}"), - } { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - host.stop().await; -} diff --git a/cli/tests/host/cli.rs b/cli/tests/host/cli.rs deleted file mode 100644 index dadeeda..0000000 --- a/cli/tests/host/cli.rs +++ /dev/null @@ -1,450 +0,0 @@ -//! Binary contracts for atomic init, environment flags, signals, and the piped console. -use sausage::{Host, world::World}; -use serde_json::{Value, json}; -use std::{ - path::{Path, PathBuf}, - process::Stdio, - time::Duration, -}; -use tokio::{ - io::AsyncWriteExt, - process::{Child, Command}, -}; - -use super::common::{CACHE, probe}; - -fn binary() -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_sausage")); - command - .arg("--cache-dir") - .arg(CACHE) - .env_remove("SAUSAGE_HEADLESS") - .env_remove("SAUSAGE_LOOP") - .kill_on_drop(true); - command -} -fn world() -> (tempfile::TempDir, PathBuf) { - let folder = tempfile::tempdir().unwrap(); - let world = World::create(&folder.path().join("world")).unwrap(); - (folder, world.root) -} -async fn status(child: &mut Child) -> std::process::ExitStatus { - tokio::time::timeout(Duration::from_secs(15), child.wait()) - .await - .expect("CLI did not exit") - .unwrap() -} -async fn journal_exists(world: &Path) { - tokio::time::timeout(Duration::from_secs(15), async { - while !world.join("logs/core/journal.sqlite").exists() { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); -} -fn signal(child: &Child, signal: nix::sys::signal::Signal) { - nix::sys::signal::kill( - nix::unistd::Pid::from_raw(child.id().unwrap() as i32), - signal, - ) - .unwrap(); -} -fn install_probe(world: &Path) { - std::fs::create_dir_all(world.join("plugins/probe")).unwrap(); - std::fs::copy(probe(), world.join("plugins/probe/plugin.wasm")).unwrap(); -} - -/// Failed init leaves no world; successful init seeds the restart policy and a working agent. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn init_builds_agent() { - tokio::time::timeout(Duration::from_secs(300), init_builds_agent_inner()) - .await - .expect("init and seeded-agent checks exceeded 300 seconds"); -} - -async fn init_builds_agent_inner() { - let temporary = tempfile::tempdir().unwrap(); - let world = temporary.path().join("world"); - // Legacy configuration variables must not affect the templates. - let configured = || { - let mut command = binary(); - command - .current_dir(temporary.path()) - .env("SAUSAGE_API", "not-an-api") - .env("SAUSAGE_BASE_URL", "http://127.0.0.1:1/") - .env("SAUSAGE_MODEL", "init-test") - .env("SAUSAGE_API_TOKEN", "fake-token") - .stdin(Stdio::null()); - command - }; - let failed = configured() - .arg("init") - .env("SAUSAGE_HEADLESS", "not-a-boolean") - .env("RUSTC", "/nonexistent-sausage-rustc") - .output() - .await - .unwrap(); - assert!(!failed.status.success()); - assert!(String::from_utf8_lossy(&failed.stderr).contains("nonexistent-sausage-rustc")); - assert!(!world.exists()); - let output = configured().arg("init").output().await.unwrap(); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes") - ); - // Bare sausage reopens the completed world instead of trying to recreate it. - assert!(configured().output().await.unwrap().status.success()); - let prompt = std::fs::read_to_string(world.join("plugins/agent/source/src/prompt.md")).unwrap(); - assert!(prompt.contains("HARD RULE: the mediator and all agents NEVER call `core.restart`")); - assert!(prompt.contains("without the person's explicit approval each time")); - assert!(prompt.contains("alias `core.exit`")); - for text in [ - "`#loop`", - "SAUSAGE_LOOP=1", - "answers before", - "exit code 75", - ] { - assert!(prompt.contains(text), "seeded prompt missing {text}"); - } - let boot = std::fs::read_to_string(world.join("config/boot.yaml")).unwrap(); - assert!(boot.contains("SAUSAGE_LOOP=1")); - let mut config: Value = - serde_yaml_ng::from_slice(&std::fs::read(world.join("config/agent.yaml")).unwrap()) - .unwrap(); - assert_eq!(config["token"], "CHANGE_ME"); - assert_eq!(config["api"], "openai-responses"); - assert!(String::from_utf8_lossy(&output.stdout).contains("config/agent.yaml")); - assert_eq!( - serde_yaml_ng::from_slice::(&std::fs::read(world.join("config/boot.yaml")).unwrap()) - .unwrap(), - json!([]) - ); - for file in [ - "plugins/cargo/sdk/Cargo.toml", - "plugins/cargo/sdk/src/lib.rs", - "plugins/agent/source/Cargo.toml", - "plugins/agent/source/src/main.rs", - "plugins/agent/plugin.wasm", - ] { - assert!(world.join(file).is_file(), "missing {file}"); - } - assert_eq!( - std::fs::read(world.join("plugins/agent/source/Cargo.lock")).unwrap(), - include_bytes!("../../../Cargo.lock") - ); - assert!(!world.join("state/cargo/target").exists()); - assert!( - Path::new(CACHE) - .join("cargo-target/wasm32-wasip2/release") - .is_dir() - ); - let host = Host::start_with_cache(&world, Path::new(CACHE)) - .await - .unwrap(); - let doc = host.call("agent.rpc.discover", json!({})).await.unwrap(); - assert_eq!(doc["x-sausage-grace"], 600); - assert!( - doc["methods"] - .as_array() - .unwrap() - .iter() - .any(|method| method["name"] == "chat") - ); - let error = host - .call("agent.chat", json!({"text":"hello"})) - .await - .unwrap_err(); - assert!( - error["message"] - .as_str() - .unwrap() - .contains("/config/agent.yaml") - ); - assert!(error["message"].as_str().unwrap().contains("token")); - let mut server = super::agent::Llm::new("anthropic-messages").await; - config["api"] = json!("anthropic-messages"); - config["base_url"] = json!(server.url); - config["model"] = json!("scripted-test"); - config["token"] = json!("fake-token"); - std::fs::write( - world.join("config/agent.yaml"), - serde_yaml_ng::to_string(&config).unwrap(), - ) - .unwrap(); - let caller = host.clone(); - let turn = - tokio::spawn(async move { caller.call("agent.chat", json!({"text":"hello"})).await }); - let (_, reply) = server.next().await; - reply - .send((200, server.answer("configured agent", &[]))) - .unwrap(); - assert_eq!(turn.await.unwrap().unwrap(), "configured agent"); - host.stop().await; -} - -/// Both headless forms ignore EOF. False and empty values preserve the plain console. -#[tokio::test] -async fn headless_flag_and_environment() { - for value in [None, Some("1"), Some("true")] { - let (_folder, world) = world(); - let mut command = binary(); - command.arg("run").arg(&world).stdin(Stdio::null()); - if let Some(value) = value { - command.env("SAUSAGE_HEADLESS", value); - } else { - command.arg("--headless"); - } - let mut child = command.spawn().unwrap(); - journal_exists(&world).await; - assert!( - tokio::time::timeout(Duration::from_millis(300), child.wait()) - .await - .is_err() - ); - signal(&child, nix::sys::signal::Signal::SIGTERM); - assert_eq!(status(&mut child).await.code(), Some(0)); - } - for value in ["0", ""] { - let (_folder, world) = world(); - let mut child = binary() - .arg("run") - .arg(world) - .env("SAUSAGE_HEADLESS", value) - .stdin(Stdio::null()) - .spawn() - .unwrap(); - assert_eq!(status(&mut child).await.code(), Some(0)); - } -} - -/// Signals interrupt an unanswered boot service instead of waiting forever. -#[tokio::test] -async fn signals_work_during_boot() { - for sig in [ - nix::sys::signal::Signal::SIGTERM, - nix::sys::signal::Signal::SIGINT, - ] { - let (_folder, world) = world(); - std::fs::write( - world.join("config/boot.yaml"), - json!([ - {"method":"core.lock","params":{"path":"boot"}}, - {"method":"core.lock","params":{"path":"boot"}} - ]) - .to_string(), - ) - .unwrap(); - let mut child = binary() - .arg("run") - .arg(&world) - .arg("--headless") - .stdin(Stdio::null()) - .spawn() - .unwrap(); - journal_exists(&world).await; - tokio::time::timeout(Duration::from_secs(15), async { - loop { - let db = - rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap(); - if let Ok(2) = db.query_row( - "SELECT count(*) FROM journal WHERE line='service start core.lock'", - [], - |row| row.get::<_, i64>(0), - ) { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - signal(&child, sig); - assert_eq!(status(&mut child).await.code(), Some(0)); - } -} - -/// Piped input supports commands, quit, and the restart exit code. -#[tokio::test] -async fn piped_console_and_exit_codes() { - for (line, supervised, expected) in [ - ("help\n", false, 0), - ("quit\n", false, 0), - ("core.restart\n", false, 0), - ("core.exit\n", false, 0), - ("core.restart\n", true, 75), - ("core.exit\n", true, 75), - ] { - let (_folder, world) = world(); - let mut command = binary(); - command.arg("run").arg(world); - if supervised { - command.env("SAUSAGE_LOOP", "1"); - } - let mut child = command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .unwrap(); - // Supervised calls must finish via restart, not a racing console EOF. - let mut input = child.stdin.take().unwrap(); - input.write_all(line.as_bytes()).await.unwrap(); - if !supervised { - drop(input); - } - let output = tokio::time::timeout(Duration::from_secs(15), child.wait_with_output()) - .await - .unwrap() - .unwrap(); - assert_eq!(output.status.code(), Some(expected)); - if !supervised && line.starts_with("core.") { - assert!(String::from_utf8_lossy(&output.stdout).contains("SAUSAGE_LOOP not set")); - } - if expected == 0 { - assert!(!output.stdout.is_empty()); - } - } -} - -/// A console input error still stops live actors and flushes the journal. -#[tokio::test] -async fn console_error_stops_host() { - let (_folder, world) = world(); - install_probe(&world); - std::fs::write( - world.join("config/boot.yaml"), - json!([{"method":"probe.serve","params":{}}]).to_string(), - ) - .unwrap(); - let mut child = binary() - .arg("run") - .arg(&world) - .stdin(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(&[255, 10]) - .await - .unwrap(); - assert_eq!(status(&mut child).await.code(), Some(1)); - let db = rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap(); - let stopped: i64 = db - .query_row( - "SELECT count(*) FROM journal WHERE plugin='probe' AND line='stop'", - [], - |row| row.get(0), - ) - .unwrap(); - assert!(stopped > 0); -} - -/// SIGINT stops only the wait, so ps can still find the live call. -#[tokio::test] -async fn interrupt_leaves_call_running() { - let (_folder, world) = world(); - install_probe(&world); - let mut child = binary() - .arg("run") - .arg(&world) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .unwrap(); - let mut input = child.stdin.take().unwrap(); - input.write_all(b"probe.busy\n").await.unwrap(); - let pid = tokio::time::timeout(Duration::from_secs(20), async { - loop { - if world.join("logs/core/journal.sqlite").exists() { - let db = - rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap(); - if let Ok(pid) = db.query_row( - "SELECT pid FROM journal WHERE plugin='probe' AND line='start busy {}'", - [], - |r| r.get::<_, String>(0), - ) { - break pid; - } - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - signal(&child, nix::sys::signal::Signal::SIGINT); - input - .write_all(format!("ps\nkill {pid}\nquit\n").as_bytes()) - .await - .unwrap(); - drop(input); - let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output()) - .await - .unwrap() - .unwrap(); - let text = String::from_utf8(output.stdout).unwrap(); - assert_eq!(output.status.code(), Some(0), "{text}"); - assert!( - text.contains("stopped waiting; the call continues, see ps"), - "{text}" - ); - assert!( - text.contains(&format!("{pid} probe busy console running")), - "{text}" - ); -} - -/// Piped calls wait for activation, and EOF or quit preserves the last result. -#[tokio::test] -async fn piped_console_runs_dependent_commands_in_order() { - for ending in ["", "quit\n"] { - let (_folder, world) = world(); - std::fs::copy(probe(), world.join("candidate.wasm")).unwrap(); - std::fs::create_dir_all(world.join("state/probe")).unwrap(); - std::fs::write(world.join("state/probe/discover.json"), r#"{"methods":[]}"#).unwrap(); - let activate = r#"core.activate {"plugin":"notes","wasm":"candidate.wasm","force":true}"#; - let call = r#"notes.echo {"ordered":true}"#; - let mut child = binary() - .arg("run") - .arg(&world) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(format!("{activate}\n{call}\n{ending}").as_bytes()) - .await - .unwrap(); - let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output()) - .await - .unwrap() - .unwrap(); - let stdout = String::from_utf8(output.stdout).unwrap(); - assert_eq!( - output.status.code(), - Some(0), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(!stdout.contains("error:"), "{stdout}"); - assert!(stdout.contains(r#""ordered": true"#), "{stdout}"); - assert!(!stdout.contains("> core.activate"), "{stdout}"); - if !ending.is_empty() { - assert!( - stdout.find(r#""ordered": true"#).unwrap() < stdout.find("Goodbye.").unwrap(), - "{stdout}" - ); - } - } -} diff --git a/cli/tests/host/common.rs b/cli/tests/host/common.rs deleted file mode 100644 index afb571c..0000000 --- a/cli/tests/host/common.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Persistent caches and guest builds shared by the host integration modules. -use std::{ - path::{Path, PathBuf}, - sync::OnceLock, -}; - -pub const CACHE: &str = concat!(env!("CARGO_TARGET_TMPDIR"), "/cache"); - -pub fn build(package: &str, binary: &str) -> PathBuf { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - let target = Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/guests")); - assert!( - std::process::Command::new("cargo") - .args([ - "build", - "--offline", - "--target", - "wasm32-wasip2", - "--manifest-path" - ]) - .arg(root.join(package).join("Cargo.toml")) - .arg("--target-dir") - .arg(target) - .status() - .unwrap() - .success() - ); - target - .join("wasm32-wasip2/debug") - .join(format!("{binary}.wasm")) -} - -pub fn probe() -> PathBuf { - static WASM: OnceLock = OnceLock::new(); - WASM.get_or_init(|| build("cli/tests/plugins/probe", "probe")) - .clone() -} - -pub fn sdk_probe() -> PathBuf { - static WASM: OnceLock = OnceLock::new(); - WASM.get_or_init(|| build("sdk/tests/plugins/probe", "sdk-probe")) - .clone() -} diff --git a/cli/tests/host/console.rs b/cli/tests/host/console.rs deleted file mode 100644 index 3c851ab..0000000 --- a/cli/tests/host/console.rs +++ /dev/null @@ -1,298 +0,0 @@ -//! Console command contracts against a temporary world and the raw probe component. -use super::common::probe as fixture; -use sausage::{ - Host, - console::{execute_line, notification_text}, - world::World, -}; -use serde_json::{Value, json}; -use std::path::PathBuf; -struct TestWorld(PathBuf); -impl TestWorld { - fn new() -> Self { - let root = std::env::temp_dir().join(format!("sausage-console-{}", uuid::Uuid::now_v7())); - let world = World::create(&root).unwrap(); - for plugin in ["probe", "agent"] { - std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap(); - std::fs::copy(fixture(), world.plugin_dir(plugin).join("plugin.wasm")).unwrap(); - } - std::fs::create_dir_all(world.state_dir("probe")).unwrap(); - std::fs::write(world.state_dir("probe").join("discover.json"),json!({"methods":[{"name":"echo","description":"Echo supplied text.","params":[{"name":"text","description":"The input text.","schema":{"type":"string"}}],"result":{"schema":{"type":"string"}}}]}).to_string()).unwrap(); - let mut candidate = std::fs::read(fixture()).unwrap(); - // A custom section creates a new version without changing probe behavior. - candidate.extend_from_slice(&[0, 2, 1, b'c']); - std::fs::write(root.join("candidate.wasm"), candidate).unwrap(); - Self(root) - } - async fn start(&self) -> Host { - let cache = PathBuf::from(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")); - Host::start_with_cache(&self.0, &cache).await.unwrap() - } -} -impl Drop for TestWorld { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} -async fn command(host: &Host, line: &str) -> String { - tokio::time::timeout(std::time::Duration::from_secs(60), execute_line(host, line)) - .await - .unwrap() - .unwrap() -} - -/// Explicit calls return indented JSON data, and RPC errors remain visible. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rpc_call() { - let world = TestWorld::new(); - let host = world.start().await; - let result = command(&host, "probe.echo {\"answer\":42}").await; - assert!(result.contains("\n \"answer\": 42\n")); - assert!(command(&host, "absent.echo {}").await.contains("error:")); - assert_eq!( - serde_json::from_str::(&command(&host, r#"agent.chat {"text":"explicit"}"#).await) - .unwrap(), - json!({"text":"explicit"}) - ); - assert_eq!( - command(&host, "probe.echo not-json").await, - "probe.echo not-json" - ); - host.stop().await; -} -/// Plain text calls agent.chat and its interim notification uses dim styling. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn chat() { - let world = TestWorld::new(); - let host = world.start().await; - let mut notifications = host.subscribe(); - assert_eq!(command(&host, "hello agent").await, "hello agent"); - let value = notifications.recv().await.unwrap(); - assert_eq!(notification_text(&value), "\x1b[2minterim\x1b[0m"); - assert_eq!( - command(&host, "Hello. Another sentence.").await, - "Hello. Another sentence." - ); - host.stop().await; -} -/// Invalid command arguments and dotted prose remain agent chat. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn prose_is_not_a_command() { - let world = TestWorld::new(); - let host = world.start().await; - for line in [ - "help me write a notes plugin", - "ps is useful", - "stop it", - "kill that", - "cat", - "methods probe extra", - "ls too many words", - "log -n nope", - "README.md says hello", - "agent.chat not-json", - "e.g. write notes", - ] { - assert_eq!(command(&host, line).await, line); - } - host.stop().await; -} - -/// Folder listings include the live mounts and accept an explicit folder. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn ls() { - let world = TestWorld::new(); - let host = world.start().await; - let result = command(&host, "ls").await; - for name in ["proc/", "run/", "self/"] { - assert!(result.contains(name)); - } - assert!(command(&host, "ls /proc/core").await.contains("plugins/")); - host.stop().await; -} -/// Cat reads multiple chunks and finite generated snapshots through the VFS. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cat() { - let world = TestWorld::new(); - let host = world.start().await; - let text = "x".repeat(150_000); - std::fs::write(world.0.join("large"), &text).unwrap(); - assert_eq!(command(&host, "cat /large").await, text); - host.call("probe.write", json!({"path":"/run/shared","text":"memory"})) - .await - .unwrap(); - assert_eq!(command(&host, "cat /run/shared").await, "memory"); - assert!( - command(&host, "cat /proc/core/PROTOCOL.md") - .await - .contains("filesystem") - ); - assert!(execute_line(&host, "cat /missing").await.is_err()); - host.stop().await; -} -/// Method summaries include parameter types, result types, and documentation. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn methods() { - let world = TestWorld::new(); - let host = world.start().await; - let result = command(&host, "methods probe").await; - for text in [ - "probe.echo(text: string) -> string", - "Echo supplied text.", - "The input text.", - ] { - assert!(result.contains(text), "{result}"); - } - host.stop().await; -} -/// Ps includes every requested column and the live process metadata. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn ps() { - let world = TestWorld::new(); - let host = world.start().await; - let pid = host.call("probe.serve", json!({})).await.unwrap(); - let text = command(&host, "ps").await; - assert!(text.contains("PID PLUGIN METHOD PARENT STATE AGE")); - assert!(text.contains(pid.as_str().unwrap())); - assert!(text.contains("probe serve console")); - host.stop().await; -} -/// Log formats local times and short pids, while core.log preserves JSON and filters. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn log() { - let world = TestWorld::new(); - std::fs::create_dir_all(world.0.join("plugins/data-logger")).unwrap(); - std::fs::copy(fixture(), world.0.join("plugins/data-logger/plugin.wasm")).unwrap(); - let host = world.start().await; - host.call("data-logger.echo", json!({})).await.unwrap(); - // Finish discovery before comparing two snapshots of the journal. - command(&host, "methods data-logger").await; - let pid = host.call("probe.serve", json!({})).await.unwrap(); - for (line, filters) in [ - ("log".to_owned(), json!({})), - ( - "log data-logger -n 1".to_owned(), - json!({"plugin":"data-logger","limit":1}), - ), - ( - "log probe -n 1".to_owned(), - json!({"plugin":"probe","limit":1}), - ), - ( - format!("log -n 1 {}", pid.as_str().unwrap()), - json!({"pid":pid,"limit":1}), - ), - ] { - let raw = command(&host, &format!("core.log {filters}")).await; - let entries: Value = serde_json::from_str(&raw).unwrap(); - let entries = entries.as_array().unwrap(); - assert!(!entries.is_empty()); - let printed = command(&host, &line).await; - let lines: Vec<_> = printed.lines().collect(); - assert_eq!(lines.len(), entries.len(), "{printed}"); - if line != "log" { - assert_eq!(entries.len(), 1); - } - for (line, entry) in lines.into_iter().zip(entries) { - let time = chrono::DateTime::parse_from_rfc3339(entry["time"].as_str().unwrap()) - .unwrap() - .with_timezone(&chrono::Local) - .format("%H:%M:%S%.3f") - .to_string(); - let plugin = entry["plugin"].as_str().unwrap(); - let pid = entry["pid"].as_str().unwrap(); - let prefix = format!("{plugin}-"); - let short = pid - .strip_prefix(&prefix) - .map(|uuid| format!("{prefix}{}", &uuid[..8])) - .unwrap_or_else(|| pid.to_owned()); - assert_eq!( - line, - format!( - "{} {} {} {}", - time, - short, - entry["stream"].as_str().unwrap(), - entry["line"].as_str().unwrap() - ) - ); - } - } - assert_eq!(command(&host, "log absent-plugin").await, ""); - assert_eq!(command(&host, "log -n").await, "log -n"); - host.stop().await; -} -/// Activate installs a candidate through core.activate. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn activate() { - let world = TestWorld::new(); - let host = world.start().await; - let result = command(&host, "activate probe candidate.wasm").await; - assert!(!result.starts_with("error:"), "{result}"); - assert!(world.0.join("plugins/probe/plugin.prev.wasm").exists()); - host.stop().await; -} -/// Revert restores the previous component through core.revert. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn revert() { - let world = TestWorld::new(); - let host = world.start().await; - command(&host, "activate probe candidate.wasm").await; - let result = command(&host, "revert probe").await; - assert!(!result.starts_with("error:"), "{result}"); - host.stop().await; -} -/// Stop closes a live process gracefully. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stop() { - let world = TestWorld::new(); - let host = world.start().await; - let pid = host.call("probe.serve", json!({})).await.unwrap(); - command(&host, &format!("stop {}", pid.as_str().unwrap())).await; - assert!(!host.processes().contains_key(pid.as_str().unwrap())); - host.stop().await; -} -/// Kill ends a live process immediately. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn kill() { - let world = TestWorld::new(); - let host = world.start().await; - let pid = host.call("probe.serve", json!({})).await.unwrap(); - command(&host, &format!("kill {}", pid.as_str().unwrap())).await; - assert!(!host.processes().contains_key(pid.as_str().unwrap())); - host.stop().await; -} -/// Help lists all console commands. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn help() { - let world = TestWorld::new(); - let host = world.start().await; - let text = command(&host, "help").await; - for name in [ - "agent", - "plugin.method", - "ls", - "cat", - "methods", - "ps", - "log", - "activate", - "revert", - "stop", - "kill", - "help", - "quit", - ] { - assert!(text.contains(name)); - } - host.stop().await; -} -/// Quit produces a final message, and blank input does nothing. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn quit() { - let world = TestWorld::new(); - let host = world.start().await; - assert_eq!(command(&host, "quit").await, "Goodbye."); - assert_eq!(command(&host, " ").await, ""); - host.stop().await; -} diff --git a/cli/tests/host/core.rs b/cli/tests/host/core.rs deleted file mode 100644 index 1ba0285..0000000 --- a/cli/tests/host/core.rs +++ /dev/null @@ -1,2181 +0,0 @@ -//! End-to-end host behavior with a raw WASI guest. -use super::common::probe as fixture; -use sausage::{Host, world::World}; -use serde_json::{Value, json}; -use std::path::PathBuf; -struct TestWorld(PathBuf); -impl TestWorld { - fn new() -> Self { - let p = std::env::temp_dir().join(format!("sausage-{}", uuid::Uuid::now_v7())); - let w = World::create(&p).unwrap(); - std::fs::create_dir_all(w.plugin_dir("probe")).unwrap(); - std::fs::copy(fixture(), w.plugin_dir("probe").join("plugin.wasm")).unwrap(); - Self(p) - } - async fn start(&self) -> Host { - Host::start_with_cache( - &self.0, - std::path::Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")), - ) - .await - .unwrap() - } -} -impl Drop for TestWorld { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} -async fn call(h: &Host, m: &str, p: Value) -> Value { - tokio::time::timeout(std::time::Duration::from_secs(20), h.call(m, p)) - .await - .unwrap() - .unwrap() -} - -/// PROTOCOL 3.3: a closed stdin does not discard a response already on stdout. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn closed_stdin_drains_stdout() { - let w = TestWorld::new(); - std::fs::write(w.0.join("state/early"), "answer").unwrap(); - let h = w.start().await; - assert_eq!( - call(&h, "probe.echo", json!({"large":"x".repeat(2*1024*1024)})).await, - "early" - ); - h.stop().await; -} - -/// PROTOCOL 3.3: failed imports remain process failures, even with initial input in flight. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn failed_imports_preserve_process_status() { - let w = TestWorld::new(); - let path = w.0.join("plugins/probe/plugin.wasm"); - let mut bytes = std::fs::read(&path).unwrap(); - for index in 0..bytes.len() - 5 { - if &bytes[index..index + 5] == b"wasi:" { - bytes[index..index + 5].copy_from_slice(b"fake:"); - } - } - std::fs::write(path, bytes).unwrap(); - let h = w.start().await; - let error = tokio::time::timeout( - std::time::Duration::from_secs(20), - h.call("probe.echo", json!({"large":"x".repeat(2*1024*1024)})), - ) - .await - .unwrap() - .unwrap_err(); - assert_eq!(error["code"], -32002); - assert!(error["data"]["status"].as_str().unwrap().contains("fake:")); - h.stop().await; -} - -/// PROTOCOL 3.3: a closed stdin preserves the actual exit status and final stderr. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn closed_stdin_preserves_failure_details() { - let w = TestWorld::new(); - std::fs::write(w.0.join("state/early"), "fail").unwrap(); - let h = w.start().await; - let error = tokio::time::timeout( - std::time::Duration::from_secs(20), - h.call("probe.echo", json!({"large":"x".repeat(2*1024*1024)})), - ) - .await - .unwrap() - .unwrap_err(); - assert_eq!(error["code"], -32002); - // WASIp2 command.run represents a nonzero exit as its unit error result. - assert_eq!(error["data"]["status"], "exit 1"); - assert!( - error["data"]["lines"] - .as_array() - .unwrap() - .iter() - .any(|line| line["line"] == "last early log") - ); - h.stop().await; -} - -/// PROTOCOL 7: invalid stderr bytes do not stop later log lines or the guest. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stderr_uses_lossy_utf8_and_continues() { - let w = TestWorld::new(); - let h = w.start().await; - let pid = call(&h, "probe.invalid_stderr", json!({})).await; - call(&h, "core.stop", json!({"pid":pid})).await; - let logs = call(&h, "core.log", json!({"pid":pid})).await; - assert!( - logs.as_array() - .unwrap() - .iter() - .any(|line| line["line"] == "\u{fffd}") - ); - assert!( - logs.as_array() - .unwrap() - .iter() - .any(|line| line["line"] == "after invalid UTF-8") - ); - h.stop().await; -} - -/// PROTOCOL 3.3 and 7: stderr drains even while a large initial stdin write blocks. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stderr_flood_does_not_block_stdin() { - let w = TestWorld::new(); - std::fs::write(w.0.join("state/early"), "flood").unwrap(); - let h = w.start().await; - let payload = json!({"large":"x".repeat(1024*1024)}); - assert_eq!(call(&h, "probe.echo", payload.clone()).await, payload); - h.stop().await; - let db = rusqlite::Connection::open(w.0.join("logs/core/journal.sqlite")).unwrap(); - let count: i64 = db - .query_row( - "SELECT count(*) FROM journal WHERE line LIKE 'flood %'", - [], - |row| row.get(0), - ) - .unwrap(); - assert!(count >= 5000); -} - -/// PROTOCOL 3.6: shutdown rejects new processes, including calls after stop returns. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shutdown_rejects_new_processes() { - let w = TestWorld::new(); - let h = w.start().await; - h.stop().await; - assert_eq!( - h.call("probe.echo", json!({})).await.unwrap_err()["code"], - -32600 - ); - assert!(h.processes().is_empty()); -} - -/// PROTOCOL 6: stdout unlock takes effect before the next result reaches its caller. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stdout_unlock_precedes_result() { - let w = TestWorld::new(); - let h = w.start().await; - for _ in 0..10 { - let pid = call(&h, "probe.unlock_answer", json!({})).await; - assert_eq!( - call( - &h, - "core.lock", - json!({"path":"ordered-unlock","wait":false}) - ) - .await, - json!({"held":true}) - ); - call(&h, "core.unlock", json!({"path":"ordered-unlock"})).await; - call(&h, "core.stop", json!({"pid":pid})).await; - } - h.stop().await; -} - -/// PROTOCOL 3.5: a notification-started process has no call to receive progress. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn notification_process_has_no_progress_destination() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!(call(&h, "probe.notification_child", json!({})).await, 0); - h.stop().await; -} - -/// PROTOCOL 3.1: an explicit null error does not replace a successful result. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn null_error_is_success() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!(call(&h, "probe.null_error", json!({})).await, 42); - h.stop().await; -} - -/// PROTOCOL 7: a journal insertion failure does not poison the host or stop later writes. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn journal_recovers_after_sqlite_write_failure() { - let w = TestWorld::new(); - let h = w.start().await; - let db = rusqlite::Connection::open(w.0.join("logs/core/journal.sqlite")).unwrap(); - db.busy_timeout(std::time::Duration::from_secs(5)).unwrap(); - db.execute_batch("CREATE TRIGGER reject_writes BEFORE INSERT ON journal BEGIN SELECT RAISE(FAIL,'test failure'); END;").unwrap(); - assert_eq!( - call(&h, "probe.echo", json!({"failed_log":true})).await, - json!({"failed_log":true}) - ); - call(&h, "core.log", json!({})).await; - let batches: Vec = std::fs::read_dir(w.0.join("logs/core/failed-batches")) - .unwrap() - .map(Result::unwrap) - // The writer atomically publishes .json files; its temporary files can - // still be incomplete while later process-exit entries are spooling. - .filter(|file| file.path().extension().is_some_and(|ext| ext == "json")) - .map(|file| serde_json::from_slice(&std::fs::read(file.path()).unwrap()).unwrap()) - .collect(); - assert!( - batches - .iter() - .flat_map(|batch| batch.as_array().unwrap()) - .any(|entry| entry["line"].as_str().unwrap().contains("failed_log")) - ); - db.execute_batch("DROP TRIGGER reject_writes;").unwrap(); - assert_eq!( - call(&h, "probe.echo", json!({"recovered":true})).await, - json!({"recovered":true}) - ); - let entries = call(&h, "core.log", json!({"grep":"recovered"})).await; - assert!(!entries.as_array().unwrap().is_empty()); - h.stop().await; -} - -/// PROTOCOL 4: trailing slashes preserve live proc folders and generated plugin folders. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn proc_directories_accept_trailing_slashes() { - use sausage_vfs::{Layer, Open}; - let w = TestWorld::new(); - let document = json!({"methods":[]}); - std::fs::create_dir_all(w.0.join("state/probe")).unwrap(); - std::fs::write(w.0.join("state/probe/discover.json"), document.to_string()).unwrap(); - let h = w.start().await; - let view = h.filesystem().await.unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(20), async { - loop { - if view.list("/proc/core/plugins/probe").await.is_ok() { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - let pid = call(&h, "probe.serve", json!({})).await; - for path in [ - "/proc/".to_string(), - "/proc/core/".into(), - "/proc/core/plugins/".into(), - "/proc/core/plugins/probe/".into(), - format!("/proc/{}/", pid.as_str().unwrap()), - ] { - assert!(view.stat(&path, true).await.unwrap().directory); - view.list(&path).await.unwrap(); - view.open( - &path, - Open { - directory: true, - ..Open::default() - }, - ) - .await - .unwrap(); - } - h.stop().await; -} - -/// PROTOCOL 3.4: Unix replacements with the same size and mtime use the new component. -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn component_identity_includes_inode_and_ctime() { - let w = TestWorld::new(); - let path = w.0.join("plugins/probe/plugin.wasm"); - let mut bytes = std::fs::read(&path).unwrap(); - bytes.extend_from_slice(&[0, 2, 1, b'a']); - std::fs::write(&path, &bytes).unwrap(); - let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); - let h = w.start().await; - let old = call(&h, "probe.serve", json!({})).await; - *bytes.last_mut().unwrap() = b'b'; - std::fs::write(&path, &bytes).unwrap(); - std::fs::File::options() - .write(true) - .open(&path) - .unwrap() - .set_times(std::fs::FileTimes::new().set_modified(modified)) - .unwrap(); - let new = call(&h, "probe.serve", json!({})).await; - let table = h.processes(); - assert_ne!( - table[old.as_str().unwrap()].version, - table[new.as_str().unwrap()].version - ); - h.stop().await; -} - -/// PROTOCOL 3.6 and 6: stopping cancels a native process's queued lock request immediately. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stopping_removes_queued_lock_acquisitions() { - use sausage_vfs::{Layer, Open}; - let w = TestWorld::new(); - let h = w.start().await; - call(&h, "core.lock", json!({"path":"queued-stop"})).await; - h.register_native( - "queued", - std::sync::Arc::new(|ctx, _, _| { - Box::pin(async move { - ctx.notify("ready", json!({"pid":ctx.pid})); - ctx.call("core.lock", json!({"path":"queued-stop"})).await - }) - }), - ); - let mut events = h.subscribe(); - let host = h.clone(); - let pending = tokio::spawn(async move { host.call("queued.wait", json!({})).await }); - let event = events.recv().await.unwrap(); - let pid = event["params"]["params"]["pid"].clone(); - let view = h.filesystem().await.unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(10), async { - loop { - let f = view - .open("/proc/core/locks.json", Open::default()) - .await - .unwrap(); - let locks: Value = serde_json::from_slice(&f.read(0, 65536).await.unwrap()).unwrap(); - if locks["queued-stop"]["waiting"] == json!([pid]) { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - tokio::time::timeout( - std::time::Duration::from_secs(1), - h.call("core.stop", json!({"pid":pid})), - ) - .await - .unwrap() - .unwrap(); - assert!(pending.await.unwrap().is_err()); - call(&h, "core.unlock", json!({"path":"queued-stop"})).await; - assert_eq!( - call(&h, "core.lock", json!({"path":"queued-stop","wait":false})).await, - json!({"held":true}) - ); - h.stop().await; -} - -/// PROTOCOL 3.1: json rpc lines carry results. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn json_rpc_lines_carry_results() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!(call(&h, "probe.echo", json!({"x":1})).await, json!({"x":1})); - - h.stop().await; -} - -/// PROTOCOL 3.2: method names split at the first dot. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn method_names_split_at_the_first_dot() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!( - call(&h, "probe.rpc.discover", json!({"x":2})).await, - json!({"x":2}) - ); - - h.stop().await; -} - -/// PROTOCOL 3.3: processes can call other plugins. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn processes_can_call_other_plugins() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!( - call( - &h, - "probe.call", - json!({"method":"probe.echo","params":{"nested":true}}) - ) - .await, - json!({"nested":true}) - ); - - h.stop().await; -} - -/// PROTOCOL 3.3: pending ids distinguish numbers from strings. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn pending_ids_distinguish_numbers_from_strings() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!( - call(&h, "probe.multicall", json!({})).await, - json!({"count":2}) - ); - - h.stop().await; -} - -/// PROTOCOL 3.3: notifications start attached processes. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn notifications_start_attached_processes() { - let w = TestWorld::new(); - let h = w.start().await; - call( - &h, - "probe.notification", - json!({"path":"/state/notification","text":"delivered"}), - ) - .await; - assert_eq!( - std::fs::read_to_string(w.0.join("state/notification")).unwrap(), - "delivered" - ); - - h.stop().await; -} - -/// PROTOCOL 3.3: unknown response ids are dropped. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unknown_response_ids_are_dropped() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!( - h.call("probe.unknownid", json!({})).await.unwrap_err()["code"], - -32002 - ); - - h.stop().await; -} - -/// PROTOCOL 3.8: process environment identifies the caller. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn process_environment_identifies_the_caller() { - let w = TestWorld::new(); - let h = w.start().await; - let env = call(&h, "probe.env", json!({})).await; - assert_eq!(env["plugin"], "probe"); - assert_eq!(env["parent"], "console"); - assert!(env["pid"].as_str().unwrap().starts_with("probe-")); - - h.stop().await; -} - -/// PROTOCOL 3.6: processes read and write the world. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn processes_read_and_write_the_world() { - let w = TestWorld::new(); - let h = w.start().await; - call( - &h, - "probe.write", - json!({"path":"/state/test","text":"hello"}), - ) - .await; - assert_eq!( - call(&h, "probe.read", json!({"path":"/state/test"})).await, - "hello" - ); - - h.stop().await; -} - -/// PROTOCOL 3.9: missing plugins return minus 32001. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn missing_plugins_return_minus_32001() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!( - h.call("absent.run", json!({})).await.unwrap_err()["code"], - -32001 - ); - - h.stop().await; -} - -/// PROTOCOL 9: unimplemented core methods return method not found. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unimplemented_core_methods_return_method_not_found() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!( - h.call("core.unknown", json!({})).await.unwrap_err()["code"], - -32601 - ); - - h.stop().await; -} - -/// PROTOCOL 3.1: params must be objects. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn params_must_be_objects() { - let w = TestWorld::new(); - let h = w.start().await; - for (params, kind) in [ - (json!("{\"text\":\"hello\"}"), "a string"), - (json!([]), "an array"), - (json!(42), "a number"), - (json!(null), "null"), - (json!(true), "a boolean"), - ] { - let expected = json!({ - "code": -32602, - "message": format!("params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string.") - }); - for method in ["probe.echo", "core.ps"] { - assert_eq!(h.call(method, params.clone()).await.unwrap_err(), expected); - } - assert_eq!( - h.call("core.call", json!({"method":"probe.echo", "params":params})) - .await - .unwrap_err(), - expected - ); - } - let object = json!({"text":"hello"}); - assert_eq!(call(&h, "probe.echo", object.clone()).await, object); - assert_eq!( - call( - &h, - "core.call", - json!({"method":"probe.echo", "params":object}) - ) - .await, - object - ); - assert_eq!( - call(&h, "core.call", json!({"method":"probe.echo"})).await, - json!({}) - ); - - h.stop().await; -} - -/// PROTOCOL 3.1: stdout fast paths reject non-objects without changing defaults. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stdout_params_must_be_objects() { - let w = TestWorld::new(); - let h = w.start().await; - let mut notifications = h.subscribe(); - for method in ["core.notify", "core.unlock", "probe.echo"] { - for (params, kind) in [ - (json!("{}"), "a string"), - (json!([]), "an array"), - (json!(1.5), "a number"), - (json!(null), "null"), - (json!(false), "a boolean"), - ] { - let response = call( - &h, - "probe.raw_call", - json!({ - "jsonrpc":"2.0", "id":1, "method":method, "params":params - }), - ) - .await; - assert_eq!(response["id"], 1); - assert_eq!( - response["error"], - json!({ - "code": -32602, - "message": format!("params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string.") - }) - ); - } - let params = match method { - "core.notify" => json!({"name":"valid", "params":{"n":1}}), - "core.unlock" => json!({"path":"/state/not-held"}), - _ => json!({"n":1}), - }; - let response = call( - &h, - "probe.raw_call", - json!({ - "jsonrpc":"2.0", "id":1, "method":method, "params":params - }), - ) - .await; - assert_eq!( - response["result"], - if method == "probe.echo" { - params - } else { - Value::Null - } - ); - assert!(response.get("error").is_none()); - let omitted = call( - &h, - "probe.raw_call", - json!({ - "jsonrpc":"2.0", "id":1, "method":method - }), - ) - .await; - match method { - "core.notify" => { - assert_eq!(omitted["error"]["message"], "name must be a string"); - assert_eq!(notifications.try_recv().unwrap()["params"]["name"], "valid"); - } - "core.unlock" => assert_eq!(omitted["error"]["message"], "path must be a string"), - _ => assert_eq!(omitted["result"], json!({})), - } - } - h.stop().await; -} - -/// PROTOCOL 3.9: exit without answer reports status and last logs. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn exit_without_answer_reports_status_and_last_logs() { - let w = TestWorld::new(); - let h = w.start().await; - let e = h.call("probe.exit", json!({})).await.unwrap_err(); - assert_eq!(e["code"], -32002); - assert!( - e["data"]["lines"] - .as_array() - .unwrap() - .iter() - .any(|l| l["line"] == "last line") - ); - - h.stop().await; -} - -/// PROTOCOL 3.5: progress notifications reach the caller. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn progress_notifications_reach_the_caller() { - let w = TestWorld::new(); - let h = w.start().await; - let mut notifications = h.subscribe(); - call(&h, "probe.notify", json!({})).await; - assert_eq!( - notifications.recv().await.unwrap()["params"]["name"], - "progress" - ); - - h.stop().await; -} - -/// PROTOCOL 3.5: every stdout notification reaches the caller before the following result. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stdout_notifications_precede_the_result() { - let w = TestWorld::new(); - let h = w.start().await; - let mut notifications = h.subscribe(); - for _ in 0..10 { - assert_eq!(call(&h, "probe.burst", json!({"count":64})).await, 64); - let mut call_id = None; - for index in 0..64 { - // A delayed notification fails here, even if it arrives later. - let notification = notifications.try_recv().unwrap(); - assert_eq!(notification["params"]["name"], "burst"); - assert_eq!(notification["params"]["params"]["index"], index); - if let Some(id) = &call_id { - assert_eq!(¬ification["params"]["call"], id); - } else { - call_id = Some(notification["params"]["call"].clone()); - } - } - assert!(matches!( - notifications.try_recv(), - Err(tokio::sync::broadcast::error::TryRecvError::Empty) - )); - } - h.stop().await; -} - -/// PROTOCOL 3.1: non message stdout warns once without its content. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn non_message_stdout_warns_once_without_its_content() { - let w = TestWorld::new(); - let h = w.start().await; - call(&h, "probe.junk", json!({})).await; - tokio::time::timeout(std::time::Duration::from_secs(5), async { - while !h.processes().is_empty() { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - let starts = call(&h, "core.log", json!({"grep":"start junk"})).await; - let pid = &starts[0]["pid"]; - let warnings = call(&h, "core.log", json!({"pid":pid,"grep":"discarded stdout"})).await; - assert_eq!(warnings.as_array().unwrap().len(), 1); - assert!(!warnings.to_string().contains("junk")); - - h.stop().await; -} - -/// PROTOCOL 3.1: stderr lines enter the journal. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stderr_lines_enter_the_journal() { - let w = TestWorld::new(); - let h = w.start().await; - call(&h, "probe.junk", json!({})).await; - h.stop().await; - let log = call(&h, "core.log", json!({"grep":"stderr line"})).await; - assert_eq!(log.as_array().unwrap().len(), 1); - - h.stop().await; -} - -/// PROTOCOL 6: try lock reports held without waiting. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn try_lock_reports_held_without_waiting() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!( - call(&h, "core.lock", json!({"path":"x"})).await, - json!({"held":true}) - ); - assert_eq!( - call(&h, "core.lock", json!({"path":"x","wait":false})).await, - json!({"held":false}) - ); - call(&h, "core.unlock", json!({"path":"x"})).await; - assert_eq!( - call(&h, "probe.lock", json!({"path":"x"})).await, - json!({"held":true}) - ); - - h.stop().await; -} - -/// PROTOCOL 9: kill ends a process at once. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn kill_ends_a_process_at_once() { - let w = TestWorld::new(); - let h = w.start().await; - let pid = call(&h, "probe.serve", json!({})).await; - call(&h, "core.kill", json!({"pid":pid})).await; - assert!(!h.processes().contains_key(pid.as_str().unwrap())); - - h.stop().await; -} - -/// PROTOCOL 9: native calls have process ids and results. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn native_calls_have_process_ids_and_results() { - let w = TestWorld::new(); - let h = w.start().await; - h.register_native( - "native", - std::sync::Arc::new(|ctx, method, params| { - Box::pin(async move { - ctx.notify("native", json!({})); - Ok(json!({"pid":ctx.pid,"method":method,"params":params})) - }) - }), - ); - let result = call(&h, "native.echo", json!({"x":1})).await; - assert_eq!(result["method"], "echo"); - assert!(result["pid"].as_str().unwrap().starts_with("native-")); - - h.stop().await; -} - -/// PROTOCOL 2: reserved world roots prevent startup. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn reserved_world_roots_prevent_startup() { - let w = TestWorld::new(); - let h = w.start().await; - for name in ["proc", "run", "self"] { - std::fs::create_dir(w.0.join(name)).unwrap(); - assert!(World::open(&w.0).is_err()); - std::fs::remove_dir(w.0.join(name)).unwrap(); - } - h.stop().await; -} - -/// PROTOCOL 8: boot waits for each readiness result before the next service. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn boot_waits_for_readiness_in_order() { - let w = TestWorld::new(); - std::fs::write( - w.0.join("config/boot.yaml"), - serde_yaml_ng::to_string(&json!([ - {"method":"probe.write","params":{"path":"/state/boot","text":"ready"}}, - {"method":"probe.read","params":{"path":"/state/boot"}} - ])) - .unwrap(), - ) - .unwrap(); - let h = w.start().await; - assert_eq!( - std::fs::read_to_string(w.0.join("state/boot")).unwrap(), - "ready" - ); - - h.stop().await; -} - -/// PROTOCOL 3.6: attached children stop when the parent ends. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn attached_children_stop_when_the_parent_ends() { - let w = TestWorld::new(); - let h = w.start().await; - let attached = call( - &h, - "probe.call", - json!({"method":"probe.serve","params":{}}), - ) - .await; - tokio::time::timeout(std::time::Duration::from_secs(7), async { - while h.processes().contains_key(attached.as_str().unwrap()) { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - - h.stop().await; -} - -/// PROTOCOL 3.4: detached calls have no parent and outlive the caller. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn detached_calls_have_no_parent_and_outlive_the_caller() { - let w = TestWorld::new(); - let h = w.start().await; - let detached = call( - &h, - "probe.call", - json!({"method":"core.call","params":{"method":"probe.serve","params":{},"detach":true}}), - ) - .await; - assert!(h.processes()[detached.as_str().unwrap()].detached); - call(&h, "core.stop", json!({"pid":detached})).await; - - h.stop().await; -} - -/// PROTOCOL 3.8: busy guests do not block other calls. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn busy_guests_do_not_block_other_calls() { - let w = TestWorld::new(); - let h = w.start().await; - let hc = h.clone(); - let busy = tokio::spawn(async move { hc.call("probe.busy", json!({})).await }); - let pid = tokio::time::timeout(std::time::Duration::from_secs(20), async { - loop { - if let Some((pid, _)) = h.processes().into_iter().find(|(_, p)| p.method == "busy") { - break pid; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert_eq!( - call(&h, "probe.echo", json!({"works":true})).await, - json!({"works":true}) - ); - call(&h, "core.kill", json!({"pid":pid})).await; - assert_eq!(busy.await.unwrap().unwrap_err()["code"], -32002); - - h.stop().await; -} - -/// PROTOCOL 6: same process lock acquisitions queue. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn same_process_lock_acquisitions_queue() { - let w = TestWorld::new(); - let h = w.start().await; - - h.register_native( - "locks", - std::sync::Arc::new(|ctx, _, _| { - Box::pin(async move { - assert_eq!( - ctx.call("core.lock", json!({"path":"fifo"})).await.unwrap(), - json!({"held":true}) - ); - assert_eq!( - ctx.call("core.lock", json!({"path":"fifo","wait":false})) - .await - .unwrap(), - json!({"held":false}) - ); - let mut second = Box::pin(ctx.call("core.lock", json!({"path":"fifo"}))); - std::future::poll_fn(|cx| { - assert!(second.as_mut().poll(cx).is_pending()); - std::task::Poll::Ready(()) - }) - .await; - ctx.call("core.unlock", json!({"path":"fifo"})) - .await - .unwrap(); - assert_eq!(second.await.unwrap(), json!({"held":true})); - Ok(json!({})) - }) - }), - ); - call(&h, "locks.test", json!({})).await; - h.stop().await; - assert_eq!( - call(&h, "core.lock", json!({"path":"fifo","wait":false})).await, - json!({"held":true}) - ); - h.stop().await; -} - -/// PROTOCOL 3.8: tcp udp and dns. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tcp_udp_and_dns() { - let w = TestWorld::new(); - let h = w.start().await; - - use tokio::io::AsyncWriteExt; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let tcp = format!("localhost:{}", listener.local_addr().unwrap().port()); - let udp = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); - let udp_addr = udp.local_addr().unwrap().to_string(); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - stream.write_all(b"tcp").await.unwrap(); - }); - assert_eq!( - call(&h, "probe.network", json!({"tcp":tcp,"udp":udp_addr})).await, - "tcp" - ); - let mut buf = [0; 3]; - udp.recv_from(&mut buf).await.unwrap(); - assert_eq!(&buf, b"udp"); - server.await.unwrap(); - - h.stop().await; -} - -/// PROTOCOL 6: native kill drops future and releases locks. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn native_kill_drops_future_and_releases_locks() { - let w = TestWorld::new(); - let h = w.start().await; - - use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }; - struct Dropped(Arc); - impl Drop for Dropped { - fn drop(&mut self) { - self.0.store(true, Ordering::SeqCst); - } - } - let dropped = Arc::new(AtomicBool::new(false)); - let flag = dropped.clone(); - h.register_native( - "native", - Arc::new(move |ctx, _, _| { - let flag = flag.clone(); - Box::pin(async move { - let _guard = Dropped(flag); - ctx.call("core.lock", json!({"path":"native-lock"})) - .await - .unwrap(); - ctx.notify("ready", json!({"pid":ctx.pid})); - std::future::pending().await - }) - }), - ); - let mut notifications = h.subscribe(); - let hc = h.clone(); - let pending = tokio::spawn(async move { hc.call("native.wait", json!({})).await }); - let notification = - tokio::time::timeout(std::time::Duration::from_secs(10), notifications.recv()) - .await - .unwrap() - .unwrap(); - let pid = ¬ification["params"]["params"]["pid"]; - assert_eq!( - call(&h, "core.lock", json!({"path":"native-lock","wait":false})).await, - json!({"held":false}) - ); - call(&h, "core.kill", json!({"pid":pid})).await; - tokio::time::timeout(std::time::Duration::from_secs(1), async { - while !dropped.load(Ordering::SeqCst) { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert_eq!( - call(&h, "core.lock", json!({"path":"native-lock","wait":false})).await, - json!({"held":true}) - ); - assert_eq!(pending.await.unwrap().unwrap_err()["code"], -32002); - - h.stop().await; -} - -/// PROTOCOL 3.6: host shutdown stops busy processes together after five seconds. -/// PROTOCOL 3.6: core.stop terminates an attached tree within the same five-second bound. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stop_deadlines() { - tokio::join!( - shutdown_stops_busy_processes_in_parallel_after_five_seconds(), - attached_tree_stops_in_parallel(), - ); -} - -/// Checks the shutdown half of the shared deadline test. -async fn shutdown_stops_busy_processes_in_parallel_after_five_seconds() { - let w = TestWorld::new(); - let h = w.start().await; - - let mut tasks = Vec::new(); - for _ in 0..2 { - let hc = h.clone(); - tasks.push(tokio::spawn(async move { - hc.call("probe.busy", json!({})).await - })); - } - tokio::time::timeout(std::time::Duration::from_secs(20), async { - while h - .processes() - .values() - .filter(|p| p.plugin == "probe" && p.method == "busy") - .count() - != 2 - { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - let before = std::time::Instant::now(); - h.stop().await; - assert!(before.elapsed() >= std::time::Duration::from_millis(4900)); - assert!(before.elapsed() < std::time::Duration::from_secs(7)); - for task in tasks { - assert_eq!(task.await.unwrap().unwrap_err()["code"], -32002); - } - assert!(h.processes().is_empty()); - h.stop().await; -} - -/// PROTOCOL 3.6: stopping rejects calls but allows notifications. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stopping_rejects_calls_but_allows_notifications() { - let w = TestWorld::new(); - let h = w.start().await; - - use std::sync::{Arc, Mutex}; - let (tx, rx) = tokio::sync::oneshot::channel(); - let tx = Arc::new(Mutex::new(Some(tx))); - let gate = Arc::new(tokio::sync::Notify::new()); - let release = gate.clone(); - h.register_native( - "native", - Arc::new(move |ctx, _, _| { - let tx = tx.lock().unwrap().take().unwrap(); - let gate = gate.clone(); - Box::pin(async move { - tx.send(ctx.clone()).ok(); - gate.notified().await; - Ok(json!({})) - }) - }), - ); - let hc = h.clone(); - let pending = tokio::spawn(async move { hc.call("native.wait", json!({})).await }); - let ctx = rx.await.unwrap(); - let hc = h.clone(); - let pid = ctx.pid.clone(); - let stop = tokio::spawn(async move { hc.call("core.stop", json!({"pid":pid})).await }); - tokio::time::timeout(std::time::Duration::from_secs(2), async { - while h.processes()[&ctx.pid].state != "stopping" { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert_eq!( - ctx.call("probe.echo", json!({})).await.unwrap_err()["code"], - -32600 - ); - let mut notifications = h.subscribe(); - ctx.call("core.notify", json!({"name":"stop-hook","params":{}})) - .await - .unwrap(); - assert_eq!( - notifications.recv().await.unwrap()["params"]["name"], - "stop-hook" - ); - release.notify_one(); - assert_eq!(pending.await.unwrap().unwrap(), json!({})); - assert_eq!(stop.await.unwrap().unwrap(), json!({})); - - h.stop().await; -} - -/// PROTOCOL 9: stop closes stdin and waits for exit. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stop_closes_stdin_and_waits_for_exit() { - let w = TestWorld::new(); - let h = w.start().await; - let hc = h.clone(); - let mut events = h.subscribe(); - let pending = tokio::spawn(async move { hc.call("probe.wait_eof", json!({})).await }); - // Admission is not readiness: stop may otherwise close stdin before the - // request reaches the guest, so wait for the handler to enter its EOF loop. - let pid = tokio::time::timeout(std::time::Duration::from_secs(20), async { - loop { - let event = events.recv().await.unwrap(); - if event["params"]["name"] == "waiting-eof" { - break event["params"]["params"]["pid"].clone(); - } - } - }) - .await - .unwrap(); - call(&h, "core.stop", json!({"pid":pid})).await; - assert_eq!( - std::fs::read_to_string(w.0.join("state/stopped")).unwrap(), - "yes" - ); - assert_eq!(pending.await.unwrap().unwrap_err()["code"], -32002); - - h.stop().await; -} - -/// PROTOCOL 3.3: process ids are uuidv7 and versions are sha256. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn process_ids_are_uuidv7_and_versions_are_sha256() { - let w = TestWorld::new(); - let h = w.start().await; - use sha2::{Digest, Sha256}; - let pid = call(&h, "probe.serve", json!({"version":1})).await; - let pid = pid.as_str().unwrap(); - let info = h.processes()[pid].clone(); - assert_eq!( - info.version, - format!("{:x}", Sha256::digest(std::fs::read(fixture()).unwrap())) - ); - assert_eq!(info.plugin, "probe"); - assert_eq!(info.method, "serve"); - assert_eq!(info.params, json!({"version":1})); - assert_eq!(info.parent.as_deref(), Some("console")); - assert_eq!(info.state, "running"); - assert!(!info.detached); - assert!(info.start_time > 0); - let uuid = uuid::Uuid::parse_str(pid.strip_prefix("probe-").unwrap()).unwrap(); - assert_eq!(uuid.get_version_num(), 7); - call(&h, "core.stop", json!({"pid":pid})).await; - - h.stop().await; -} - -/// PROTOCOL 7: journal filters and entries persist in sqlite. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn journal_filters_and_entries_persist_in_sqlite() { - let w = TestWorld::new(); - let h = w.start().await; - let pid = call(&h, "probe.serve", json!({})).await; - call(&h, "core.stop", json!({"pid":pid})).await; - let entries = call( - &h, - "core.log", - json!({"plugin":"probe","pid":pid,"limit":2}), - ) - .await; - assert_eq!(entries.as_array().unwrap().len(), 2); - let entry = &entries[0]; - let id = &entry["id"]; - let single = call(&h, "core.log", json!({"since":id,"until":id})).await; - assert_eq!(single, json!([entry])); - h.stop().await; - drop(h); - let h = w.start().await; - assert_eq!( - call(&h, "core.log", json!({"since":id,"until":id})).await, - single - ); - h.stop().await; - assert!( - std::fs::read(w.0.join("logs/core/journal.sqlite")) - .unwrap() - .starts_with(b"SQLite format 3") - ); - h.stop().await; -} - -/// PROTOCOL 3.1: blank stdout lines do not warn. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn blank_stdout_lines_do_not_warn() { - let w = TestWorld::new(); - let h = w.start().await; - call(&h, "probe.blank", json!({})).await; - h.stop().await; - let warnings = call(&h, "core.log", json!({"grep":"discarded stdout"})).await; - assert_eq!(warnings, json!([])); - h.stop().await; -} - -/// PROTOCOL 3.1: large requests and responses cross pipe capacity. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn large_requests_and_responses_cross_pipe_capacity() { - let w = TestWorld::new(); - let h = w.start().await; - let params = json!({"text":"x".repeat(192*1024)}); - assert_eq!(call(&h, "probe.echo", params.clone()).await, params); - let outgoing = json!({"method":"probe.echo","params":params}); - assert_eq!(call(&h, "probe.call", outgoing).await, params); - h.stop().await; -} - -/// PROTOCOL 3.8: two plugins execute calls at the same time. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn two_plugins_execute_calls_at_the_same_time() { - let w = TestWorld::new(); - let h = w.start().await; - let other = w.0.join("plugins/other"); - std::fs::create_dir_all(&other).unwrap(); - std::fs::copy(fixture(), other.join("plugin.wasm")).unwrap(); - let hc = h.clone(); - let slow = tokio::spawn(async move { hc.call("probe.wait_eof", json!({})).await }); - let pid = tokio::time::timeout(std::time::Duration::from_secs(10), async { - while !w.0.join("state/waiting").exists() { - tokio::task::yield_now().await; - } - loop { - if let Some((pid, _)) = h - .processes() - .into_iter() - .find(|(_, p)| p.method == "wait_eof") - { - break pid; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert_eq!( - call(&h, "other.echo", json!({"ready":true})).await, - json!({"ready":true}) - ); - assert!(!slow.is_finished()); - call(&h, "core.stop", json!({"pid":pid})).await; - assert_eq!(slow.await.unwrap().unwrap_err()["code"], -32002); - h.stop().await; -} - -/// PROTOCOL 3.3: request id is the process id. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn request_id_is_the_process_id() { - let w = TestWorld::new(); - let h = w.start().await; - let pid = call(&h, "probe.request_id", json!({})).await; - let uuid = - uuid::Uuid::parse_str(pid.as_str().unwrap().strip_prefix("probe-").unwrap()).unwrap(); - assert_eq!(uuid.get_version_num(), 7); - h.stop().await; -} - -/// PROTOCOL 9: core call returns the target result. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn core_call_returns_the_target_result() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!( - call( - &h, - "core.call", - json!({"method":"probe.echo","params":{"n":1}}) - ) - .await, - json!({"n":1}) - ); - h.stop().await; -} - -/// PROTOCOL 9: core unlock releases the named lock. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn core_unlock_releases_the_named_lock() { - let w = TestWorld::new(); - let h = w.start().await; - call(&h, "core.lock", json!({"path":"unlock"})).await; - call(&h, "core.unlock", json!({"path":"unlock"})).await; - assert_eq!( - call(&h, "core.lock", json!({"path":"unlock","wait":false})).await, - json!({"held":true}) - ); - h.stop().await; -} - -/// PROTOCOL 6: locks do not block file writes. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn locks_do_not_block_file_writes() { - let w = TestWorld::new(); - let h = w.start().await; - call(&h, "core.lock", json!({"path":"/state/advisory"})).await; - call( - &h, - "probe.write", - json!({"path":"/state/advisory","text":"allowed"}), - ) - .await; - assert_eq!( - std::fs::read_to_string(w.0.join("state/advisory")).unwrap(), - "allowed" - ); - h.stop().await; -} - -/// PROTOCOL 6: crashes release all process locks. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn crashes_release_all_process_locks() { - let w = TestWorld::new(); - let h = w.start().await; - h.register_native( - "crash", - std::sync::Arc::new(|ctx, _, _| { - Box::pin(async move { - ctx.call("core.lock", json!({"path":"crash"})) - .await - .unwrap(); - panic!("test crash"); - }) - }), - ); - assert_eq!( - h.call("crash.run", json!({})).await.unwrap_err()["code"], - -32002 - ); - assert_eq!( - call(&h, "core.lock", json!({"path":"crash","wait":false})).await, - json!({"held":true}) - ); - h.stop().await; -} - -/// PROTOCOL 7: journal entries have uuid time identity stream and line. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn journal_entries_have_uuid_time_identity_stream_and_line() { - let w = TestWorld::new(); - let h = w.start().await; - call(&h, "probe.junk", json!({})).await; - h.stop().await; - let entries = call(&h, "core.log", json!({"plugin":"probe"})).await; - for entry in entries.as_array().unwrap() { - assert_eq!( - uuid::Uuid::parse_str(entry["id"].as_str().unwrap()) - .unwrap() - .get_version_num(), - 7 - ); - assert!(entry["time"].as_str().unwrap().contains('T')); - assert!(entry["time"].as_str().unwrap().ends_with('Z')); - assert!(entry["pid"].as_str().unwrap().starts_with("probe-")); - assert!(["stderr", "core"].contains(&entry["stream"].as_str().unwrap())); - assert!(entry["line"].is_string()); - } - h.stop().await; -} - -/// PROTOCOL 7: journal has wal and identity indexes. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn journal_has_wal_and_identity_indexes() { - let w = TestWorld::new(); - let h = w.start().await; - assert!(!w.0.join("state/core/journal.sqlite").exists()); - h.stop().await; - let db = rusqlite::Connection::open(w.0.join("logs/core/journal.sqlite")).unwrap(); - assert_eq!( - db.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0)) - .unwrap(), - "wal" - ); - for name in ["journal_pid", "journal_plugin"] { - assert_eq!( - db.query_row( - "SELECT count(*) FROM sqlite_master WHERE type='index' AND name=?1", - [name], - |r| r.get::<_, i64>(0) - ) - .unwrap(), - 1 - ); - } - h.stop().await; -} - -/// PROTOCOL 6: acquisitions from different processes keep their FIFO positions. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn lock_acquisitions_from_different_processes_are_fifo() { - let w = TestWorld::new(); - let h = w.start().await; - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let gate = std::sync::Arc::new(tokio::sync::Semaphore::new(0)); - let release = gate.clone(); - h.register_native( - "queued", - std::sync::Arc::new(move |ctx, _, _| { - let tx = tx.clone(); - let gate = gate.clone(); - Box::pin(async move { - tx.send(ctx).unwrap(); - gate.acquire().await.unwrap().forget(); - Ok(json!({})) - }) - }), - ); - let first_host = h.clone(); - let first = tokio::spawn(async move { first_host.call("queued.wait", json!({})).await }); - let second_host = h.clone(); - let second = tokio::spawn(async move { second_host.call("queued.wait", json!({})).await }); - let first_ctx = rx.recv().await.unwrap(); - let second_ctx = rx.recv().await.unwrap(); - call(&h, "core.lock", json!({"path":"ordered"})).await; - let mut first_lock = Box::pin(first_ctx.call("core.lock", json!({"path":"ordered"}))); - let mut second_lock = Box::pin(second_ctx.call("core.lock", json!({"path":"ordered"}))); - std::future::poll_fn(|cx| { - assert!(first_lock.as_mut().poll(cx).is_pending()); - assert!(second_lock.as_mut().poll(cx).is_pending()); - std::task::Poll::Ready(()) - }) - .await; - call(&h, "core.unlock", json!({"path":"ordered"})).await; - assert_eq!(first_lock.await.unwrap(), json!({"held":true})); - std::future::poll_fn(|cx| { - assert!(second_lock.as_mut().poll(cx).is_pending()); - std::task::Poll::Ready(()) - }) - .await; - first_ctx - .call("core.unlock", json!({"path":"ordered"})) - .await - .unwrap(); - assert_eq!(second_lock.await.unwrap(), json!({"held":true})); - release.add_permits(2); - first.await.unwrap().unwrap(); - second.await.unwrap().unwrap(); - h.stop().await; -} - -/// PROTOCOL 3.5: notifications after the result do not reach the caller. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn notifications_after_the_result_are_dropped() { - let w = TestWorld::new(); - let h = w.start().await; - let (tx, rx) = tokio::sync::oneshot::channel(); - let tx = std::sync::Mutex::new(Some(tx)); - h.register_native( - "native", - std::sync::Arc::new(move |ctx, _, _| { - tx.lock().unwrap().take().unwrap().send(ctx).ok(); - Box::pin(async { Ok(json!({})) }) - }), - ); - let mut notifications = h.subscribe(); - call(&h, "native.answer", json!({})).await; - let ctx = rx.await.unwrap(); - ctx.notify("late", json!({})); - assert!(matches!( - notifications.try_recv(), - Err(tokio::sync::broadcast::error::TryRecvError::Empty) - )); - h.stop().await; -} - -/// PROTOCOL 3.1: non-UTF-8 stdout is discarded, not treated as blank framing. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn non_utf8_stdout_is_not_blank_framing() { - let w = TestWorld::new(); - let h = w.start().await; - call(&h, "probe.invalid_utf8", json!({})).await; - h.stop().await; - let warnings = call(&h, "core.log", json!({"grep":"discarded stdout"})).await; - assert_eq!(warnings.as_array().unwrap().len(), 1); -} - -/// PROTOCOL 8: a failed service enters the journal and does not prevent later services. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn failed_boot_services_are_logged_and_boot_continues() { - let w = TestWorld::new(); - let services = json!([ - {"method":"absent.start","params":{}}, - {"method":"probe.write","params":{"path":"/state/continued","text":"yes"}} - ]); - std::fs::write( - w.0.join("config/boot.yaml"), - serde_json::to_vec(&services).unwrap(), - ) - .unwrap(); - let h = w.start().await; - assert_eq!( - std::fs::read_to_string(w.0.join("state/continued")).unwrap(), - "yes" - ); - let errors = call( - &h, - "core.log", - json!({"plugin":"core","grep":"no such plugin"}), - ) - .await; - assert_eq!(errors.as_array().unwrap().len(), 1); - h.stop().await; -} - -/// PROTOCOL 8: boot services are detached and remain alive after their readiness result. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn boot_services_are_detached() { - let w = TestWorld::new(); - std::fs::write( - w.0.join("config/boot.yaml"), - br#"[{"method":"probe.serve","params":{}}]"#, - ) - .unwrap(); - let h = w.start().await; - let processes = h.processes(); - let services: Vec<_> = processes.values().filter(|p| p.method == "serve").collect(); - assert_eq!(services.len(), 1); - assert!(services[0].detached); - h.stop().await; -} - -/// PROTOCOL 7: the core records start parameters, stop, kill, and exit status. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn core_lifetime_events_enter_the_journal() { - let w = TestWorld::new(); - let h = w.start().await; - let stopped = call(&h, "probe.serve", json!({"n":1})).await; - call(&h, "core.stop", json!({"pid":stopped})).await; - let killed = call(&h, "probe.tree", json!({"depth":0})).await; - call(&h, "core.kill", json!({"pid":killed})).await; - let entries = call(&h, "core.log", json!({"plugin":"probe","limit":100})).await; - let lines: Vec<_> = entries - .as_array() - .unwrap() - .iter() - .map(|entry| entry["line"].as_str().unwrap()) - .collect(); - assert!(lines.contains(&"start serve {\"n\":1}")); - for line in ["stop", "kill", "exit 0", "killed"] { - assert!(lines.contains(&line), "missing {line}"); - } - h.stop().await; -} - -/// PROTOCOL 4: the root merges world files and mounts, and cross-mount rename fails. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn filesystem_root_and_mount_boundaries() { - let w = TestWorld::new(); - let h = w.start().await; - call(&h, "probe.filesystem", json!({"topic":"root"})).await; - assert_eq!( - std::fs::read_to_string(w.0.join("state/root")).unwrap(), - "world" - ); - h.stop().await; -} - -/// PROTOCOL 4: a process publishes self files read-only under its own live proc folder. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn filesystem_self_and_proc_lifetime() { - use sausage_vfs::Layer; - let w = TestWorld::new(); - let h = w.start().await; - let view = h.filesystem().await.unwrap(); - let pid = call(&h, "probe.filesystem", json!({"topic":"self"})).await; - call(&h, "core.stop", json!({"pid":pid})).await; - assert!( - view.stat(&format!("/proc/{}", pid.as_str().unwrap()), true) - .await - .is_err() - ); - let live = call(&h, "probe.self_serve", json!({})).await; - call(&h, "probe.filesystem", json!({"topic":"isolated"})).await; - assert_eq!( - call( - &h, - "probe.read", - json!({"path":format!("/proc/{}/endpoint",live.as_str().unwrap())}) - ) - .await, - "endpoint" - ); - call(&h, "core.stop", json!({"pid":live})).await; - assert!( - view.stat(&format!("/proc/{}", live.as_str().unwrap()), true) - .await - .is_err() - ); - h.stop().await; -} - -/// PROTOCOL 4: core-owned self names reject creation and replacement. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn filesystem_reserved_self_names() { - let w = TestWorld::new(); - let h = w.start().await; - call(&h, "probe.filesystem", json!({"topic":"reserved"})).await; - h.stop().await; -} - -/// PROTOCOL 4: generated logs capture a finite snapshot at open. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn filesystem_log_snapshot_ends() { - let w = TestWorld::new(); - let h = w.start().await; - call(&h, "probe.filesystem", json!({"topic":"snapshot"})).await; - h.stop().await; -} - -/// PROTOCOL 4: proc/core provides protocol, graph, lock, and plugin inspection files. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn filesystem_core_inspection_files() { - let w = TestWorld::new(); - let h = w.start().await; - call(&h, "probe.filesystem", json!({"topic":"core"})).await; - h.stop().await; -} - -/// PROTOCOL 4: proc/core exposes each discovered document and its generated Rust client. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn filesystem_discovery_document_and_client() { - use sausage_vfs::{Layer, Open}; - let w = TestWorld::new(); - let document = - json!({"methods":[{"name":"echo","params":[],"result":{"schema":{"type":"string"}}}]}); - std::fs::create_dir_all(w.0.join("state/probe")).unwrap(); - std::fs::write(w.0.join("state/probe/discover.json"), document.to_string()).unwrap(); - let h = w.start().await; - let view = h.filesystem().await.unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(20), async { - loop { - if view - .open("/proc/core/plugins/probe/discover.json", Open::default()) - .await - .is_ok() - { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - let published = call( - &h, - "probe.read", - json!({"path":"/proc/core/plugins/probe/discover.json"}), - ) - .await; - assert_eq!( - serde_json::from_str::(published.as_str().unwrap()).unwrap(), - document - ); - let client = call( - &h, - "probe.read", - json!({"path":"/proc/core/plugins/probe/client.rs"}), - ) - .await; - assert_eq!( - client, - sausage_interface::client_rs("probe", &document).unwrap() - ); - h.stop().await; -} - -/// PROTOCOL 4: run is shared across processes but empty after a host restart. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn filesystem_run_is_shared_and_ephemeral() { - use sausage_vfs::Layer; - let w = TestWorld::new(); - let h = w.start().await; - call( - &h, - "probe.write", - json!({"path":"/run/shared","text":"yes"}), - ) - .await; - assert_eq!( - call(&h, "probe.read", json!({"path":"/run/shared"})).await, - "yes" - ); - h.stop().await; - let h = w.start().await; - assert!( - h.filesystem() - .await - .unwrap() - .list("/run") - .await - .unwrap() - .is_empty() - ); - h.stop().await; -} - -/// PROTOCOL 6: no write from a killed process lands after the next holder acquires its lock. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn filesystem_kill_drains_writes_before_lock_transfer() { - let w = TestWorld::new(); - let h = w.start().await; - let pid = call(&h, "probe.write_loop", json!({})).await; - let next = h.clone(); - let waiter = tokio::spawn(async move { call(&next, "probe.locked_write", json!({})).await }); - use sausage_vfs::{Layer, Open}; - let view = h.filesystem().await.unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(20), async { - loop { - let file = view - .open("/proc/core/locks.json", Open::default()) - .await - .unwrap(); - let locks: Value = serde_json::from_slice(&file.read(0, 65536).await.unwrap()).unwrap(); - if locks["/state/loop"]["waiting"] - .as_array() - .is_some_and(|queue| !queue.is_empty()) - { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - call(&h, "core.kill", json!({"pid":pid})).await; - waiter.await.unwrap(); - assert_eq!( - std::fs::read_to_string(w.0.join("state/loop")).unwrap(), - "new" - ); - h.stop().await; -} - -/// PROTOCOL 4: native processes appear in proc and locks.json identifies holders and waiters. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn filesystem_native_processes_and_lock_snapshot() { - use sausage_vfs::{Layer, Open}; - let w = TestWorld::new(); - let h = w.start().await; - h.register_native( - "native", - std::sync::Arc::new(|ctx, _, _| { - Box::pin(async move { - ctx.call("core.lock", json!({"path":"live"})).await.unwrap(); - ctx.notify("ready", json!({"pid":ctx.pid})); - std::future::pending().await - }) - }), - ); - let mut events = h.subscribe(); - let host = h.clone(); - let task = tokio::spawn(async move { host.call("native.wait", json!({})).await }); - let event = events.recv().await.unwrap(); - let pid = event["params"]["params"]["pid"].as_str().unwrap(); - let view = h.filesystem().await.unwrap(); - assert!( - view.list("/proc") - .await - .unwrap() - .iter() - .any(|e| e.name == pid) - ); - let info = call( - &h, - "probe.read", - json!({"path":format!("/proc/{pid}/info.json")}), - ) - .await; - assert_eq!( - serde_json::from_str::(info.as_str().unwrap()).unwrap()["version"], - "native" - ); - let host = h.clone(); - let waiter = tokio::spawn(async move { host.call("core.lock", json!({"path":"live"})).await }); - tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - let file = view - .open("/proc/core/locks.json", Open::default()) - .await - .unwrap(); - let locks: Value = serde_json::from_slice(&file.read(0, 65536).await.unwrap()).unwrap(); - if locks["live"]["waiting"] == json!(["console"]) { - assert_eq!(locks["live"]["owner"], pid); - break; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - call(&h, "core.kill", json!({"pid":pid})).await; - assert_eq!(waiter.await.unwrap().unwrap(), json!({"held":true})); - assert!(task.await.unwrap().is_err()); - assert!(view.stat(&format!("/proc/{pid}"), true).await.is_err()); - h.stop().await; -} - -/// PROTOCOL 2: invalid plugin names cannot route calls, and core cannot be shadowed. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn plugin_names_and_core_reservation() { - let w = TestWorld::new(); - let h = w.start().await; - for name in ["Upper", "bad!", "with space", "é", "", "../probe"] { - h.register_native( - name, - std::sync::Arc::new(|_, _, _| Box::pin(async { Ok(json!("bad")) })), - ); - assert_eq!( - h.call(&format!("{name}.echo"), json!({})) - .await - .unwrap_err()["code"], - -32601 - ); - } - h.register_native( - "core", - std::sync::Arc::new(|_, _, _| Box::pin(async { Ok(json!("bad")) })), - ); - assert_eq!( - h.call("core.unknown", json!({})).await.unwrap_err()["code"], - -32601 - ); - for name in ["a-z_09", "0"] { - h.register_native( - name, - std::sync::Arc::new(|_, _, _| Box::pin(async { Ok(json!(true)) })), - ); - assert_eq!(call(&h, &format!("{name}.echo"), json!({})).await, true); - } - h.stop().await; -} - -/// PROTOCOL 3.1: a batch on guest stdout does not execute any of its requests. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn batches_are_discarded() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!(call(&h, "probe.batch", json!({})).await, true); - h.stop().await; - assert!(!w.0.join("state/batch").exists()); - assert_eq!( - call(&h, "core.log", json!({"grep":"discarded stdout"})) - .await - .as_array() - .unwrap() - .len(), - 1 - ); -} - -/// PROTOCOL 3.3: a second response to an answered call cannot replace its result. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn late_responses_are_dropped() { - let w = TestWorld::new(); - let h = w.start().await; - assert_eq!(call(&h, "probe.late_answer", json!({})).await, 42); - assert_eq!( - call(&h, "probe.echo", json!({"next":true})).await, - json!({"next":true}) - ); - h.stop().await; -} - -/// PROTOCOL 3.4: core.call notifications forget detached children after the caller ends. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn notification_core_call_forgets_detached_child() { - let w = TestWorld::new(); - let h = w.start().await; - let parent = call(&h, "probe.forget", json!({})).await; - let child: Value = tokio::time::timeout(std::time::Duration::from_secs(10), async { - loop { - if let Ok(bytes) = std::fs::read(w.0.join("state/forgotten")) - && let Ok(value) = serde_json::from_slice::(&bytes) - { - break value; - } - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(10), async { - while h.processes().contains_key(parent["pid"].as_str().unwrap()) { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert!(h.processes()[child["pid"].as_str().unwrap()].detached); - assert!( - h.processes()[child["pid"].as_str().unwrap()] - .parent - .is_none() - ); - call(&h, "core.stop", json!({"pid":child["pid"]})).await; - assert!(!h.processes().contains_key(child["pid"].as_str().unwrap())); - h.stop().await; -} - -/// Checks the attached-tree half of the shared deadline test. -async fn attached_tree_stops_in_parallel() { - let w = TestWorld::new(); - let h = w.start().await; - let root = call(&h, "probe.tree", json!({"depth":2})).await; - assert_eq!( - h.processes() - .values() - .filter(|p| p.method == "tree") - .count(), - 3 - ); - let before = std::time::Instant::now(); - call(&h, "core.stop", json!({"pid":root})).await; - tokio::time::timeout(std::time::Duration::from_secs(2), async { - while h.processes().values().any(|p| p.method == "tree") { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert!(before.elapsed() >= std::time::Duration::from_millis(4900)); - assert!(before.elapsed() < std::time::Duration::from_secs(7)); - h.stop().await; -} - -/// PROTOCOL 3.8: a nested call receives its caller's process ID as SAUSAGE_PARENT. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn nested_parent_environment() { - let w = TestWorld::new(); - let h = w.start().await; - let result = call(&h, "probe.nested_environment", json!({})).await; - assert_eq!(result["child"]["parent"], result["caller"]); - assert_ne!(result["child"]["pid"], result["caller"]); - h.stop().await; -} - -/// PROTOCOL 8 and 3.8: boot services receive core as SAUSAGE_PARENT. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn service_parent_environment() { - let w = TestWorld::new(); - std::fs::write( - w.0.join("config/boot.yaml"), - br#"[{"method":"probe.forgotten","params":{}}]"#, - ) - .unwrap(); - let h = w.start().await; - let env: Value = - serde_json::from_slice(&std::fs::read(w.0.join("state/forgotten")).unwrap()).unwrap(); - assert_eq!(env["parent"], "core"); - assert_eq!(env["plugin"], "probe"); - assert!(h.processes()[env["pid"].as_str().unwrap()].detached); - h.stop().await; -} - -/// PROTOCOL 4: info.json exposes every field of the live process snapshot. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn info_json_has_all_process_fields() { - let w = TestWorld::new(); - let h = w.start().await; - let params = json!({"marker":"complete"}); - let pid = call(&h, "probe.serve", params.clone()).await; - let text = call( - &h, - "probe.read", - json!({"path":format!("/proc/{}/info.json", pid.as_str().unwrap())}), - ) - .await; - let info: Value = serde_json::from_str(text.as_str().unwrap()).unwrap(); - let expected = serde_json::to_value(&h.processes()[pid.as_str().unwrap()]).unwrap(); - assert_eq!(info, expected); - for field in [ - "plugin", - "version", - "method", - "params", - "parent", - "detached", - "start_time", - "state", - ] { - assert!(info.get(field).is_some(), "{field}"); - } - assert_eq!(info["params"], params); - assert_eq!(info["parent"], "console"); - assert_eq!(info["state"], "running"); - h.stop().await; -} - -/// PROTOCOL 3.6: detached processes persist until stop, kill, or host shutdown explicitly ends them. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn detached_processes_need_explicit_termination() { - let w = TestWorld::new(); - let h = w.start().await; - let mut pids = Vec::new(); - for _ in 0..3 { - let pid = call( - &h, - "core.call", - json!({"method":"probe.serve","params":{},"detach":true}), - ) - .await; - assert!(h.processes()[pid.as_str().unwrap()].parent.is_none()); - pids.push(pid); - } - call(&h, "probe.echo", json!({})).await; - for pid in &pids { - assert!(h.processes().contains_key(pid.as_str().unwrap())); - } - call(&h, "core.stop", json!({"pid":pids[0]})).await; - call(&h, "core.kill", json!({"pid":pids[1]})).await; - assert!(h.processes().contains_key(pids[2].as_str().unwrap())); - h.stop().await; - for pid in pids { - assert!(!h.processes().contains_key(pid.as_str().unwrap())); - } -} - -/// PROTOCOL 9: restart answers the detached caller, then uses normal stop and status 75. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn core_restart_cleanly_stops_detached_caller() { - tokio::time::timeout(std::time::Duration::from_secs(30), async { - let w = TestWorld::new(); - std::fs::write( - w.0.join("config/boot.yaml"), - br#"[{"method":"probe.restart","params":{}}]"#, - ) - .unwrap(); - let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_sausage")) - .env("SAUSAGE_LOOP", "1") - .arg("--headless") - .arg("--cache-dir") - .arg(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")) - .arg("run") - .arg(&w.0) - .stdin(std::process::Stdio::null()) - .kill_on_drop(true) - .output() - .await - .unwrap(); - assert_eq!( - output.status.code(), - Some(75), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - std::fs::read_to_string(w.0.join("state/restart-answer")).unwrap(), - "received" - ); - assert_eq!( - std::fs::read_to_string(w.0.join("state/restart-stop")).unwrap(), - "persisted after EOF" - ); - let db = rusqlite::Connection::open(w.0.join("logs/core/journal.sqlite")).unwrap(); - let count: i64 = db - .query_row( - "SELECT count(*) FROM journal WHERE line = 'restart stop hook finished'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(count, 1); - let killed: i64 = db - .query_row( - "SELECT count(*) FROM journal WHERE line = 'kill'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(killed, 0); - }) - .await - .unwrap(); -} - -/// PROTOCOL 9: supervised core.exit from boot uses the restart shutdown path and code 75. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn core_exit_alias_sets_executable_status_75() { - let w = TestWorld::new(); - std::fs::write( - w.0.join("config/boot.yaml"), - br#"[{"method":"core.exit","params":{}}]"#, - ) - .unwrap(); - let output = tokio::time::timeout( - std::time::Duration::from_secs(30), - tokio::process::Command::new(env!("CARGO_BIN_EXE_sausage")) - .arg("--headless") - .arg("--cache-dir") - .arg(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")) - .arg("run") - .arg(&w.0) - .env("SAUSAGE_LOOP", "1") - .stdin(std::process::Stdio::null()) - .kill_on_drop(true) - .output(), - ) - .await - .unwrap() - .unwrap(); - assert_eq!( - output.status.code(), - Some(75), - "{}", - String::from_utf8_lossy(&output.stderr) - ); -} - -/// PROTOCOL 7: logs move, state and the old journal do not; core.log merges both. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn journal_location_preserves_legacy_history() { - let w = TestWorld::new(); - let old = w.0.join("state/core/journal.sqlite"); - let db = rusqlite::Connection::open(&old).unwrap(); - db.execute_batch("CREATE TABLE journal (id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT); - INSERT INTO journal VALUES ('0000','old time','old-pid','old-plugin','core','legacy history');").unwrap(); - drop(db); - let before = std::fs::read(&old).unwrap(); - let history = w.0.join("state/core/console-history"); - std::fs::write(&history, "keep console history").unwrap(); - let h = w.start().await; - call(&h, "probe.echo", json!({})).await; - assert!(w.0.join("logs/core/journal.sqlite").exists()); - let entries = call(&h, "core.log", json!({"limit":-1})).await; - assert_eq!(entries[0]["line"], "legacy history"); - assert!(entries.as_array().unwrap().len() > 1); - assert_eq!( - call(&h, "core.log", json!({"pid":"old-pid"})).await[0]["line"], - "legacy history" - ); - h.stop().await; - assert_eq!(std::fs::read(&old).unwrap(), before); - assert_eq!( - std::fs::read_to_string(history).unwrap(), - "keep console history" - ); -} - -/// PROTOCOL 7: the journal retains the full trap, not just its short status reason. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn full_trap_diagnostic_is_journaled() { - let w = TestWorld::new(); - let h = w.start().await; - let error = tokio::time::timeout( - std::time::Duration::from_secs(20), - h.call("probe.trap", json!({})), - ) - .await - .unwrap() - .unwrap_err(); - assert_eq!(error["code"], -32002); - assert!( - error["data"]["status"] - .as_str() - .unwrap() - .starts_with("trap: wasm trap:") - ); - let entries = call( - &h, - "core.log", - json!({"plugin":"probe","grep":"wasm backtrace"}), - ) - .await; - assert!( - entries.as_array().unwrap().iter().any(|e| { - let text = e["line"].as_str().unwrap(); - text.contains("Caused by:") && text.contains("wasm trap:") - }), - "{entries}" - ); - h.stop().await; -} diff --git a/cli/tests/host/main.rs b/cli/tests/host/main.rs deleted file mode 100644 index d0d6732..0000000 --- a/cli/tests/host/main.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Host integration contracts run in one parallel libtest pool. -mod activation; -mod agent; -mod cargo; -mod cli; -mod common; -mod console; -mod core; -mod sdk; -mod watches; diff --git a/cli/tests/host/sdk.rs b/cli/tests/host/sdk.rs deleted file mode 100644 index f405086..0000000 --- a/cli/tests/host/sdk.rs +++ /dev/null @@ -1,399 +0,0 @@ -//! Exercise SDK plugins through the real host, without a fake core. -use sausage::{Host, world::World}; -use serde_json::{Value, json}; -use std::{path::PathBuf, sync::OnceLock, time::Duration}; - -fn fixtures() -> &'static PathBuf { - static TARGET: OnceLock = OnceLock::new(); - TARGET.get_or_init(|| { - for (package, binary) in [ - ("examples/notes", "notes"), - ("examples/consumer", "consumer"), - ("examples/www", "www"), - ("examples/secrets", "secrets"), - ("sdk/tests/plugins/consumer", "sdk-consumer"), - ] { - super::common::build(package, binary); - } - super::common::sdk_probe().parent().unwrap().to_owned() - }) -} -struct TestWorld(PathBuf); -impl TestWorld { - fn new() -> Self { - let path = std::env::temp_dir().join(format!("sausage-sdk-{}", uuid::Uuid::now_v7())); - let world = World::create(&path).unwrap(); - for (plugin, binary) in [ - ("notes", "notes"), - ("consumer", "consumer"), - ("www", "www"), - ("secrets", "secrets"), - ("probe", "sdk-probe"), - ("typed", "sdk-consumer"), - ] { - std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap(); - std::fs::copy( - fixtures().join(format!("{binary}.wasm")), - world.plugin_dir(plugin).join("plugin.wasm"), - ) - .unwrap(); - } - Self(path) - } - async fn start(&self) -> Host { - Host::start_with_cache( - &self.0, - std::path::Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")), - ) - .await - .unwrap() - } -} -impl Drop for TestWorld { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} -async fn call(host: &Host, method: &str, params: Value) -> Value { - tokio::time::timeout(Duration::from_secs(60), host.call(method, params)) - .await - .unwrap() - .unwrap() -} - -/// Concurrent read-modify-write calls retain every note under the SDK lock. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn notes_lock_preserves_parallel_additions() { - let world = TestWorld::new(); - let host = world.start().await; - assert_eq!( - call(&host, "notes.add", json!({"text":"first"})).await["count"], - 1 - ); - let calls = (0..12) - .map(|n| { - let h = host.clone(); - tokio::spawn( - async move { call(&h, "notes.add", json!({"text":format!("note-{n}")})).await }, - ) - }) - .collect::>(); - for task in calls { - task.await.unwrap(); - } - let notes = call(&host, "notes.list", json!({})).await; - assert_eq!(notes.as_array().unwrap().len(), 13); - assert_eq!(notes[0], "first"); - for n in 0..12 { - assert!( - notes - .as_array() - .unwrap() - .contains(&json!(format!("note-{n}"))) - ); - } - host.stop().await; -} -/// The example consumer invokes notes through its generated import. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn generated_notes_import() { - let world = TestWorld::new(); - let host = world.start().await; - assert_eq!( - call(&host, "consumer.note", json!({"text":"typed"})).await, - 1 - ); - assert_eq!(call(&host, "notes.list", json!({})).await, json!(["typed"])); - host.stop().await; -} -/// Discovery retains the complete document and satisfies the consumer contract. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn discovery_is_compatible() { - let world = TestWorld::new(); - let host = world.start().await; - let actual = call(&host, "notes.rpc.discover", json!({})).await; - let consumer = call(&host, "consumer.rpc.discover", json!({})).await; - let expected: Value = serde_json::from_str(include_str!( - "../../../examples/consumer/imports/notes.json" - )) - .unwrap(); - assert_eq!(actual, expected); - assert_eq!(actual["openrpc"], "1.2.6"); - assert!(actual["components"]["schemas"]["AddParams"].is_object()); - assert!( - actual["methods"] - .as_array() - .unwrap() - .iter() - .find(|m| m["name"] == "list") - .unwrap() - .get("x-sausage-params") - .is_none() - ); - assert!(actual["components"]["schemas"].get("Empty").is_none()); - assert!( - sausage_interface::compat::check(&consumer["x-sausage-imports"]["notes"], &actual) - .is_empty() - ); - host.stop().await; -} -/// Detached SDK calls receive progress and forward it through core.notify. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn detached_call_notifications() { - let world = TestWorld::new(); - let host = world.start().await; - let mut events = host.subscribe(); - assert_eq!(call(&host, "probe.relay", json!({})).await, 42); - let event = tokio::time::timeout(Duration::from_secs(5), events.recv()) - .await - .unwrap() - .unwrap(); - assert_eq!(event["params"]["name"], "step"); - host.stop().await; -} -/// Detached typed calls complete and detached services survive their original caller. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn detached_service_survives_parent() { - let world = TestWorld::new(); - let host = world.start().await; - assert_eq!(call(&host, "typed.run", json!({})).await, 5); - let child = call(&host, "probe.launch", json!({})).await; - tokio::time::timeout(Duration::from_secs(5), async { - while host.processes().values().any(|p| p.method == "launch") { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert!(host.processes().contains_key(child.as_str().unwrap())); - host.stop().await; -} -/// A core.stop request closes stdin and executes the SDK stop hook. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn stop_runs_hook() { - let world = TestWorld::new(); - let host = world.start().await; - let pid = call(&host, "probe.service", json!({})).await; - call(&host, "core.stop", json!({"pid":pid})).await; - assert_eq!(std::fs::read(world.0.join("stopped")).unwrap(), b"yes"); - host.stop().await; -} -/// PROTOCOL 3.7: an idle SDK listener opts into drain and exits before EOF/grace. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn superseded_idle_listener_drains_before_grace() { - let world = TestWorld::new(); - let host = world.start().await; - let (notice, mut notices) = tokio::sync::mpsc::unbounded_channel(); - host.register_native( - "other", - std::sync::Arc::new(move |_, method, _| { - let notice = notice.clone(); - Box::pin(async move { - assert_eq!(method, "superseded"); - notice.send(std::time::Instant::now()).unwrap(); - Ok(json!({})) - }) - }), - ); - let pid = call(&host, "probe.drain_service", json!({})).await; - // Replace with a different real component; force only skips unrelated test consumers. - std::fs::copy( - fixtures().join("notes.wasm"), - world.0.join("state/next.wasm"), - ) - .unwrap(); - call( - &host, - "core.activate", - json!({"plugin":"probe","wasm":"/state/next.wasm","force":true}), - ) - .await; - // Activation preparation/discovery is outside upgrade grace. Time from the - // hook's outgoing call, not from the start of candidate compilation. - let before = tokio::time::timeout(Duration::from_secs(60), notices.recv()) - .await - .unwrap() - .unwrap(); - // Waiting on its lifetime lock is an exit event, not process-liveness polling. - call(&host, "core.lock", json!({"path":"/drain-owner"})).await; - assert!(before.elapsed() < Duration::from_secs(5)); - assert!(!host.processes().contains_key(pid.as_str().unwrap())); - assert!( - !world - .0 - .join(format!("stopped-{}", pid.as_str().unwrap())) - .exists(), - "drain must not run on_stop (discovery processes may run their own hooks)" - ); - host.stop().await; -} - -/// SDK framing carries payloads above 64 KiB in both directions. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn large_messages() { - let world = TestWorld::new(); - let host = world.start().await; - let text = "z".repeat(192 * 1024); - assert_eq!(call(&host, "probe.echo", json!({"text":text})).await, text); - host.stop().await; -} -/// Normal SDK stdout never produces the discarded-output journal warning. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn no_discarded_stdout() { - let world = TestWorld::new(); - let host = world.start().await; - call(&host, "probe.echo", json!({"text":"framing"})).await; - host.stop().await; - assert_eq!( - call(&host, "core.log", json!({"grep":"discarded stdout"})).await, - json!([]) - ); -} -/// Two SDK processes exchange endpoint requests and progress at a world path. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn world_endpoint() { - let world = TestWorld::new(); - let host = world.start().await; - call(&host, "probe.service", json!({})).await; - assert_eq!(call(&host, "typed.world_endpoint", json!({})).await, 7); - host.stop().await; -} -/// The HTTP example exposes its self endpoint through the process namespace. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn www_control_through_proc() { - let world = TestWorld::new(); - let host = world.start().await; - assert_eq!( - call(&host, "consumer.demo", json!({"text":"web","port":0})).await, - 0 - ); - host.stop().await; -} -/// The vault example publishes a fixed endpoint in the shared run namespace. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn secrets_fixed_endpoint() { - let world = TestWorld::new(); - let host = world.start().await; - call(&host, "secrets.serve", json!({})).await; - assert_eq!(call(&host, "consumer.vault", json!({})).await, "hello"); - host.stop().await; -} - -/// PROTOCOL 3.6: a background task retains its SDK process until the task completes. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn background_task_retains_process() { - let world = TestWorld::new(); - let host = world.start().await; - let pid = call(&host, "probe.background_hold", json!({})).await; - assert!(host.processes().contains_key(pid.as_str().unwrap())); - assert_eq!(call(&host, "notes.list", json!({})).await, json!([])); - assert!(host.processes().contains_key(pid.as_str().unwrap())); - std::fs::write(world.0.join("release-background"), "release").unwrap(); - tokio::time::timeout(Duration::from_secs(10), async { - while host.processes().contains_key(pid.as_str().unwrap()) { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - assert_eq!( - std::fs::read_to_string(world.0.join("background-ended")).unwrap(), - "done" - ); - host.stop().await; -} - -/// PROTOCOL 5: stale endpoint identities and interfaces fail through genuine SDK processes. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn endpoint_handshake_rejects_mismatch() { - let world = TestWorld::new(); - let host = world.start().await; - let pid = call(&host, "probe.service", json!({})).await; - let path = world.0.join("state/control"); - let original = std::fs::read_to_string(&path).unwrap(); - let words: Vec<_> = original.split_whitespace().collect(); - for changed in [ - format!("{} wrong-owner {}\n", words[0], words[2]), - format!("{} {} wrong-interface\n", words[0], words[1]), - ] { - std::fs::write(&path, changed).unwrap(); - let error = host - .call("typed.world_endpoint", json!({})) - .await - .unwrap_err(); - assert_eq!(error["code"], -32000); - assert!(error["message"].as_str().unwrap().contains("mismatch")); - assert!(host.processes().contains_key(pid.as_str().unwrap())); - } - std::fs::write(path, original).unwrap(); - assert_eq!(call(&host, "typed.world_endpoint", json!({})).await, 7); - host.stop().await; -} - -/// PROTOCOL 10.1 and 5: discovery through core retains params, endpoints, imports, and grace. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn discovery_extensions_through_core() { - let world = TestWorld::new(); - let host = world.start().await; - let provider = call(&host, "probe.rpc.discover", json!({})).await; - assert_eq!(provider["openrpc"], "1.2.6"); - assert_eq!(provider["x-sausage-grace"], 12); - assert!( - provider["x-sausage-endpoints"]["control"]["methods"] - .as_array() - .unwrap() - .iter() - .any(|m| m["name"] == "stats") - ); - let add = provider["methods"] - .as_array() - .unwrap() - .iter() - .find(|m| m["name"] == "add") - .unwrap(); - assert_eq!(add["paramStructure"], "by-name"); - let reference = add["x-sausage-params"]["$ref"].as_str().unwrap(); - assert_eq!( - provider.pointer(reference.trim_start_matches('#')).unwrap()["type"], - "object" - ); - let consumer = call(&host, "typed.rpc.discover", json!({})).await; - assert!( - consumer["x-sausage-imports"]["probe"]["methods"] - .as_array() - .unwrap() - .iter() - .any(|m| m["name"] == "add") - ); - host.stop().await; -} - -/// PROTOCOL 3.2: rpc.discover is SDK control traffic and endpoint control is not a main method. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn reserved_rpc_control_names() { - let world = TestWorld::new(); - let host = world.start().await; - assert!(call(&host, "probe.rpc.discover", json!({})).await["methods"].is_array()); - for method in ["probe.rpc.endpoint", "probe.rpc.unknown"] { - assert_eq!( - host.call(method, json!({})).await.unwrap_err()["code"], - -32601 - ); - } - host.stop().await; -} - -/// PROTOCOL 3.1: the SDK's leading newline isolates an unfinished diagnostic from its response. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn leading_newline_protects_sdk_response() { - let world = TestWorld::new(); - let host = world.start().await; - assert_eq!( - call(&host, "probe.framed_output", json!({})).await, - "intact" - ); - host.stop().await; - let warnings = call(&host, "core.log", json!({"grep":"discarded stdout"})).await; - assert_eq!(warnings.as_array().unwrap().len(), 1); - assert!(!warnings.to_string().contains("partial diagnostic")); -} diff --git a/cli/tests/host/watches.rs b/cli/tests/host/watches.rs deleted file mode 100644 index 03c2a0f..0000000 --- a/cli/tests/host/watches.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! Real SDK watch callbacks and raw WASI filesystem mutations share the host namespace. -use super::common::{CACHE, probe, sdk_probe}; -use sausage::{Host, world::World}; -use serde_json::{Value, json}; -use std::time::Duration; - -// Same call bound as core.rs; no sleeps or polling. -async fn call(h: &Host, method: &str, p: Value) -> Value { - tokio::time::timeout(Duration::from_secs(20), h.call(method, p)) - .await - .unwrap() - .unwrap() -} -async fn event(h: &Host, rx: &mut tokio::sync::broadcast::Receiver, name: &str) -> Value { - let result = tokio::time::timeout(Duration::from_secs(20), async { - loop { - let e = rx.recv().await.unwrap(); - if e["params"]["name"] == name { - return e["params"]["params"].clone(); - } - } - }) - .await; - match result { - Ok(v) => v, - Err(e) => { - eprintln!( - "JOURNAL {}", - h.call("core.log", json!({"limit":100})).await.unwrap() - ); - panic!("{e}"); - } - } -} -async fn start() -> (tempfile::TempDir, Host) { - let dir = tempfile::tempdir().unwrap(); - let world = World::create(&dir.path().join("world")).unwrap(); - for (name, wasm) in [("probe", probe()), ("sdk", sdk_probe())] { - std::fs::create_dir_all(world.plugin_dir(name)).unwrap(); - std::fs::copy(wasm, world.plugin_dir(name).join("plugin.wasm")).unwrap(); - } - let host = Host::start_with_cache(&world.root, std::path::Path::new(CACHE)) - .await - .unwrap(); - (dir, host) -} -fn watch(h: &Host, path: &str) -> tokio::task::JoinHandle { - let (h, path) = (h.clone(), path.to_owned()); - tokio::spawn(async move { h.call("sdk.watch", json!({"path":path})).await }) -} -async fn mutate(h: &Host, op: &str, path: &str) { - call(h, "probe.mutate", json!({"op":op,"path":path})).await; -} -/// PROTOCOL 4: pre-arm state is in the first scan; each post-scan mutation wakes -/// the SDK without completing the watch. Covers descriptor/stream/truncate paths. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn armed_scan_and_later_changes() { - let (_dir, h) = start().await; - mutate(&h, "mkdir", "/state/mailbox").await; - call( - &h, - "probe.write", - json!({"path":"/state/mailbox/before", "text":"before"}), - ) - .await; - let mut rx = h.subscribe(); - let task = watch(&h, "/state/mailbox/../mailbox/"); - let armed = event(&h, &mut rx, "armed").await; - assert_eq!(armed["entries"], json!(["before"])); - assert!(!task.is_finished()); - for (op, expected) in [ - ("descriptor", "descriptor"), - ("stream", "streamptor"), - ("append", "streamptorappend"), - ("size", ""), - ("descriptor", "descriptor"), - ("truncate", ""), - ] { - mutate(&h, op, "/state/mailbox/before").await; - let mut scan = event(&h, &mut rx, "changed").await; - while scan["text"] != expected { - scan = event(&h, &mut rx, "changed").await; - } - } - mutate(&h, "burst", "/state/mailbox").await; - let mut scanned = event(&h, &mut rx, "changed").await; - while !scanned["entries"].as_array().unwrap().contains(&json!("4")) { - scanned = event(&h, &mut rx, "changed").await; - } - while rx.try_recv().is_ok() {} - mutate(&h, "delete", "/state/mailbox/4").await; - let mut scanned = event(&h, &mut rx, "changed").await; - while scanned["entries"].as_array().unwrap().contains(&json!("4")) { - scanned = event(&h, &mut rx, "changed").await; - } - call(&h, "core.kill", json!({"pid":armed["pid"]})).await; - assert!(task.await.unwrap().is_err()); - h.stop().await; -} -/// PROTOCOL 4: atomic publication, rename source/destination, mounted paths, -/// absent paths, and caller-death cleanup. No external host writes are required. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rename_delete_and_mounts() { - let (_dir, h) = start().await; - for base in ["/state/inbox", "/run/inbox"] { - let mut rx = h.subscribe(); - let task = watch(&h, base); - let armed = event(&h, &mut rx, "armed").await; - assert_eq!(armed["entries"], json!([])); - let staging = format!("{base}-staging"); - mutate(&h, "mkdir", &staging).await; - call( - &h, - "probe.mutate", - json!({"op":"rename","path":staging,"to":base}), - ) - .await; - event(&h, &mut rx, "changed").await; - mutate(&h, "atomic", &format!("{base}/published")).await; - let mut scan = event(&h, &mut rx, "changed").await; - while !scan["entries"] - .as_array() - .unwrap() - .contains(&json!("published")) - { - scan = event(&h, &mut rx, "changed").await; - } - assert_eq!( - call( - &h, - "probe.read", - json!({"path":format!("{base}/published")}) - ) - .await, - "complete" - ); - while rx.try_recv().is_ok() {} - call( - &h, - "probe.mutate", - json!({"op":"rename","path":base,"to":format!("{base}-moved")}), - ) - .await; - let mut scan = event(&h, &mut rx, "changed").await; - while scan["entries"] != json!([]) { - scan = event(&h, &mut rx, "changed").await; - } - call(&h, "core.kill", json!({"pid":armed["pid"]})).await; - assert!(task.await.unwrap().is_err()); - } - // /self watches bind to this caller, and mutations are visible via /proc/. - let mut rx = h.subscribe(); - let task = watch(&h, "/self"); - let armed = event(&h, &mut rx, "armed").await; - assert!( - armed["entries"] - .as_array() - .unwrap() - .contains(&json!("owned")) - ); - event(&h, &mut rx, "changed").await; - assert_eq!( - call( - &h, - "probe.read", - json!({"path":format!("/proc/{}/owned", armed["pid"].as_str().unwrap())}) - ) - .await, - "visible via proc" - ); - call(&h, "core.kill", json!({"pid":armed["pid"]})).await; - assert!(task.await.unwrap().is_err()); - h.stop().await; -} diff --git a/cli/tests/plugins/activation/Cargo.toml b/cli/tests/plugins/activation/Cargo.toml deleted file mode 100644 index 3dd20d1..0000000 --- a/cli/tests/plugins/activation/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "activation-guest" -version = "0.1.0" -edition = "2024" - -[workspace] - -[dependencies] -serde_json = "1" diff --git a/cli/tests/plugins/activation/src/main.rs b/cli/tests/plugins/activation/src/main.rs deleted file mode 100644 index a664212..0000000 --- a/cli/tests/plugins/activation/src/main.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Raw discovery and lifecycle guest for activation protocol tests. -use serde_json::{Value, json}; -use std::io::{self, BufRead, Write}; -/// Emits one flushed JSON-RPC response. -fn send(value: Value) { - println!("{value}"); - io::stdout().flush().unwrap(); -} -/// Calls a provider after activation releases an old consumer process. -fn deferred(request: &Value, plugin: &str, lines: &mut impl Iterator>) { - send( - json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()}), - ); - let before = std::time::Instant::now(); - while !std::path::Path::new(&format!("/state/{plugin}/release-call")).exists() { - assert!(before.elapsed() < std::time::Duration::from_secs(30)); - } - send(json!({"jsonrpc":"2.0","id":"deferred","method":"notes.legacy","params":{}})); - for line in lines { - let response: Value = serde_json::from_str(&line.unwrap()).unwrap(); - if response["id"] == "deferred" { - std::fs::write(format!("/state/{plugin}/call-result"), response.to_string()).unwrap(); - break; - } - } -} -/// Answers a method that version two removes from its interface. -fn legacy(request: &Value, document: &Value, version: &str) { - if document["methods"] - .as_array() - .unwrap() - .iter() - .any(|m| m["name"] == "legacy") - { - send(json!({"jsonrpc":"2.0","id":request["id"],"result":version})); - } else { - send( - json!({"jsonrpc":"2.0","id":request["id"],"error":{"code":-32601,"message":"legacy removed","data":{"version":version}}}), - ); - } -} -/// Reads a version-specific discovery document without the SDK. -fn main() { - let version = env!("FIXTURE_VERSION"); - let plugin = std::env::var("SAUSAGE_PLUGIN").unwrap(); - let mut lines = io::stdin().lock().lines(); - let request: Value = serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap(); - let document: Value = - serde_json::from_slice(&std::fs::read(format!("/state/{plugin}/{version}.json")).unwrap()) - .unwrap(); - let result = match request["method"].as_str().unwrap() { - "deferred" => { - deferred(&request, &plugin, &mut lines); - return; - } - "legacy" => { - legacy(&request, &document, version); - return; - } - "rpc.discover" => { - if document["x-test-no-answer"] == true { - for _ in &mut lines {} - return; - } - if document["x-test-wait"] == true { - std::fs::write(format!("/state/{plugin}/discover-started"), "ready").unwrap(); - let before = std::time::Instant::now(); - while !std::path::Path::new(&format!("/state/{plugin}/discover-release")).exists() { - assert!(before.elapsed() < std::time::Duration::from_secs(30)); - } - } - document - } - "read" => { - let text = - std::fs::read_to_string(request["params"]["path"].as_str().unwrap()).unwrap(); - serde_json::from_str(&text).unwrap_or(json!(text)) - } - "rpc.retired" => { - std::fs::write( - format!("/state/{plugin}/retired"), - request["params"]["previous"].as_str().unwrap(), - ) - .unwrap(); - json!({"retired":version}) - } - "work" => { - // Keep an actual request unanswered through grace; notices do not drain it. - send( - json!({"jsonrpc":"2.0","method":"core.notify","params":{"name":"ready","params":{}}}), - ); - for _ in lines {} - return; - } - "serve" | "hold" => { - // One genuine process failure, followed by a healthy service, without - // timers or liveness polling in either the fixture or supervisor. - let crash = request["params"]["crash_once"].as_str(); - let first = crash.is_some() - && std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(format!("/state/{plugin}/crashed-once")) - .is_ok(); - if first && crash == Some("before") { - std::process::exit(1); - } - send(json!({"jsonrpc":"2.0","id":request["id"],"result":{"version":version}})); - if first && crash == Some("after") { - std::process::exit(1); - } - if request["method"] == "serve" { - std::fs::write(format!("/state/{plugin}/service"), version).unwrap(); - } - for line in lines { - let Ok(message) = serde_json::from_str::(&line.unwrap()) else { - continue; - }; - if message["method"] == "rpc.superseded" { - std::fs::write( - format!( - "/state/{plugin}/superseded-{}", - std::env::var("SAUSAGE_PID").unwrap() - ), - message.to_string(), - ) - .unwrap(); - if request["params"]["exit_on_superseded"] == true { - return; - } - } - } - std::fs::write(format!("/state/{plugin}/ended-{version}"), "ended").unwrap(); - return; - } - _ => json!({"version":version}), - }; - send(json!({"jsonrpc":"2.0","id":request["id"],"result":result})); - if request["method"] == "rpc.discover" && result["x-test-linger"] == true { - if result["x-test-stubborn"] == true { - let before = std::time::Instant::now(); - while !std::path::Path::new(&format!("/state/{plugin}/discovery-exit")).exists() { - assert!(before.elapsed() < std::time::Duration::from_secs(30)); - } - } else { - for _ in lines {} - } - std::fs::write( - format!("/state/{plugin}/discovery-ended-{version}"), - "ended", - ) - .unwrap(); - } -} diff --git a/cli/tests/plugins/probe/Cargo.toml b/cli/tests/plugins/probe/Cargo.toml deleted file mode 100644 index 0ad0294..0000000 --- a/cli/tests/plugins/probe/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "probe" -version = "0.1.0" -edition = "2024" -[workspace] -[dependencies] -serde_json = "1" -wasip2 = "1" diff --git a/cli/tests/plugins/probe/src/main.rs b/cli/tests/plugins/probe/src/main.rs deleted file mode 100644 index 7ba2cf9..0000000 --- a/cli/tests/plugins/probe/src/main.rs +++ /dev/null @@ -1,589 +0,0 @@ -//! Raw JSON-RPC methods used by the host's protocol tests. -use serde_json::{Value, json}; -use std::io::{self, BufRead, Read, Write}; -type Lines = io::Lines>; - -fn send(value: Value) { - let mut output = io::stdout().lock(); - writeln!(output, "\n{value}").unwrap(); - output.flush().unwrap(); -} -fn receive(lines: &mut Lines) -> Value { - serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap() -} -fn echo(params: &Value) -> Value { - params.clone() -} -fn chat(params: &Value) -> Value { - send(json!({"jsonrpc":"2.0", "method":"core.notify", "params":{ - "name":"text", "params":{"text":"interim"} - }})); - json!({"text":params["text"]}) -} -fn discover(params: &Value) -> Value { - if let Ok(bytes) = std::fs::read("/state/probe/discover.json") { - serde_json::from_slice(&bytes).unwrap() - } else { - params.clone() - } -} -fn environment() -> Value { - json!({ - "pid": std::env::var("SAUSAGE_PID").unwrap(), - "plugin": std::env::var("SAUSAGE_PLUGIN").unwrap(), - "parent": std::env::var("SAUSAGE_PARENT").unwrap(), - }) -} -fn multicall(lines: &mut Lines) -> Value { - send(json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "probe.echo", - "params": {"n": 1} - })); - send(json!({ - "jsonrpc": "2.0", - "id": "1", - "method": "probe.echo", - "params": {"n": 2} - })); - for _ in 0..2 { - let response = receive(lines); - if response["id"] == 1 { - assert_eq!(response["result"]["n"], 1); - } else { - assert_eq!(response["id"], "1"); - assert_eq!(response["result"]["n"], 2); - } - } - json!({"count":2}) -} -fn notification(params: &Value) -> Value { - send(json!({ - "jsonrpc": "2.0", - "method": "probe.write", - "params": params - })); - let before = std::time::Instant::now(); - loop { - let contents = std::fs::read_to_string(params["path"].as_str().unwrap()).ok(); - if contents.as_deref() == params["text"].as_str() { - break; - } - assert!(before.elapsed() < std::time::Duration::from_secs(10)); - } - json!({}) -} -fn unknown_id() { - send(json!({ - "jsonrpc": "2.0", - "id": "late", - "result": 7 - })); -} -fn wait_eof(lines: &mut Lines) { - std::fs::write("/state/waiting", "yes").unwrap(); - send(json!({"jsonrpc":"2.0","method":"core.notify","params":{ - "name":"waiting-eof", "params":{"pid":std::env::var("SAUSAGE_PID").unwrap()} - }})); - for _ in lines {} - std::fs::write("/state/stopped", "yes").unwrap(); -} -fn busy() -> Value { - loop { - std::hint::spin_loop(); - } -} -fn exit_without_answer() { - eprintln!("last line"); -} -fn junk() -> Value { - io::stdout().write_all(&[255, 10]).unwrap(); - println!("junk\n{{}}\n[]"); - eprintln!("stderr line"); - json!({}) -} -fn invalid_utf8() -> Value { - io::stdout().write_all(&[255, 10]).unwrap(); - json!({}) -} -fn blank() -> Value { - println!("\n \t\n"); - json!({}) -} -fn burst(request: &Value) { - let count = request["params"]["count"].as_u64().unwrap(); - let mut output = String::new(); - for index in 0..count { - let notification = json!({ - "jsonrpc": "2.0", - "method": "core.notify", - "params": {"name": "burst", "params": {"index": index}} - }); - output.push_str(¬ification.to_string()); - output.push('\n'); - } - let response = json!({"jsonrpc":"2.0", "id":request["id"], "result":count}); - output.push_str(&response.to_string()); - output.push('\n'); - let mut stdout = io::stdout().lock(); - stdout.write_all(output.as_bytes()).unwrap(); - stdout.flush().unwrap(); -} -fn notify() -> Value { - send(json!({ - "jsonrpc": "2.0", - "method": "core.notify", - "params": { - "name": "progress", - "params": {"n": 1} - } - })); - json!({}) -} -fn serve(request: &Value, lines: &mut Lines) { - send(json!({ - "jsonrpc": "2.0", - "id": request["id"], - "result": std::env::var("SAUSAGE_PID").unwrap() - })); - for _ in lines {} -} -fn network(params: &Value) -> Value { - let mut socket = std::net::TcpStream::connect(params["tcp"].as_str().unwrap()).unwrap(); - let mut text = String::new(); - socket.read_to_string(&mut text).unwrap(); - let udp = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); - udp.send_to(b"udp", params["udp"].as_str().unwrap()) - .unwrap(); - json!(text) -} -fn read_file(params: &Value) -> Value { - json!(std::fs::read_to_string(params["path"].as_str().unwrap()).unwrap()) -} -fn write_file(params: &Value) -> Value { - std::fs::write( - params["path"].as_str().unwrap(), - params["text"].as_str().unwrap(), - ) - .unwrap(); - json!({}) -} -fn outgoing_call(params: &Value, lines: &mut Lines) -> Value { - send(json!({ - "jsonrpc": "2.0", - "id": {"nested": 7}, - "method": params["method"], - "params": params["params"] - })); - loop { - let response = receive(lines); - if response["id"] == json!({"nested":7}) { - return response["result"].clone(); - } - } -} -fn lock(params: &Value, lines: &mut Lines) -> Value { - outgoing_call(&json!({"method":"core.lock", "params":params}), lines) -} -fn filesystem(params: &Value) -> Value { - use std::fs; - let pid = std::env::var("SAUSAGE_PID").unwrap(); - match params["topic"].as_str().unwrap() { - "root" => { - let preopens = wasip2::filesystem::preopens::get_directories(); - assert_eq!(preopens.len(), 1); - assert_eq!(preopens[0].1, "/"); - let names: Vec<_> = fs::read_dir("/") - .unwrap() - .map(|e| e.unwrap().file_name().into_string().unwrap()) - .collect(); - for name in ["proc", "self", "run"] { - assert!(names.iter().any(|n| n == name)); - } - fs::write("/state/root", "world").unwrap(); - fs::write("/run/shared", "shared").unwrap(); - assert!(fs::rename("/run/shared", "/state/moved").is_err()); - assert!(fs::rename("/state/root", "/self/moved").is_err()); - assert_eq!(fs::read_to_string("/run/../state/root").unwrap(), "world"); - } - "reserved" => { - for name in ["info.json", "log", "fs"] { - assert!(fs::write(format!("/self/{name}"), "bad").is_err()); - assert!(fs::create_dir(format!("/self/{name}")).is_err()); - fs::write("/self/temp", "ok").unwrap(); - assert!(fs::rename("/self/temp", format!("/self/{name}")).is_err()); - fs::remove_file("/self/temp").unwrap(); - } - } - "self" => { - fs::write("/self/endpoint.tmp", "endpoint").unwrap(); - fs::rename("/self/endpoint.tmp", "/self/endpoint").unwrap(); - assert_eq!( - fs::read_to_string(format!("/proc/{pid}/endpoint")).unwrap(), - "endpoint" - ); - assert!(fs::write(format!("/proc/{pid}/endpoint"), "bad").is_err()); - assert!(fs::remove_file(format!("/proc/{pid}/endpoint")).is_err()); - let info: Value = - serde_json::from_slice(&fs::read("/self/info.json").unwrap()).unwrap(); - assert_eq!(info["plugin"], "probe"); - assert_eq!(info["method"], "filesystem"); - return json!(pid); - } - "isolated" => { - assert!(!fs::exists("/self/endpoint").unwrap()); - } - "snapshot" => { - eprintln!("before snapshot"); - let mut file = fs::File::open("/self/log").unwrap(); - eprintln!("after snapshot"); - let mut contents = String::new(); - file.read_to_string(&mut contents).unwrap(); - assert!(contents.contains("start filesystem")); - assert!(!contents.contains("after snapshot")); - assert_eq!(file.read(&mut [0; 1]).unwrap(), 0); - } - "core" => { - assert!( - fs::read_to_string("/proc/core/PROTOCOL.md") - .unwrap() - .contains("## 4. The filesystem") - ); - for name in ["graph.json", "locks.json"] { - let _: Value = - serde_json::from_slice(&fs::read(format!("/proc/core/{name}")).unwrap()) - .unwrap(); - } - assert!( - fs::read_to_string("/proc/core/graph.dot") - .unwrap() - .contains("digraph") - ); - assert!(fs::read_dir("/proc/core/plugins").is_ok()); - assert!(fs::write("/proc/core/graph.json", "bad").is_err()); - } - _ => panic!("unknown filesystem topic"), - } - json!({}) -} -fn write_loop(request: &Value, lines: &mut Lines) { - assert_eq!( - lock(&json!({"path":"/state/loop"}), lines), - json!({"held":true}) - ); - std::fs::write("/state/loop", "old").unwrap(); - send( - json!({"jsonrpc":"2.0", "id":request["id"], "result":std::env::var("SAUSAGE_PID").unwrap()}), - ); - loop { - std::fs::write("/state/loop", "old").unwrap(); - } -} -fn locked_write(lines: &mut Lines) -> Value { - assert_eq!( - lock(&json!({"path":"/state/loop"}), lines), - json!({"held":true}) - ); - std::fs::write("/state/loop", "new").unwrap(); - outgoing_call(&json!({"method":"probe.echo","params":{}}), lines); - assert_eq!(std::fs::read_to_string("/state/loop").unwrap(), "new"); - json!({}) -} -fn unlock_answer(request: &Value, lines: &mut Lines) { - lock(&json!({"path":"ordered-unlock"}), lines); - let unlock = json!({"jsonrpc":"2.0","method":"core.unlock","params":{"path":"ordered-unlock"}}); - let answer = - json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()}); - let mut stdout = io::stdout().lock(); - writeln!(stdout, "{unlock}\n{answer}").unwrap(); - stdout.flush().unwrap(); - drop(stdout); - for _ in lines {} -} -fn notification_child(lines: &mut Lines) -> Value { - send(json!({"jsonrpc":"2.0","method":"probe.orphan_notify","params":{}})); - let before = std::time::Instant::now(); - let pid = loop { - if let Ok(pid) = std::fs::read_to_string("/state/orphan") { - break pid; - } - assert!(before.elapsed() < std::time::Duration::from_secs(10)); - }; - send(json!({"jsonrpc":"2.0","id":7,"method":"core.stop","params":{"pid":pid}})); - let mut count = 0; - loop { - let response = receive(lines); - if response["method"] == "core.notify" { - count += 1; - } - if response["id"] == 7 { - break; - } - } - json!(count) -} -fn batch(lines: &mut Lines) -> Value { - send( - json!([{"jsonrpc":"2.0","method":"probe.write","params":{"path":"/state/batch","text":"bad"}}]), - ); - outgoing_call( - &json!({"method":"probe.echo","params":{"barrier":true}}), - lines, - ); - json!(!std::path::Path::new("/state/batch").exists()) -} -fn late_answer(request: &Value, lines: &mut Lines) { - send(json!({"jsonrpc":"2.0","id":request["id"],"result":42})); - send(json!({"jsonrpc":"2.0","id":request["id"],"result":99})); - for _ in lines {} -} -fn nested_environment(lines: &mut Lines) -> Value { - let child = outgoing_call(&json!({"method":"probe.env","params":{}}), lines); - json!({"caller":std::env::var("SAUSAGE_PID").unwrap(),"child":child}) -} -fn forgotten(request: &Value, lines: &mut Lines) { - std::fs::write("/state/forgotten", environment().to_string()).unwrap(); - serve(request, lines); -} -fn forget(lines: &mut Lines) -> Value { - send( - json!({"jsonrpc":"2.0","method":"core.call","params":{"method":"probe.forgotten","params":{},"detach":true}}), - ); - let before = std::time::Instant::now(); - while !std::path::Path::new("/state/forgotten").exists() { - assert!(before.elapsed() < std::time::Duration::from_secs(10)); - } - send(json!({"jsonrpc":"2.0","id":"barrier","method":"probe.echo","params":{}})); - let response = receive(lines); - assert_eq!( - response["id"], "barrier", - "a forgotten call must not send a result" - ); - environment() -} -fn tree(request: &Value, lines: &mut Lines) { - let depth = request["params"]["depth"].as_u64().unwrap(); - if depth > 0 { - outgoing_call( - &json!({"method":"probe.tree","params":{"depth":depth-1}}), - lines, - ); - } - send( - json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()}), - ); - busy(); -} -fn dispatch(request: &Value, lines: &mut Lines) -> Option { - let params = &request["params"]; - let result = match request["method"].as_str().unwrap() { - "batch" => batch(lines), - "trap" => std::arch::wasm32::unreachable(), - "nested_environment" => nested_environment(lines), - "forget" => forget(lines), - "forgotten" => { - forgotten(request, lines); - return None; - } - "tree" => { - tree(request, lines); - return None; - } - "late_answer" => { - late_answer(request, lines); - return None; - } - "invalid_stderr" => { - io::stderr().write_all(&[255, 10]).unwrap(); - eprintln!("after invalid UTF-8"); - json!(std::env::var("SAUSAGE_PID").unwrap()) - } - "unlock_answer" => { - unlock_answer(request, lines); - return None; - } - "null_error" => { - send(json!({"jsonrpc":"2.0","id":request["id"],"error":null,"result":42})); - return None; - } - "notification_child" => notification_child(lines), - "orphan_notify" => { - std::fs::write("/state/orphan", std::env::var("SAUSAGE_PID").unwrap()).unwrap(); - notify() - } - "self_serve" => { - std::fs::write("/self/endpoint", "endpoint").unwrap(); - serve(request, lines); - return None; - } - "filesystem" => filesystem(params), - "write_loop" => { - write_loop(request, lines); - return None; - } - "locked_write" => locked_write(lines), - "echo" => echo(params), - "chat" => chat(params), - "rpc.discover" => discover(params), - "env" => environment(), - "multicall" => multicall(lines), - "notification" => notification(params), - "unknownid" => { - unknown_id(); - return None; - } - "request_id" => request["id"].clone(), - "wait_eof" => { - wait_eof(lines); - return None; - } - "busy" => busy(), - "exit" => { - exit_without_answer(); - return None; - } - "junk" => junk(), - "blank" => blank(), - "invalid_utf8" => invalid_utf8(), - "notify" => notify(), - "burst" => { - burst(request); - return None; - } - "restart" => { - assert_eq!( - lock(&json!({"path":"/state/restart-stop"}), lines), - json!({"held":true}) - ); - assert_eq!( - outgoing_call(&json!({"method":"core.restart", "params":{}}), lines), - json!({}) - ); - std::fs::write("/state/restart-answer", "received").unwrap(); - serve(request, lines); - std::fs::write("/state/restart-stop", "persisted after EOF").unwrap(); - eprintln!("restart stop hook finished"); - return None; - } - "serve" => { - serve(request, lines); - return None; - } - "network" => network(params), - "mutate" => mutate(params), - "read" => read_file(params), - "write" => write_file(params), - "raw_call" => { - send(params.clone()); - receive(lines) - } - "call" => outgoing_call(params, lines), - "lock" => lock(params, lines), - _ => { - send(json!({ - "jsonrpc": "2.0", - "id": request["id"], - "error": { - "code": -32601, - "message": "method not found" - } - })); - return None; - } - }; - Some(result) -} -fn main() { - if let Ok(mode) = std::fs::read_to_string("/state/early") { - if mode == "flood" { - for index in 0..5000 { - eprintln!("flood {index} {}", "x".repeat(128)); - } - } else { - eprintln!("last early log"); - if mode == "answer" { - send( - json!({"jsonrpc":"2.0","id":std::env::var("SAUSAGE_PID").unwrap(),"result":"early"}), - ); - } - std::process::exit(23); - } - } - let mut lines = io::BufReader::new(io::stdin()).lines(); - let request = receive(&mut lines); - if let Some(result) = dispatch(&request, &mut lines) - && let Some(id) = request.get("id") - { - send(json!({ - "jsonrpc": "2.0", - "id": id, - "result": result - })); - } -} - -/// Separate mutation calls let the host await a scan before each subsequent change. -fn mutate(p: &Value) -> Value { - use wasip2::filesystem::{ - preopens, - types::{DescriptorFlags as D, OpenFlags as O, PathFlags as P}, - }; - let path = p["path"].as_str().unwrap(); - match p["op"].as_str().unwrap() { - "mkdir" => std::fs::create_dir_all(path).unwrap(), - "rename" => std::fs::rename(path, p["to"].as_str().unwrap()).unwrap(), - "delete" => std::fs::remove_file(path).unwrap(), - "rmdir" => std::fs::remove_dir(path).unwrap(), - "atomic" => { - let temp = format!("{path}.tmp"); - std::fs::write(&temp, "complete").unwrap(); - std::fs::rename(temp, path).unwrap(); - } - "burst" => { - for i in 0..5 { - std::fs::write(format!("{path}/{i}"), "complete").unwrap(); - } - } - op => { - let root = preopens::get_directories().remove(0).0; - let file = root - .open_at( - P::empty(), - path.trim_start_matches('/'), - O::empty(), - D::READ | D::WRITE, - ) - .unwrap(); - match op { - "descriptor" => { - file.write(b"descriptor", 0).unwrap(); - } - "stream" => { - file.write_via_stream(0) - .unwrap() - .blocking_write_and_flush(b"stream") - .unwrap(); - } - "append" => { - file.append_via_stream() - .unwrap() - .blocking_write_and_flush(b"append") - .unwrap(); - } - "size" => file.set_size(0).unwrap(), - "truncate" => { - root.open_at( - P::empty(), - path.trim_start_matches('/'), - O::TRUNCATE, - D::WRITE, - ) - .unwrap(); - } - _ => panic!("unknown mutation"), - } - } - } - json!({}) -} diff --git a/examples/consumer/Cargo.toml b/examples/consumer/Cargo.toml deleted file mode 100644 index 53dd5e9..0000000 --- a/examples/consumer/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "consumer" -version = "0.1.0" -edition = "2024" -[workspace] -[dependencies] -sausage-plugin = { path = "../../sdk" } diff --git a/examples/consumer/imports/notes.json b/examples/consumer/imports/notes.json deleted file mode 100644 index e5129ce..0000000 --- a/examples/consumer/imports/notes.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "components": { - "schemas": { - "AddParams": { - "description": "Parameters for a new note.", - "properties": { - "text": { - "description": "The complete note text, including any newlines.", - "type": "string" - } - }, - "required": [ - "text" - ], - "type": "object" - }, - "Added": { - "description": "The result after a note enters the list.", - "properties": { - "count": { - "description": "The number of notes, including the new note.", - "format": "uint", - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "count" - ], - "type": "object" - } - } - }, - "info": { - "title": "notes", - "version": "0.1.0" - }, - "methods": [ - { - "description": "Add a note and return the new count.", - "name": "add", - "paramStructure": "by-name", - "params": [ - { - "description": "The complete note text, including any newlines.", - "name": "text", - "required": true, - "schema": { - "description": "The complete note text, including any newlines.", - "type": "string" - } - } - ], - "result": { - "name": "result", - "schema": { - "$ref": "#/components/schemas/Added" - } - }, - "summary": "Add a note and return the new count.", - "x-sausage-params": { - "$ref": "#/components/schemas/AddParams" - } - }, - { - "description": "List all notes, oldest first.", - "name": "list", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "summary": "List all notes, oldest first." - } - ], - "openrpc": "1.2.6", - "x-sausage-endpoints": {}, - "x-sausage-imports": {} -} diff --git a/examples/consumer/imports/secrets.json b/examples/consumer/imports/secrets.json deleted file mode 100644 index 7239d1f..0000000 --- a/examples/consumer/imports/secrets.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "components": { - "schemas": { - "GetParams": { - "description": "The name of a secret to read.", - "properties": { - "name": { - "description": "The unique name within this vault.", - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - }, - "PutParams": { - "description": "The name and value of a secret.", - "properties": { - "name": { - "description": "The unique name within this vault.", - "type": "string" - }, - "value": { - "description": "The text to store until process exit.", - "type": "string" - } - }, - "required": [ - "name", - "value" - ], - "type": "object" - } - } - }, - "info": { - "title": "secrets", - "version": "0.1.0" - }, - "methods": [ - { - "description": "Open the in-memory vault and publish its endpoint.", - "name": "serve", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "type": "null" - } - }, - "summary": "Open the in-memory vault and publish its endpoint." - } - ], - "openrpc": "1.2.6", - "x-sausage-endpoints": { - "vault": { - "methods": [ - { - "description": "Store a secret and report its name to the caller.", - "name": "put", - "paramStructure": "by-name", - "params": [ - { - "description": "The unique name within this vault.", - "name": "name", - "required": true, - "schema": { - "description": "The unique name within this vault.", - "type": "string" - } - }, - { - "description": "The text to store until process exit.", - "name": "value", - "required": true, - "schema": { - "description": "The text to store until process exit.", - "type": "string" - } - } - ], - "result": { - "name": "result", - "schema": { - "type": "null" - } - }, - "summary": "Store a secret and report its name to the caller.", - "x-sausage-params": { - "$ref": "#/components/schemas/PutParams" - } - }, - { - "description": "Read a secret, or return null when its name is absent.", - "name": "get", - "paramStructure": "by-name", - "params": [ - { - "description": "The unique name within this vault.", - "name": "name", - "required": true, - "schema": { - "description": "The unique name within this vault.", - "type": "string" - } - } - ], - "result": { - "name": "result", - "schema": { - "type": [ - "string", - "null" - ] - } - }, - "summary": "Read a secret, or return null when its name is absent.", - "x-sausage-params": { - "$ref": "#/components/schemas/GetParams" - } - } - ] - } - }, - "x-sausage-imports": {} -} diff --git a/examples/consumer/imports/www.json b/examples/consumer/imports/www.json deleted file mode 100644 index a0bb983..0000000 --- a/examples/consumer/imports/www.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "components": { - "schemas": { - "StartParams": { - "description": "The server configuration.", - "properties": { - "port": { - "description": "The HTTP port on all network interfaces.", - "format": "uint16", - "maximum": 65535, - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "port" - ], - "type": "object" - } - } - }, - "info": { - "title": "www", - "version": "0.1.0" - }, - "methods": [ - { - "description": "Start the HTTP server and return its process ID.", - "name": "start", - "paramStructure": "by-name", - "params": [ - { - "description": "The HTTP port on all network interfaces.", - "name": "port", - "required": true, - "schema": { - "description": "The HTTP port on all network interfaces.", - "format": "uint16", - "maximum": 65535, - "minimum": 0, - "type": "integer" - } - } - ], - "result": { - "name": "result", - "schema": { - "type": "string" - } - }, - "summary": "Start the HTTP server and return its process ID.", - "x-sausage-params": { - "$ref": "#/components/schemas/StartParams" - } - } - ], - "openrpc": "1.2.6", - "x-sausage-endpoints": { - "control": { - "methods": [ - { - "description": "Count the HTTP requests this server answered.", - "name": "stats", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "format": "uint64", - "minimum": 0, - "type": "integer" - } - }, - "summary": "Count the HTTP requests this server answered." - }, - { - "description": "End this process after the response reaches the client.", - "name": "stop", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "type": "null" - } - }, - "summary": "End this process after the response reaches the client." - } - ] - } - }, - "x-sausage-grace": 5, - "x-sausage-imports": {} -} diff --git a/examples/consumer/src/main.rs b/examples/consumer/src/main.rs deleted file mode 100644 index d88a827..0000000 --- a/examples/consumer/src/main.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Use typed calls from discovered provider documents in `imports/`. -//! Generated modules own the wire types, so calls need no manual JSON. -//! The detached HTTP service survives this consumer. Its endpoint refers to that service's process-local state. -//! Copy fresh documents from `/proc/core/plugins//discover.json` after an interface changes. -use sausage_plugin::{Result, rpc, schema}; -sausage_plugin::import!("notes"); -sausage_plugin::import!("www"); -sausage_plugin::import!("secrets"); -/// Demonstrate typed imports without manual JSON messages. -struct Consumer; -/// A note for the imported notes plugin. -#[schema] -struct NoteParams { - /// The text to append. - text: String, -} -/// The text and HTTP port for this demonstration. -#[schema] -struct DemoParams { - /// The note text to append. - text: String, - /// The port for the detached HTTP server. - port: u16, -} -#[rpc] -impl Consumer { - /// Add a note through the generated import and return its count. - async fn note(&self, p: NoteParams) -> Result { - Ok(notes::add(notes::AddParams { text: p.text }).await?.count as usize) - } - /// Add a note, start a server, and inspect its control endpoint. - async fn demo(&self, p: DemoParams) -> Result { - let added = notes::add(notes::AddParams { text: p.text }).await?; - eprintln!("{} notes", added.count); - let pid = www::start(www::StartParams { port: p.port }) - .detach() - .await?; - let control = www::control::connect(format!("/proc/{pid}/control")).await?; - control.stats().await - } - /// Store and read a value through the boot service's fixed endpoint. - async fn vault(&self) -> Result> { - let vault = secrets::vault::connect("/run/secrets").await?; - vault - .put(secrets::PutParams { - name: "example".into(), - value: "hello".into(), - }) - .on_notify(|name, params| eprintln!("{name}: {params}")) - .await?; - vault - .get(secrets::GetParams { - name: "example".into(), - }) - .await - } -} -/// Start the SDK transport for this plugin. -fn main() { - sausage_plugin::run(Consumer) -} -/// Tests check generated wire types without other plugins in the test world. -#[cfg(test)] -mod tests { - use super::*; - /// Generated params retain their wire field names. - #[test] - fn generated_params_use_text() { - let value = sausage_plugin::serde_json::to_value(notes::AddParams { - text: "milk".into(), - }) - .unwrap(); - assert_eq!(value, sausage_plugin::serde_json::json!({"text":"milk"})); - } - /// A generated result decodes without another plugin in the test world. - #[test] - fn generated_result_decodes_count() { - let added: notes::Added = sausage_plugin::serde_json::from_str(r#"{"count":2}"#).unwrap(); - assert_eq!(added.count, 2); - } -} diff --git a/examples/notes/Cargo.toml b/examples/notes/Cargo.toml deleted file mode 100644 index 741be46..0000000 --- a/examples/notes/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "notes" -version = "0.1.0" -edition = "2024" -[workspace] -[dependencies] -sausage-plugin = { path = "../../sdk" } diff --git a/examples/notes/src/main.rs b/examples/notes/src/main.rs deleted file mode 100644 index 80ed84e..0000000 --- a/examples/notes/src/main.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Persistent notes in `/state/notes/notes.json`. -//! Each call has its own process. The add method locks the file before its read-modify-write operation. -//! Atomic replacement lets readers observe a complete list without a lock. -use sausage_plugin::serde_json; -use sausage_plugin::{Result, lock, rpc, schema, state_dir, write_atomic}; -/// The stateless notes plugin reads its file on each call. -struct Notes; -/// Parameters for a new note. -#[schema] -struct AddParams { - /// The complete note text, including any newlines. - text: String, -} -/// The result after a note enters the list. -#[schema] -struct Added { - /// The number of notes, including the new note. - count: usize, -} -#[rpc] -impl Notes { - /// Add a note and return the new count. - async fn add(&self, AddParams { text }: AddParams) -> Result { - let path = state_dir().join("notes.json"); - let _guard = lock(&path).await?; - let mut notes = load()?; - notes.push(text); - std::fs::create_dir_all(state_dir())?; - write_atomic(path, serde_json::to_vec(¬es)?)?; - Ok(Added { count: notes.len() }) - } - /// List all notes, oldest first. - fn list(&self) -> Result> { - load() - } -} -/// Read the list, or return an empty list when the file does not exist. -fn load() -> Result> { - match std::fs::read(state_dir().join("notes.json")) { - Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]), - Err(e) => Err(e.into()), - } -} -/// Start the SDK transport for notes. -fn main() { - sausage_plugin::run(Notes) -} - -/// Tests run in fresh worlds under the WASI test runner. -#[cfg(test)] -mod tests { - use super::*; - /// The core lock protects two sequential additions and their counts. - #[sausage_plugin::test] - async fn add_appends_and_counts() { - assert_eq!( - Notes - .add(AddParams { - text: "milk".into() - }) - .await - .unwrap() - .count, - 1 - ); - assert_eq!( - Notes - .add(AddParams { - text: "eggs".into() - }) - .await - .unwrap() - .count, - 2 - ); - assert_eq!(Notes.list().unwrap(), ["milk", "eggs"]); - } - /// A fresh test world has no notes file. - #[test] - fn list_is_empty_without_a_file() { - assert!(Notes.list().unwrap().is_empty()); - } -} diff --git a/examples/secrets/Cargo.toml b/examples/secrets/Cargo.toml deleted file mode 100644 index d43901b..0000000 --- a/examples/secrets/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "secrets" -version = "0.1.0" -edition = "2024" -[workspace] -[dependencies] -sausage-plugin = { path = "../../sdk" } diff --git a/examples/secrets/src/main.rs b/examples/secrets/src/main.rs deleted file mode 100644 index 7370442..0000000 --- a/examples/secrets/src/main.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Keep a process-local vault behind a fixed endpoint at `/run/secrets`. -//! Start `secrets.serve` as a detached boot service. Its memory lasts until process exit. -use sausage_plugin::{Ctx, Result, listen, rpc, schema}; -use std::{cell::RefCell, collections::BTreeMap, rc::Rc}; -/// The service entry point publishes one process-local vault. -struct Secrets; -#[rpc(endpoints(vault=Vault))] -impl Secrets { - /// Open the in-memory vault and publish its endpoint. - async fn serve(&self) -> Result<()> { - listen("/run/secrets", Rc::new(Vault::default())).await - } -} -/// The endpoint owns secrets in memory, not on disk. -#[derive(Default)] -struct Vault { - /// Named values disappear when this process ends. - entries: RefCell>, -} -/// The name and value of a secret. -#[schema] -struct PutParams { - /// The unique name within this vault. - name: String, - /// The text to store until process exit. - value: String, -} -/// The name of a secret to read. -#[schema] -struct GetParams { - /// The unique name within this vault. - name: String, -} -#[rpc] -impl Vault { - /// Store a secret and report its name to the caller. - fn put(&self, ctx: &Ctx, p: PutParams) -> Result<()> { - ctx.notify("stored", sausage_plugin::serde_json::json!({"name":p.name}))?; - self.entries.borrow_mut().insert(p.name, p.value); - Ok(()) - } - /// Read a secret, or return null when its name is absent. - fn get(&self, p: GetParams) -> Result> { - Ok(self.entries.borrow().get(&p.name).cloned()) - } -} -/// Start the SDK transport for the vault service. -fn main() { - sausage_plugin::run(Secrets) -} -/// Tests verify absent keys and process-local storage. -#[cfg(test)] -mod tests { - use super::*; - /// Missing names return null instead of an RPC error. - #[test] - fn absent_secret() { - assert_eq!( - Vault::default() - .get(GetParams { - name: "missing".into() - }) - .unwrap(), - None - ); - } - /// Store a value and retrieve it through the same process-local vault. - #[sausage_plugin::test] - async fn stores_secret() { - let vault = Vault::default(); - vault - .put( - &Ctx::default(), - PutParams { - name: "token".into(), - value: "example".into(), - }, - ) - .unwrap(); - assert_eq!( - vault - .get(GetParams { - name: "token".into() - }) - .unwrap() - .as_deref(), - Some("example") - ); - } -} diff --git a/examples/www/Cargo.toml b/examples/www/Cargo.toml deleted file mode 100644 index aafafd1..0000000 --- a/examples/www/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "www" -version = "0.1.0" -edition = "2024" -[workspace] -[dependencies] -sausage-plugin = { path = "../../sdk" } -wstd = "0.6" diff --git a/examples/www/src/main.rs b/examples/www/src/main.rs deleted file mode 100644 index 5aa4bdc..0000000 --- a/examples/www/src/main.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Serve files from `/state/www` and expose a control endpoint at `/self/control`. -//! Start this process detached or as a service so it outlives its caller. -//! Clients read `/proc//control`. A fresh core call cannot inspect this process's counters. -use sausage_plugin::{Ctx, Result, background, listen, pid, rpc, schema, state_dir}; -use std::{cell::Cell, rc::Rc}; -use wstd::{ - io::{AsyncRead, AsyncWrite}, - iter::AsyncIterator, - net::TcpListener, -}; -/// The HTTP plugin starts a server and returns its process ID. -struct Www; -/// The server configuration. -#[schema] -struct StartParams { - /// The HTTP port on all network interfaces. - port: u16, -} -#[rpc(endpoints(control=Control),grace_seconds=5)] -impl Www { - /// Start the HTTP server and return its process ID. - async fn start(&self, p: StartParams) -> Result { - let tcp = TcpListener::bind(&format!("0.0.0.0:{}", p.port)).await?; - let control = Rc::new(Control { hits: Cell::new(0) }); - listen("/self/control", control.clone()).await?; - background(serve(tcp, control)); - Ok(pid()) - } -} -/// Shared process-local state for the HTTP task and its endpoint. -struct Control { - /// The number of answered requests. - hits: Cell, -} -#[rpc] -impl Control { - /// Count the HTTP requests this server answered. - fn stats(&self) -> Result { - Ok(self.hits.get()) - } - /// End this process after the response reaches the client. - fn stop(&self, ctx: &Ctx) -> Result<()> { - ctx.exit_after_response(); - Ok(()) - } -} -/// Answer HTTP requests with files and count all responses, including missing files. -async fn serve(tcp: TcpListener, control: Rc) { - let mut incoming = tcp.incoming(); - while let Some(Ok(mut stream)) = incoming.next().await { - let mut request = [0; 8192]; - let Ok(size) = stream.read(&mut request).await else { - continue; - }; - let line = String::from_utf8_lossy(&request[..size]); - let path = line - .split_whitespace() - .nth(1) - .unwrap_or("/") - .trim_start_matches('/'); - let (status, body) = match std::fs::read(state_dir().join(if path.is_empty() { - "index.html" - } else { - path - })) { - Ok(body) => ("200 OK", body), - Err(_) => ("404 Not Found", b"Not found".to_vec()), - }; - control.hits.set(control.hits.get() + 1); - let header = format!( - "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); - let _ = stream.write_all(header.as_bytes()).await; - let _ = stream.write_all(&body).await; - let _ = stream.flush().await; - } -} -/// Start the SDK transport for the HTTP plugin. -fn main() { - sausage_plugin::run(Www) -} -/// Tests check local counters and the service entry point. -#[cfg(test)] -mod tests { - use super::*; - /// Control reports its shared counter without a core round trip. - #[test] - fn stats_reads_counter() { - assert_eq!(Control { hits: Cell::new(3) }.stats().unwrap(), 3); - } - /// The listener binds an ephemeral port and publishes its control endpoint. - #[sausage_plugin::test] - async fn starts_on_available_port() { - assert_eq!(Www.start(StartParams { port: 0 }).await.unwrap(), pid()); - } -} diff --git a/interface/Cargo.toml b/interface/Cargo.toml deleted file mode 100644 index 3e080a6..0000000 --- a/interface/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "sausage-interface" -version = "0.1.0" -edition = "2024" -description = "Sausage plugin interfaces: OpenRPC documents, the compatibility check, and client code generation." - -[features] -default = ["codegen"] -codegen = ["dep:typify", "dep:schemars08", "dep:syn", "dep:quote", "dep:prettyplease", "dep:proc-macro2"] - -[dependencies] -serde_json = "1" -typify = { version = "0.6", optional = true } -schemars08 = { package = "schemars", version = "0.8", optional = true } -syn = { version = "2", features = ["full", "visit-mut"], optional = true } -quote = { version = "1", optional = true } -prettyplease = { version = "0.2", optional = true } -proc-macro2 = { version = "1", optional = true } - -[dev-dependencies] -proptest = "1" -schemars = "1" -serde = { version = "1", features = ["derive"] } diff --git a/interface/src/client.rs b/interface/src/client.rs deleted file mode 100644 index baf3a94..0000000 --- a/interface/src/client.rs +++ /dev/null @@ -1,202 +0,0 @@ -//! Generate Rust clients from OpenRPC documents with typify's Draft 7 converter. -use quote::quote; -use serde_json::{Value, json}; - -/// Generate a formatted private client module for a provider's document. -pub fn client_rs(name: &str, doc: &Value) -> Result { - fn local_refs(value: &mut Value) { - match value { - Value::Object(map) => { - if let Some(Value::String(r)) = map.get_mut("$ref") { - *r = r.replace("#/components/schemas/", "#/definitions/"); - } - for v in map.values_mut() { - local_refs(v); - } - } - Value::Array(a) => { - for v in a { - local_refs(v); - } - } - _ => {} - } - } - let module = syn::parse_str::(&name.replace('-', "_")) - .or_else(|_| syn::parse_str::(&format!("r#{}", name.replace('-', "_")))) - .map_err(|e| e.to_string())?; - let mut space = typify::TypeSpace::default(); - let mut defs = doc["components"]["schemas"] - .as_object() - .cloned() - .unwrap_or_default(); - for schema in defs.values_mut() { - local_refs(schema); - } - let defs: Vec<(String, schemars08::schema::Schema)> = defs - .into_iter() - .map(|(n, v)| serde_json::from_value(v).map(|v| (n, v))) - .collect::>() - .map_err(|e| e.to_string())?; - space.add_ref_types(defs).map_err(|e| e.to_string())?; - fn methods( - space: &mut typify::TypeSpace, - provider: &str, - methods: &Value, - endpoint: bool, - ) -> Result, String> { - let mut result = vec![]; - for method in methods.as_array().into_iter().flatten() { - let wire = method["name"].as_str().ok_or("method name missing")?; - let ident = syn::parse_str::(wire) - .or_else(|_| syn::parse_str::(&format!("r#{wire}"))) - .map_err(|e| e.to_string())?; - let mut rs = method["result"]["schema"].clone(); - local_refs(&mut rs); - let empty = method["params"].as_array().is_none_or(Vec::is_empty); - let p = if empty { - None - } else { - let mut properties = serde_json::Map::new(); - let mut required = vec![]; - for param in method["params"].as_array().into_iter().flatten() { - let name = param["name"].as_str().ok_or("parameter name missing")?; - properties.insert(name.into(), param["schema"].clone()); - if param["required"] == true { - required.push(name); - } - } - let mut ps = method.get("x-sausage-params").cloned().unwrap_or_else(|| { - let title = wire.split('_').filter(|s| !s.is_empty()).map(|s| { - let mut chars = s.chars(); - chars.next().unwrap().to_uppercase().to_string() + chars.as_str() - }).collect::(); - json!({"title":format!("{title}Params"),"type":"object","properties":properties,"required":required}) - }); - local_refs(&mut ps); - Some( - space - .add_type(&serde_json::from_value(ps).map_err(|e| e.to_string())?) - .map_err(|e| e.to_string())?, - ) - }; - let r = space - .add_type(&serde_json::from_value(rs).map_err(|e| e.to_string())?) - .map_err(|e| e.to_string())?; - let p = p - .map(|p| { - space - .get_type(&p) - .map(|p| p.ident()) - .map_err(|e| e.to_string()) - }) - .transpose()?; - let r = space.get_type(&r).map_err(|e| e.to_string())?.ident(); - let docs = method - .get("description") - .or_else(|| method.get("summary")) - .and_then(Value::as_str) - .unwrap_or(""); - let arg = if empty { quote!() } else { quote!(params:#p) }; - let params = if empty { - quote!(::sausage_plugin::serde_json::json!({})) - } else { - quote!(params) - }; - let target = format!("{provider}.{wire}"); - if endpoint { - let arg = if empty { quote!() } else { quote!(, #arg) }; - result.push(quote!(#[doc=#docs] #[warn(dead_code)] pub fn #ident(&self #arg)->::sausage_plugin::EndpointCall<#r>{self.0.call(#wire,#params)})); - } else { - result.push(quote!(#[doc=#docs] #[warn(dead_code)] pub fn #ident(#arg)->::sausage_plugin::TypedCall<#r>{::sausage_plugin::TypedCall::new(::sausage_plugin::call(#target,#params))})); - } - } - Ok(result) - } - let functions = methods(&mut space, name, &doc["methods"], false)?; - let mut endpoints = vec![]; - for (name, description) in doc["x-sausage-endpoints"].as_object().into_iter().flatten() { - let ident = syn::parse_str::(name) - .or_else(|_| syn::parse_str::(&format!("r#{name}"))) - .map_err(|e| e.to_string())?; - let functions = methods(&mut space, "", &description["methods"], true)?; - endpoints.push(quote!{pub mod #ident {use super::*;pub struct Client(::sausage_plugin::Endpoint);pub async fn connect(path:impl AsRef<::std::path::Path>)->::sausage_plugin::Result{let endpoint=::sausage_plugin::connect(path).await?;endpoint.require_interface(#name)?;Ok(Client(endpoint))} impl Client {#(#functions)*}}}); - } - let types = space.to_stream().to_string(); - let mut file = syn::parse_file(&types).map_err(|e| e.to_string())?; - fn serde_attrs(items: &mut [syn::Item]) { - for item in items { - match item { - syn::Item::Struct(s) => s - .attrs - .push(syn::parse_quote!(#[serde(crate="::sausage_plugin::serde")])), - syn::Item::Enum(e) => e - .attrs - .push(syn::parse_quote!(#[serde(crate="::sausage_plugin::serde")])), - syn::Item::Mod(m) => { - if let Some((_, items)) = &mut m.content { - serde_attrs(items) - } - } - _ => {} - } - } - } - // Only data types have serde derives. ConversionError is a support type. - for item in &mut file.items { - if let syn::Item::Mod(m) = item - && m.ident == "error" - { - continue; - } - serde_attrs(std::slice::from_mut(item)); - } - let uses_error = file - .items - .iter() - .filter(|item| !matches!(item, syn::Item::Mod(m) if m.ident == "error")) - .any(|item| { - quote!(#item) - .to_string() - .contains("error :: ConversionError") - }); - if !uses_error { - file.items - .retain(|item| !matches!(item, syn::Item::Mod(m) if m.ident == "error")); - } - let types = &file.items; - let mut file = - syn::parse2::(quote!(mod #module {#(#types)* #(#functions)* #(#endpoints)*})) - .map_err(|e| e.to_string())?; - struct Reexports; - impl syn::visit_mut::VisitMut for Reexports { - fn visit_path_mut(&mut self, path: &mut syn::Path) { - syn::visit_mut::visit_path_mut(self, path); - if path.leading_colon.is_some() - && path - .segments - .first() - .is_some_and(|s| s.ident == "serde" || s.ident == "serde_json") - { - path.segments.insert(0, syn::parse_quote!(sausage_plugin)); - } - } - fn visit_attribute_mut(&mut self, attr: &mut syn::Attribute) { - if attr.path().is_ident("derive") { - let mut paths = attr - .parse_args_with( - syn::punctuated::Punctuated::::parse_terminated, - ) - .expect("derive paths"); - for path in &mut paths { - self.visit_path_mut(path); - } - *attr = syn::parse_quote!(#[derive(#paths)]); - } else { - syn::visit_mut::visit_attribute_mut(self, attr); - } - } - } - syn::visit_mut::VisitMut::visit_file_mut(&mut Reexports, &mut file); - Ok(prettyplease::unparse(&file)) -} diff --git a/interface/src/compat.rs b/interface/src/compat.rs deleted file mode 100644 index f7d1040..0000000 --- a/interface/src/compat.rs +++ /dev/null @@ -1,548 +0,0 @@ -//! Checks imports against provider interfaces under PROTOCOL.md section 10.2. -//! Every sender value must fit the receiver schema. Sender objects are closed, -//! while receiver objects are open unless they reject additional properties. -use serde_json::{Map, Value, json}; -use std::{collections::HashSet, fmt}; - -/// One incompatible method, with a sender value when a finite example exists. -#[derive(Clone, Debug, PartialEq)] -pub struct Problem { - pub method: String, - pub message: String, - pub counterexample: Option, -} - -impl fmt::Display for Problem { - /// Formats the failure and identifies the receiver of its counterexample. - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "- {}: {}.", self.method, self.message)?; - if let Some(value) = &self.counterexample { - write!( - formatter, - "\n Counterexample: {value} is not valid for {}.", - if self.message.starts_with("the provider's params") { - "the provider's params" - } else { - "the consumer's result" - } - )?; - } - Ok(()) - } -} - -/// Checks every imported method, including endpoint methods. Extra provider methods are harmless. -pub fn check(expected: &Value, actual: &Value) -> Vec { - let mut problems = Vec::new(); - methods(expected, actual, expected, actual, "", &mut problems); - if let Some(endpoints) = expected["x-sausage-endpoints"].as_object() { - for (name, interface) in endpoints { - methods( - interface, - &actual["x-sausage-endpoints"][name], - expected, - actual, - &format!("{name}."), - &mut problems, - ); - } - } - problems -} - -/// Builds an object schema from the OpenRPC by-name parameter list. -fn params(method: &Value) -> Value { - let mut properties = Map::new(); - let mut required = Vec::new(); - for param in method["params"].as_array().into_iter().flatten() { - let name = param["name"].as_str().unwrap_or_default(); - properties.insert(name.into(), param["schema"].clone()); - if param["required"] == true { - required.push(name); - } - } - json!({"type":"object", "properties":properties, "required":required}) -} - -/// Checks imported methods in both directions and records contextual diagnostics. -fn methods( - expected: &Value, - actual: &Value, - expected_doc: &Value, - actual_doc: &Value, - prefix: &str, - problems: &mut Vec, -) { - for method in expected["methods"].as_array().into_iter().flatten() { - let name = method["name"].as_str().unwrap_or_default(); - let label = format!("{prefix}{name}"); - let Some(provider) = actual["methods"] - .as_array() - .into_iter() - .flatten() - .find(|m| m["name"] == name) - else { - problems.push(Problem { - method: label, - message: "the provider has no such method".into(), - counterexample: None, - }); - continue; - }; - for (part, sender, receiver, sender_doc, receiver_doc) in [ - ( - "params", - params(method), - params(provider), - expected_doc, - actual_doc, - ), - ( - "result", - provider["result"]["schema"].clone(), - method["result"]["schema"].clone(), - actual_doc, - expected_doc, - ), - ] { - let failure = supported(&sender, sender_doc, &mut HashSet::new()) - .and_then(|()| supported(&receiver, receiver_doc, &mut HashSet::new())) - .err() - .map(|message| (message, None)) - .or_else(|| { - subset( - &sender, - &receiver, - sender_doc, - receiver_doc, - &mut HashSet::new(), - ) - .err() - }); - if let Some((message, counterexample)) = failure { - problems.push(Problem { - method: label.clone(), - message: { - let (sender_name, receiver_name) = if part == "params" { - ("consumer", "provider") - } else { - ("provider", "consumer") - }; - let detail = message - .replace("the receiver", &format!("the {receiver_name}")) - .replace("the sender", &format!("the {sender_name}")); - let detail = if part == "params" { - detail.replacen("the field", "the param", 1) - } else { - detail - }; - let verb = if part == "params" { "are" } else { "is" }; - format!("the {receiver_name}'s {part} {verb} incompatible: {detail}") - }, - counterexample, - }); - } - } - } -} - -/// Resolves references in one document and stops at reference cycles. -fn resolve<'document>(sender: &'document Value, document: &'document Value) -> &'document Value { - let mut sender = sender; - let mut seen = HashSet::new(); - while let Some(reference) = sender["$ref"].as_str() { - if !seen.insert(reference) { - break; - } - let Some(next) = reference - .strip_prefix('#') - .and_then(|p| document.pointer(p)) - else { - break; - }; - sender = next; - } - sender -} - -/// Rejects constructs outside the supported subset before comparison. -fn supported(sender: &Value, document: &Value, seen: &mut HashSet) -> Result<(), String> { - if !seen.insert(sender as *const Value as usize) { - return Ok(()); - } - if sender.is_boolean() { - return Ok(()); - } - let Some(object) = sender.as_object() else { - return Err("unsupported schema construct non-schema".into()); - }; - for (key, value) in object { - match key.as_str() { - "$ref" => { - let target = resolve(sender, document); - if std::ptr::eq(target, sender) { - return Err("unsupported schema construct unresolved $ref".into()); - } - supported(target, document, seen)?; - } - "properties" | "definitions" | "$defs" => { - for child in value.as_object().into_iter().flat_map(|m| m.values()) { - supported(child, document, seen)?; - } - } - "items" => supported(value, document, seen)?, - "anyOf" | "oneOf" => { - let branches = value - .as_array() - .ok_or_else(|| format!("unsupported schema construct {key}"))?; - if !branches.iter().all(|b| strings(b).is_some()) - && !(branches.len() == 2 && branches.iter().any(|b| b["type"] == "null")) - { - return Err(format!("unsupported schema construct {key}")); - } - for child in branches { - supported(child, document, seen)?; - } - } - "additionalProperties" if value.is_boolean() => (), - "enum" - if value - .as_array() - .is_some_and(|v| v.iter().all(Value::is_string)) => {} - "const" if value.is_string() => (), - "type" => { - let types: Vec<_> = value - .as_array() - .map(|actual| actual.iter().collect()) - .unwrap_or_else(|| vec![value]); - if !types.iter().all(|t| { - matches!( - t.as_str(), - Some( - "object" - | "array" - | "string" - | "integer" - | "number" - | "null" - | "boolean" - ) - ) - }) { - return Err("unsupported schema construct type".into()); - } - } - "required" | "minimum" | "maximum" | "title" | "description" | "default" - | "examples" | "format" | "$schema" | "$id" | "deprecated" | "readOnly" - | "writeOnly" => (), - _ => return Err(format!("unsupported schema construct {key}")), - } - } - Ok(()) -} - -/// Expands nullable schemas and string unions into individual alternatives. -fn alternatives(sender: &Value) -> Vec { - if let Some(types) = sender["type"].as_array() { - return types - .iter() - .map(|t| { - let mut alternative = sender.clone(); - alternative["type"] = t.clone(); - alternative - }) - .collect(); - } - for key in ["anyOf", "oneOf"] { - if let Some(branches) = sender[key].as_array() { - return branches.iter().flat_map(alternatives).collect(); - } - } - vec![sender.clone()] -} -/// Returns the primitive type, including the implicit type of string enums. -fn kind(sender: &Value) -> &str { - sender["type"].as_str().unwrap_or( - if sender["const"].is_string() || sender["enum"].is_array() { - "string" - } else { - "any" - }, - ) -} -/// Returns the finite string values of an enum or constant schema. -fn strings(sender: &Value) -> Option> { - sender["enum"] - .as_array() - .cloned() - .or_else(|| sender.get("const").map(|v| vec![v.clone()])) -} -/// Constructs a finite sender value, or returns None for an unavoidable cycle. -fn sample(sender: &Value, document: &Value, seen: &mut HashSet) -> Option { - let sender = resolve(sender, document); - if !seen.insert(sender as *const Value as usize) { - return None; - } - let result = alternatives(sender).into_iter().find_map(|sender| { - if sender == false { - return None; - } - if let Some(values) = strings(&sender) { - return values.first().cloned(); - } - Some(match kind(&sender) { - "object" => { - let mut object = Map::new(); - for key in sender["required"] - .as_array() - .into_iter() - .flatten() - .filter_map(Value::as_str) - { - object.insert( - key.into(), - sample(&sender["properties"][key], document, seen)?, - ); - } - Value::Object(object) - } - "array" => json!([]), - "string" => json!("milk"), - "boolean" => json!(false), - "integer" | "number" => sender - .get("minimum") - .or(sender.get("maximum")) - .cloned() - .unwrap_or(json!(0)), - _ => Value::Null, - }) - }); - seen.remove(&(sender as *const Value as usize)); - result -} - -type Failure = (String, Option); -/// Checks sender inclusion in the receiver and assumes active reference pairs fit. -fn subset( - sender: &Value, - receiver: &Value, - sender_doc: &Value, - receiver_doc: &Value, - active: &mut HashSet<(usize, usize)>, -) -> Result<(), Failure> { - let (sender, receiver) = (resolve(sender, sender_doc), resolve(receiver, receiver_doc)); - let pair = ( - sender as *const Value as usize, - receiver as *const Value as usize, - ); - if !active.insert(pair) { - return Ok(()); - } - let result = (|| { - if sender == false || receiver == true { - return Ok(()); - } - let sender_alternatives: Vec<_> = alternatives(sender) - .into_iter() - .flat_map(|v| { - if let Some(values) = v["enum"].as_array() { - values.iter().map(|value| json!({"const": value})).collect() - } else { - vec![v] - } - }) - .collect(); - let mut receiver_alternatives = alternatives(receiver); - if receiver_alternatives.iter().all(|v| strings(v).is_some()) { - receiver_alternatives = vec![ - json!({"enum": receiver_alternatives.iter().flat_map(|v| strings(v).unwrap()).collect::>()}), - ]; - } - for sender_variant in &sender_alternatives { - let mut failure = None; - let mut accepted = false; - for receiver_variant in &receiver_alternatives { - match compare_variants( - sender_variant, - receiver_variant, - sender_doc, - receiver_doc, - active, - ) { - Ok(()) => { - accepted = true; - break; - } - Err(problem) => failure = Some(problem), - } - } - if !accepted { - return Err(failure.unwrap_or(( - "no receiver alternative accepts the sender value".into(), - sample(sender_variant, sender_doc, &mut HashSet::new()), - ))); - } - } - Ok(()) - })(); - active.remove(&pair); - result -} -/// Compares individual alternatives and wraps nested counterexamples in their parent values. -fn compare_variants( - sender: &Value, - receiver: &Value, - sender_doc: &Value, - receiver_doc: &Value, - active: &mut HashSet<(usize, usize)>, -) -> Result<(), Failure> { - if sender.get("$ref").is_some() || receiver.get("$ref").is_some() { - return subset(sender, receiver, sender_doc, receiver_doc, active); - } - let fail = |message: String| (message, sample(sender, sender_doc, &mut HashSet::new())); - let (sender_type, receiver_type) = (kind(sender), kind(receiver)); - if sender_type == "number" && receiver_type == "integer" { - let low = sender["minimum"] - .as_f64() - .unwrap_or_else(|| sender["maximum"].as_f64().map_or(0.0, |v| v.floor() - 1.0)); - let high = sender["maximum"].as_f64().unwrap_or(low + 2.0); - let value = if low.fract() != 0.0 { low } else { low + 0.5 }; - return Err(( - "the sender sends number, but the receiver expects integer".into(), - (value <= high).then(|| json!(value)), - )); - } - if receiver == false - || (receiver_type != "any" - && sender_type != receiver_type - && !(sender_type == "integer" && receiver_type == "number")) - { - return Err(fail(format!( - "the sender sends {sender_type}, but the receiver expects {receiver_type}" - ))); - } - if receiver_type == "any" { - return Ok(()); - } - match sender_type { - "object" => { - for key in receiver["required"].as_array().into_iter().flatten() { - if !sender["required"] - .as_array() - .is_some_and(|v| v.contains(key)) - { - return Err(fail(format!( - "the receiver requires the field {key} ({}), but the sender does not send it", - kind(resolve( - &receiver["properties"][key.as_str().unwrap_or_default()], - receiver_doc - )) - ))); - } - } - for (key, child) in sender["properties"] - .as_object() - .into_iter() - .flat_map(|m| m.iter()) - { - let error = if let Some(other) = receiver["properties"].get(key) { - subset(child, other, sender_doc, receiver_doc, active).err() - } else if receiver["additionalProperties"] == false { - Some(( - format!( - "the receiver rejects the field {key:?} ({}), which the sender can send", - kind(resolve(child, sender_doc)) - ), - sample(child, sender_doc, &mut HashSet::new()), - )) - } else { - None - }; - if let Some((message, example)) = error { - let mut object = sample(sender, sender_doc, &mut HashSet::new()); - if let (Some(Value::Object(object)), Some(value)) = (&mut object, example) { - object.insert(key.clone(), value); - } else { - object = None; - } - return Err(( - format!("the field {key:?} is incompatible: {message}"), - object, - )); - } - } - } - "array" => { - if let Err((message, example)) = subset( - sender.get("items").unwrap_or(&Value::Bool(true)), - receiver.get("items").unwrap_or(&Value::Bool(true)), - sender_doc, - receiver_doc, - active, - ) { - return Err((message, example.map(|v| json!([v])))); - } - } - "integer" | "number" => { - for (key, low) in [("minimum", true), ("maximum", false)] { - if let Some(bound) = receiver[key].as_f64() - && sender[key].as_f64().is_none_or(|v| { - let integer = |v: &Value| { - v.as_i64() - .map(i128::from) - .or_else(|| v.as_u64().map(i128::from)) - }; - if let (Some(sender), Some(receiver)) = - (integer(&sender[key]), integer(&receiver[key])) - { - if low { - sender < receiver - } else { - sender > receiver - } - } else if low { - v < bound - } else { - v > bound - } - }) - { - let witness = sender - .get(key) - .cloned() - .unwrap_or_else(|| json!(if low { bound - 1.0 } else { bound + 1.0 })); - return Err(( - format!( - "the sender sends {sender_type} outside the receiver's {key} of {}", - receiver[key] - ), - Some(witness), - )); - } - } - } - "string" => { - if let Some(allowed) = strings(receiver) { - let bad = match strings(sender) { - Some(values) => values.into_iter().find(|v| !allowed.contains(v)), - None => { - let mut text = "milk".to_string(); - while allowed.contains(&json!(text)) { - text.push('!'); - } - Some(json!(text)) - } - }; - if let Some(value) = bad { - return Err(( - "the sender sends a string variant outside the receiver's string enum" - .into(), - Some(value), - )); - } - } - } - _ => (), - } - Ok(()) -} diff --git a/interface/src/document.rs b/interface/src/document.rs deleted file mode 100644 index 7efc887..0000000 --- a/interface/src/document.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Shared access to OpenRPC documents and their Sausage extensions. -use serde_json::{Value, json}; - -/// An OpenRPC document, including JSON Schema and extension fields. -#[derive(Clone, Debug, PartialEq)] -pub struct Document(pub Value); - -impl Document { - /// Create an empty main interface. - pub fn new(title: &str, version: &str) -> Self { - Self( - json!({"openrpc":"1.2.6", "info":{"title":title,"version":version}, - "methods":[], "components":{"schemas":{}}, - "x-sausage-endpoints":{}, "x-sausage-imports":{}}), - ) - } - - /// Read the method descriptions. - pub fn methods(&self) -> &[Value] { - self.0["methods"] - .as_array() - .map(Vec::as_slice) - .unwrap_or_default() - } - - /// Read a named schema. - pub fn schema(&self, name: &str) -> Option<&Value> { - self.0.get("components")?.get("schemas")?.get(name) - } - - /// Consume the document as a JSON value. - pub fn into_value(self) -> Value { - self.0 - } -} diff --git a/interface/src/lib.rs b/interface/src/lib.rs deleted file mode 100644 index 1eb2d3c..0000000 --- a/interface/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Sausage plugin interfaces: OpenRPC documents, the compatibility check -//! (`compat`), and the generator of client code for `import!`. - -pub mod compat; -pub mod document; -pub use document::Document; -#[cfg(feature = "codegen")] -mod client; -#[cfg(feature = "codegen")] -pub use client::client_rs; diff --git a/interface/tests/client.rs b/interface/tests/client.rs deleted file mode 100644 index fc29765..0000000 --- a/interface/tests/client.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Check the shared generator against a real example's discovered document. - -/// String enums retain the conversion support that their parsers need. -#[test] -fn enum_client_keeps_used_error_support() { - let doc = serde_json::json!({ - "components":{"schemas":{"Mode":{"type":"string","enum":["fast","slow"]}}}, - "methods":[{"name":"mode","params":[],"result":{"schema":{"$ref":"#/components/schemas/Mode"}}}] - }); - let code = sausage_interface::client_rs("settings", &doc).unwrap(); - assert!(code.contains("pub mod error")); - assert!(code.contains("error::ConversionError")); - assert!(!code.contains("ModeParams")); - syn::parse_file(&code).unwrap(); -} -#[test] -fn notes_client_preserves_names_and_docs() { - let document = - serde_json::from_str(include_str!("../../examples/consumer/imports/notes.json")).unwrap(); - let code = sausage_interface::client_rs("notes", &document).unwrap(); - assert!(code.contains("pub struct AddParams")); - assert!(code.contains("pub struct Added")); - assert!(!code.contains("AddedResult")); - assert!(!code.contains("pub struct Empty")); - assert!(!code.contains("pub mod error")); - assert!(code.contains("The complete note text")); - assert!(code.contains("pub fn add(params: AddParams)")); - syn::parse_file(&code).unwrap(); - let target = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/sdk"); - std::fs::create_dir_all(&target).unwrap(); - std::fs::write(target.join("notes-client.rs"), code).unwrap(); -} diff --git a/interface/tests/compat.rs b/interface/tests/compat.rs deleted file mode 100644 index 6117d8b..0000000 --- a/interface/tests/compat.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! Draft 7 schemas and randomized subset values exercise the compatibility contract. -#![allow(dead_code)] -use proptest::prelude::*; -use sausage_interface::compat::check; -use schemars::{JsonSchema, generate::SchemaSettings}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -/// Generates a shared-definition schema under the selected serde contract. -fn schema(serialize: bool) -> Value { - let settings = SchemaSettings::draft07().with(|settings| { - settings.definitions_path = "/components/schemas/".into(); - }); - let mut generator = if serialize { - settings.for_serialize() - } else { - settings.for_deserialize() - } - .into_generator(); - let root = serde_json::to_value(generator.subschema_for::()).unwrap(); - let definitions = generator.take_definitions(true); - let mut root = root["$ref"] - .as_str() - .and_then(|reference| reference.strip_prefix("#/components/schemas/")) - .and_then(|name| definitions.get(name)) - .cloned() - .unwrap_or(root); - root["definitions"] = json!(definitions); - root -} - -/// Builds a method document with deserialize params and a serialize result. -fn doc() -> Value { - let params_schema = schema::(false); - let mut result_schema = schema::(true); - let mut definitions = params_schema["definitions"] - .as_object() - .cloned() - .unwrap_or_default(); - definitions.extend( - result_schema - .as_object_mut() - .unwrap() - .remove("definitions") - .unwrap() - .as_object() - .unwrap() - .clone(), - ); - let params: Vec<_> = params_schema["properties"].as_object().into_iter().flat_map(|properties| properties.iter()).map(|(name, schema)| { - json!({"name":name,"required":params_schema["required"].as_array().is_some_and(|required| required.contains(&json!(name))),"schema":schema}) - }).collect(); - json!({"methods":[{"name":"add","params":params,"result":{"name":"result","schema":result_schema}}],"components":{"schemas":definitions}}) -} -#[derive(JsonSchema, Serialize, Deserialize)] -struct Text { - text: String, -} -#[derive(JsonSchema, Serialize, Deserialize)] -struct Body { - body: String, -} -#[derive(JsonSchema, Serialize, Deserialize)] -struct Optional { - text: String, - extra: Option, -} -#[derive(JsonSchema, Serialize, Deserialize)] -struct Nullable { - text: Option, -} -#[derive(JsonSchema, Serialize, Deserialize)] -struct Defaulted { - text: String, - #[serde(default)] - extra: String, -} -#[derive(JsonSchema, Serialize, Deserialize)] -struct Nested { - value: Text, -} -#[derive(JsonSchema, Serialize, Deserialize)] -struct Recursive { - next: Option>, -} -#[derive(JsonSchema, Serialize, Deserialize)] -enum Small { - A, -} -#[derive(JsonSchema, Serialize, Deserialize)] -enum Large { - A, - /// The other variant. - B, -} - -/// A renamed required param produces a concrete diagnostic for the provider's params. -#[test] -fn compat_renamed_param() { - let failures = check(&doc::(), &doc::()); - assert_eq!(failures.len(), 1); - assert_eq!(failures[0].counterexample, Some(json!({"text":"milk"}))); - assert_eq!( - failures[0].to_string(), - "- add: the provider's params are incompatible: the provider requires the param \"body\" (string), but the consumer does not send it.\n Counterexample: {\"text\":\"milk\"} is not valid for the provider's params." - ); -} -/// The provider can add an optional param. -#[test] -fn compat_optional_param() { - assert!(check(&doc::(), &doc::()).is_empty()); -} -/// The provider can add a result field because the consumer accepts unknown fields. -#[test] -fn compat_added_result_field() { - assert!(check(&doc::(), &doc::()).is_empty()); -} -/// A removed method breaks its consumer. -#[test] -fn compat_removed_method() { - let expected = doc::(); - let mut actual = expected.clone(); - actual["methods"] = json!([]); - assert_eq!(check(&expected, &actual)[0].method, "add"); -} -/// A changed result type identifies the consumer as the receiver. -#[test] -fn compat_changed_type() { - let failures = check(&doc::(), &doc::()); - assert!( - failures[0] - .message - .contains("the provider sends integer, but the consumer expects string") - ); - assert!( - failures[0] - .to_string() - .ends_with("is not valid for the consumer's result.") - ); -} -/// A new required param rejects an existing consumer call. -#[test] -fn compat_required_param() { - let expected = doc::(); - let mut actual = expected.clone(); - actual["methods"][0]["params"] - .as_array_mut() - .unwrap() - .push(json!({"name":"extra","required":true,"schema":{"type":"string"}})); - assert!(!check(&expected, &actual).is_empty()); -} -/// Option fields permit omission and null, which a required string rejects. -#[test] -fn compat_option_fields() { - assert!(!check(&doc::(), &doc::()).is_empty()); -} -/// A serde default makes a new param optional for deserialization. -#[test] -fn compat_default_fields() { - assert!(check(&doc::(), &doc::()).is_empty()); -} -/// Nested struct definitions resolve within each document. -#[test] -fn compat_nested_refs() { - assert!(check(&doc::(), &doc::()).is_empty()); -} -/// Recursive references terminate through active schema pairs. -#[test] -fn compat_recursive_types() { - assert!( - check( - &doc::(), - &doc::() - ) - .is_empty() - ); -} -/// Documented enum variants support safe narrowing but reject new result variants. -#[test] -fn compat_enums() { - assert!(check(&doc::<(), Large>(), &doc::<(), Small>()).is_empty()); - assert!(!check(&doc::<(), Small>(), &doc::<(), Large>()).is_empty()); -} -/// Integer ranges preserve u8 versus u32 and exact bounds above the f64 integer range. -#[test] -fn compat_integer_ranges() { - assert!(check(&doc::<(), u32>(), &doc::<(), u8>()).is_empty()); - assert!(!check(&doc::<(), u8>(), &doc::<(), u32>()).is_empty()); - assert!(!fits( - json!({"type":"integer","maximum":9007199254740993u64}), - json!({"type":"integer","maximum":9007199254740992u64}) - )); -} -/// Endpoint methods use the same rules and include the endpoint name in problems. -#[test] -fn compat_endpoint_methods() { - let expected = json!({"methods":[],"x-sausage-endpoints":{"control":doc::()}}); - let mut actual = expected.clone(); - actual["x-sausage-endpoints"]["control"]["methods"] = json!([]); - assert_eq!(check(&expected, &actual)[0].method, "control.add"); -} -/// Unsupported constructs fail explicitly, including constructs behind references. -#[test] -fn compat_unsupported_construct() { - let mut actual = result_doc(json!({"$ref":"#/definitions/Text"})); - actual["definitions"] = json!({"Text":{"type":"string","pattern":"x"}}); - assert!( - check(&result_doc(json!({"type":"string"})), &actual)[0] - .message - .contains("unsupported schema construct pattern") - ); -} -/// Nullable union spellings accept the same values, but plain strings reject null. -#[test] -fn compat_nullable_unions() { - assert!(fits( - json!({"anyOf":[{"type":"string"},{"type":"null"}]}), - json!({"type":["string","null"]}) - )); - assert!(!fits( - json!({"type":["string","null"]}), - json!({"type":"string"}) - )); -} -/// Nested nullable unions still reject values outside every branch. -#[test] -fn compat_nested_nullable_unions() { - assert!(!fits( - json!({"type":"integer","minimum":0,"maximum":0}), - json!({"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]}) - )); -} -/// A closed receiver rejects extra fields that an open receiver accepts. -#[test] -fn compat_closed_objects() { - let sender = json!({"type":"object","properties":{"extra":{"type":"string"}}}); - assert!(fits(sender.clone(), json!({"type":"object"}))); - assert!(!fits( - sender, - json!({"type":"object","additionalProperties":false}) - )); -} -/// Integers fit numbers, but numbers do not fit integers. -#[test] -fn compat_numeric_types() { - assert!(fits(json!({"type":"integer"}), json!({"type":"number"}))); - assert!(!fits(json!({"type":"number"}), json!({"type":"integer"}))); -} -/// Legacy definitions references resolve in the sender document. -#[test] -fn compat_definitions_ref() { - let mut actual = result_doc(json!({"$ref":"#/definitions/Text"})); - actual["definitions"] = json!({"Text":{"type":"string"}}); - assert!(check(&result_doc(json!({"type":"string"})), &actual).is_empty()); -} -/// Wraps one result schema in a minimal OpenRPC document. -fn result_doc(schema: Value) -> Value { - json!({"methods":[{"name":"value","params":[],"result":{"schema":schema}}]}) -} -/// Checks inclusion in the result direction. -fn fits(sender: Value, receiver: Value) -> bool { - check(&result_doc(receiver), &result_doc(sender)).is_empty() -} - -/// Generates bounded recursive schemas across the supported data shapes. -fn random_schema() -> BoxedStrategy { - let leaf = prop_oneof![ - Just(json!({"type":"string"})), - Just(json!({"type":"boolean"})), - Just(json!({"type":"null"})), - (-20i64..20, 0i64..40).prop_map( - |(minimum, width)| json!({"type":"integer","minimum":minimum,"maximum":minimum+width}) - ), - proptest::collection::vec("[a-c]{1,3}", 1..5) - .prop_map(|values| json!({"type":"string","enum":values})), - ]; - leaf.prop_recursive(3, 32, 4, |inner| prop_oneof![ - inner.clone().prop_map(|items|json!({"type":"array","items":items})), - inner.clone().prop_map(|schema|json!({"anyOf":[schema,{"type":"null"}]})), - (inner.clone(),inner,any::(),any::(),any::()).prop_map(|(first,second,require_first,require_second,closed)| { - let required: Vec<_> = [("first",require_first),("second",require_second)].into_iter().filter_map(|(name,required)|required.then_some(name)).collect(); - json!({"type":"object","properties":{"first":first,"second":second},"required":required,"additionalProperties":!closed}) - }), - ]).boxed() -} -/// Independently validates generated schemas with open receiver object semantics. -fn valid(schema: &Value, value: &Value) -> bool { - if let Some(branches) = schema["anyOf"].as_array() { - return branches.iter().any(|branch| valid(branch, value)); - } - if let Some(values) = schema["enum"].as_array() { - return values.contains(value); - } - match schema["type"].as_str().unwrap() { - "null" => value.is_null(), - "string" => value.is_string(), - "boolean" => value.is_boolean(), - "integer" => value.as_i64().is_some_and(|number| { - number >= schema["minimum"].as_i64().unwrap() - && number <= schema["maximum"].as_i64().unwrap() - }), - "array" => value - .as_array() - .is_some_and(|items| items.iter().all(|item| valid(&schema["items"], item))), - "object" => value.as_object().is_some_and(|object| { - schema["required"] - .as_array() - .unwrap() - .iter() - .all(|name| object.contains_key(name.as_str().unwrap())) - && object.iter().all(|(name, value)| { - schema["properties"] - .get(name) - .map_or(schema["additionalProperties"] != false, |child| { - valid(child, value) - }) - }) - }), - _ => unreachable!(), - } -} -/// Generates a valid sender value from random choices without discarding optional fields globally. -fn generate_value(schema: &Value, choices: &mut impl Iterator) -> Value { - let choice = choices.next().unwrap_or(0) as usize; - if let Some(branches) = schema["anyOf"].as_array() { - return generate_value(&branches[choice % branches.len()], choices); - } - if let Some(values) = schema["enum"].as_array() { - return values[choice % values.len()].clone(); - } - match schema["type"].as_str().unwrap() { - "null" => Value::Null, - "string" => json!(format!("text{choice}")), - "boolean" => json!(choice.is_multiple_of(2)), - "integer" => { - let minimum = schema["minimum"].as_i64().unwrap(); - let maximum = schema["maximum"].as_i64().unwrap(); - json!(minimum + choice as i64 % (maximum - minimum + 1)) - } - "array" => json!( - (0..choice % 4) - .map(|_| generate_value(&schema["items"], choices)) - .collect::>() - ), - "object" => { - let mut object = serde_json::Map::new(); - for (name, child) in schema["properties"].as_object().unwrap() { - if schema["required"] - .as_array() - .unwrap() - .contains(&json!(name)) - || choices.next().unwrap_or(0).is_multiple_of(2) - { - object.insert(name.clone(), generate_value(child, choices)); - } - } - Value::Object(object) - } - _ => unreachable!(), - } -} -proptest! { - #![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })] - /// Random supported schemas preserve every sampled sender value after successful inclusion checks. - #[test] - fn compat_subset_property(sender in random_schema(), independent_receiver in random_schema(), reuse_sender in any::(), choices in proptest::collection::vec(proptest::collection::vec(any::(),64..128),1..12)) { - let receiver = if reuse_sender { sender.clone() } else { independent_receiver }; - for choices in choices { - let value = generate_value(&sender,&mut choices.into_iter()); - prop_assert!(valid(&sender,&value)); - if fits(sender.clone(),receiver.clone()) { prop_assert!(valid(&receiver,&value), "sender={sender}, receiver={receiver}, value={value}"); } - } - } -} diff --git a/interface/tests/compat_params.rs b/interface/tests/compat_params.rs deleted file mode 100644 index fa410df..0000000 --- a/interface/tests/compat_params.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! A provider may add its first optional named parameter. -use serde_json::json; -#[test] -fn first_optional_param_is_compatible() { - let consumer = - json!({"methods":[{"name":"status","params":[],"result":{"schema":{"type":"string"}}}]}); - let mut provider = consumer.clone(); - provider["methods"][0]["params"] = - json!([{"name":"detail","required":false,"schema":{"type":"boolean"}}]); - provider["methods"][0]["x-sausage-params"] = - json!({"type":"object","properties":{"detail":{"type":"boolean"}}}); - assert!(sausage_interface::compat::check(&consumer, &provider).is_empty()); -} diff --git a/interface/tests/review_client.rs b/interface/tests/review_client.rs deleted file mode 100644 index fef2358..0000000 --- a/interface/tests/review_client.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Client generation accepts unusual method names and reports invalid parameter names. -use serde_json::json; -#[test] -fn underscore_segments_and_empty_params_do_not_panic() { - for name in ["_internal", "a__b", "__"] { - for params in [ - json!([]), - json!([{"name":"value","schema":{"type":"string"}}]), - ] { - let doc = json!({"methods":[{"name":name,"params":params,"result":{"schema":{"type":"null"}}}]}); - syn::parse_file(&sausage_interface::client_rs("probe", &doc).unwrap()).unwrap(); - } - } -} -#[test] -fn unnamed_param_returns_error() { - let doc = json!({"methods":[{"name":"call","params":[{"schema":{"type":"string"}}],"result":{"schema":{"type":"null"}}}]}); - assert!(sausage_interface::client_rs("probe", &doc).is_err()); -} -#[test] -fn invalid_endpoint_identifier_returns_error() { - assert!( - sausage_interface::client_rs( - "probe", - &json!({"x-sausage-endpoints":{"bad-name":{"methods":[]}}}) - ) - .is_err() - ); -} diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml deleted file mode 100644 index e81d29b..0000000 --- a/sdk/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "sausage-plugin" -version = "0.1.0" -edition = "2024" -description = "SDK for Sausage plugins." - -[dependencies] -sausage-plugin-macros = { version = "0.1", path = "macros" } -sausage-interface = { path = "../interface", default-features = false } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -schemars = "1" -futures = "0.3" -inventory = "0.3" - -[target.'cfg(target_os = "wasi")'.dependencies] -wstd = "0.6" - -[dev-dependencies] -sausage-interface = { path = "../interface", features = ["codegen"] } -trybuild = "1" diff --git a/sdk/imports/notes.json b/sdk/imports/notes.json deleted file mode 100644 index 33267ba..0000000 --- a/sdk/imports/notes.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "openrpc": "1.2.6", - "info": {"title": "notes", "version": "0.1.0"}, - "components": {"schemas": { - "AddParams": { - "description": "The parameters of add.", - "type": "object", - "properties": {"text": {"type": "string", "description": "The text to append."}}, - "required": ["text"] - }, - "Added": { - "description": "The result of add.", - "type": "object", - "properties": {"count": {"type": "integer", "minimum": 0, "description": "The total number of notes."}}, - "required": ["count"] - } - }}, - "methods": [{ - "name": "add", - "summary": "Append a note and return the new count.", - "params": [{"name": "text", "required": true, "schema": {"type": "string"}}], - "x-sausage-params": {"$ref": "#/components/schemas/AddParams"}, - "result": {"name": "result", "schema": {"$ref": "#/components/schemas/Added"}} - }] -} diff --git a/sdk/imports/www.json b/sdk/imports/www.json deleted file mode 100644 index 9643143..0000000 --- a/sdk/imports/www.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "openrpc": "1.2.6", - "info": {"title": "www", "version": "0.1.0"}, - "components": {"schemas": { - "StartParams": { - "description": "The server configuration.", - "type": "object", - "properties": {"port": {"type": "integer", "format": "uint16", "minimum": 0, "maximum": 65535, "description": "The HTTP port."}}, - "required": ["port"] - } - }}, - "methods": [{ - "name": "start", - "summary": "Start the HTTP server and return its process ID.", - "params": [{"name": "port", "required": true, "schema": {"type": "integer", "format": "uint16", "minimum": 0, "maximum": 65535}}], - "x-sausage-params": {"$ref": "#/components/schemas/StartParams"}, - "result": {"name": "result", "schema": {"type": "string"}} - }], - "x-sausage-endpoints": {"control": {"methods": [{ - "name": "stats", - "summary": "Return the number of HTTP requests.", - "params": [], - "result": {"name": "result", "schema": {"type": "integer", "format": "uint64", "minimum": 0}} - }]}} -} diff --git a/sdk/macros/Cargo.toml b/sdk/macros/Cargo.toml deleted file mode 100644 index 41b0b34..0000000 --- a/sdk/macros/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "sausage-plugin-macros" -version = "0.1.0" -edition = "2024" -description = "Macros of the Sausage plugin SDK. Use them through `sausage-plugin`." - -[lib] -proc-macro = true - -[dependencies] -sausage-interface = { path = "../../interface" } -serde_json = "1" -syn = { version = "2", features = ["full"] } -quote = "1" -proc-macro2 = "1" diff --git a/sdk/macros/src/lib.rs b/sdk/macros/src/lib.rs deleted file mode 100644 index a998865..0000000 --- a/sdk/macros/src/lib.rs +++ /dev/null @@ -1,364 +0,0 @@ -//! Attribute macros for documented schemas and plugin dispatch. -use proc_macro::TokenStream; -use quote::quote; -use syn::{ - Attribute, Fields, FnArg, ImplItem, Item, ItemImpl, ReturnType, Type, ext::IdentExt, - parse_macro_input, spanned::Spanned, -}; -fn docs(attrs: &[Attribute]) -> String { - attrs - .iter() - .filter_map(|a| { - if a.path().is_ident("doc") - && let syn::Meta::NameValue(n) = &a.meta - && let syn::Expr::Lit(e) = &n.value - && let syn::Lit::Str(s) = &e.lit - { - return Some(s.value().trim().to_string()); - } - None - }) - .collect::>() - .join("\n") -} -fn documented(attrs: &[Attribute], span: proc_macro2::Span) -> syn::Result<()> { - if docs(attrs).is_empty() { - Err(syn::Error::new(span, "a doc comment is required")) - } else { - Ok(()) - } -} -fn serde_subset(attrs: &[Attribute]) -> syn::Result<()> { - for a in attrs { - if a.path().is_ident("schemars") { - return Err(syn::Error::new( - a.span(), - "schemars attributes are outside the schema subset", - )); - } - if a.path().is_ident("serde") { - a.parse_nested_meta(|m| { - if !["rename", "rename_all", "default", "skip_serializing_if"] - .iter() - .any(|s| m.path.is_ident(s)) - { - return Err(m.error("serde attribute is outside the schema subset")); - } - if m.input.peek(syn::Token![=]) { - let _: syn::Expr = m.value()?.parse()?; - } - Ok(()) - })?; - } - } - Ok(()) -} -/// Derive serde and Draft 7 schemas for a documented type in the supported subset. -#[proc_macro_attribute] -pub fn schema(_: TokenStream, input: TokenStream) -> TokenStream { - let item = parse_macro_input!(input as Item); - let result = (|| -> syn::Result { - let (name, attrs, fields, is_params) = match &item { - Item::Struct(s) => { - if !matches!(s.fields, Fields::Named(_)) { - return Err(syn::Error::new( - s.span(), - "schema structs require named fields", - )); - } - ( - &s.ident, - &s.attrs, - s.fields.iter().collect::>(), - true, - ) - } - Item::Enum(e) => { - for v in &e.variants { - if !matches!(v.fields, Fields::Unit) { - return Err(syn::Error::new( - v.span(), - "schema enums require unit variants", - )); - } - serde_subset(&v.attrs)?; - } - (&e.ident, &e.attrs, vec![], false) - } - _ => { - return Err(syn::Error::new( - item.span(), - "schema requires a struct or unit enum", - )); - } - }; - documented(attrs, name.span())?; - serde_subset(attrs)?; - let mut types = vec![]; - for f in fields { - documented(&f.attrs, f.span())?; - serde_subset(&f.attrs)?; - types.push(&f.ty); - } - let params = if is_params { - quote!(impl ::sausage_plugin::Params for #name {}) - } else { - quote!() - }; - Ok(quote! { - #[derive(::sausage_plugin::serde::Serialize,::sausage_plugin::serde::Deserialize,::sausage_plugin::schemars::JsonSchema)] - #[serde(crate="::sausage_plugin::serde")] - #[schemars(crate="::sausage_plugin::schemars")] - #item - impl ::sausage_plugin::SchemaType for #name {} - #params - const _: () = { fn check() { fn supported() {} #(supported::<#types>();)* } }; - }) - })(); - result.unwrap_or_else(syn::Error::into_compile_error).into() -} -/// Generate interface dispatch and discovery from documented methods. -#[proc_macro_attribute] -pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream { - let mut item = parse_macro_input!(input as ItemImpl); - let args = proc_macro2::TokenStream::from(args); - let result = (|| -> syn::Result { - let mut grace = None; - let mut endpoints: Vec<(syn::Ident, syn::Type)> = vec![]; - let parser = syn::meta::parser(|m| { - if m.path.is_ident("grace_seconds") { - grace = Some(m.value()?.parse::()?); - Ok(()) - } else if m.path.is_ident("endpoints") { - let content; - syn::parenthesized!(content in m.input); - while !content.is_empty() { - let name = content.parse()?; - let _: syn::Token![=] = content.parse()?; - let ty = content.parse()?; - endpoints.push((name, ty)); - if !content.is_empty() { - let _: syn::Token![,] = content.parse()?; - } - } - Ok(()) - } else { - Err(m.error("unknown RPC option")) - } - }); - syn::parse::Parser::parse2(parser, args)?; - let ty = &item.self_ty; - let mut arms = vec![]; - let mut entries = vec![]; - let mut stop = quote!(); - let mut has_stop = false; - let mut has_retired = false; - let mut has_superseded = false; - for member in &mut item.items { - let ImplItem::Fn(f) = member else { continue }; - let retired = f.attrs.iter().any(|a| { - a.path() - .segments - .last() - .is_some_and(|s| s.ident == "on_retired") - }); - let superseded = f.attrs.iter().any(|a| { - a.path() - .segments - .last() - .is_some_and(|s| s.ident == "on_superseded") - }); - let stopping = f.attrs.iter().any(|a| { - a.path() - .segments - .last() - .is_some_and(|s| s.ident == "on_stop") - }); - if stopping && std::mem::replace(&mut has_stop, true) { - return Err(syn::Error::new( - f.span(), - "only one on_stop method is allowed", - )); - } - if retired && std::mem::replace(&mut has_retired, true) { - return Err(syn::Error::new( - f.span(), - "only one on_retired method is allowed", - )); - } - if superseded && std::mem::replace(&mut has_superseded, true) { - return Err(syn::Error::new( - f.span(), - "only one on_superseded method is allowed", - )); - } - f.attrs.retain(|a| { - !a.path().segments.last().is_some_and(|s| { - s.ident == "on_retired" || s.ident == "on_stop" || s.ident == "on_superseded" - }) - }); - documented(&f.attrs, f.span())?; - if f.sig.inputs.is_empty() - || !matches!(f.sig.inputs.first(),Some(FnArg::Receiver(r)) if r.reference.is_some() && r.mutability.is_none()) - { - return Err(syn::Error::new(f.sig.span(), "RPC methods require &self")); - } - let name = &f.sig.ident; - let mut args = vec![]; - let mut param = None; - for arg in f.sig.inputs.iter().skip(1) { - let FnArg::Typed(p) = arg else { unreachable!() }; - let is_ctx = matches!(p.ty.as_ref(),Type::Reference(r) if matches!(r.elem.as_ref(),Type::Path(t) if t.path.segments.last().is_some_and(|s|s.ident=="Ctx"))); - if is_ctx { - if !args.is_empty() { - return Err(syn::Error::new(p.span(), "ctx must follow &self")); - } - args.push(quote!(ctx)); - } else { - if param.is_some() { - return Err(syn::Error::new( - p.span(), - "RPC methods take at most one params struct", - )); - } - param = Some(p.ty.as_ref()); - args.push(quote!( - ::sausage_plugin::serde_json::from_value(params) - .map_err(|e| ::sausage_plugin::Error::new(-32602, e.to_string()))? - )); - } - } - let await_ = f.sig.asyncness.map(|_| quote!(.await)); - if stopping { - if !args.is_empty() { - return Err(syn::Error::new(f.span(), "stop hook takes only &self")); - } - stop = quote!(fn stop(&self)->::std::pin::Pin + '_>> {Box::pin(async move {let _=self.#name() #await_;})}); - continue; - } - let wire = if superseded { - "rpc.superseded".to_string() - } else if retired { - "rpc.retired".to_string() - } else { - name.unraw().to_string() - }; - let p = param - .map(|t| quote!(#t)) - .unwrap_or(quote!(::sausage_plugin::Empty)); - let output = match &f.sig.output { - ReturnType::Type(_, t) => match t.as_ref() { - Type::Path(t) - if t.path.segments.last().is_some_and(|s| s.ident == "Result") => - { - let syn::PathArguments::AngleBracketed(a) = - &t.path.segments.last().unwrap().arguments - else { - return Err(syn::Error::new(t.span(), "expected Result")); - }; - let Some(syn::GenericArgument::Type(t)) = a.args.first() else { - return Err(syn::Error::new(a.span(), "expected Result")); - }; - quote!(#t) - } - _ => return Err(syn::Error::new(t.span(), "RPC methods return Result")), - }, - _ => return Err(syn::Error::new(f.span(), "RPC methods return Result")), - }; - let doc = docs(&f.attrs); - arms.push(quote!(#wire => { fn params_type() {} params_type::<#p>(); let result=self.#name(#(#args),*) #await_ ?; Ok(::sausage_plugin::serde_json::to_value(result)?) })); - if !retired && !superseded { - entries.push(quote!(::sausage_plugin::method::<#p,#output>(&mut doc,#wire,#doc);)); - } - } - let grace = grace - .map(|n| quote!(doc.value["x-sausage-grace"]=::sausage_plugin::serde_json::json!(#n);)); - let names: Vec<_> = endpoints - .iter() - .map(|(n, _)| n.unraw().to_string()) - .collect(); - let types: Vec<_> = endpoints.iter().map(|(_, t)| t).collect(); - Ok(quote! {#item impl ::sausage_plugin::Interface for #ty { - fn endpoint_types(&self)->Vec<(::std::any::TypeId,String)>{vec![#((::std::any::TypeId::of::<#types>(),#names.into())),*]} - fn add_methods(mut doc:&mut ::sausage_plugin::Discovery) {#(#entries)*} - fn methods_document()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); Self::add_methods(&mut doc); #grace ::sausage_plugin::finish_document(doc)} - fn describe()->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); Self::add_methods(&mut doc); #grace #(doc.endpoint::<#types>(#names);)* ::sausage_plugin::finish_document(doc)} - fn discover(&self)->::sausage_plugin::serde_json::Value {::describe()} - fn dispatch<'a>(&'a self, method:&'a str, params: ::sausage_plugin::serde_json::Value,ctx:&'a ::sausage_plugin::Ctx)->::std::pin::Pin>+'a>> {Box::pin(async move {if !params.is_object() {return Err(::sausage_plugin::Error::params_not_object(¶ms));} match method {#(#arms,)* _=>Err(::sausage_plugin::Error::new(-32601,"method not found"))}})} - #stop - }}) - })(); - result.unwrap_or_else(syn::Error::into_compile_error).into() -} -/// Generate a private client from `imports/.json`. -#[proc_macro] -pub fn import(input: TokenStream) -> TokenStream { - let name = parse_macro_input!(input as syn::LitStr); - let result = (|| -> Result { - let path = std::path::PathBuf::from( - std::env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?, - ) - .join("imports") - .join(format!("{}.json", name.value())); - let contents = - std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; - let doc = serde_json::from_str(&contents).map_err(|e| e.to_string())?; - let code = sausage_interface::client_rs(&name.value(), &doc)?; - let generated: proc_macro2::TokenStream = code - .parse() - .map_err(|e: proc_macro2::LexError| e.to_string())?; - fn source_spans( - tokens: proc_macro2::TokenStream, - span: proc_macro2::Span, - ) -> proc_macro2::TokenStream { - tokens - .into_iter() - .map(|mut token| { - if let proc_macro2::TokenTree::Group(group) = &token { - let mut replacement = proc_macro2::Group::new( - group.delimiter(), - source_spans(group.stream(), span), - ); - replacement.set_span(span); - token = proc_macro2::TokenTree::Group(replacement); - } - token.set_span(span); - token - }) - .collect() - } - let generated = source_spans(generated, name.span()); - let path = path.to_str().ok_or("non-UTF8 import path")?; - Ok( - quote! {#generated ::sausage_plugin::inventory::submit! {::sausage_plugin::Import {name:#name,document:include_str!(#path)}}}, - ) - })(); - result - .unwrap_or_else(|e| syn::Error::new(name.span(), e).into_compile_error()) - .into() -} -/// Mark the method that handles `rpc.retired` after an upgrade. -#[proc_macro_attribute] -pub fn on_retired(_: TokenStream, input: TokenStream) -> TokenStream { - input -} -/// Mark the method that handles `rpc.superseded` at the start of upgrade grace. -/// Calls still work here. Use `sausage_plugin::drain` to finish active work and exit. -#[proc_macro_attribute] -pub fn on_superseded(_: TokenStream, input: TokenStream) -> TokenStream { - input -} -/// Mark the hook that runs at stdin EOF without a live call transport. -#[proc_macro_attribute] -pub fn on_stop(_: TokenStream, input: TokenStream) -> TokenStream { - input -} -/// Run an async test with the SDK runtime and the available core transport. -#[proc_macro_attribute] -pub fn test(_: TokenStream, input: TokenStream) -> TokenStream { - let mut f = parse_macro_input!(input as syn::ItemFn); - f.sig.asyncness = None; - let body = f.block; - f.block = Box::new(syn::parse_quote!({::sausage_plugin::test_run(async #body)})); - quote!(#[::core::prelude::v1::test] #f).into() -} diff --git a/sdk/src/calls.rs b/sdk/src/calls.rs deleted file mode 100644 index 156e5bc..0000000 --- a/sdk/src/calls.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! Outgoing core calls and notification callbacks. -use super::*; -/// Removes a dropped call's local routing state, without cancelling host work. -struct PendingCall(u64); -impl Drop for PendingCall { - fn drop(&mut self) { - let pending = STATE.with(|s| s.borrow_mut().pending.remove(&self.0)); - if let Some(mut pending) = pending { - // A lock may still be granted: release it when its late response arrives. - if let Some(lease) = pending.unlock.take() { - STATE.with(|s| s.borrow_mut().abandoned_locks.insert(self.0, lease)); - } - // Drop user-owned callback captures outside the STATE borrow. - } - } -} -/// An outgoing request builder. -#[must_use = "await the call to send it"] -pub struct Call { - method: String, - params: Value, - detach: bool, - notify: Option, - pub(super) unlock: Option>, -} -/// Build an outgoing call with object parameters. -pub fn call(method: impl Into, params: impl serde::Serialize) -> Call { - Call { - method: method.into(), - params: serde_json::to_value(params).expect("serializable params"), - detach: false, - notify: None, - unlock: None, - } -} -impl Call { - /// Detach the child process from its caller. - pub fn detach(mut self) -> Self { - self.detach = true; - self - } - /// Receive progress notifications before the result arrives. - /// Dropping the call future removes its local pending entry and callback; - /// later notifications and responses are ignored. It does not cancel host work. - /// - /// For `core.watch`, poll the call future alongside work in the handler, then - /// drop it when the handler returns. Do not put it in `background()` just to - /// keep the watch alive: background tasks keep the process alive, while the - /// watch only ends when the process ends. Dropping the future does not cancel - /// the host watch, but it lets an otherwise idle process exit normally. A - /// long-lived service should keep one watch per purpose for its lifetime, - /// rather than repeatedly opening and dropping watches on the host. - /// - /// Scan only after `armed`, and rescan on `changed`. Events may coalesce, but - /// are not rate-limited: watch the narrowest useful path to avoid rescanning - /// for unrelated writes (for example, watch a mailbox, not all session data). - /// Do not use endpoint connect retries to wait for file publication. - pub fn on_notify(mut self, f: impl FnMut(&str, Value) + 'static) -> Self { - self.notify = Some(Box::new(f)); - self - } -} -impl IntoFuture for Call { - type Output = Result; - type IntoFuture = LocalBoxFuture<'static, Self::Output>; - fn into_future(self) -> Self::IntoFuture { - Box::pin(async move { - if !self.params.is_object() { - return Err(Error::params_not_object(&self.params)); - } - let (tx, rx) = oneshot::channel(); - let id = STATE.with(|s| { - let mut s = s.borrow_mut(); - if !s.active || s.stopping { - return Err(Error::new( - -32000, - "no core transport (or stdin reached EOF)", - )); - } - s.next += 1; - let id = s.next; - s.pending.insert( - id, - Pending { - tx, - notify: self.notify, - unlock: self.unlock, - }, - ); - Ok(id) - })?; - let _pending = PendingCall(id); - let (method, params) = if self.detach { - ( - "core.call".to_string(), - json!({"method":self.method,"params":self.params,"detach":true}), - ) - } else { - (self.method, self.params) - }; - if let Err(e) = send(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})) { - let pending = STATE.with(|s| s.borrow_mut().pending.remove(&id)); - drop(pending); - return Err(e); - } - rx.await - .unwrap_or_else(|_| Err(Error::new(-32000, "core transport closed"))) - }) - } -} - -#[cfg(test)] -mod tests { - use super::{STATE, State, block_on, call, clear_pending, lock, receive}; - use crate::Result; - use futures::{FutureExt, future::LocalBoxFuture}; - use serde_json::{Value, json}; - use std::{cell::RefCell, future::IntoFuture, rc::Rc, task::Context}; - - fn activate() { - STATE.with(|s| { - *s.borrow_mut() = State { - active: true, - ..State::default() - }; - }); - } - fn start( - mut call: LocalBoxFuture<'static, Result>, - ) -> (u64, LocalBoxFuture<'static, Result>) { - assert!( - call.as_mut() - .poll(&mut Context::from_waker(std::task::Waker::noop())) - .is_pending() - ); - (STATE.with(|s| s.borrow().next), call) - } - fn notification(id: u64, name: &str) { - receive(json!({"method":"core.notify","params":{"call":id,"name":name,"params":{}}})); - } - fn response(id: u64, result: Value) { - receive(json!({"id":id,"result":result})); - } - - #[test] - fn dropped_call_removes_pending_and_callback() { - activate(); - let events = Rc::new(RefCell::new(Vec::new())); - let captured = events.clone(); - let (id, future) = start( - call("core.watch", json!({"path":"/mailbox"})) - .on_notify(move |name, _| captured.borrow_mut().push(name.to_owned())) - .into_future(), - ); - notification(id, "armed"); - assert_eq!(&*events.borrow(), &["armed"]); - assert_eq!(Rc::strong_count(&events), 2); - drop(future); - STATE.with(|s| { - assert!(s.borrow().pending.is_empty()); - assert!(s.borrow().abandoned_locks.is_empty()); - }); - assert_eq!(Rc::strong_count(&events), 1); - notification(id, "changed"); - response(id, Value::Null); - receive(json!({"id":id,"error":{"code":-32000,"message":"late error"}})); - assert_eq!(&*events.borrow(), &["armed"]); - } - - #[test] - fn kept_watch_delivers_until_result() { - activate(); - let events = Rc::new(RefCell::new(Vec::new())); - let captured = events.clone(); - let (id, future) = start( - call("core.watch", json!({"path":"/mailbox"})) - .on_notify(move |name, _| captured.borrow_mut().push(name.to_owned())) - .into_future(), - ); - notification(id, "armed"); - notification(id, "changed"); - notification(id, "changed"); - response(id, json!({"done":true})); - assert_eq!(block_on(future).unwrap(), json!({"done":true})); - notification(id, "late"); - assert_eq!(&*events.borrow(), &["armed", "changed", "changed"]); - STATE.with(|s| assert!(s.borrow().pending.is_empty())); - assert_eq!(Rc::strong_count(&events), 1); - } - - #[test] - fn callback_can_drop_its_own_call_without_being_restored() { - activate(); - let holder = Rc::new(RefCell::new(None)); - let captured = holder.clone(); - let (id, future) = start( - call("core.watch", json!({"path":"/mailbox"})) - .on_notify(move |_, _| { - // Panics on a second invocation; the first must remove the entry. - drop(captured.borrow_mut().take().expect("live call")); - }) - .into_future(), - ); - *holder.borrow_mut() = Some(future); - notification(id, "armed"); - STATE.with(|s| assert!(s.borrow().pending.is_empty())); - notification(id, "changed"); - response(id, Value::Null); - assert_eq!(Rc::strong_count(&holder), 1); - } - - #[test] - fn eof_cleanup_can_drop_callback_owned_futures() { - activate(); - let holder = Rc::new(RefCell::new(None)); - let weak = Rc::downgrade(&holder); - let captured = holder.clone(); - let (_, future) = start( - call("core.watch", json!({"path":"/mailbox"})) - .on_notify(move |_, _| { - let _ = &captured; - }) - .into_future(), - ); - *holder.borrow_mut() = Some(future); - drop(holder); - STATE.with(|s| s.borrow_mut().stopping = true); - clear_pending(); - assert!(weak.upgrade().is_none()); - STATE.with(|s| assert!(s.borrow().pending.is_empty())); - } - - #[test] - fn unpolled_call_never_registers() { - activate(); - drop(call("core.watch", json!({"path":"/mailbox"})).into_future()); - STATE.with(|s| { - assert!(s.borrow().pending.is_empty()); - assert_eq!(s.borrow().next, 0); - }); - } - - #[test] - fn dropped_lock_keeps_only_late_grant_cleanup() { - activate(); - let (id, future) = start( - lock("/late") - .into_future() - .map(|r| r.map(|_| Value::Null)) - .boxed_local(), - ); - drop(future); - STATE.with(|s| { - assert!(s.borrow().pending.is_empty()); - assert_eq!(s.borrow().abandoned_locks.len(), 1); - }); - notification(id, "ignored"); - response(id, json!({"held":true})); - STATE.with(|s| assert!(s.borrow().abandoned_locks.is_empty())); - } -} diff --git a/sdk/src/discovery.rs b/sdk/src/discovery.rs deleted file mode 100644 index 19d0673..0000000 --- a/sdk/src/discovery.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! Schema contracts and OpenRPC document generation. -use crate::*; -use serde_json::{Value, json}; - -/// Types within the supported schema subset. -#[diagnostic::on_unimplemented(message = "this type is outside the Sausage schema subset")] -pub trait SchemaType: schemars::JsonSchema {} -/// Named parameter objects. -#[diagnostic::on_unimplemented(message = "RPC params must be a #[schema] struct with named fields")] -pub trait Params: SchemaType {} -macro_rules! primitive { ($($t:ty),*) => { $(impl SchemaType for $t {})* }; } -primitive!( - String, - bool, - u8, - u16, - u32, - u64, - usize, - i8, - i16, - i32, - i64, - isize, - f32, - f64, - () -); -impl SchemaType for Vec {} -impl SchemaType for Option {} - -/// Create an OpenRPC document builder for generated interfaces. -#[doc(hidden)] -pub fn document(title: &str, version: &str) -> Discovery { - fn generator(serialize: bool) -> schemars::SchemaGenerator { - let mut settings = schemars::generate::SchemaSettings::draft07(); - settings.definitions_path = if serialize { - "/sdk-output" - } else { - "/sdk-input" - } - .into(); - if serialize { - settings.for_serialize() - } else { - settings.for_deserialize() - } - .into_generator() - } - Discovery { - value: sausage_interface::Document::new(title, version).into_value(), - input: generator(false), - output: generator(true), - } -} - -/// Shared generators for all methods and endpoint types in one document. -#[doc(hidden)] -pub struct Discovery { - /// The OpenRPC document before reference resolution. - pub value: Value, - input: schemars::SchemaGenerator, - output: schemars::SchemaGenerator, -} -impl Discovery { - /// Add an endpoint with the same input and output generators. - pub fn endpoint(&mut self, name: &str) { - let methods = std::mem::replace(&mut self.value["methods"], json!([])); - T::add_methods(self); - let endpoint = std::mem::replace(&mut self.value["methods"], methods); - self.value["x-sausage-endpoints"][name] = json!({"methods":endpoint}); - } -} - -/// Add a method with independent deserialize and serialize contracts. -#[doc(hidden)] -pub fn method(doc: &mut Discovery, name: &str, docs: &str) { - let params_ref = if P::schema_id() == ::schema_id() { - Value::Null - } else { - serde_json::to_value(doc.input.subschema_for::

()).unwrap() - }; - let schema = params_ref["$ref"] - .as_str() - .and_then(|reference| reference.strip_prefix("#/sdk-input/")) - .and_then(|name| doc.input.definitions().get(name)) - .unwrap_or(&Value::Null); - let params:Vec<_>=schema["properties"].as_object().into_iter().flatten().map(|(name,field)| { - json!({ - "name": name, - "description": field.get("description").cloned().unwrap_or(Value::Null), - "required": schema["required"].as_array().is_some_and(|required|required.contains(&json!(name))), - "schema": field, - }) - }).collect(); - let result_schema = serde_json::to_value(doc.output.subschema_for::()).unwrap(); - let has_params = !params.is_empty(); - let mut method = json!({ - "name":name, - "summary":docs.split("\n\n").next().unwrap_or_default(), - "description":docs, - "paramStructure":"by-name", - "params":params, - "result":{"name":"result","schema":result_schema}, - }); - if has_params { - method["x-sausage-params"] = params_ref; - } - doc.value["methods"].as_array_mut().unwrap().push(method); -} - -/// Resolve shared schema names after all methods contribute their contracts. -#[doc(hidden)] -pub fn finish_document(mut discovery: Discovery) -> Value { - let input = serde_json::to_value(discovery.input.take_definitions(true)).unwrap(); - let output = serde_json::to_value(discovery.output.take_definitions(true)).unwrap(); - let mut doc = discovery.value; - fn refs(value: &mut Value, names: &std::collections::HashMap) { - match value { - Value::Object(map) => { - if let Some(Value::String(reference)) = map.get_mut("$ref") { - if let Some(name) = reference.strip_prefix("#/sdk-input/") { - *reference = format!("#/components/schemas/{name}"); - } else if let Some(name) = reference.strip_prefix("#/sdk-output/") { - *reference = format!( - "#/components/schemas/{}", - names.get(name).map(String::as_str).unwrap_or(name) - ); - } - } - for child in map.values_mut() { - refs(child, names); - } - } - Value::Array(values) => { - for child in values { - refs(child, names); - } - } - _ => {} - } - } - let mut names = std::collections::HashMap::new(); - for (name, schema) in output.as_object().unwrap() { - if let Some(other) = input.get(name) { - let mut a = schema.clone(); - let mut b = other.clone(); - refs(&mut a, &names); - refs(&mut b, &names); - if a != b { - names.insert(name.clone(), format!("{name}Result")); - } - } - } - // Propagate differences through references to nested contracts. - loop { - let before = names.len(); - for (name, schema) in output.as_object().unwrap() { - if let Some(other) = input.get(name) { - let mut a = schema.clone(); - let mut b = other.clone(); - refs(&mut a, &names); - refs(&mut b, &names); - if a != b { - names.insert(name.clone(), format!("{name}Result")); - } - } - } - if names.len() == before { - break; - } - } - doc["components"]["schemas"] = input; - for (name, schema) in output.as_object().unwrap() { - let name = names.get(name).unwrap_or(name); - doc["components"]["schemas"] - .as_object_mut() - .unwrap() - .insert(name.clone(), schema.clone()); - } - refs(&mut doc, &names); - for import in inventory::iter:: { - doc["x-sausage-imports"][import.name] = - serde_json::from_str(import.document).expect("validated import document"); - } - doc -} - -/// Empty parameters for methods without arguments. -#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)] -pub struct Empty {} -impl SchemaType for Empty {} -impl Params for Empty {} diff --git a/sdk/src/endpoints.rs b/sdk/src/endpoints.rs deleted file mode 100644 index 37c5b64..0000000 --- a/sdk/src/endpoints.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! TCP endpoint publication, verified connections, and per-request notifications. -use crate::*; -type Notify = Box; -use serde_json::Value; -#[cfg(target_os = "wasi")] -use serde_json::json; -use std::{future::IntoFuture, marker::PhantomData, path::Path, rc::Rc}; - -/// A verified endpoint address and identity. -/// -/// Connect verifies and closes one connection. Each call opens a new connection -/// and repeats the identity handshake before its request. The first call costs three round trips. -#[derive(Clone)] -#[cfg_attr(not(target_os = "wasi"), allow(dead_code))] -pub struct Endpoint { - address: String, - owner: String, - interface: String, -} -impl Endpoint { - /// Require the interface expected by a generated client. - pub fn require_interface(&self, name: &str) -> Result<()> { - if self.interface == name { - Ok(()) - } else { - Err(Error::new(-32000, "endpoint interface mismatch")) - } - } - /// Build a typed endpoint request. - pub fn call(&self, method: &str, params: impl serde::Serialize) -> EndpointCall { - EndpointCall { - endpoint: self.clone(), - method: method.into(), - params: serde_json::to_value(params).expect("serializable params"), - notify: None, - result: PhantomData, - } - } -} -/// A lazy endpoint request with optional progress callbacks. -#[must_use = "await the endpoint call to send it"] -pub struct EndpointCall { - endpoint: Endpoint, - method: String, - params: Value, - notify: Option, - result: PhantomData, -} -impl EndpointCall { - /// Receive progress from this endpoint handler. - pub fn on_notify(mut self, f: impl FnMut(&str, Value) + 'static) -> Self { - self.notify = Some(Box::new(f)); - self - } -} -impl IntoFuture for EndpointCall { - type Output = Result; - type IntoFuture = futures::future::LocalBoxFuture<'static, Result>; - fn into_future(self) -> Self::IntoFuture { - Box::pin(async move { - Ok(serde_json::from_value( - endpoint_request(self.endpoint, &self.method, self.params, self.notify).await?, - )?) - }) - } -} - -/// Publish a declared interface and keep its listener alive until stdin EOF or [`drain`]. -#[cfg(target_os = "wasi")] -pub async fn listen(path: impl AsRef, value: Rc) -> Result<()> { - use wstd::iter::AsyncIterator; - let name = crate::transport::endpoint_name::()?; - let listener = wstd::net::TcpListener::bind("127.0.0.1:0").await?; - write_atomic( - path, - format!("tcp:{} {} {}\n", listener.local_addr()?, pid(), name), - )?; - crate::transport::endpoint_task(async move { - let mut incoming = listener.incoming(); - while let Some(Ok(stream)) = incoming.next().await { - let value = value.clone(); - let name = name.clone(); - background(async move { - let _ = serve_connection(stream, value, name).await; - }); - } - }); - Ok(()) -} -/// Publish an endpoint. Native programs require the WASI runtime. -#[cfg(not(target_os = "wasi"))] -pub async fn listen(_path: impl AsRef, _value: Rc) -> Result<()> { - crate::transport::endpoint_name::()?; - Err(Error::new(-32000, "endpoints require WASI")) -} - -/// Read an endpoint file and verify its owner before returning a client. -#[cfg(target_os = "wasi")] -pub async fn connect(path: impl AsRef) -> Result { - let mut attempts = 0; - let contents = loop { - match std::fs::read_to_string(path.as_ref()) { - Ok(s) => break s, - Err(e) if e.kind() == std::io::ErrorKind::NotFound && attempts < 20 => { - attempts += 1; - wstd::task::sleep(wstd::time::Duration::from_millis(25)).await; - } - Err(e) => return Err(e.into()), - } - }; - let words: Vec<_> = contents.split_whitespace().collect(); - if words.len() != 3 { - return Err(Error::new(-32602, "invalid endpoint file")); - } - let endpoint = Endpoint { - address: words[0] - .strip_prefix("tcp:") - .ok_or_else(|| Error::new(-32602, "invalid endpoint address"))? - .into(), - owner: words[1].into(), - interface: words[2].into(), - }; - let _ = handshake(&endpoint).await?; - Ok(endpoint) -} -/// Connect to an endpoint. Native programs require the WASI runtime. -#[cfg(not(target_os = "wasi"))] -pub async fn connect(_path: impl AsRef) -> Result { - Err(Error::new(-32000, "endpoints require WASI")) -} - -#[cfg(target_os = "wasi")] -async fn send(stream: &mut impl wstd::io::AsyncWrite, value: Value) -> Result<()> { - stream.write_all(format!("{value}\n").as_bytes()).await?; - stream.flush().await?; - Ok(()) -} -#[cfg(target_os = "wasi")] -async fn handshake(endpoint: &Endpoint) -> Result { - let mut stream = wstd::net::TcpStream::connect(endpoint.address.as_str()).await?; - send( - &mut stream, - json!({"jsonrpc":"2.0","id":0,"method":"rpc.endpoint","params":{}}), - ) - .await?; - let reply = crate::lines::Lines::default() - .rpc(&mut stream) - .await? - .ok_or_else(|| Error::new(-32000, "endpoint closed"))?; - let result = &reply["result"]; - let plugin = endpoint - .owner - .len() - .checked_sub(37) - .and_then(|end| endpoint.owner.get(..end)); - if result["owner"] != endpoint.owner - || result["interface"] != endpoint.interface - || result["plugin"].as_str() != plugin - { - return Err(Error::new(-32000, "endpoint identity mismatch")); - } - Ok(stream) -} -#[cfg(target_os = "wasi")] -async fn endpoint_request( - endpoint: Endpoint, - method: &str, - params: Value, - mut notify: Option, -) -> Result { - let mut stream = handshake(&endpoint).await?; - let mut lines = crate::lines::Lines::default(); - send( - &mut stream, - json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}), - ) - .await?; - while let Some(value) = lines.rpc(&mut stream).await? { - if value["method"] == "core.notify" { - if let Some(f) = &mut notify { - f( - value["params"]["name"].as_str().unwrap_or_default(), - value["params"]["params"].clone(), - ); - } - } else if value["id"] == 1 { - if let Some(error) = value.get("error") { - return Err(serde_json::from_value(error.clone())?); - } - return Ok(value["result"].clone()); - } - } - Err(Error::new(-32000, "endpoint closed before response")) -} -#[cfg(not(target_os = "wasi"))] -async fn endpoint_request( - _endpoint: Endpoint, - _method: &str, - _params: Value, - _notify: Option, -) -> Result { - Err(Error::new(-32000, "endpoints require WASI")) -} - -#[cfg(target_os = "wasi")] -async fn serve_connection( - stream: wstd::net::TcpStream, - value: Rc, - name: String, -) -> Result<()> { - use futures::{FutureExt, StreamExt}; - let (mut reader, mut writer) = stream.split(); - let mut lines = crate::lines::Lines::default(); - while let Some(request) = crate::transport::until_drained(lines.rpc(&mut reader)) - .await - .transpose()? - .flatten() - { - let method = request["method"].as_str().unwrap_or_default(); - let (tx, mut rx) = futures::channel::mpsc::unbounded(); - let ctx = Ctx { - sink: Some(tx), - ..Ctx::default() - }; - let handler=async { - if method=="rpc.endpoint" {Ok(json!({"owner":pid(),"plugin":std::env::var("SAUSAGE_PLUGIN").unwrap_or_default(),"interface":name}))} - else {value.dispatch(method,request.get("params").cloned().unwrap_or(json!({})),&ctx).await} - }.fuse(); - futures::pin_mut!(handler); - let result = loop { - futures::select! {result=handler=>break result,notification=rx.next().fuse()=>{if let Some(notification)=notification {send(&mut writer,notification).await?;}}} - }; - while let Ok(notification) = rx.try_recv() { - send(&mut writer, notification).await?; - } - if let Some(id) = request.get("id") { - let response = match result { - Ok(result) => json!({"jsonrpc":"2.0","id":id,"result":result}), - Err(error) => json!({"jsonrpc":"2.0","id":id,"error":error}), - }; - send(&mut writer, response).await?; - } - if ctx.exit.get() { - crate::transport::exit(); - break; - } - } - Ok(()) -} diff --git a/sdk/src/error.rs b/sdk/src/error.rs deleted file mode 100644 index 132a8a9..0000000 --- a/sdk/src/error.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! JSON-RPC failures and conversions from local errors. -use serde_json::Value; -/// An SDK operation that can fail. -pub type Result = std::result::Result; - -/// A JSON-RPC failure. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Error { - /// The JSON-RPC error code. - pub code: i64, - /// The diagnostic message. - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - /// Optional structured error details. - pub data: Option, -} -impl Error { - /// Build the object-params diagnostic used by calls and generated dispatch. - #[doc(hidden)] - pub fn params_not_object(params: &Value) -> Self { - let kind = match params { - Value::String(_) => "a string", - Value::Array(_) => "an array", - Value::Number(_) => "a number", - Value::Null => "null", - Value::Bool(_) => "a boolean", - Value::Object(_) => "an object", - }; - Self::new( - -32602, - format!( - "params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string." - ), - ) - } - - /// Construct an error without structured details. - pub fn new(code: i64, message: impl Into) -> Self { - Self { - code, - message: message.into(), - data: None, - } - } -} -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) - } -} -impl std::error::Error for Error {} -impl From for Error { - fn from(e: std::io::Error) -> Self { - Self::new(-32603, e.to_string()) - } -} -impl From for Error { - fn from(e: serde_json::Error) -> Self { - Self::new(-32603, e.to_string()) - } -} diff --git a/sdk/src/helpers.rs b/sdk/src/helpers.rs deleted file mode 100644 index ff2dea9..0000000 --- a/sdk/src/helpers.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Process identity and atomic state-file helpers. -use crate::Result; -use std::path::{Path, PathBuf}; -/// Return the process ID from `SAUSAGE_PID`. -pub fn pid() -> String { - std::env::var("SAUSAGE_PID").unwrap_or_default() -} -/// Return the persistent state directory of this plugin. -pub fn state_dir() -> PathBuf { - PathBuf::from("/state").join(std::env::var("SAUSAGE_PLUGIN").unwrap_or_default()) -} -/// Replace a file atomically after a complete temporary write. -pub fn write_atomic(path: impl AsRef, bytes: impl AsRef<[u8]>) -> Result<()> { - let path = path.as_ref(); - let temp = path.with_extension(format!("tmp-{}", pid())); - std::fs::write(&temp, bytes)?; - std::fs::rename(temp, path)?; - Ok(()) -} diff --git a/sdk/src/imports.rs b/sdk/src/imports.rs deleted file mode 100644 index 0bbadd8..0000000 --- a/sdk/src/imports.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Typed outgoing calls and statically registered imported interfaces. -use crate::{Call, Result}; -use serde_json::Value; -use std::{future::IntoFuture, marker::PhantomData}; - -/// An imported provider document registered by `import!`. -#[doc(hidden)] -pub struct Import { - /// The provider name. - pub name: &'static str, - /// Its complete OpenRPC document. - pub document: &'static str, -} -inventory::collect!(Import); - -/// An outgoing call whose result decodes to a generated Rust type. -#[must_use = "await the call to send it"] -pub struct TypedCall { - call: Call, - result: PhantomData, -} -impl TypedCall { - /// Wrap an untyped call without sending it yet. - pub fn new(call: Call) -> Self { - Self { - call, - result: PhantomData, - } - } - /// Detach the child process from its caller. - pub fn detach(mut self) -> Self { - self.call = self.call.detach(); - self - } - /// Receive progress notifications while the call runs. - pub fn on_notify(mut self, f: impl FnMut(&str, Value) + 'static) -> Self { - self.call = self.call.on_notify(f); - self - } -} -impl IntoFuture for TypedCall { - type Output = Result; - type IntoFuture = futures::future::LocalBoxFuture<'static, Result>; - fn into_future(self) -> Self::IntoFuture { - Box::pin(async move { Ok(serde_json::from_value(self.call.await?)?) }) - } -} diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs deleted file mode 100644 index 582c79a..0000000 --- a/sdk/src/lib.rs +++ /dev/null @@ -1,426 +0,0 @@ -//! Write single-threaded WASI plugins for Sausage. -//! -//! # Start here -//! -//! A plugin depends on `sausage-plugin = "0.1"` in its `Cargo.toml`. -//! Use Rust edition 2024. The cargo plugin supplies the local SDK dependency. -//! The SDK re-exports serde, schemars, serde_json, and its macros. -//! Build the plugin through `cargo.build`. Activate the returned WASM file through `core.activate`. -//! Read `/proc/core/PROTOCOL.md` for the host contract. -//! -//! Each core call starts a process. Keep the value passed to [`run`] cheap to construct. -//! The core also starts a process just for `rpc.discover`. -//! Open files, databases, and listeners in methods, not in `main`. -//! Log with `eprintln!`. Stdout belongs to JSON-RPC. -//! -//! # Methods and schemas -//! -//! Each `fn` in an [`macro@rpc`] impl becomes an RPC method. -//! Put helpers in a separate impl block without `#[rpc]`. -//! Both synchronous and async methods must follow these rules: -//! -//! - Take `&self` first. -//! - Optionally take `ctx: &Ctx` next. -//! - Take at most one parameter argument after that. -//! - Use a [`macro@schema`] struct with named fields for that argument. -//! - Return [`Result`]. -//! - Give each method, schema type, and field a doc comment. -//! -//! The parameter fields become the named JSON parameters. -//! A method without parameters accepts an empty object. -//! Its discovery entry has no `x-sausage-params` field. -//! Raw identifiers such as `r#type` use the plain wire name `type`. -//! Method docs become the OpenRPC summary and description. -//! Type and field docs become schema descriptions. Generated clients retain these docs. -//! Docs are part of the interface. Other agents read them to write callers. -//! -//! `#[schema]` adds serde and schemars derives. Do not add those derives yourself. -//! These types form the supported subset: -//! -//! - `String`, `bool`, and `()`. -//! - `u8`, `u16`, `u32`, `u64`, and `usize`. -//! - `i8`, `i16`, `i32`, `i64`, and `isize`. -//! - `f32` and `f64`. -//! - `Vec` and `Option` with supported element types. -//! - Other `#[schema]` structs and enums. -//! -//! Structs must have named fields. Enums must have unit variants. -//! Document the variants to explain their meanings to callers. -//! The permitted serde attributes are `rename`, `rename_all`, `default`, and `skip_serializing_if`. -//! For example, use `#[serde(default)]` for a field with a default value. -//! Use `#[serde(default, skip_serializing_if = "Option::is_none")]` for an omitted optional field. -//! `#[schema]` rejects other serde attributes and all `#[schemars(...)]` attributes. -//! Tuple structs, unit structs, tuple fields, maps, arbitrary JSON values, and data-carrying variants are outside this subset. -//! Such fields cannot appear in a `#[schema]` type. -//! -//! # A persistent plugin -//! -//! Calls can run concurrently in separate processes. Protect shared changes with [`lock`]. -//! Bind the guard to a name: `let _guard = lock(p).await?;`. -//! `let _ = lock(p).await?;` releases the lock at once. -//! The guard releases the lock when its scope ends. Process exit also releases locks. -//! `try_now()` does not wait. Check the returned guard with `held()`. -//! `keep()` retains an acquired lock until process exit. -//! -//! This complete plugin stores notes in [`state_dir`]. [`write_atomic`] replaces the file after a complete temporary write. -//! Create the state directory before the first write. -//! -//! ```no_run -//! use sausage_plugin::{lock, rpc, schema, state_dir, write_atomic, Result}; -//! use sausage_plugin::serde_json; -//! use std::path::PathBuf; -//! -//! /// Persistent notes shared by all calls. -//! struct Notes; -//! /// The parameters of add. -//! #[schema] -//! struct AddParams { -//! /// The text to append. Newlines are permitted. -//! text: String, -//! } -//! /// The result of add. -//! #[schema] -//! struct Added { -//! /// The total number of notes. -//! count: usize, -//! } -//! #[rpc] -//! impl Notes { -//! /// Append a note and return the new count. -//! async fn add(&self, p: AddParams) -> Result { -//! std::fs::create_dir_all(state_dir())?; -//! let _guard = lock(Self::file()).await?; -//! let mut notes = Self::load()?; -//! notes.push(p.text); -//! write_atomic(Self::file(), serde_json::to_vec(¬es)?)?; -//! Ok(Added { count: notes.len() }) -//! } -//! /// List the notes, oldest first. -//! fn list(&self) -> Result> { Self::load() } -//! } -//! // These helpers do not become RPC methods. -//! impl Notes { -//! fn file() -> PathBuf { state_dir().join("notes.json") } -//! fn load() -> Result> { -//! match std::fs::read(Self::file()) { -//! Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), -//! Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]), -//! Err(e) => Err(e.into()), -//! } -//! } -//! } -//! fn main() { sausage_plugin::run(Notes); } -//! ``` -//! -//! # Calls and process lifetime -//! -//! [`call`] returns a lazy builder. Await it to send the request and receive its JSON result. -//! `detach()` lets the child outlive this process. `on_notify(f)` receives its progress notifications. -//! Use [`Ctx::notify`] to send progress to the caller of the current request. -//! Do not call [`block_on`] in a handler. The runtime cannot nest `block_on` calls. -//! Write an async handler and use `.await` instead. -//! -//! A process ends after its request finishes if it has no remaining work. -//! [`background`] keeps it alive while a task runs. An endpoint listener also keeps it alive. -//! Neither mechanism detaches the process from its caller. Start long-lived processes detached or as services. -//! At stdin EOF, pending outgoing calls fail with -32000. The SDK stops endpoint listeners and runs the optional stop hook. -//! The stop hook can write files and send notifications. It cannot make new calls. -//! After the serve loop, the SDK flushes stdout and exits, even if background tasks remain. -//! [`Ctx::exit_after_response`] ends the process after its response. This also works in an endpoint handler. -//! -//! ```no_run -//! use sausage_plugin::{background, call, rpc, Ctx, Result}; -//! use sausage_plugin::serde_json::json; -//! /// A worker that delegates a job. -//! struct Worker; -//! #[rpc] -//! impl Worker { -//! /// Start an attached background task and return immediately. -//! fn start(&self, ctx: &Ctx) -> Result<()> { -//! ctx.notify("text", "worker started")?; -//! background(async { -//! let result = call("notes.list", json!({})) -//! .on_notify(|name, params| eprintln!("{name}: {params}")) -//! .await; -//! if let Err(error) = result { eprintln!("{error}"); } -//! }); -//! Ok(()) -//! } -//! /// Flush local files before this process stops. -//! #[sausage_plugin::on_stop] -//! fn stop(&self) -> Result<()> { -//! eprintln!("worker stopped"); -//! Ok(()) -//! } -//! } -//! fn main() { sausage_plugin::run(Worker); } -//! ``` -//! -//! # A server with a process endpoint -//! -//! Declare endpoint types on the main interface. Publish them with [`listen`]. -//! `listen` returns after it publishes the endpoint file, not after the listener ends. -//! `/self/control` belongs to this process. Clients find it at `/proc//control`. -//! This server counts requests in memory. A new core call cannot inspect the old process's counter. -//! -//! ```no_run -//! use sausage_plugin::{listen, pid, rpc, Ctx, Result}; -//! use std::{cell::Cell, rc::Rc}; -//! /// A server with process-local counters. -//! struct Server; -//! /// The interface of one running server. -//! struct Control { hits: Cell } -//! #[rpc(endpoints(control = Control), grace_seconds = 5)] -//! impl Server { -//! /// Start a counter server and return its process ID. -//! async fn start(&self) -> Result { -//! listen("/self/control", Rc::new(Control { hits: Cell::new(0) })).await?; -//! Ok(pid()) -//! } -//! } -//! #[rpc] -//! impl Control { -//! /// Record a request and return the new count. -//! fn record(&self) -> Result { -//! self.hits.set(self.hits.get() + 1); -//! Ok(self.hits.get()) -//! } -//! /// Return the number of recorded requests. -//! fn stats(&self) -> Result { Ok(self.hits.get()) } -//! /// Stop this process after the client receives the response. -//! fn stop(&self, ctx: &Ctx) -> Result<()> { -//! ctx.exit_after_response(); -//! Ok(()) -//! } -//! } -//! fn main() { sausage_plugin::run(Server); } -//! ``` -//! -//! # A service with a fixed endpoint -//! -//! A service can publish a fixed path in `/run`. The core clears `/run` when the host starts. -//! Configure this service in `/config/boot.yaml` with the entry `- method: secrets.serve`. -//! Clients can then connect to `/run/secrets`. -//! -//! ```no_run -//! use sausage_plugin::{listen, rpc, schema, Result}; -//! use std::rc::Rc; -//! /// The service entry point. -//! struct Secrets; -//! /// The in-memory vault of this service process. -//! struct Vault { token: String } -//! /// The name of a secret. -//! #[schema] -//! struct GetParams { -//! /// The secret to read. -//! name: String, -//! } -//! #[rpc(endpoints(vault = Vault))] -//! impl Secrets { -//! /// Load the vault and publish its fixed endpoint. -//! async fn serve(&self) -> Result<()> { -//! let token = std::fs::read_to_string("/config/secret-token")?; -//! listen("/run/secrets", Rc::new(Vault { token })).await -//! } -//! } -//! #[rpc] -//! impl Vault { -//! /// Read the token, or return None for an unknown name. -//! fn get(&self, p: GetParams) -> Result> { -//! Ok((p.name == "token").then(|| self.token.clone())) -//! } -//! } -//! fn main() { sausage_plugin::run(Secrets); } -//! ``` -//! -//! [`connect`] reads the endpoint file and verifies its owner through `rpc.endpoint`. -//! The handshake checks the plugin, owner process ID, and interface name. -//! A generated endpoint client also requires its expected interface name. -//! A mismatch returns -32000. Endpoint calls bypass a fresh core process. -//! `connect` opens and closes a verification connection. Each call opens another connection and repeats the handshake. -//! The first call costs three round trips: verification, another handshake, and the request. -//! Later calls cost a handshake and a request each. The SDK does not pool connections. -//! -//! ```no_run -//! use sausage_plugin::{connect, Result}; -//! use sausage_plugin::serde_json::json; -//! async fn read_token() -> Result> { -//! let vault = connect("/run/secrets").await?; -//! vault.require_interface("vault")?; -//! vault.call("get", json!({"name":"token"})) -//! .on_notify(|name, params| eprintln!("{name}: {params}")) -//! .await -//! } -//! fn main() {} -//! ``` -//! -//! # Import another plugin -//! -//! 1. Copy `/proc/core/plugins//discover.json` to `source/imports/.json` in your plugin folder. -//! 2. Read `/proc/core/plugins//client.rs` for the exact generated client API. -//! 3. Write `sausage_plugin::import!("")` and the code that calls it. -//! 4. Build with `cargo.build`. Remove methods that rustc calls unused from the imported JSON document. -//! 5. Build again after that change. Call `core.activate` with the resulting WASM path. -//! -//! The macro reads `imports/.json` relative to `CARGO_MANIFEST_DIR`, not the current folder. -//! It generates a private module with typed [`TypedCall`] builders. -//! The module retains schema type names and documentation. Await its functions to get their typed results. -//! Builders support `detach()` and `on_notify(f)`. Generated endpoint modules provide typed `connect` functions. -//! The macro tracks the JSON file with `include_str!`, so Cargo rebuilds after changes. -//! It registers the imported contract for `rpc.discover` through inventory. -//! Remove unused methods from the JSON, not from generated Rust. This keeps `x-sausage-imports` small. -//! Retain the schemas that the remaining methods reference. -//! -//! This example imports the notes plugin above and a server with a `control` endpoint. -//! The `www` provider exposes `start(StartParams { port })` and `control.stats()`. -//! -//! ```no_run -//! use sausage_plugin::{rpc, Result}; -//! sausage_plugin::import!("notes"); -//! sausage_plugin::import!("www"); -//! /// A consumer of notes and the HTTP server. -//! struct Consumer; -//! #[rpc] -//! impl Consumer { -//! /// Add a note, start a detached server, and return its request count. -//! async fn example(&self) -> Result { -//! let added = notes::add(notes::AddParams { text: "milk".into() }).await?; -//! eprintln!("{} notes", added.count); -//! let pid = www::start(www::StartParams { port: 8080 }).detach().await?; -//! let control = www::control::connect(&format!("/proc/{pid}/control")).await?; -//! control.stats().await -//! } -//! } -//! fn main() { sausage_plugin::run(Consumer); } -//! ``` -//! -//! # Tests in a world -//! -//! `cargo.build` runs tests under the WASI test runner. -//! Both plain `#[test]` and async [`macro@test`] tests work. Each test gets its own instance and empty temporary world at `/`. -//! The current folder is `/`, not the package folder. Read fixtures through `env!("CARGO_MANIFEST_DIR")`. -//! `SAUSAGE_PLUGIN` is `test`, so `state_dir()` returns `/state/test`. -//! Create directories before writes. `std::env::temp_dir()` does not work under this runner. -//! Use an explicit path in the temporary world instead. -//! The runner answers core lock, unlock, notify, and log requests. Calls to other plugins fail because no other plugins exist. -//! Name filters, `tests/*.rs`, and `--ignored` work. `#[should_panic]` cannot work because WASI cannot catch panics. -//! -//! ```no_run -//! use sausage_plugin::{lock, state_dir, write_atomic}; -//! #[test] -//! fn reads_package_fixture() { -//! let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); -//! assert!(std::fs::read_to_string(path).unwrap().contains("[package]")); -//! } -//! #[sausage_plugin::test] -//! async fn writes_local_state() { -//! let file = state_dir().join("test.txt"); -//! std::fs::create_dir_all(state_dir()).unwrap(); -//! let _guard = lock(&file).await.unwrap(); -//! write_atomic(&file, b"saved").unwrap(); -//! assert_eq!(std::fs::read(file).unwrap(), b"saved"); -//! } -//! fn main() {} -//! ``` -//! -//! # Errors -//! -//! [`Error`] carries `code`, `message`, and optional structured `data`. -//! `Error::new` constructs an error without data. Assign `data` when callers need structured details. -//! Do not reduce an error to its message when another agent needs its code or data. -//! -//! | Code | Meaning | -//! | --- | --- | -//! | -32700 | Invalid JSON. | -//! | -32600 | Invalid JSON-RPC request. | -//! | -32601 | Unknown method. | -//! | -32602 | Invalid method parameters. | -//! | -32603 | Internal error, including converted I/O and serde errors. | -//! | -32000 | Local SDK error: no transport, stdin EOF, endpoint closure, or identity or interface mismatch. | -//! | -32001 | Core: no such plugin. | -//! | -32002 | Core: the process ended without an answer. Data includes its exit status and journal tail. | -//! | -32003 | Core: activation failed. Data describes the reasons. | -//! -//! Parameter decoding failures use -32602 instead of the general serde conversion code. -//! -//! ``` -//! use sausage_plugin::Error; -//! use sausage_plugin::serde_json::json; -//! let mut error = Error::new(-32602, "unknown secret"); -//! error.data = Some(json!({"name":"missing"})); -//! assert_eq!(error.code, -32602); -//! assert_eq!(error.data.unwrap()["name"], "missing"); -//! ``` -//! -//! # State and upgrades -//! -//! The interface check covers calls, not state. Old and new processes share files in `/state`. -//! `core.revert` restores code, not state. Preserve compatibility or migrate the state. -//! Core gives every old process 5 seconds of upgrade grace, then closes stdin and allows -//! a separate 5-second stop budget before killing it. Discovery-only processes are excluded. -//! `#[rpc(grace_seconds = N)]` still emits legacy `x-sausage-grace` metadata, but core ignores it. -//! New calls use the new version immediately. Old processes can still use old imports during their grace period. -//! [`macro@on_superseded`] handles the notice at grace start, with normal calls still available. -//! It is opt-in: without a hook the process continues as before until core stops it. -//! Call [`drain`] in this hook to close SDK endpoint listeners and idle connections, -//! let active handlers and background tasks finish, and exit without waiting for grace. -//! Custom listeners and long-lived background tasks must be ended by the plugin itself. -//! [`macro@on_retired`] handles `rpc.retired` after the last process of an old version ends. -//! This hook is best effort. If the host stops first, the hook does not run. -//! New processes must handle unmigrated state. Check a migration marker and migrate if it is absent. -//! See section 3.7 of `/proc/core/PROTOCOL.md`. -//! Declare at most one hook of each kind per interface. -//! -//! ```no_run -//! use sausage_plugin::{rpc, schema, state_dir, write_atomic, Result}; -//! /// A plugin that records retirement notices. -//! struct Migrator; -//! /// The version that no longer has running processes. -//! #[schema] -//! struct RetiredParams { -//! /// The SHA-256 of the previous WASM file. -//! previous: String, -//! } -//! /// The version now receiving new calls. -//! #[schema] -//! struct SupersededParams { -//! /// The SHA-256 of the newly active WASM file. -//! current: String, -//! } -//! #[rpc] -//! impl Migrator { -//! /// Finish current work and exit early during upgrade grace. -//! #[sausage_plugin::on_superseded] -//! fn superseded(&self, p: SupersededParams) -> Result<()> { -//! eprintln!("replaced by {}", p.current); -//! sausage_plugin::drain(); -//! Ok(()) -//! } -//! /// Record a retired version. Normal methods must also handle missing markers. -//! #[sausage_plugin::on_retired] -//! fn retired(&self, p: RetiredParams) -> Result<()> { -//! std::fs::create_dir_all(state_dir())?; -//! write_atomic(state_dir().join("retired-version"), p.previous.as_bytes()) -//! } -//! } -//! fn main() { sausage_plugin::run(Migrator); } -//! ``` -#![warn(missing_docs)] -mod discovery; -mod endpoints; -mod error; -mod helpers; -mod imports; -mod lines; -mod sqlite; -mod transport; -pub use discovery::*; -pub use endpoints::*; -pub use error::*; -pub use helpers::*; -pub use imports::*; -#[doc(hidden)] -pub use inventory; -pub use sausage_plugin_macros::{import, on_retired, on_stop, on_superseded, rpc, schema, test}; -pub use transport::*; -pub use {schemars, serde, serde_json}; diff --git a/sdk/src/lines.rs b/sdk/src/lines.rs deleted file mode 100644 index 02f654a..0000000 --- a/sdk/src/lines.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Buffered newline framing shared by stdin and endpoint connections. -#[cfg(any(target_os = "wasi", test))] -#[derive(Default)] -pub(crate) struct Lines { - bytes: Vec, - scanned: usize, -} -#[cfg(any(target_os = "wasi", test))] -impl Lines { - fn frame(&mut self) -> Option> { - if let Some(end) = self.bytes[self.scanned..].iter().position(|b| *b == b'\n') { - let end = self.scanned + end; - self.scanned = 0; - return Some(self.bytes.drain(..=end).collect()); - } - self.scanned = self.bytes.len(); - None - } - #[cfg(target_os = "wasi")] - pub(crate) async fn next( - &mut self, - input: &mut impl wstd::io::AsyncRead, - ) -> std::io::Result>> { - loop { - if let Some(line) = self.frame() { - return Ok(Some(line)); - } - let mut buffer = [0; 8192]; - let count = input.read(&mut buffer).await?; - if count == 0 { - self.scanned = 0; - return Ok(if self.bytes.is_empty() { - None - } else { - Some(std::mem::take(&mut self.bytes)) - }); - } - self.bytes.extend_from_slice(&buffer[..count]); - } - } - #[cfg(target_os = "wasi")] - pub(crate) async fn rpc( - &mut self, - input: &mut impl wstd::io::AsyncRead, - ) -> std::io::Result> { - while let Some(bytes) = self.next(input).await? { - if let Ok(value) = serde_json::from_slice::(&bytes) - && value.is_object() - && value["jsonrpc"] == "2.0" - { - return Ok(Some(value)); - } - } - Ok(None) - } -} -#[cfg(test)] -mod tests { - #[test] - fn incomplete_frames_retain_the_scanned_length() { - let mut lines = super::Lines::default(); - lines.bytes.extend_from_slice(b"first"); - assert!(lines.frame().is_none()); - assert_eq!(lines.scanned, 5); - lines.bytes.extend_from_slice(b"\nsecond\n"); - assert_eq!(lines.frame().unwrap(), b"first\n"); - assert_eq!(lines.frame().unwrap(), b"second\n"); - assert!(lines.frame().is_none()); - } -} diff --git a/sdk/src/locks.rs b/sdk/src/locks.rs deleted file mode 100644 index 297b5c9..0000000 --- a/sdk/src/locks.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Advisory lock acquisition and guard lifetime. -use super::*; -/// An advisory lock acquisition. -#[must_use = "await the lock to acquire it"] -pub struct Lock { - path: String, - wait: bool, -} -/// Acquire an advisory lock on a path. -pub fn lock(path: impl AsRef) -> Lock { - Lock { - path: path.as_ref().to_string_lossy().into_owned(), - wait: true, - } -} -impl Lock { - /// Return immediately, without waiting for a held lock. - pub fn try_now(mut self) -> Self { - self.wait = false; - self - } -} -/// Releases its lock on drop, unless kept until process exit. -#[must_use = "bind the guard to hold the lock"] -pub struct Guard { - path: Option, -} -impl Guard { - /// Hold this lock until process exit. - pub fn keep(mut self) { - self.path.take(); - } - /// Report whether this guard holds the lock. - pub fn held(&self) -> bool { - self.path.is_some() - } -} -impl Drop for Guard { - fn drop(&mut self) { - if let Some(path) = self.path.take() { - let _ = notify("core.unlock", json!({"path":path})); - } - } -} -impl IntoFuture for Lock { - type Output = Result; - type IntoFuture = LocalBoxFuture<'static, Self::Output>; - fn into_future(self) -> Self::IntoFuture { - Box::pin(async move { - let lease = std::rc::Rc::new(Lease { - path: self.path.clone(), - held: std::cell::Cell::new(false), - }); - let mut c = call("core.lock", json!({"path":self.path,"wait":self.wait})); - c.unlock = Some(lease.clone()); - let result = c.await?; - lease.held.set(false); - Ok(Guard { - path: if result["held"] == true { - Some(self.path) - } else { - None - }, - }) - }) - } -} diff --git a/sdk/src/sqlite.rs b/sdk/src/sqlite.rs deleted file mode 100644 index 981bec9..0000000 --- a/sdk/src/sqlite.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Shared SQLite open policy, expanded against the plugin's own rusqlite version. - -/// Open SQLite under an already-held Sausage host lock. The caller must depend -/// on `rusqlite` (bundled) and hold the SAME host lock for EVERY connection's -/// entire lifetime, including reads and old/new plugin versions. This macro does -/// not acquire the lock. Do not use it for the host journal or databases accessed -/// outside Sausage. Native builds retain OS locks; WASI selects `unix-none`, so -/// crash-leftover dotfile lock directories cannot block recovery. -/// -/// Temporary tables/sorts stay in memory before any schema access. Writable opens -/// use DELETE journals; SQLite rolls back hot journals itself. Never delete a -/// leftover journal. Read-only opens cannot recover a hot journal: recover with -/// a writable open first. -/// -/// The flags form preserves the caller's open flags. A macro avoids coupling the -/// SDK to a particular rusqlite/libsqlite3-sys version (the host and plugins use -/// different versions, which cannot coexist as dependencies due to `links`). -/// -/// ```ignore -/// let _guard = sausage_plugin::lock("/state/example/db.lock").await?; -/// let c = sausage_plugin::sqlite_open!("/state/example/db.sqlite")?; -/// // Query here; c must drop before _guard. -/// ``` -#[macro_export] -macro_rules! sqlite_open { - ($path:expr) => { - $crate::sqlite_open!($path, ::rusqlite::OpenFlags::default()) - }; - ($path:expr, $flags:expr) => {{ - (|| -> ::rusqlite::Result<::rusqlite::Connection> { - let flags = $flags; - #[cfg(target_os = "wasi")] - let c = ::rusqlite::Connection::open_with_flags_and_vfs($path, flags, "unix-none")?; - #[cfg(not(target_os = "wasi"))] - let c = ::rusqlite::Connection::open_with_flags($path, flags)?; - c.pragma_update(None, "temp_store", "MEMORY")?; - if !flags.contains(::rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) { - c.pragma_update(None, "journal_mode", "DELETE")?; - } - Ok(c) - })() - }}; -} diff --git a/sdk/src/transport.rs b/sdk/src/transport.rs deleted file mode 100644 index 815b6dd..0000000 --- a/sdk/src/transport.rs +++ /dev/null @@ -1,441 +0,0 @@ -//! Stdin routing, stdout framing, and process lifetime. -#[cfg(target_os = "wasi")] -use crate::lines::Lines; -use crate::*; -use futures::{channel::oneshot, future::LocalBoxFuture}; -use serde_json::{Value, json}; -use std::{ - cell::RefCell, - collections::HashMap, - future::{Future, IntoFuture}, - io::Write, - path::Path, -}; -#[path = "calls.rs"] -mod calls; -#[path = "locks.rs"] -mod locks; -pub use calls::*; -pub use locks::*; -/// Generated main-interface dispatch. -pub trait Interface: 'static { - /// Describe this interface without constructing its value. - fn describe() -> Value - where - Self: Sized; - /// Add this type's methods to shared document generators. - #[doc(hidden)] - fn add_methods(doc: &mut crate::Discovery) - where - Self: Sized; - /// Describe only this type's methods, without endpoint declarations. - fn methods_document() -> Value - where - Self: Sized; - /// List the declared endpoint types and their names. - fn endpoint_types(&self) -> Vec<(std::any::TypeId, String)> { - vec![] - } - /// Describe this interface as OpenRPC. - fn discover(&self) -> Value; - /// Dispatch a request to a generated handler. - fn dispatch<'a>( - &'a self, - method: &'a str, - params: Value, - ctx: &'a Ctx, - ) -> LocalBoxFuture<'a, Result>; - /// Run the EOF hook. - fn stop(&self) -> LocalBoxFuture<'_, ()> { - Box::pin(async {}) - } -} - -/// Context of an incoming call. -#[derive(Default)] -pub struct Ctx { - pub(crate) exit: std::cell::Cell, - pub(crate) sink: Option>, -} -impl Ctx { - /// Send progress to the caller of this request. - pub fn notify(&self, name: &str, params: impl serde::Serialize) -> Result<()> { - let params = json!({"name":name,"params":params}); - if let Some(sink) = &self.sink { - sink.unbounded_send(json!({"jsonrpc":"2.0","method":"core.notify","params":params})) - .map_err(|_| Error::new(-32000, "endpoint closed")) - } else { - notify("core.notify", params) - } - } - /// End the process after this request receives its response. - pub fn exit_after_response(&self) { - self.exit.set(true); - } -} - -type Notify = Box; -struct Pending { - tx: oneshot::Sender>, - notify: Option, - unlock: Option>, -} -struct Lease { - path: String, - held: std::cell::Cell, -} -impl Drop for Lease { - fn drop(&mut self) { - if self.held.get() { - let _ = notify("core.unlock", json!({"path":self.path})); - } - } -} -#[derive(Default)] -struct State { - active: bool, - endpoints: HashMap, - stopping: bool, - draining: bool, - next: u64, - pending: HashMap, - // Only leases, never callbacks or response senders, survive dropped lock acquisitions. - abandoned_locks: HashMap>, - #[cfg(target_os = "wasi")] - tasks: usize, - #[cfg(target_os = "wasi")] - wake: Option, - #[cfg(target_os = "wasi")] - exit: bool, - #[cfg(target_os = "wasi")] - listeners: HashMap, -} -thread_local! { static STATE: RefCell = RefCell::new(State::default()); } -#[cfg(any(target_os = "wasi", test))] -fn clear_pending() { - let pending = STATE.with(|s| { - let mut s = s.borrow_mut(); - ( - std::mem::take(&mut s.pending), - std::mem::take(&mut s.abandoned_locks), - ) - }); - // Callback captures may own other call futures whose drop guards use STATE. - drop(pending); -} -pub(crate) fn endpoint_name() -> Result { - STATE - .with(|s| { - s.borrow() - .endpoints - .get(&std::any::TypeId::of::()) - .cloned() - }) - .ok_or_else(|| { - Error::new( - -32602, - "endpoint type is not declared by the main interface", - ) - }) -} -#[cfg(target_os = "wasi")] -pub(crate) fn endpoint_task(future: impl Future + 'static) { - background(async move { - let _ = until_drained(future).await; - }); -} -/// Cancel an accept/read wait when draining, but never an active handler. -#[cfg(target_os = "wasi")] -pub(crate) async fn until_drained(future: F) -> Option { - let (handle, registration) = futures::future::AbortHandle::new_pair(); - let id = STATE.with(|s| { - let mut s = s.borrow_mut(); - if s.draining { - return None; - } - s.next += 1; - let id = s.next; - s.listeners.insert(id, handle); - Some(id) - })?; - let result = futures::future::Abortable::new(future, registration) - .await - .ok(); - STATE.with(|s| s.borrow_mut().listeners.remove(&id)); - result -} -/// Stop accepting SDK endpoint work, then exit when handlers and background tasks finish. -/// -/// Calls and notifications still work. This does not run the EOF stop hook or cancel -/// background tasks: services with their own listeners/tasks must arrange for those -/// to finish too. Repeated calls are harmless. Core's upgrade grace remains the bound. -pub fn drain() { - STATE.with(|s| { - let mut s = s.borrow_mut(); - s.draining = true; - #[cfg(target_os = "wasi")] - { - for (_, listener) in s.listeners.drain() { - listener.abort(); - } - if let Some(w) = s.wake.take() { - w.wake(); - } - } - }); -} -#[cfg(target_os = "wasi")] -pub(crate) fn exit() { - STATE.with(|s| { - let mut s = s.borrow_mut(); - s.exit = true; - if let Some(w) = s.wake.take() { - w.wake(); - } - }); -} -fn send(value: Value) -> Result<()> { - let bytes = format!("\n{value}\n").into_bytes(); - let mut stdout = std::io::stdout().lock(); - stdout.write_all(&bytes)?; - stdout.flush()?; - Ok(()) -} -fn notify(method: &str, params: Value) -> Result<()> { - if !STATE.with(|s| s.borrow().active) { - return Err(Error::new(-32000, "no core transport")); - } - send(json!({"jsonrpc":"2.0","method":method,"params":params})) -} - -#[cfg(target_os = "wasi")] -/// Execute a future with the SDK runtime. -pub fn block_on(f: F) -> F::Output { - wstd::runtime::block_on(f) -} -#[cfg(not(target_os = "wasi"))] -/// Execute a future with the SDK runtime. -pub fn block_on(f: F) -> F::Output { - futures::executor::block_on(f) -} - -/// Run an SDK test with a core transport when the WASI runner supplies one. -#[cfg(target_os = "wasi")] -/// Execute an async test with the available core transport. -pub fn test_run(f: F) -> F::Output { - block_on(async { - STATE.with(|s| s.borrow_mut().active = true); - let reader = wstd::runtime::spawn(async { - let mut input = wstd::io::stdin(); - let mut lines = Lines::default(); - while let Ok(Some(line)) = lines.next(&mut input).await { - if let Ok(v) = serde_json::from_slice::(&line) - && v["jsonrpc"] == "2.0" - { - receive(v); - } - } - STATE.with(|s| { - let mut s = s.borrow_mut(); - s.stopping = true; - }); - clear_pending(); - }); - let result = f.await; - drop(reader); - STATE.with(|s| { - let mut s = s.borrow_mut(); - s.active = false; - }); - clear_pending(); - result - }) -} -#[cfg(not(target_os = "wasi"))] -/// Execute an async test with the available core transport. -pub fn test_run(f: F) -> F::Output { - block_on(f) -} - -/// Register a task that keeps the process alive. -#[cfg(target_os = "wasi")] -/// Keep this process alive while a future runs. -pub fn background(f: impl Future + 'static) { - STATE.with(|s| s.borrow_mut().tasks += 1); - wstd::runtime::spawn(async move { - f.await; - STATE.with(|s| { - let mut s = s.borrow_mut(); - s.tasks -= 1; - if let Some(w) = s.wake.take() { - w.wake(); - } - }); - }) - .detach(); -} -#[cfg(not(target_os = "wasi"))] -/// Keep this process alive while a future runs. -pub fn background(_f: impl Future + 'static) { - panic!("background requires the WASI runtime"); -} - -#[doc(hidden)] -pub fn receive(value: Value) { - if value["method"] == "core.notify" { - if let Some(id) = value["params"]["call"].as_u64() { - let callback = STATE.with(|s| { - s.borrow_mut() - .pending - .get_mut(&id) - .and_then(|p| p.notify.take()) - }); - if let Some(mut f) = callback { - f( - value["params"]["name"].as_str().unwrap_or_default(), - value["params"]["params"].clone(), - ); - STATE.with(|s| { - if let Some(p) = s.borrow_mut().pending.get_mut(&id) { - p.notify = Some(f); - } - }); - } - } - return; - } - let pending = value["id"] - .as_u64() - .and_then(|id| STATE.with(|s| s.borrow_mut().pending.remove(&id))); - if let Some(p) = pending { - let result = if value.get("error").is_some() { - serde_json::from_value(value["error"].clone()).map_or_else(|e| Err(e.into()), Err) - } else { - Ok(value["result"].clone()) - }; - if let Some(lease) = &p.unlock { - lease - .held - .set(result.as_ref().is_ok_and(|v| v["held"] == true)); - } - let _ = p.tx.send(result); - } else { - let lease = value["id"] - .as_u64() - .and_then(|id| STATE.with(|s| s.borrow_mut().abandoned_locks.remove(&id))); - if let Some(lease) = lease { - lease - .held - .set(value.get("error").is_none() && value["result"]["held"] == true); - } else { - eprintln!("note: response with unknown ID {}", value["id"]); - } - } -} - -/// Serve the main interface until its work ends or stdin reaches EOF. -#[cfg(target_os = "wasi")] -/// Serve the main interface over stdin and stdout. -pub fn run(value: T) { - block_on(serve(value)); -} -#[cfg(not(target_os = "wasi"))] -/// Serve the main interface over stdin and stdout. -pub fn run(_value: T) { - panic!("run requires WASI stdin/stdout"); -} - -#[cfg(target_os = "wasi")] -async fn serve(value: T) { - use std::{rc::Rc, task::Poll}; - STATE.with(|s| { - *s.borrow_mut() = State { - active: true, - endpoints: value.endpoint_types().into_iter().collect(), - ..State::default() - } - }); - let value = Rc::new(value); - let mut input = wstd::io::stdin(); - let mut lines = Lines::default(); - let mut started = false; - loop { - let read = lines.next(&mut input); - let idle = futures::future::poll_fn(|cx| { - STATE.with(|s| { - let mut s = s.borrow_mut(); - s.wake = Some(cx.waker().clone()); - if started && (s.tasks == 0 || s.exit) { - Poll::Ready(()) - } else { - Poll::Pending - } - }) - }); - futures::pin_mut!(read, idle); - let bytes = match futures::future::select(read, idle).await { - futures::future::Either::Left((Ok(Some(b)), _)) => b, - futures::future::Either::Left((_, _)) => { - STATE.with(|s| { - let mut s = s.borrow_mut(); - s.stopping = true; - for (_, listener) in s.listeners.drain() { - listener.abort(); - } - }); - clear_pending(); - value.stop().await; - break; - } - futures::future::Either::Right(_) => break, - }; - let Ok(msg) = serde_json::from_slice::(&bytes) else { - continue; - }; - if msg["jsonrpc"] != "2.0" { - continue; - } - if let Some(method) = msg["method"].as_str() { - if method == "core.notify" { - receive(msg); - continue; - } - started = true; - let value = value.clone(); - let method = method.to_owned(); - background(async move { - let ctx = Ctx::default(); - let result = if method == "rpc.discover" { - Ok(value.discover()) - } else { - value - .dispatch( - &method, - msg.get("params").cloned().unwrap_or(json!({})), - &ctx, - ) - .await - }; - if let Some(id) = msg.get("id") { - let response = match result { - Ok(v) => json!({"jsonrpc":"2.0","id":id,"result":v}), - Err(e) => json!({"jsonrpc":"2.0","id":id,"error":e}), - }; - let _ = send(response); - } - if ctx.exit.get() { - STATE.with(|s| s.borrow_mut().exit = true); - } - }); - } else { - receive(msg); - } - } - STATE.with(|s| { - let mut s = s.borrow_mut(); - s.active = false; - }); - clear_pending(); - let _ = std::io::stdout().flush(); - std::process::exit(0); -} diff --git a/sdk/tests/plugins/consumer/Cargo.toml b/sdk/tests/plugins/consumer/Cargo.toml deleted file mode 100644 index 89b5efe..0000000 --- a/sdk/tests/plugins/consumer/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "sdk-consumer" -version = "0.1.0" -edition = "2024" -[workspace] -[dependencies] -sausage-plugin = { path = "../../.." } diff --git a/sdk/tests/plugins/consumer/imports/probe.json b/sdk/tests/plugins/consumer/imports/probe.json deleted file mode 100644 index 848048c..0000000 --- a/sdk/tests/plugins/consumer/imports/probe.json +++ /dev/null @@ -1,291 +0,0 @@ -{ - "components": { - "schemas": { - "Add": { - "description": "The values to add.", - "properties": { - "a": { - "description": "The first number.", - "format": "int32", - "type": "integer" - }, - "b": { - "description": "The second number.", - "format": "int32", - "type": "integer" - } - }, - "required": [ - "a", - "b" - ], - "type": "object" - }, - "Echo": { - "description": "A large payload.", - "properties": { - "text": { - "description": "The text to return unchanged.", - "type": "string" - } - }, - "required": [ - "text" - ], - "type": "object" - } - } - }, - "info": { - "title": "sdk-probe", - "version": "0.1.0" - }, - "methods": [ - { - "description": "Publish a control endpoint until EOF.", - "name": "serve", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "type": "null" - } - }, - "summary": "Publish a control endpoint until EOF." - }, - { - "description": "Forward detached progress through the real core.", - "name": "relay", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "format": "int32", - "type": "integer" - } - }, - "summary": "Forward detached progress through the real core." - }, - { - "description": "Send progress before the final result.", - "name": "progress", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "format": "int32", - "type": "integer" - } - }, - "summary": "Send progress before the final result." - }, - { - "description": "Start a detached service that survives this caller.", - "name": "launch", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "type": "string" - } - }, - "summary": "Start a detached service that survives this caller." - }, - { - "description": "Keep the process alive until the core closes stdin.", - "name": "service", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "type": "string" - } - }, - "summary": "Keep the process alive until the core closes stdin." - }, - { - "description": "Add two numbers.\n\nThe result is their sum.", - "name": "add", - "paramStructure": "by-name", - "params": [ - { - "description": "The first number.", - "name": "a", - "required": true, - "schema": { - "description": "The first number.", - "format": "int32", - "type": "integer" - } - }, - { - "description": "The second number.", - "name": "b", - "required": true, - "schema": { - "description": "The second number.", - "format": "int32", - "type": "integer" - } - } - ], - "result": { - "name": "result", - "schema": { - "format": "int32", - "type": "integer" - } - }, - "summary": "Add two numbers.", - "x-sausage-params": { - "$ref": "#/components/schemas/Add" - } - }, - { - "description": "Echo a payload larger than a host I/O buffer.", - "name": "echo", - "paramStructure": "by-name", - "params": [ - { - "description": "The text to return unchanged.", - "name": "text", - "required": true, - "schema": { - "description": "The text to return unchanged.", - "type": "string" - } - } - ], - "result": { - "name": "result", - "schema": { - "type": "string" - } - }, - "summary": "Echo a payload larger than a host I/O buffer.", - "x-sausage-params": { - "$ref": "#/components/schemas/Echo" - } - }, - { - "description": "Return an internal serialization error.", - "name": "serde_error", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "type": "string" - } - }, - "summary": "Return an internal serialization error." - }, - { - "description": "Return a deliberate error.", - "name": "fail", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "type": "string" - } - }, - "summary": "Return a deliberate error." - }, - { - "description": "Exercise calls, notifications, and advisory locks.", - "name": "exercise", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "type": "string" - } - }, - "summary": "Exercise calls, notifications, and advisory locks." - }, - { - "description": "Drop an acquisition before the core grants its lock.", - "name": "late", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "type": "string" - } - }, - "summary": "Drop an acquisition before the core grants its lock." - }, - { - "description": "Wait until EOF, then run the stop hook.", - "name": "wait", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "type": "string" - } - }, - "summary": "Wait until EOF, then run the stop hook." - }, - { - "description": "Keep the process alive with a background call.", - "name": "start", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "type": "string" - } - }, - "summary": "Keep the process alive with a background call." - } - ], - "openrpc": "1.2.6", - "x-sausage-endpoints": { - "control": { - "methods": [ - { - "description": "Report progress and return a stable counter.", - "name": "stats", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "format": "uint64", - "minimum": 0, - "type": "integer" - } - }, - "summary": "Report progress and return a stable counter." - }, - { - "description": "End the endpoint process after the response.", - "name": "stop", - "paramStructure": "by-name", - "params": [], - "result": { - "name": "result", - "schema": { - "type": "null" - } - }, - "summary": "End the endpoint process after the response." - } - ] - } - }, - "x-sausage-grace": 12, - "x-sausage-imports": {} -} \ No newline at end of file diff --git a/sdk/tests/plugins/consumer/src/main.rs b/sdk/tests/plugins/consumer/src/main.rs deleted file mode 100644 index 61de0c3..0000000 --- a/sdk/tests/plugins/consumer/src/main.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Consumer of the probe's actual discovered interface. -use sausage_plugin::{Result, rpc}; -sausage_plugin::import!("probe"); -struct Consumer; -#[rpc] -impl Consumer { - /// Route an imported call through the core. - async fn run(&self) -> Result { - Ok(probe::add(probe::Add { a: 2, b: 3 }) - .detach() - .on_notify(|name, _| eprintln!("typed progress: {name}")) - .await? as i32) - } - /// Connect to a world endpoint from the real core. - async fn world_endpoint(&self) -> Result { - let control = probe::control::connect("/state/control").await?; - let received = std::rc::Rc::new(std::cell::Cell::new(false)); - let progress = received.clone(); - let result = control - .stats() - .on_notify(move |name, value| { - progress.set(name == "endpoint-progress" && value["value"] == 7); - }) - .await?; - assert!( - received.get(), - "the endpoint must deliver progress before its result" - ); - Ok(result) - } - /// Connect to the provider's endpoint and receive its notification. - async fn endpoint(&self) -> Result { - let control = probe::control::connect("/endpoint").await?; - control - .stats() - .on_notify(|name, _| eprintln!("endpoint notification: {name}")) - .await - } -} -fn main() { - sausage_plugin::run(Consumer) -} diff --git a/sdk/tests/plugins/probe/Cargo.toml b/sdk/tests/plugins/probe/Cargo.toml deleted file mode 100644 index 1b3e7c5..0000000 --- a/sdk/tests/plugins/probe/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "sdk-probe" -version = "0.1.0" -edition = "2024" -[workspace] -[dependencies] -sausage-plugin = { path = "../../.." } -wstd = "0.6" diff --git a/sdk/tests/plugins/probe/src/main.rs b/sdk/tests/plugins/probe/src/main.rs deleted file mode 100644 index 83aeb61..0000000 --- a/sdk/tests/plugins/probe/src/main.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! Probe of SDK transport and lifecycle behavior. -use sausage_plugin::serde_json::{Value, json}; -use sausage_plugin::{Ctx, Result, call, lock, rpc, schema}; -struct Probe; -/// A virtual path to observe. -#[schema] -struct WatchPath { - /// Directory or file, not necessarily present yet. - path: String, -} -/// The values to add. -#[schema] -struct Add { - /// The first number. - a: i32, - /// The second number. - b: i32, -} -/// The retired version. -#[schema] -struct Retired { - /// The previous hash. - previous: String, -} -/// The replacement version. -#[schema] -struct Superseded { - /// The current hash. - current: String, -} -/// A large payload. -#[schema] -struct Echo { - /// The text to return unchanged. - text: String, -} -#[rpc(grace_seconds = 12,endpoints(control=Control))] -impl Probe { - /// Scan only after the watch is armed; keep the call open until process death. - async fn watch(&self, p: WatchPath) -> Result<()> { - let path = p.path.clone(); - call("core.watch", json!({"path":p.path})) - .on_notify(move |name, _| { - if name == "armed" && path == "/self" { - std::fs::write("/self/owned", "visible via proc").unwrap(); - } - let mut entries: Vec<_> = std::fs::read_dir(&path) - .into_iter() - .flatten() - .filter_map(std::result::Result::ok) - .map(|e| e.file_name().into_string().unwrap()) - .collect(); - entries.sort(); - let text = std::fs::read_to_string(format!("{path}/before")).ok(); - Ctx::default() - .notify( - name, - json!({"pid":sausage_plugin::pid(), "entries":entries, "text":text}), - ) - .unwrap(); - }) - .await?; - Ok(()) - } - - /// Poll the watch in the handler, then return idle without a background task. - async fn watch_once(&self, p: WatchPath) -> Result<()> { - use std::{ - cell::RefCell, - rc::Rc, - task::{Poll, Waker}, - }; - let state = Rc::new(RefCell::new((false, None::))); - let received = state.clone(); - let mut watch = std::pin::pin!(std::future::IntoFuture::into_future( - call("core.watch", json!({"path":p.path})).on_notify(move |name, _| { - if name == "armed" { - let mut state = received.borrow_mut(); - state.0 = true; - if let Some(waker) = state.1.take() { - waker.wake(); - } - } - }) - )); - std::future::poll_fn(|cx| { - if let Poll::Ready(result) = watch.as_mut().poll(cx) { - return Poll::Ready(result.map(|_| ())); - } - let mut state = state.borrow_mut(); - state.1 = Some(cx.waker().clone()); - if state.0 { - Poll::Ready(Ok(())) - } else { - Poll::Pending - } - }) - .await - } - - /// Drop one watch while another still delivers, in a process that remains alive. - async fn drop_watch(&self) -> Result> { - use std::{cell::RefCell, future::IntoFuture, rc::Rc, task::Poll}; - let events = Rc::new(RefCell::new(Vec::new())); - let captured = events.clone(); - let mut dropped = call("core.watch", json!({"path":"/dropped"})) - .on_notify(move |_, _| captured.borrow_mut().push("dropped".to_owned())) - .into_future(); - std::future::poll_fn(|cx| { - assert!(dropped.as_mut().poll(cx).is_pending()); - Poll::Ready(()) - }) - .await; - drop(dropped); - let captured = events.clone(); - let mut kept = call("core.watch", json!({"path":"/kept"})) - .on_notify(move |name, _| captured.borrow_mut().push(name.to_owned())) - .into_future(); - std::future::poll_fn(|cx| { - assert!(kept.as_mut().poll(cx).is_pending()); - Poll::Ready(()) - }) - .await; - // The harness sends late traffic and kept-watch events before answering. - call("other.barrier", json!({})).await?; - drop(kept); - let result = events.borrow().clone(); - Ok(result) - } - - /// Publish a control endpoint until EOF. - async fn serve(&self) -> Result<()> { - sausage_plugin::listen("/endpoint", std::rc::Rc::new(Control)).await - } - /// Forward detached progress through the real core. - async fn relay(&self) -> Result { - call("probe.progress", json!({})) - .detach() - .on_notify(move |name, params| { - Ctx::default().notify(name, params).unwrap(); - }) - .await - .map(|value| value.as_i64().unwrap() as i32) - } - /// Send progress before the final result. - fn progress(&self, ctx: &Ctx) -> Result { - ctx.notify("step", json!({"done":true}))?; - Ok(42) - } - /// Start a detached service that survives this caller. - async fn launch(&self) -> Result { - Ok(sausage_plugin::serde_json::from_value( - call("probe.service", json!({})).detach().await?, - )?) - } - /// Keep the process alive until the core closes stdin. - async fn service(&self) -> Result { - sausage_plugin::listen("/state/control", std::rc::Rc::new(Control)).await?; - Ok(sausage_plugin::pid()) - } - /// Keep an idle listener and ownership lock until opt-in drain ends this process. - async fn drain_service(&self) -> Result { - lock("/drain-owner").await?.keep(); - self.service().await - } - /// Add two numbers. - /// - /// The result is their sum. - fn add(&self, p: Add) -> Result { - Ok(p.a + p.b) - } - /// Echo a payload larger than a host I/O buffer. - fn echo(&self, p: Echo) -> Result { - Ok(p.text) - } - /// Return an internal serialization error. - fn serde_error(&self) -> Result { - Ok(sausage_plugin::serde_json::from_str::("invalid")?) - } - /// Return a deliberate error. - fn fail(&self) -> Result { - Err(sausage_plugin::Error { - code: -32010, - message: "deliberate".into(), - data: Some(json!({"diagnostic":"probe error details"})), - }) - } - /// Exercise calls, notifications, and advisory locks. - async fn exercise(&self, ctx: &Ctx) -> Result { - let a = - call("other.first", json!({})).on_notify(|name, _| eprintln!("notification: {name}")); - let b = call("other.second", json!({})).detach(); - let (a, b) = join(a, b).await; - a?; - b?; - ctx.notify("progress", json!({"done":true}))?; - { - let guard = lock("/state/probe/file").await?; - assert!(guard.held()); - } - assert!(!lock("/state/probe/file").try_now().await?.held()); - Ok("done".into()) - } - /// Drop an acquisition before the core grants its lock. - async fn late(&self) -> Result { - { - let mut future = std::pin::pin!(std::future::IntoFuture::into_future(lock("/late"))); - std::future::poll_fn(|cx| { - assert!(future.as_mut().poll(cx).is_pending()); - std::task::Poll::Ready(()) - }) - .await; - } - call("other.barrier", json!({})).await?; - lock("/kept").await?.keep(); - Ok("late done".into()) - } - /// Mark a retired version. - #[sausage_plugin::on_retired] - fn retired(&self, p: Retired) -> Result { - Ok(p.previous) - } - /// Drain on replacement while calls are still available. - #[sausage_plugin::on_superseded] - async fn superseded(&self, p: Superseded) -> Result<()> { - call("other.superseded", json!({"current":p.current})).await?; - sausage_plugin::drain(); - sausage_plugin::drain(); - Ctx::default().notify("draining", json!({}))?; - Ok(()) - } - /// Wait until EOF, then run the stop hook. - async fn wait(&self) -> Result { - call("other.wait", json!({})).await?; - Ok("done".into()) - } - /// Keep the process alive with a background call. - fn start(&self) -> Result { - sausage_plugin::background(async { - let _ = call("other.background", json!({})).await; - }); - Ok("started".into()) - } - /// Leave an unfinished diagnostic before the SDK writes its framed response. - fn framed_output(&self) -> Result { - print!("partial diagnostic"); - Ok("intact".into()) - } - /// Keep the process alive until the test releases its background task. - fn background_hold(&self) -> Result { - sausage_plugin::background(async { - while !std::path::Path::new("/release-background").exists() { - wstd::task::sleep(wstd::time::Duration::from_millis(10)).await; - } - std::fs::write("/background-ended", "done").unwrap(); - }); - Ok(sausage_plugin::pid()) - } - /// Keep a pollable alive for one hour. - fn sleepy(&self) -> Result<()> { - sausage_plugin::background(async { - wstd::task::sleep(wstd::time::Duration::from_secs(3600)).await; - }); - Ok(()) - } - /// Exit after the response despite a pending background pollable. - fn sleepy_exit(&self, ctx: &Ctx) -> Result<()> { - self.sleepy()?; - ctx.exit_after_response(); - Ok(()) - } - /// Save an EOF marker. - #[sausage_plugin::on_stop] - async fn stop(&self) { - std::fs::write("/stopped", b"yes").unwrap(); - std::fs::write(format!("/stopped-{}", sausage_plugin::pid()), b"yes").unwrap(); - Ctx::default().notify("stopped", json!({})).unwrap(); - assert!(call("other.no", json!({})).await.is_err()); - } -} -async fn join( - a: impl std::future::IntoFuture>, - b: impl std::future::IntoFuture>, -) -> (Result, Result) { - let mut a = std::pin::pin!(a.into_future()); - let mut b = std::pin::pin!(b.into_future()); - let (mut av, mut bv) = (None, None); - std::future::poll_fn(move |cx| { - if av.is_none() { - if let std::task::Poll::Ready(v) = a.as_mut().poll(cx) { - av = Some(v); - } - } - if bv.is_none() { - if let std::task::Poll::Ready(v) = b.as_mut().poll(cx) { - bv = Some(v); - } - } - if av.is_some() && bv.is_some() { - std::task::Poll::Ready((av.take().unwrap(), bv.take().unwrap())) - } else { - std::task::Poll::Pending - } - }) - .await -} -struct Control; -#[rpc] -impl Control { - /// Report progress and return a stable counter. - fn stats(&self, ctx: &Ctx) -> Result { - ctx.notify("endpoint-progress", json!({"value":7}))?; - Ok(7) - } - /// Finish an in-flight call after draining begins. - async fn wait(&self) -> Result { - call("other.inflight", json!({})).await?; - Ok("finished".into()) - } - /// End the endpoint process after the response. - fn stop(&self, ctx: &Ctx) -> Result<()> { - ctx.exit_after_response(); - Ok(()) - } -} -fn main() { - sausage_plugin::run(Probe) -} diff --git a/sdk/tests/review.rs b/sdk/tests/review.rs deleted file mode 100644 index 31f53d7..0000000 --- a/sdk/tests/review.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Regression checks for shared schema names and wire identifiers. -use sausage_plugin::{Interface, Result, rpc}; -mod a { - /// Text status. - #[sausage_plugin::schema] - pub struct Status { - /// Text value. - pub text: String, - } -} -mod b { - /// Counter status. - #[sausage_plugin::schema] - pub struct Status { - /// Counter value. - pub count: u32, - } -} -struct Main; -struct Control; -#[rpc(endpoints(control = Control))] -impl Main { - /// Return text status. - fn one(&self, p: a::Status) -> Result { - Ok(p) - } - /// Return counter status. - fn two(&self, p: b::Status) -> Result { - Ok(p) - } - /// Use an unescaped wire name. - fn r#type(&self) -> Result<()> { - Ok(()) - } -} -#[rpc] -impl Control { - /// Return endpoint counter status. - fn status(&self) -> Result { - Ok(b::Status { count: 1 }) - } -} -#[test] -fn distinct_status_types_share_document_generators() { - let doc = Main::describe(); - let methods = doc["methods"].as_array().unwrap(); - let one = methods.iter().find(|m| m["name"] == "one").unwrap(); - let two = methods.iter().find(|m| m["name"] == "two").unwrap(); - assert_eq!(one["params"][0]["name"], "text"); - assert_eq!(two["params"][0]["name"], "count"); - assert_ne!(one["result"]["schema"], two["result"]["schema"]); - for (method, field) in [ - (one, "text"), - (two, "count"), - ( - &doc["x-sausage-endpoints"]["control"]["methods"][0], - "count", - ), - ] { - let schema = doc - .pointer( - method["result"]["schema"]["$ref"] - .as_str() - .unwrap() - .trim_start_matches('#'), - ) - .unwrap(); - assert!(schema["properties"].get(field).is_some(), "{doc}"); - } -} -#[test] -fn raw_identifier_has_plain_wire_name() { - assert!( - Main::describe()["methods"] - .as_array() - .unwrap() - .iter() - .any(|m| m["name"] == "type") - ); -} -#[test] -fn local_transport_errors_have_reserved_sdk_code() { - assert_eq!( - sausage_plugin::Ctx::default() - .notify("x", ()) - .unwrap_err() - .code, - -32000 - ); -} diff --git a/sdk/tests/sdk.rs b/sdk/tests/sdk.rs deleted file mode 100644 index ce12cba..0000000 --- a/sdk/tests/sdk.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Native checks for generated schemas, dispatch, and missing core transports. -use sausage_plugin::serde_json::json; -use sausage_plugin::{Ctx, Interface, Result, rpc, schema}; -/// The addition parameters. -#[schema] -struct Add { - /// The first integer. - a: i32, - /// The second integer. - b: i32, -} -/// A type with different serialize and deserialize contracts. -#[schema] -struct Defaulted { - /// A value which the provider defaults. - #[serde(default)] - value: String, -} -/// A documented string enum. -#[schema] -enum Choice { - /// The first choice. - First, - /// The second choice. - Second, -} -struct Plugin; -#[rpc(endpoints(control=Plugin))] -impl Plugin { - /// Return the same named object without a contract change. - fn identity(&self, p: Add) -> Result { - Ok(p) - } - /// Choose the first value. - fn choice(&self) -> Result { - Ok(Choice::First) - } - /// Round-trip the defaulted object. - fn defaults(&self, p: Defaulted) -> Result { - Ok(p) - } - /// Add integers. - /// - /// Return their sum. - fn add(&self, p: Add) -> Result { - Ok(p.a + p.b) - } -} -#[test] -fn discovery_and_dispatch() { - let doc = Plugin.discover(); - assert!(doc["components"]["schemas"].get("Empty").is_none()); - let choice = doc["methods"] - .as_array() - .unwrap() - .iter() - .find(|m| m["name"] == "choice") - .unwrap(); - assert!(choice.get("x-sausage-params").is_none()); - assert!( - doc["x-sausage-endpoints"]["control"]["methods"] - .as_array() - .unwrap() - .iter() - .any(|m| m["name"] == "add") - ); - assert!(doc["components"]["schemas"].get("AddResult").is_none()); - assert_eq!( - doc["methods"] - .as_array() - .unwrap() - .iter() - .find(|m| m["name"] == "identity") - .unwrap()["result"]["schema"]["$ref"], - "#/components/schemas/Add" - ); - assert!( - doc["components"]["schemas"]["Defaulted"] - .get("required") - .is_none() - ); - assert_eq!( - doc["components"]["schemas"]["DefaultedResult"]["required"], - json!(["value"]) - ); - assert_eq!( - doc["components"]["schemas"]["Choice"]["oneOf"][0]["description"], - json!("The first choice.") - ); - let add = doc["methods"] - .as_array() - .unwrap() - .iter() - .find(|m| m["name"] == "add") - .unwrap(); - assert_eq!(add["summary"], "Add integers."); - assert_eq!( - doc["components"]["schemas"]["Add"]["description"], - "The addition parameters." - ); - assert_eq!(add["params"][0]["description"], "The first integer."); - eprintln!("DISCOVER={doc}"); - assert_eq!( - sausage_plugin::block_on(Plugin.dispatch("add", json!({"a":2,"b":3}), &Ctx::default())) - .unwrap(), - 5 - ); - assert_eq!( - sausage_plugin::block_on(Plugin.dispatch("add", json!({}), &Ctx::default())) - .unwrap_err() - .code, - -32602 - ); - let internal: sausage_plugin::Error = sausage_plugin::serde_json::from_str::("bad") - .unwrap_err() - .into(); - assert_eq!(internal.code, -32603); -} -#[sausage_plugin::test] -async fn params_must_be_objects() { - for (params, kind) in [ - (json!("{\"a\":2,\"b\":3}"), "a string"), - (json!([]), "an array"), - (json!(42), "a number"), - (json!(null), "null"), - (json!(true), "a boolean"), - ] { - let message = format!( - "params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string." - ); - let outgoing = sausage_plugin::call("plugin.add", params.clone()) - .await - .unwrap_err(); - assert_eq!(outgoing.code, -32602); - assert_eq!(outgoing.message, message); - for method in ["add", "choice", "unknown"] { - let incoming = Plugin - .dispatch(method, params.clone(), &Ctx::default()) - .await - .unwrap_err(); - assert_eq!(incoming.code, -32602); - assert_eq!(incoming.message, message); - } - } - assert_eq!( - Plugin - .dispatch("add", json!({"a":2,"b":3}), &Ctx::default()) - .await - .unwrap(), - 5 - ); - assert_eq!( - Plugin - .dispatch("defaults", json!({}), &Ctx::default()) - .await - .unwrap(), - json!({"value":""}) - ); - // An object reaches transport validation rather than the params rejection. - let outgoing = sausage_plugin::call("plugin.add", json!({"a":2,"b":3})) - .await - .unwrap_err(); - assert!(outgoing.message.contains("no core transport")); -} - -#[sausage_plugin::test] -async fn missing_core_fails_without_wait() { - assert!( - Ctx::default() - .notify("progress", json!({})) - .unwrap_err() - .message - .contains("no core transport") - ); - assert!( - sausage_plugin::call("core.lock", json!({})) - .await - .unwrap_err() - .message - .contains("no core transport") - ); -} -#[test] -fn compile_fail() { - trybuild::TestCases::new().compile_fail("tests/ui/*.rs"); -} diff --git a/sdk/tests/ui/enum.rs b/sdk/tests/ui/enum.rs deleted file mode 100644 index d7f2c01..0000000 --- a/sdk/tests/ui/enum.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Reject data-carrying enums. -/// An invalid enum. -#[sausage_plugin::schema] -enum Bad { - /// A payload variant. - Value(String), -} -fn main() {} diff --git a/sdk/tests/ui/enum.stderr b/sdk/tests/ui/enum.stderr deleted file mode 100644 index 3054232..0000000 --- a/sdk/tests/ui/enum.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: schema enums require unit variants - --> tests/ui/enum.rs:5:5 - | -5 | /// A payload variant. - | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/sdk/tests/ui/field_docs.rs b/sdk/tests/ui/field_docs.rs deleted file mode 100644 index 694d02d..0000000 --- a/sdk/tests/ui/field_docs.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Require field documentation. -/// A documented type. -#[sausage_plugin::schema] -struct Bad { value: String } -fn main() {} diff --git a/sdk/tests/ui/field_docs.stderr b/sdk/tests/ui/field_docs.stderr deleted file mode 100644 index b2614b0..0000000 --- a/sdk/tests/ui/field_docs.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: a doc comment is required - --> tests/ui/field_docs.rs:4:14 - | -4 | struct Bad { value: String } - | ^^^^^ diff --git a/sdk/tests/ui/hooks.rs b/sdk/tests/ui/hooks.rs deleted file mode 100644 index 96609c9..0000000 --- a/sdk/tests/ui/hooks.rs +++ /dev/null @@ -1,22 +0,0 @@ -use sausage_plugin::{rpc, on_stop, on_retired, Result}; -struct Stops; -#[rpc] -impl Stops { - /// First stop hook. - #[on_stop] - fn a(&self) {} - /// Second stop hook. - #[on_stop] - fn b(&self) {} -} -struct Retires; -#[rpc] -impl Retires { - /// First retired hook. - #[on_retired] - fn a(&self) -> Result<()> { Ok(()) } - /// Second retired hook. - #[on_retired] - fn b(&self) -> Result<()> { Ok(()) } -} -fn main() {} diff --git a/sdk/tests/ui/hooks.stderr b/sdk/tests/ui/hooks.stderr deleted file mode 100644 index a9e7d9a..0000000 --- a/sdk/tests/ui/hooks.stderr +++ /dev/null @@ -1,19 +0,0 @@ -error: only one on_stop method is allowed - --> tests/ui/hooks.rs:8:5 - | -8 | /// Second stop hook. - | ^^^^^^^^^^^^^^^^^^^^^ - -error: only one on_retired method is allowed - --> tests/ui/hooks.rs:18:5 - | -18 | /// Second retired hook. - | ^^^^^^^^^^^^^^^^^^^^^^^^ - -warning: unused imports: `Result`, `on_retired`, and `on_stop` - --> tests/ui/hooks.rs:1:27 - | -1 | use sausage_plugin::{rpc, on_stop, on_retired, Result}; - | ^^^^^^^ ^^^^^^^^^^ ^^^^^^ - | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/sdk/tests/ui/method_docs.rs b/sdk/tests/ui/method_docs.rs deleted file mode 100644 index 74987c1..0000000 --- a/sdk/tests/ui/method_docs.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Require method documentation. -struct Bad; -#[sausage_plugin::rpc] -impl Bad { fn bad(&self)->sausage_plugin::Result<()> {Ok(())} } -fn main() {} diff --git a/sdk/tests/ui/method_docs.stderr b/sdk/tests/ui/method_docs.stderr deleted file mode 100644 index 9087506..0000000 --- a/sdk/tests/ui/method_docs.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: a doc comment is required - --> tests/ui/method_docs.rs:4:12 - | -4 | impl Bad { fn bad(&self)->sausage_plugin::Result<()> {Ok(())} } - | ^^ diff --git a/sdk/tests/ui/missing_docs.rs b/sdk/tests/ui/missing_docs.rs deleted file mode 100644 index 84033f3..0000000 --- a/sdk/tests/ui/missing_docs.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Reject undocumented schemas. -#[sausage_plugin::schema] -struct Bad { value: String } -fn main() {} diff --git a/sdk/tests/ui/missing_docs.stderr b/sdk/tests/ui/missing_docs.stderr deleted file mode 100644 index 0815c52..0000000 --- a/sdk/tests/ui/missing_docs.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: a doc comment is required - --> tests/ui/missing_docs.rs:3:8 - | -3 | struct Bad { value: String } - | ^^^ diff --git a/sdk/tests/ui/must_use.rs b/sdk/tests/ui/must_use.rs deleted file mode 100644 index 3ae6a6b..0000000 --- a/sdk/tests/ui/must_use.rs +++ /dev/null @@ -1,11 +0,0 @@ -#![deny(unused_must_use)] -use sausage_plugin::{call, lock, Endpoint, Guard, TypedCall}; -fn builders(endpoint: Endpoint) { - call("p.m", ()); - TypedCall::<()>::new(call("p.m", ())); - lock("/state/file"); - endpoint.call::<()>("m", ()); -} -fn guard(g: Guard) -> Guard { g } -fn dropped(g: Guard) { guard(g); } -fn main() {} diff --git a/sdk/tests/ui/must_use.stderr b/sdk/tests/ui/must_use.stderr deleted file mode 100644 index 5842531..0000000 --- a/sdk/tests/ui/must_use.stderr +++ /dev/null @@ -1,64 +0,0 @@ -error: unused `Call` that must be used - --> tests/ui/must_use.rs:4:5 - | -4 | call("p.m", ()); - | ^^^^^^^^^^^^^^^ - | - = note: await the call to send it -note: the lint level is defined here - --> tests/ui/must_use.rs:1:9 - | -1 | #![deny(unused_must_use)] - | ^^^^^^^^^^^^^^^ -help: use `let _ = ...` to ignore the resulting value - | -4 | let _ = call("p.m", ()); - | +++++++ - -error: unused `TypedCall` that must be used - --> tests/ui/must_use.rs:5:5 - | -5 | TypedCall::<()>::new(call("p.m", ())); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - | - = note: await the call to send it -help: use `let _ = ...` to ignore the resulting value - | -5 | let _ = TypedCall::<()>::new(call("p.m", ())); - | +++++++ - -error: unused `Lock` that must be used - --> tests/ui/must_use.rs:6:5 - | -6 | lock("/state/file"); - | ^^^^^^^^^^^^^^^^^^^ - | - = note: await the lock to acquire it -help: use `let _ = ...` to ignore the resulting value - | -6 | let _ = lock("/state/file"); - | +++++++ - -error: unused `EndpointCall` that must be used - --> tests/ui/must_use.rs:7:5 - | -7 | endpoint.call::<()>("m", ()); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - | - = note: await the endpoint call to send it -help: use `let _ = ...` to ignore the resulting value - | -7 | let _ = endpoint.call::<()>("m", ()); - | +++++++ - -error: unused `Guard` that must be used - --> tests/ui/must_use.rs:10:24 - | -10 | fn dropped(g: Guard) { guard(g); } - | ^^^^^^^^ - | - = note: bind the guard to hold the lock -help: use `let _ = ...` to ignore the resulting value - | -10 | fn dropped(g: Guard) { let _ = guard(g); } - | +++++++ diff --git a/sdk/tests/ui/params.rs b/sdk/tests/ui/params.rs deleted file mode 100644 index d8a766e..0000000 --- a/sdk/tests/ui/params.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Reject primitive params. -struct Bad; -#[sausage_plugin::rpc] -impl Bad { - /// Invalid params. - fn bad(&self, value:String)->sausage_plugin::Result {Ok(value)} -} -fn main() {} diff --git a/sdk/tests/ui/params.stderr b/sdk/tests/ui/params.stderr deleted file mode 100644 index 7bebe38..0000000 --- a/sdk/tests/ui/params.stderr +++ /dev/null @@ -1,34 +0,0 @@ -error[E0277]: RPC params must be a #[schema] struct with named fields - --> tests/ui/params.rs:6:25 - | -6 | fn bad(&self, value:String)->sausage_plugin::Result {Ok(value)} - | ^^^^^^ the trait `Params` is not implemented for `std::string::String` - | -help: the trait `Params` is implemented for `sausage_plugin::Empty` - --> src/discovery.rs - | - | impl Params for Empty {} - | ^^^^^^^^^^^^^^^^^^^^^ -note: required by a bound in `sausage_plugin::method` - --> src/discovery.rs - | - | pub fn method(doc: &mut Discovery, name: &str, docs: &str) { - | ^^^^^^ required by this bound in `method` - -error[E0277]: RPC params must be a #[schema] struct with named fields - --> tests/ui/params.rs:6:25 - | -6 | fn bad(&self, value:String)->sausage_plugin::Result {Ok(value)} - | ^^^^^^ the trait `Params` is not implemented for `std::string::String` - | -help: the trait `Params` is implemented for `sausage_plugin::Empty` - --> src/discovery.rs - | - | impl Params for Empty {} - | ^^^^^^^^^^^^^^^^^^^^^ -note: required by a bound in `params_type` - --> tests/ui/params.rs:3:1 - | -3 | #[sausage_plugin::rpc] - | ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `params_type` - = note: this error originates in the attribute macro `sausage_plugin::rpc` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/sdk/tests/ui/schemars.rs b/sdk/tests/ui/schemars.rs deleted file mode 100644 index e656c31..0000000 --- a/sdk/tests/ui/schemars.rs +++ /dev/null @@ -1,9 +0,0 @@ -use sausage_plugin::schema; -/// A documented object. -#[schema] -struct Object { - /// A documented field. - #[schemars(with = "std::collections::HashMap")] - value: String, -} -fn main() {} diff --git a/sdk/tests/ui/schemars.stderr b/sdk/tests/ui/schemars.stderr deleted file mode 100644 index 657a2d6..0000000 --- a/sdk/tests/ui/schemars.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: schemars attributes are outside the schema subset - --> tests/ui/schemars.rs:6:5 - | -6 | #[schemars(with = "std::collections::HashMap")] - | ^ diff --git a/sdk/tests/ui/serde.rs b/sdk/tests/ui/serde.rs deleted file mode 100644 index a7a8f30..0000000 --- a/sdk/tests/ui/serde.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Reject serde shapes outside the subset. -/// A schema with flatten. -#[sausage_plugin::schema] -struct Bad { - /// A flattened value. - #[serde(flatten)] - value: String, -} -fn main() {} diff --git a/sdk/tests/ui/serde.stderr b/sdk/tests/ui/serde.stderr deleted file mode 100644 index 464f17e..0000000 --- a/sdk/tests/ui/serde.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: serde attribute is outside the schema subset - --> tests/ui/serde.rs:6:13 - | -6 | #[serde(flatten)] - | ^^^^^^^ diff --git a/sdk/tests/ui/signature.rs b/sdk/tests/ui/signature.rs deleted file mode 100644 index 4aed616..0000000 --- a/sdk/tests/ui/signature.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Reject invalid receiver signatures. -struct Bad; -#[sausage_plugin::rpc] -impl Bad { - /// An invalid receiver. - fn bad(&mut self) -> sausage_plugin::Result<()> {Ok(())} -} -fn main() {} diff --git a/sdk/tests/ui/signature.stderr b/sdk/tests/ui/signature.stderr deleted file mode 100644 index 0dbe7b6..0000000 --- a/sdk/tests/ui/signature.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: RPC methods require &self - --> tests/ui/signature.rs:6:5 - | -6 | fn bad(&mut self) -> sausage_plugin::Result<()> {Ok(())} - | ^^ diff --git a/sdk/tests/ui/subset.rs b/sdk/tests/ui/subset.rs deleted file mode 100644 index 667464a..0000000 --- a/sdk/tests/ui/subset.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Reject unsupported field types. -/// An invalid schema. -#[sausage_plugin::schema] -struct Bad { - /// An unsupported dictionary. - value: std::collections::HashMap, -} -fn main() {} diff --git a/sdk/tests/ui/subset.stderr b/sdk/tests/ui/subset.stderr deleted file mode 100644 index 1d10145..0000000 --- a/sdk/tests/ui/subset.stderr +++ /dev/null @@ -1,22 +0,0 @@ -error[E0277]: this type is outside the Sausage schema subset - --> tests/ui/subset.rs:6:12 - | -6 | value: std::collections::HashMap, - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `SchemaType` is not implemented for `HashMap` - | - = help: the following other types implement trait `SchemaType`: - () - Bad - Vec - bool - f32 - f64 - i16 - i32 - and $N others -note: required by a bound in `supported` - --> tests/ui/subset.rs:3:1 - | -3 | #[sausage_plugin::schema] - | ^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `supported` - = note: this error originates in the attribute macro `sausage_plugin::schema` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/sdk/tests/ui/superseded.rs b/sdk/tests/ui/superseded.rs deleted file mode 100644 index ad7826f..0000000 --- a/sdk/tests/ui/superseded.rs +++ /dev/null @@ -1,11 +0,0 @@ -struct Supersedes; -#[sausage_plugin::rpc] -impl Supersedes { - /// First superseded hook. - #[sausage_plugin::on_superseded] - fn a(&self) -> sausage_plugin::Result<()> { Ok(()) } - /// Second superseded hook. - #[sausage_plugin::on_superseded] - fn b(&self) -> sausage_plugin::Result<()> { Ok(()) } -} -fn main() {} diff --git a/sdk/tests/ui/superseded.stderr b/sdk/tests/ui/superseded.stderr deleted file mode 100644 index 3383a8a..0000000 --- a/sdk/tests/ui/superseded.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: only one on_superseded method is allowed - --> tests/ui/superseded.rs:7:5 - | -7 | /// Second superseded hook. - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/sdk/tests/ui/tuple.rs b/sdk/tests/ui/tuple.rs deleted file mode 100644 index c88b464..0000000 --- a/sdk/tests/ui/tuple.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Reject tuple params. -/// A tuple is not a named object. -#[sausage_plugin::schema] -struct Bad(String); -fn main() {} diff --git a/sdk/tests/ui/tuple.stderr b/sdk/tests/ui/tuple.stderr deleted file mode 100644 index b27c3d3..0000000 --- a/sdk/tests/ui/tuple.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: schema structs require named fields - --> tests/ui/tuple.rs:2:1 - | -2 | /// A tuple is not a named object. - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/sdk/tests/wasi.rs b/sdk/tests/wasi.rs deleted file mode 100644 index a996109..0000000 --- a/sdk/tests/wasi.rs +++ /dev/null @@ -1,475 +0,0 @@ -//! End-to-end protocol checks against a WASI plugin under Wasmtime. -use sausage_plugin::serde_json::{Value, json}; -use std::{ - io::{BufRead, BufReader, Write}, - path::{Path, PathBuf}, - process::{Command, Stdio}, -}; -struct Process { - child: std::process::Child, - input: Option, - output: BufReader, -} -impl Process { - fn finishes(&mut self) { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4); - loop { - if let Some(status) = self.child.try_wait().unwrap() { - assert!(status.success()); - return; - } - if std::time::Instant::now() >= deadline { - self.child.kill().unwrap(); - let _ = self.child.wait(); - panic!("process survived exit or EOF for four seconds"); - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - } - fn send(&mut self, v: Value) { - writeln!(self.input.as_mut().unwrap(), "{v}").unwrap(); - } - fn read(&mut self) -> Value { - loop { - let mut s = String::new(); - if self.output.read_line(&mut s).unwrap() == 0 { - let mut stderr = String::new(); - let _ = - std::io::Read::read_to_string(self.child.stderr.as_mut().unwrap(), &mut stderr); - panic!("unexpected EOF from wasmtime. Its stderr:\n{stderr}"); - } - if let Ok(v) = sausage_plugin::serde_json::from_str::(&s) - && v["jsonrpc"] == "2.0" - { - return v; - } - } - } - fn request(&mut self, method: &str, params: Value) { - self.send(json!({"jsonrpc":"2.0","id":"probe-1","method":method,"params":params})); - } - fn answer(&mut self, r: &Value, result: Value) { - self.send(json!({"jsonrpc":"2.0","id":r["id"],"result":result})); - } -} -fn start(wasm: &Path, dir: &Path) -> Process { - let mut child = Command::new("wasmtime") - .args([ - "run", - // Without a disk cache: the Nix sandbox has no writable home folder. - "-C", - "cache=n", - "-S", - "inherit-network=y", - "--env", - "SAUSAGE_PID=probe-00000000-0000-7000-8000-000000000000", - "--env", - "SAUSAGE_PLUGIN=probe", - "--dir", - ]) - .arg(format!("{}::/", dir.display())) - .arg(wasm) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let input = child.stdin.take(); - let output = BufReader::new(child.stdout.take().unwrap()); - Process { - child, - input, - output, - } -} -#[test] -fn protocol() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let target = root.join("../target/sdk/probe"); - assert!( - Command::new("cargo") - .env("CARGO_TARGET_DIR", &target) - .args(["build", "--offline", "--manifest-path"]) - .arg(root.join("tests/plugins/probe/Cargo.toml")) - .args(["--target", "wasm32-wasip2"]) - .status() - .unwrap() - .success() - ); - let wasm = target.join("wasm32-wasip2/debug/sdk-probe.wasm"); - let dir = std::env::temp_dir().join(format!("sausage-sdk-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - for method in ["sleepy_exit", "sleepy"] { - let mut p = start(&wasm, &dir); - p.request(method, json!({})); - assert_eq!(p.read()["result"], Value::Null); - if method == "sleepy" { - drop(p.input.take()); - } - p.finishes(); - } - // A handler-owned watch does not keep an idle process alive after return, - // even though the host never answers the watch and stdin remains open. - let mut p = start(&wasm, &dir); - p.request("watch_once", json!({"path":"/mailbox"})); - let watch = p.read(); - assert_eq!(watch["method"], "core.watch"); - p.send(json!({"jsonrpc":"2.0","method":"core.notify","params":{"call":watch["id"],"name":"armed","params":{}}})); - assert_eq!(p.read()["result"], Value::Null); - p.finishes(); - // Keep the process alive after dropping a call: late traffic is ignored, - // while the kept watch continues to deliver every notification. - let mut p = start(&wasm, &dir); - p.request("drop_watch", json!({})); - let dropped = p.read(); - let kept = p.read(); - let barrier = p.read(); - assert_eq!(dropped["params"]["path"], "/dropped"); - assert_eq!(kept["params"]["path"], "/kept"); - assert_eq!(barrier["method"], "other.barrier"); - for watch in [&dropped, &kept] { - for name in ["armed", "changed", "changed"] { - p.send(json!({"jsonrpc":"2.0","method":"core.notify","params":{"call":watch["id"],"name":name,"params":{}}})); - } - } - p.answer(&dropped, Value::Null); - p.answer(&barrier, json!({})); - assert_eq!(p.read()["result"], json!(["armed", "changed", "changed"])); - p.finishes(); - let mut p = start(&wasm, &dir); - p.request("rpc.discover", json!({})); - let d = p.read(); - assert_eq!(d["result"]["x-sausage-grace"], 12); - assert_eq!( - d["result"]["methods"] - .as_array() - .unwrap() - .iter() - .find(|m| m["name"] == "add") - .unwrap()["description"], - "Add two numbers.\n\nThe result is their sum." - ); - assert!(p.child.wait().unwrap().success()); - let mut p = start(&wasm, &dir); - p.request("add", json!({"a":2,"b":4})); - assert_eq!(p.read()["result"], 6); - assert!(p.child.wait().unwrap().success()); - let mut p = start(&wasm, &dir); - p.request("echo", json!({"text":"x".repeat(70_000)})); - assert_eq!(p.read()["result"], "x".repeat(70_000)); - assert!(p.child.wait().unwrap().success()); - let mut p = start(&wasm, &dir); - p.request("echo", json!({"text":5})); - assert_eq!(p.read()["error"]["code"], -32602); - assert!(p.child.wait().unwrap().success()); - let mut p = start(&wasm, &dir); - p.request("serde_error", json!({})); - assert_eq!(p.read()["error"]["code"], -32603); - assert!(p.child.wait().unwrap().success()); - let mut p = start(&wasm, &dir); - p.request("fail", json!({})); - assert_eq!(p.read()["error"]["code"], -32010); - assert!(p.child.wait().unwrap().success()); - let mut p = start(&wasm, &dir); - p.request("exercise", json!({})); - let a = p.read(); - let b = p.read(); - assert_eq!(a["method"], "other.first"); - assert_eq!(b["method"], "core.call"); - assert_eq!(b["params"]["detach"], true); - p.send(json!({"jsonrpc":"2.0","id":9876,"result":{}})); - p.send(json!({"jsonrpc":"2.0","method":"core.notify","params":{"call":a["id"],"name":"tick","params":{}}})); - p.answer(&b, json!({})); - p.answer(&a, json!({})); - assert_eq!(p.read()["method"], "core.notify"); - let grant = p.read(); - assert_eq!(grant["method"], "core.lock"); - p.answer(&grant, json!({"held":true})); - assert_eq!(p.read()["method"], "core.unlock"); - let attempt = p.read(); - assert_eq!(attempt["params"]["wait"], false); - p.answer(&attempt, json!({"held":false})); - assert_eq!(p.read()["result"], "done"); - assert!(p.child.wait().unwrap().success()); - let mut err = String::new(); - std::io::Read::read_to_string(p.child.stderr.as_mut().unwrap(), &mut err).unwrap(); - assert!(err.contains("unknown ID 9876")); - assert!(err.contains("notification: tick")); - let mut p = start(&wasm, &dir); - p.request("rpc.retired", json!({"previous":"hash"})); - assert_eq!(p.read()["result"], "hash"); - assert!(p.child.wait().unwrap().success()); - let mut p = start(&wasm, &dir); - p.request("late", json!({})); - let grant = p.read(); - let barrier = p.read(); - p.answer(&grant, json!({"held":true})); - assert_eq!(p.read()["method"], "core.unlock"); - p.answer(&barrier, json!({})); - let kept = p.read(); - p.answer(&kept, json!({"held":true})); - assert_eq!(p.read()["result"], "late done"); - assert!(p.child.wait().unwrap().success()); - let mut p = start(&wasm, &dir); - p.request("wait", json!({})); - assert_eq!(p.read()["method"], "other.wait"); - drop(p.input.take()); - assert_eq!(p.read()["params"]["name"], "stopped"); - assert!(p.child.wait().unwrap().success()); - assert_eq!(std::fs::read(dir.join("stopped")).unwrap(), b"yes"); - let mut p = start(&wasm, &dir); - p.request("start", json!({})); - let first = p.read(); - let second = p.read(); - let outgoing = if first["method"] == "other.background" { - first - } else { - second - }; - assert_eq!(outgoing["method"], "other.background"); - p.answer(&outgoing, json!({})); - assert!(p.child.wait().unwrap().success()); - // Replacement is a notification, not EOF. Drain keeps both endpoint handlers - // and background calls alive, while closing idle connections and the listener. - assert!( - !d["result"]["methods"] - .as_array() - .unwrap() - .iter() - .any(|m| m["name"] == "rpc.superseded" || m["name"] == "superseded") - ); - std::fs::remove_file(dir.join("stopped")).unwrap(); - let mut provider = start(&wasm, &dir); - provider.request("serve", json!({})); - assert_eq!(provider.read()["result"], Value::Null); - provider.request("start", json!({})); - let first = provider.read(); - let second = provider.read(); - let background = if first["method"] == "other.background" { - first - } else { - second - }; - assert_eq!(background["method"], "other.background"); - let address = std::fs::read_to_string(dir.join("endpoint")) - .unwrap() - .split_whitespace() - .next() - .unwrap() - .trim_start_matches("tcp:") - .to_owned(); - let mut idle = std::net::TcpStream::connect(&address).unwrap(); - idle.set_read_timeout(Some(std::time::Duration::from_secs(4))) - .unwrap(); - writeln!( - idle, - "{}", - json!({"jsonrpc":"2.0","id":0,"method":"rpc.endpoint","params":{}}) - ) - .unwrap(); - let mut idle = BufReader::new(idle); - let mut line = String::new(); - idle.read_line(&mut line).unwrap(); - assert_eq!( - sausage_plugin::serde_json::from_str::(&line).unwrap()["id"], - 0 - ); - let mut active = std::net::TcpStream::connect(&address).unwrap(); - active - .set_read_timeout(Some(std::time::Duration::from_secs(4))) - .unwrap(); - writeln!( - active, - "{}", - json!({"jsonrpc":"2.0","id":1,"method":"wait","params":{}}) - ) - .unwrap(); - let inflight = provider.read(); - assert_eq!(inflight["method"], "other.inflight"); - provider - .send(json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":"new-hash"}})); - let hook = provider.read(); - assert_eq!(hook["method"], "other.superseded"); - assert_eq!(hook["params"]["current"], "new-hash"); - provider.answer(&hook, json!({})); - assert_eq!(provider.read()["params"]["name"], "draining"); - line.clear(); - assert_eq!(idle.read_line(&mut line).unwrap(), 0); - // drain() wakes independent accept/read cancellations. Idle EOF does not - // order listener destruction: TCP may still complete a handshake, but that - // connection must close without serving work. Do not retry the connect. - if let Ok(mut late) = std::net::TcpStream::connect(&address) { - eprintln!("TCP handshake raced listener cancellation; waiting for close"); - // Reuse the four-second socket bound used by idle/active above. - late.set_read_timeout(Some(std::time::Duration::from_secs(4))) - .unwrap(); - let closed = std::io::Read::read(&mut late, &mut [0]); - assert!( - matches!(closed, Ok(0)) - || matches!(&closed, Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset), - "late connection survived drain: {closed:?}" - ); - } - assert!(provider.child.try_wait().unwrap().is_none()); - provider.answer(&inflight, json!({})); - line.clear(); - BufReader::new(active).read_line(&mut line).unwrap(); - assert_eq!( - sausage_plugin::serde_json::from_str::(&line).unwrap()["result"], - "finished" - ); - assert!(provider.child.try_wait().unwrap().is_none()); - provider.answer(&background, json!({})); - provider.finishes(); - assert!(std::net::TcpStream::connect(&address).is_err()); - assert!( - !dir.join("stopped").exists(), - "drain is not the EOF stop hook" - ); - let provider_doc = &d["result"]; - let imports = target.join("imports"); - std::fs::create_dir_all(&imports).unwrap(); - std::fs::write( - imports.join("probe.json"), - sausage_plugin::serde_json::to_vec_pretty(provider_doc).unwrap(), - ) - .unwrap(); - let generated = sausage_interface::client_rs("probe", provider_doc).unwrap(); - std::fs::write(target.join("client.rs"), generated).unwrap(); - assert!( - Command::new("cargo") - .env("CARGO_TARGET_DIR", &target) - .args(["build", "--offline", "--manifest-path"]) - .arg(root.join("tests/plugins/consumer/Cargo.toml")) - .args(["--target", "wasm32-wasip2"]) - .status() - .unwrap() - .success() - ); - let consumer = target.join("wasm32-wasip2/debug/sdk-consumer.wasm"); - let mut client = start(&consumer, &dir); - client.request("rpc.discover", json!({})); - let consumer_doc = client.read()["result"].clone(); - assert!(client.child.wait().unwrap().success()); - let imported = &consumer_doc["x-sausage-imports"]["probe"]; - assert!(sausage_interface::compat::check(imported, provider_doc).is_empty()); - let mut broken = provider_doc.clone(); - broken["methods"] - .as_array_mut() - .unwrap() - .retain(|m| m["name"] != "add"); - assert!(!sausage_interface::compat::check(imported, &broken).is_empty()); - let mut client = start(&consumer, &dir); - client.request("run", json!({})); - let outgoing = client.read(); - assert_eq!(outgoing["method"], "core.call"); - assert_eq!(outgoing["params"]["method"], "probe.add"); - let mut provider = start(&wasm, &dir); - provider.request("add", outgoing["params"]["params"].clone()); - let reply = provider.read(); - assert!(provider.child.wait().unwrap().success()); - client.send(json!({"jsonrpc":"2.0","method":"core.notify","params":{"call":outgoing["id"],"name":"typed","params":{}}})); - client.answer(&outgoing, reply["result"].clone()); - assert_eq!(client.read()["result"], 5); - assert!(client.child.wait().unwrap().success()); - let mut provider = start(&wasm, &dir); - provider.request("serve", json!({})); - assert_eq!(provider.read()["result"], Value::Null); - let mut client = start(&consumer, &dir); - client.request("endpoint", json!({})); - assert_eq!(client.read()["result"], 7); - assert!(client.child.wait().unwrap().success()); - let mut stderr = String::new(); - std::io::Read::read_to_string(client.child.stderr.as_mut().unwrap(), &mut stderr).unwrap(); - assert!(stderr.contains("endpoint notification: endpoint-progress")); - let path = dir.join("endpoint"); - let original = std::fs::read_to_string(&path).unwrap(); - std::fs::write(&path, original.replace("probe-00000000", "probe-11111111")).unwrap(); - let mut client = start(&consumer, &dir); - client.request("endpoint", json!({})); - assert!( - client.read()["error"]["message"] - .as_str() - .unwrap() - .contains("identity mismatch") - ); - assert!(client.child.wait().unwrap().success()); - std::fs::write(&path, original).unwrap(); - // The endpoint server ignores non-RPC lines before a request. - let address = std::fs::read_to_string(&path) - .unwrap() - .split_whitespace() - .next() - .unwrap() - .trim_start_matches("tcp:") - .to_owned(); - let mut stream = std::net::TcpStream::connect(address).unwrap(); - stream - .set_read_timeout(Some(std::time::Duration::from_secs(4))) - .unwrap(); - stream.write_all(b"\nnot-json\n[]\n{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"rpc.endpoint\",\"params\":{}}\n").unwrap(); - let mut reader = BufReader::new(stream); - let mut line = String::new(); - reader.read_line(&mut line).unwrap(); - assert_eq!( - sausage_plugin::serde_json::from_str::(&line).unwrap()["id"], - 99 - ); - reader - .get_mut() - .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":100,\"method\":\"stop\",\"params\":{}}\n") - .unwrap(); - line.clear(); - reader.read_line(&mut line).unwrap(); - assert_eq!( - sausage_plugin::serde_json::from_str::(&line).unwrap()["id"], - 100 - ); - provider.finishes(); - - // The endpoint client ignores non-RPC lines during verification and responses. - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let owner = "probe-00000000-0000-7000-8000-000000000000"; - std::fs::write( - &path, - format!("tcp:{} {owner} control\n", listener.local_addr().unwrap()), - ) - .unwrap(); - let server = std::thread::spawn(move || { - for _ in 0..2 { - let (stream, _) = listener.accept().unwrap(); - stream - .set_read_timeout(Some(std::time::Duration::from_secs(4))) - .unwrap(); - let mut reader = BufReader::new(stream); - loop { - let mut line = String::new(); - if reader.read_line(&mut line).unwrap() == 0 { - break; - } - let request: Value = sausage_plugin::serde_json::from_str(&line).unwrap(); - let result = if request["method"] == "rpc.endpoint" { - json!({"owner":owner,"plugin":"probe","interface":"control"}) - } else { - json!(7) - }; - writeln!( - reader.get_mut(), - "\nnot-json\n[]\n{}", - json!({"jsonrpc":"2.0","id":request["id"],"result":result}) - ) - .unwrap(); - } - } - }); - let mut client = start(&consumer, &dir); - client.request("endpoint", json!({})); - assert_eq!(client.read()["result"], 7); - client.finishes(); - server.join().unwrap(); - assert_eq!( - std::fs::read(root.join("tests/plugins/consumer/imports/probe.json")).unwrap(), - include_bytes!("plugins/consumer/imports/probe.json") - ); - std::fs::remove_dir_all(dir).unwrap(); -} From e7be031238ed155e1610f9daef11d64da166443c Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 22:21:03 +0000 Subject: [PATCH 03/22] Package the complete v3 bundle and retain WITX sources --- README.md | 3 ++- flake.nix | 39 +++++++++++++++++++++++++-------------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8e83941..feca733 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sausage v3 -Sausage is a world of WASI programs and durable agents. The Rust host combines a process table, a shared virtual filesystem, and a durable JavaScript harness. Agents and their programs see the same world. The filesystem prevents mistakes, not attacks between agents. The host credentials manager is the intended security boundary. Hierarchical coordination and the credentials manager remain later phases of the plan. +Sausage is a world of WASI programs and durable agents. The Rust host combines a process table, a shared virtual filesystem, and a durable JavaScript harness. Agents and their programs see the same world. The filesystem prevents mistakes, not attacks between agents. The host credentials manager is the intended security boundary. Hierarchical coordination remains a later phase of the plan. ## Build and run @@ -12,6 +12,7 @@ result/bin/sausage sh /path/to/world -c 'echo hi | tr a-z A-Z; git --version' The package includes the userland bundle. At world creation, the host copies its tools into `/bin` as `.wasm` files. +It also copies a committed source snapshot into `/src/sausage`. The console uses `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` from the host environment. Secrets do not enter the world. diff --git a/flake.nix b/flake.nix index 4be7cb2..29699b9 100644 --- a/flake.nix +++ b/flake.nix @@ -9,7 +9,7 @@ crane.url = "github:ipetkov/crane"; }; - outputs = { nixpkgs, flake-utils, rust-overlay, crane, ... }: + outputs = { self, nixpkgs, flake-utils, rust-overlay, crane, ... }: flake-utils.lib.eachSystem flake-utils.lib.allSystems (system: let pkgs = import nixpkgs { @@ -40,7 +40,8 @@ let relative = pkgs.lib.removePrefix (toString ./. + "/") path; in !(pkgs.lib.hasPrefix "userland/" relative || pkgs.lib.hasPrefix "spikes/" relative) && (craneLib.filterCargoSources path type - || pkgs.lib.hasSuffix ".wit" path || pkgs.lib.hasSuffix ".js" path); + || pkgs.lib.hasSuffix ".wit" path || pkgs.lib.hasSuffix ".witx" path + || pkgs.lib.hasSuffix ".js" path); }; v8Archive = pkgs.fetchurl { url = "https://github.com/denoland/rusty_v8/releases/download/v150.4.0/librusty_v8_release_${pkgs.stdenv.hostPlatform.rust.rustcTarget}.a.gz"; @@ -69,13 +70,9 @@ rev = "943553433ae04f5b3bd55d2cf207e39eb9ef2af7"; hash = "sha256-a1eMkqx4nBqJ8R+h+rxtU35pAungcEnXoxwZnb1poH8="; }; - userlandSrc = pkgs.lib.cleanSourceWith { - src = ./.; - filter = path: type: - let relative = pkgs.lib.removePrefix (toString ./. + "/") path; - in type == "directory" || pkgs.lib.hasPrefix "userland/" relative - || pkgs.lib.hasPrefix "host/wit/" relative; - }; + # bundle.nu archives these sources, not the sandbox's development patches. + userlandSrc = pkgs.lib.cleanSource ./.; + revision = self.rev or self.dirtyRev or "unknown"; userlandCommon = { pname = "sausage-userland"; version = "0.3.0"; @@ -85,7 +82,16 @@ cargoLockList = [ ./userland/Cargo.lock ./userland/brush/Cargo.lock ]; }; nativeBuildInputs = [ pkgs.nushell pkgs.wasm-tools pkgs.git pkgs.pkg-config ] ++ mold; + GIT_AUTHOR_NAME = "Sausage"; + GIT_AUTHOR_EMAIL = "sausage@localhost"; + GIT_COMMITTER_NAME = "Sausage"; + GIT_COMMITTER_EMAIL = "sausage@localhost"; + GIT_AUTHOR_DATE = "2000-01-01T00:00:00+0000"; + GIT_COMMITTER_DATE = "2000-01-01T00:00:00+0000"; postPatch = '' + git init --quiet --initial-branch=main + git add host vfs userland Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore + git commit --no-gpg-sign --quiet -m 'Bundle source ${revision}' cp -r ${brushSource} userland/brush/source chmod -R u+w userland/brush/source # The fixed-output source fetch replaces the development checkout's Git check. @@ -97,7 +103,12 @@ preBuild = '' export CARGO_TARGET_DIR="$PWD/target" ''; - buildPhaseCargoCommand = "nu userland/bundle.nu"; + buildPhaseCargoCommand = '' + nu userland/bundle.nu + # bundle.nu sees the reconstructed repository's HEAD. Restore the flake revision. + git -C target/userland/bundle/src/sausage commit --amend --no-gpg-sign --quiet \ + -m 'Bundle source ${revision}' + ''; doCheck = false; } // wasiCcEnv // moldEnv; # Keep the real local patch crates: registry dependencies use their full APIs. @@ -108,7 +119,7 @@ cargoArtifacts = userlandArtifacts; installPhaseCommand = '' mkdir -p $out - cp -a target/userland/bin $out/bin + cp -a target/userland/bundle/. $out/ ''; doInstallCargoArtifacts = false; }); @@ -118,7 +129,7 @@ doCheck = false; nativeBuildInputs = common.nativeBuildInputs ++ [ pkgs.makeWrapper ]; postInstall = '' - wrapProgram $out/bin/sausage --set-default SAUSAGE_USERLAND ${userland}/bin + wrapProgram $out/bin/sausage --set-default SAUSAGE_USERLAND ${userland} ''; meta.mainProgram = "sausage"; }); @@ -127,10 +138,10 @@ checks.package = sausage; checks.tests = craneLib.cargoTest (common // wasiCcEnv // { inherit cargoArtifacts; - cargoExtraArgs = "--workspace --lib --test harness --test p1 --test layers --test regressions --test streams --test wasi"; + cargoExtraArgs = "--workspace --lib --test harness --test p1 --test durable --test 'cred*' --test layers --test regressions --test streams --test wasi"; preCheck = '' mkdir -p target/userland - ln -s ${userland}/bin target/userland/bin + ln -s ${userland} target/userland/bundle ''; doInstallCargoArtifacts = false; }); From a2bc0545a41d43377abcef3319a8e9228177437f Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 00:01:03 +0000 Subject: [PATCH 04/22] Match the V8 archive to deno_core's simdutf feature --- flake.nix | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/flake.nix b/flake.nix index 29699b9..8856430 100644 --- a/flake.nix +++ b/flake.nix @@ -44,12 +44,13 @@ || pkgs.lib.hasSuffix ".js" path); }; v8Archive = pkgs.fetchurl { - url = "https://github.com/denoland/rusty_v8/releases/download/v150.4.0/librusty_v8_release_${pkgs.stdenv.hostPlatform.rust.rustcTarget}.a.gz"; + # deno_core enables rusty_v8's simdutf feature. + url = "https://github.com/denoland/rusty_v8/releases/download/v150.4.0/librusty_v8_simdutf_release_${pkgs.stdenv.hostPlatform.rust.rustcTarget}.a.gz"; hash = { - x86_64-linux = "sha256-WGn9twcbHyHyAKl86X0gElh34PMc2ALtmd4sU/SIsGw="; - aarch64-linux = "sha256-txd9Uq0zNycv4NO453gjnIIalcJdWVnexiue/WVPfdM="; - x86_64-darwin = "sha256-5ex9E/kUgT6/IB1Ee/j9J2h7exkuFsR/KCb+VBUXHyk="; - aarch64-darwin = "sha256-zNj4FIW4IsWxiuun+d65KaM4LYasZzu/DzZvBod+axA="; + x86_64-linux = "sha256-9IdiyhDR8fxgWkQcWuQw7Izh6egPFNePvELLh4wwtHY="; + aarch64-linux = "sha256-U54oOBWjlqV5bzKFi0LlF7hY66rqqtBdAykO6MhkpSc="; + x86_64-darwin = "sha256-p1AnH+xrIRRX7Qpc99LqsZJLJlYhqC2oarlZ1v8II+Q="; + aarch64-darwin = "sha256-Wu/9jVoMG3msHXCvg9WxkJllX9nGRaeU3EPxAfd5g4w="; }.${system}; }; common = { From 2d381018707a6c79e95f418d8a901b764cfd665c Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 02:38:33 +0000 Subject: [PATCH 05/22] Give sandbox checks a writable compilation cache --- flake.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flake.nix b/flake.nix index 8856430..4a5cb2b 100644 --- a/flake.nix +++ b/flake.nix @@ -141,12 +141,14 @@ inherit cargoArtifacts; cargoExtraArgs = "--workspace --lib --test harness --test p1 --test durable --test 'cred*' --test layers --test regressions --test streams --test wasi"; preCheck = '' + export SAUSAGE_CACHE="$TMPDIR/sausage-cache" mkdir -p target/userland ln -s ${userland} target/userland/bundle ''; doInstallCargoArtifacts = false; }); checks.shell = pkgs.runCommand "sausage-shell" { } '' + export SAUSAGE_CACHE="$TMPDIR/sausage-cache" ${sausage}/bin/sausage sh "$TMPDIR/world" -c \ 'ls /bin | wc -l; echo hi | tr a-z A-Z; git --version; rg --version' > output cat output From ff542f4946855e73d86f8932326a7c25fbe2ad3b Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 06:54:40 +0000 Subject: [PATCH 06/22] Keep world copies writable and include embedded host assets --- flake.nix | 3 ++- host/src/world.rs | 44 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 4a5cb2b..1942c8b 100644 --- a/flake.nix +++ b/flake.nix @@ -41,7 +41,8 @@ in !(pkgs.lib.hasPrefix "userland/" relative || pkgs.lib.hasPrefix "spikes/" relative) && (craneLib.filterCargoSources path type || pkgs.lib.hasSuffix ".wit" path || pkgs.lib.hasSuffix ".witx" path - || pkgs.lib.hasSuffix ".js" path); + || pkgs.lib.hasSuffix ".js" path || pkgs.lib.hasSuffix ".html" path + || pkgs.lib.hasSuffix ".wasm" path); }; v8Archive = pkgs.fetchurl { # deno_core enables rusty_v8's simdutf feature. diff --git a/host/src/world.rs b/host/src/world.rs index b77674e..2a6f79d 100644 --- a/host/src/world.rs +++ b/host/src/world.rs @@ -5,6 +5,7 @@ //! - `host/`: host state outside the sandbox (harness storage, later the vault). No guest sees it. use sausage_vfs::{Generated, Host, Layer, Memory, Mounts, Process}; use std::{ + os::unix::fs::PermissionsExt, path::{Path, PathBuf}, sync::Arc, }; @@ -21,13 +22,19 @@ pub struct World { fn copy_tree(source: &Path, destination: &Path) -> std::io::Result<()> { std::fs::create_dir_all(destination)?; + let mut permissions = std::fs::metadata(destination)?.permissions(); + permissions.set_mode(permissions.mode() | 0o200); + std::fs::set_permissions(destination, permissions)?; for entry in std::fs::read_dir(source)? { let entry = entry?; let target = destination.join(entry.file_name()); if entry.file_type()?.is_dir() { copy_tree(&entry.path(), &target)?; } else { - std::fs::copy(entry.path(), target)?; + std::fs::copy(entry.path(), &target)?; + let mut permissions = std::fs::metadata(&target)?.permissions(); + permissions.set_mode(permissions.mode() | 0o200); + std::fs::set_permissions(&target, permissions)?; } } Ok(()) @@ -85,3 +92,38 @@ impl World { }) } } + +#[cfg(test)] +#[test] +fn readonly_bundle_copies_are_writable() -> std::io::Result<()> { + use std::fs; + let bundle = tempfile::tempdir()?; + let world = tempfile::tempdir()?; + for (name, mode) in [("bin/tool.wasm", 0o555), ("src/lib.rs", 0o444)] { + let path = bundle.path().join(name); + fs::create_dir_all(path.parent().unwrap())?; + fs::write(&path, b"original")?; + fs::set_permissions(path, fs::Permissions::from_mode(mode))?; + } + for path in [bundle.path().join("bin"), bundle.path().join("src"), bundle.path().to_owned(), world.path().to_owned()] { + fs::set_permissions(path, fs::Permissions::from_mode(0o555))?; + } + let result = copy_tree(bundle.path(), world.path()); + // Restore source directories so the temporary directory can remove its files. + for path in [bundle.path(), &bundle.path().join("bin"), &bundle.path().join("src")] { + fs::set_permissions(path, fs::Permissions::from_mode(0o755))?; + } + result?; + assert_eq!(fs::metadata(world.path())?.permissions().mode() & 0o777, 0o755); + for (name, mode) in [("bin/tool.wasm", 0o555), ("src/lib.rs", 0o444)] { + let source = bundle.path().join(name); + let target = world.path().join(name); + assert_eq!(fs::metadata(&source)?.permissions().mode() & 0o777, mode); + assert_eq!(fs::metadata(&target)?.permissions().mode() & 0o777, mode | 0o200); + fs::write(&target, b"changed")?; + assert_eq!(fs::read(source)?, b"original"); + } + fs::write(world.path().join("bin/added.wasm"), b"new")?; + fs::write(world.path().join("src/added.rs"), b"new")?; + Ok(()) +} From b807d5f31eb5812aaca52b63ba1cb31a466c13a5 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 06:55:17 +0000 Subject: [PATCH 07/22] Format the read-only bundle regression test --- host/src/world.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/host/src/world.rs b/host/src/world.rs index 2a6f79d..7231b19 100644 --- a/host/src/world.rs +++ b/host/src/world.rs @@ -105,21 +105,36 @@ fn readonly_bundle_copies_are_writable() -> std::io::Result<()> { fs::write(&path, b"original")?; fs::set_permissions(path, fs::Permissions::from_mode(mode))?; } - for path in [bundle.path().join("bin"), bundle.path().join("src"), bundle.path().to_owned(), world.path().to_owned()] { + for path in [ + bundle.path().join("bin"), + bundle.path().join("src"), + bundle.path().to_owned(), + world.path().to_owned(), + ] { fs::set_permissions(path, fs::Permissions::from_mode(0o555))?; } let result = copy_tree(bundle.path(), world.path()); // Restore source directories so the temporary directory can remove its files. - for path in [bundle.path(), &bundle.path().join("bin"), &bundle.path().join("src")] { + for path in [ + bundle.path(), + &bundle.path().join("bin"), + &bundle.path().join("src"), + ] { fs::set_permissions(path, fs::Permissions::from_mode(0o755))?; } result?; - assert_eq!(fs::metadata(world.path())?.permissions().mode() & 0o777, 0o755); + assert_eq!( + fs::metadata(world.path())?.permissions().mode() & 0o777, + 0o755 + ); for (name, mode) in [("bin/tool.wasm", 0o555), ("src/lib.rs", 0o444)] { let source = bundle.path().join(name); let target = world.path().join(name); assert_eq!(fs::metadata(&source)?.permissions().mode() & 0o777, mode); - assert_eq!(fs::metadata(&target)?.permissions().mode() & 0o777, mode | 0o200); + assert_eq!( + fs::metadata(&target)?.permissions().mode() & 0o777, + mode | 0o200 + ); fs::write(&target, b"changed")?; assert_eq!(fs::read(source)?, b"original"); } From 94f8d6d30c458cc410b8d150541a2a03a1cc557c Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 10:35:26 +0000 Subject: [PATCH 08/22] Preserve the configured kache wrapper in the development shell --- flake.nix | 3 --- 1 file changed, 3 deletions(-) diff --git a/flake.nix b/flake.nix index 1942c8b..2cd4975 100644 --- a/flake.nix +++ b/flake.nix @@ -161,9 +161,6 @@ apps.default = { type = "app"; program = "${sausage}/bin/sausage"; }; devShells.default = pkgs.mkShell (wasiCcEnv // moldEnv // { packages = [ rust pkgs.wasmtime pkgs.wasm-tools pkgs.openssl pkgs.pkg-config ] ++ mold; - shellHook = '' - export CARGO_HOME="$PWD/.cargo-home" - ''; }); }); } From 4ac1024e1ceb916e1230bbf31d48943299c47b16 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 16:03:34 +0000 Subject: [PATCH 09/22] Preserve upstream source symlinks in the packaged snapshot --- flake.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flake.nix b/flake.nix index b8091af..9961bbf 100644 --- a/flake.nix +++ b/flake.nix @@ -138,6 +138,8 @@ cp -a target/userland/bundle/. $out/ ''; doInstallCargoArtifacts = false; + # Preserve upstream source links to build outputs that the snapshot does not contain. + dontCheckForBrokenSymlinks = true; }); sausage = craneLib.buildPackage (common // { inherit cargoArtifacts; From 1d06547936af492fb2c64a57452eca48b8db8e60 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 16:23:08 +0000 Subject: [PATCH 10/22] Omit the dangling browser fixture from the world source snapshot --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 9961bbf..757fbf6 100644 --- a/flake.nix +++ b/flake.nix @@ -104,6 +104,8 @@ postPatch = '' git init --quiet --initial-branch=main git add host vfs userland extensions agents skills Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore + # This browser fixture links to an unbuilt upstream artifact, not a WASI tool. + git rm userland/esbuild/source/scripts/browser/esbuild.wasm.bagel git commit --no-gpg-sign --quiet -m 'Bundle source ${revision}' cp -r ${brushSource} userland/brush/source chmod -R u+w userland/brush/source @@ -138,8 +140,6 @@ cp -a target/userland/bundle/. $out/ ''; doInstallCargoArtifacts = false; - # Preserve upstream source links to build outputs that the snapshot does not contain. - dontCheckForBrokenSymlinks = true; }); sausage = craneLib.buildPackage (common // { inherit cargoArtifacts; From 983fd0a234cc94cfa73765e8b48bf92484ec5dc9 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 16:23:42 +0000 Subject: [PATCH 11/22] Exclude the browser fixture from the initial snapshot index --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 757fbf6..1772e42 100644 --- a/flake.nix +++ b/flake.nix @@ -105,7 +105,7 @@ git init --quiet --initial-branch=main git add host vfs userland extensions agents skills Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore # This browser fixture links to an unbuilt upstream artifact, not a WASI tool. - git rm userland/esbuild/source/scripts/browser/esbuild.wasm.bagel + git rm --cached userland/esbuild/source/scripts/browser/esbuild.wasm.bagel git commit --no-gpg-sign --quiet -m 'Bundle source ${revision}' cp -r ${brushSource} userland/brush/source chmod -R u+w userland/brush/source From 2d5449b3dc4021e089972b6815bdf949c4377623 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 18:23:01 +0000 Subject: [PATCH 12/22] Handle world Cargo help locally before the host request --- userland/cargo/src/lib.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/userland/cargo/src/lib.rs b/userland/cargo/src/lib.rs index 7eaa064..656cf4b 100644 --- a/userland/cargo/src/lib.rs +++ b/userland/cargo/src/lib.rs @@ -23,8 +23,17 @@ struct Executable { fn error(value: impl std::fmt::Debug) -> io::Error { io::Error::other(format!("{value:?}")) } +fn local_help(argv: &[String]) -> Option<&'static str> { + matches!(argv.first().map(String::as_str), Some("--help" | "-h" | "help")).then_some( + "Usage: cargo [OPTIONS]\n\nWorld commands: build, check, clippy, test, run, fmt, fetch, add, remove\n", + ) +} pub fn run() -> io::Result { let argv: Vec = std::env::args().skip(1).collect(); + if let Some(help) = local_help(&argv) { + io::stdout().write_all(help.as_bytes())?; + return Ok(0); + } let data = serde_json::to_vec( &serde_json::json!({"argv": argv, "cwd": std::env::current_dir()?.to_string_lossy()}), )?; @@ -108,6 +117,17 @@ pub fn run() -> io::Result { } Ok(0) } +#[cfg(test)] +#[test] +fn help_is_local_and_lists_world_commands() { + for flag in ["--help", "-h", "help"] { + let help = local_help(&[flag.into()]).expect("local help"); + assert!(help.starts_with("Usage: cargo [OPTIONS]\n")); + assert!(help.contains("build, check, clippy, test, run, fmt, fetch, add, remove")); + } + assert!(local_help(&["build".into()]).is_none()); + assert!(local_help(&[]).is_none()); +} fn execution_args(argv: &[String]) -> Vec { let split = argv.iter().position(|a| a == "--").unwrap_or(argv.len()); let mut args = argv.get(split + 1..).unwrap_or_default().to_vec(); From 092eb4772ec56af415e71083619aed7bf7e88d40 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 19:58:33 +0000 Subject: [PATCH 13/22] Point sandbox integration tests at the packaged bundle cache --- flake.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flake.nix b/flake.nix index 1772e42..028ed70 100644 --- a/flake.nix +++ b/flake.nix @@ -163,7 +163,9 @@ cargoExtraArgs = "--workspace --lib --test harness --test p1 --test durable --test 'cred*' --test layers --test regressions --test streams --test wasi"; preCheck = '' export SAUSAGE_CACHE="$TMPDIR/sausage-cache" + export CARGO_TARGET_DIR="$PWD/target" mkdir -p target/userland + ln -s ${userland} target/bundle ln -s ${userland} target/userland/bundle ''; doInstallCargoArtifacts = false; From 84266611ba353c7b95a35ae07206adeb37b19c4a Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 20:56:17 +0000 Subject: [PATCH 14/22] Include agent definitions and skills in the development bundle --- flake.nix | 2 -- userland/bundle.nu | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/flake.nix b/flake.nix index 028ed70..9dd15d3 100644 --- a/flake.nix +++ b/flake.nix @@ -113,8 +113,6 @@ substituteInPlace userland/brush/build.nu --replace-fail \ ${pkgs.lib.escapeShellArg "if (^git -C $source rev-parse HEAD | str trim) != $revision { error make {msg: 'unexpected brush revision'} }"} \ '# Revision verified by the fixed-output Nix fetch.' - substituteInPlace userland/bundle.nu --replace-fail \ - 'userland extensions Cargo.toml' 'userland extensions agents skills Cargo.toml' patchShebangs userland/cwd-init/link.nu ''; preBuild = '' diff --git a/userland/bundle.nu b/userland/bundle.nu index 7ca2c0c..f2b3977 100644 --- a/userland/bundle.nu +++ b/userland/bundle.nu @@ -41,14 +41,14 @@ def main [destination?: path] { if ($snapshot | path exists) { rm --recursive --force $snapshot } mkdir $snapshot let archive = ($destination | path join source.tar) - ^git -C $repo archive --output $archive HEAD host vfs userland extensions Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore + ^git -C $repo archive --output $archive HEAD host vfs userland extensions agents skills Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source archive failed'} } ^tar -xf $archive -C $snapshot if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source extraction failed'} } rm $archive ^git -C $snapshot init --quiet --initial-branch=main if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source git init failed'} } - ^git -C $snapshot add host vfs userland extensions Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore + ^git -C $snapshot add host vfs userland extensions agents skills Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source git add failed'} } with-env { GIT_AUTHOR_NAME: 'Sausage', GIT_AUTHOR_EMAIL: 'sausage@localhost', From 78dbe78bb10ab1acd2c964a24298bc9a92383a19 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 21:22:24 +0000 Subject: [PATCH 15/22] Preserve the full upstream snapshot with symlink-aware world copies --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 9dd15d3..fe5d008 100644 --- a/flake.nix +++ b/flake.nix @@ -104,8 +104,6 @@ postPatch = '' git init --quiet --initial-branch=main git add host vfs userland extensions agents skills Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore - # This browser fixture links to an unbuilt upstream artifact, not a WASI tool. - git rm --cached userland/esbuild/source/scripts/browser/esbuild.wasm.bagel git commit --no-gpg-sign --quiet -m 'Bundle source ${revision}' cp -r ${brushSource} userland/brush/source chmod -R u+w userland/brush/source @@ -138,6 +136,8 @@ cp -a target/userland/bundle/. $out/ ''; doInstallCargoArtifacts = false; + # The upstream browser fixture links to an optional, unbuilt artifact. + dontCheckForBrokenSymlinks = true; }); sausage = craneLib.buildPackage (common // { inherit cargoArtifacts; From c8ff6448701f38c64b0cdbdef0bda80e7367c89c Mon Sep 17 00:00:00 2001 From: Surma Date: Sun, 8 Nov 2026 00:22:01 +0000 Subject: [PATCH 16/22] Temporarily skip the looping P1 test per T17 Note 3 --- flake.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index fe5d008..2960d81 100644 --- a/flake.nix +++ b/flake.nix @@ -158,7 +158,8 @@ checks.package = sausage; checks.tests = craneLib.cargoTest (common // wasiCcEnv // { inherit cargoArtifacts; - cargoExtraArgs = "--workspace --lib --test harness --test p1 --test durable --test 'cred*' --test layers --test regressions --test streams --test wasi"; + # T17 brief, Note 3: skip p1 until T70 fixes its loop in v3. + cargoExtraArgs = "--workspace --lib --test harness --test durable --test 'cred*' --test layers --test regressions --test streams --test wasi"; preCheck = '' export SAUSAGE_CACHE="$TMPDIR/sausage-cache" export CARGO_TARGET_DIR="$PWD/target" From 3c8502f3af9a41648088c0c02285070bbc739ce9 Mon Sep 17 00:00:00 2001 From: Surma Date: Sun, 8 Nov 2026 01:08:30 +0000 Subject: [PATCH 17/22] Restore the P1 sandbox check after the shell stream fix --- flake.nix | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 2960d81..fe5d008 100644 --- a/flake.nix +++ b/flake.nix @@ -158,8 +158,7 @@ checks.package = sausage; checks.tests = craneLib.cargoTest (common // wasiCcEnv // { inherit cargoArtifacts; - # T17 brief, Note 3: skip p1 until T70 fixes its loop in v3. - cargoExtraArgs = "--workspace --lib --test harness --test durable --test 'cred*' --test layers --test regressions --test streams --test wasi"; + cargoExtraArgs = "--workspace --lib --test harness --test p1 --test durable --test 'cred*' --test layers --test regressions --test streams --test wasi"; preCheck = '' export SAUSAGE_CACHE="$TMPDIR/sausage-cache" export CARGO_TARGET_DIR="$PWD/target" From b38da84b31edaeabe2c84020101cfede4e35a191 Mon Sep 17 00:00:00 2001 From: Surma Date: Sun, 8 Nov 2026 01:58:49 +0000 Subject: [PATCH 18/22] Retain the vendored Deno sources that runtime initialization loads --- flake.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flake.nix b/flake.nix index fe5d008..5cb0636 100644 --- a/flake.nix +++ b/flake.nix @@ -141,6 +141,8 @@ }); sausage = craneLib.buildPackage (common // { inherit cargoArtifacts; + # Deno loads extension TypeScript from the vendored sources at runtime. + doNotRemoveReferencesToVendorDir = true; cargoExtraArgs = "--locked -p sausage"; doCheck = false; nativeBuildInputs = common.nativeBuildInputs ++ [ pkgs.makeWrapper ]; From e939978e4830b1cae78833fb6f0f7f115087e077 Mon Sep 17 00:00:00 2001 From: Surma Date: Sun, 8 Nov 2026 02:32:55 +0000 Subject: [PATCH 19/22] Select sandbox-safe modules from the shared host test binary --- flake.nix | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 5cb0636..fed26bb 100644 --- a/flake.nix +++ b/flake.nix @@ -160,7 +160,14 @@ checks.package = sausage; checks.tests = craneLib.cargoTest (common // wasiCcEnv // { inherit cargoArtifacts; - cargoExtraArgs = "--workspace --lib --test harness --test p1 --test durable --test 'cred*' --test layers --test regressions --test streams --test wasi"; + # Keep the same sandbox-compatible modules in the shared host test executable. + checkPhaseCargoCommand = '' + cargoWithProfile test --locked --workspace --lib + cargoWithProfile test --locked -p sausage --test it -- \ + harness:: p1:: durable:: cred:: cred_ --skip undo_harness:: + cargoWithProfile test --locked -p sausage-vfs \ + --test layers --test regressions --test streams --test wasi + ''; preCheck = '' export SAUSAGE_CACHE="$TMPDIR/sausage-cache" export CARGO_TARGET_DIR="$PWD/target" From 67b99c50a910fbe9d1275fcd7c20f1716e770f90 Mon Sep 17 00:00:00 2001 From: Surma Date: Sun, 8 Nov 2026 02:33:15 +0000 Subject: [PATCH 20/22] Retain compile-time fixtures for the shared integration executable --- flake.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index fed26bb..d043b5b 100644 --- a/flake.nix +++ b/flake.nix @@ -52,7 +52,8 @@ && (craneLib.filterCargoSources path type || pkgs.lib.hasSuffix ".wit" path || pkgs.lib.hasSuffix ".witx" path || pkgs.lib.hasSuffix ".js" path || pkgs.lib.hasSuffix ".html" path - || pkgs.lib.hasSuffix ".wasm" path || pkgs.lib.hasSuffix ".nu" path); + || pkgs.lib.hasSuffix ".wasm" path || pkgs.lib.hasSuffix ".nu" path + || pkgs.lib.hasSuffix ".c" path || pkgs.lib.hasSuffix ".patch" path); }; v8Archive = pkgs.fetchurl { # deno_core enables rusty_v8's simdutf feature. From d797857c94abefe266f3529821adee44f579f722 Mon Sep 17 00:00:00 2001 From: Surma Date: Sun, 8 Nov 2026 02:52:37 +0000 Subject: [PATCH 21/22] Run the selected test commands through a custom Crane check phase --- flake.nix | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index d043b5b..1e63f09 100644 --- a/flake.nix +++ b/flake.nix @@ -159,8 +159,11 @@ in { packages.default = sausage; checks.package = sausage; - checks.tests = craneLib.cargoTest (common // wasiCcEnv // { + checks.tests = craneLib.mkCargoDerivation (common // wasiCcEnv // { inherit cargoArtifacts; + pnameSuffix = "-test"; + doCheck = true; + buildPhaseCargoCommand = ""; # Keep the same sandbox-compatible modules in the shared host test executable. checkPhaseCargoCommand = '' cargoWithProfile test --locked --workspace --lib From e6c13f2518d4c0d5b828d9d5fc46d68a86e947ea Mon Sep 17 00:00:00 2001 From: Surma Date: Sun, 8 Nov 2026 04:34:45 +0000 Subject: [PATCH 22/22] Provide independent source tree references for the sandbox P1 checks --- flake.nix | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/flake.nix b/flake.nix index 5bc694e..a3df002 100644 --- a/flake.nix +++ b/flake.nix @@ -162,6 +162,7 @@ checks.tests = craneLib.mkCargoDerivation (common // wasiCcEnv // { inherit cargoArtifacts; pnameSuffix = "-test"; + nativeBuildInputs = common.nativeBuildInputs ++ [ pkgs.git ]; doCheck = true; buildPhaseCargoCommand = ""; # Keep the same sandbox-compatible modules in the shared host test executable. @@ -175,6 +176,15 @@ preCheck = '' export SAUSAGE_CACHE="$TMPDIR/sausage-cache" export CARGO_TARGET_DIR="$PWD/target" + export RUST_BACKTRACE=1 + # Flake sources omit Git history. Map the recorded revision to independently + # staged source trees for P1's directory-hash comparison. + cp -r ${userlandSrc}/agents ${userlandSrc}/skills ${userlandSrc}/extensions . + git init --quiet + git add agents skills extensions + git -c user.name=Sausage -c user.email=sausage@localhost \ + commit --no-gpg-sign --quiet -m 'Flake source trees' + git update-ref refs/replace/${revision} HEAD mkdir -p target/userland ln -s ${userland} target/bundle ln -s ${userland} target/userland/bundle