From 69b5c9d899979a1b6fe22e5aedff05863b21e987 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:56:00 -0700 Subject: [PATCH 1/2] ADR 0014 phase 2: components in the wasm service, with a per-write storage quota The shell's wasm service loads components from fns/ beside modules, keeps one instance per component between calls, gives it the app's storage folder by the engines' rules, and charges what each write adds against what is left of the app's quota (wasm-host component/files.rs). Writes run on the worker thread. Typed JSON accepts a one-item positional array. Measurements, docs (en, zh-CN) and tests. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/shell/src/wasm_service.rs | 457 +++++++++++++++++- crates/wasm-host/Cargo.toml | 2 + .../wasm-host/examples/measure_component.rs | 242 ++++++++++ crates/wasm-host/src/component.rs | 248 +++++++--- crates/wasm-host/src/component/files.rs | 314 ++++++++++++ .../tests/component-guest/notes/src/lib.rs | 37 ++ .../tests/component-guest/notes/wit/notes.wit | 15 + crates/wasm-host/tests/component.rs | 141 +++++- .../tests/fixtures/notes.component.wasm | Bin 310601 -> 320538 bytes .../adr/0014-app-components-in-webassembly.md | 65 ++- ...014-app-components-in-webassembly.zh-CN.md | 46 +- docs/wasm.md | 105 +++- docs/wasm.zh-CN.md | 77 ++- 14 files changed, 1571 insertions(+), 179 deletions(-) create mode 100644 crates/wasm-host/examples/measure_component.rs create mode 100644 crates/wasm-host/src/component/files.rs diff --git a/Cargo.lock b/Cargo.lock index f0da8242b..dc4d0ead9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11984,6 +11984,7 @@ name = "octosense-wasm-host" version = "0.1.0" dependencies = [ "base64 0.22.1", + "bytes", "libc", "serde_json", "sha2 0.10.9", diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index 79bbed822..12356efcf 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -23,6 +23,19 @@ //! Admission, cancellation and deadline are checked before execution and delivery; //! an update or withdrawal invalidates loaded Programs. Workers exit when idle. //! The host-only disk cache holds compiled code keyed by module digest, not data. +//! +//! **Components** (ADR 0014). A `fns/*.wasm` may also be a WebAssembly +//! component, built with plain `cargo build --target wasm32-wasip2`. Each of +//! its exported functions is callable as `wasm.` with JSON (snake_case +//! names; `octosense_wasm_host::component` maps the values). Unlike a +//! module's, a component's instance lives on in the worker between calls, +//! so it can keep a document or a cache; a trap, a deadline, a changed grant +//! or the worker's exit (a minute without calls) ends it. Its filesystem is +//! the app's storage, the jail its script's `fs.*` sees, decided per call by +//! the same rules as an engine's ([`crate::host_tools::areas::app_area`]): +//! none without the `storage` capability or a signed-in account, and what +//! is left of the quota is what a call may add (a write past it fails inside +//! the component, as a full disk). use std::collections::{BTreeMap, HashMap}; use std::io::Write; @@ -33,18 +46,34 @@ use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use octosense_appstore::services::{HostService, Replier, ServiceCall, ServiceHost}; +use octosense_wasm_host::component::{self, ComponentInstance, ComponentProgram, Grants}; use octosense_wasm_host::{Limits, Program, Runtime}; use serde_json::{json, Value}; +use crate::host_tools::areas::{self, AreaEnv}; use crate::host_tools::script_apps; const MAX_MODULES: usize = 8; const MAX_WORKERS: usize = 4; const MAX_QUEUED_PER_APP: usize = 4; -const MAX_INPUT_BYTES: usize = 1 << 20; -const MAX_BUFFERED_BYTES: usize = 16 << 20; +const MAX_INPUT_BYTES: usize = 8 << 20; +const MAX_BUFFERED_BYTES: usize = 32 << 20; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); const WORKER_IDLE: Duration = Duration::from_secs(5); +/// How long a worker that holds a component's live instance waits for the +/// next call before it exits (and the instance with it). +const COMPONENT_IDLE: Duration = Duration::from_secs(60); + +/// Where an app's component works: the shell's own rules, or a test's. +static AREA_ENV: Mutex>> = Mutex::new(None); + +fn area_env() -> Arc { + AREA_ENV + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get_or_insert_with(areas::shell) + .clone() +} static RUNTIME: OnceLock> = OnceLock::new(); static WORKERS: Mutex>> = Mutex::new(None); @@ -136,6 +165,8 @@ impl Admission { struct Job { method: String, input: Vec, + /// The request's arguments were a JSON string, and `input` is its text. + text: bool, host_dir: PathBuf, admission: Arc, deadline: Instant, @@ -189,7 +220,10 @@ fn input_bytes(args: &Value) -> Result, String> { impl Write for Bounded { fn write(&mut self, bytes: &[u8]) -> std::io::Result { if bytes.len() > MAX_INPUT_BYTES.saturating_sub(self.0.len()) { - return Err(std::io::Error::other("wasm input exceeds 1 MiB")); + return Err(std::io::Error::other(format!( + "wasm input exceeds {} MiB", + MAX_INPUT_BYTES >> 20 + ))); } self.0.extend_from_slice(bytes); Ok(bytes.len()) @@ -200,7 +234,7 @@ fn input_bytes(args: &Value) -> Result, String> { } if let Value::String(text) = args { if text.len() > MAX_INPUT_BYTES { - return Err("wasm input exceeds 1 MiB".into()); + return Err(format!("wasm input exceeds {} MiB", MAX_INPUT_BYTES >> 20)); } return Ok(text.as_bytes().to_vec()); } @@ -241,6 +275,7 @@ impl HostService for WasmService { let app = call.app_id.clone(); let mut job = Job { method: call.method().to_string(), + text: matches!(call.args, Value::String(_)), host_dir: call.host_dir, input, admission: Arc::default(), @@ -354,7 +389,14 @@ pub fn register() { fn work(app: String, jobs: Receiver, retired: &Arc) { let mut lab: Option = None; loop { - let job = match jobs.recv_timeout(WORKER_IDLE) { + // A component's live instance keeps its state: give the app longer + // to come back before it is dropped. + let idle = if lab.as_ref().is_some_and(Lab::holds_instances) { + COMPONENT_IDLE + } else { + WORKER_IDLE + }; + let job = match jobs.recv_timeout(idle) { Ok(job) => job, Err(_) => { // Serialize idle retirement with enqueue. A sender that won @@ -412,10 +454,13 @@ fn process_checked( *lab = None; return Err(error); } - let result = - lab.as_mut() - .unwrap() - .answer(&job.method, &job.input, job.deadline, job.reply.clone()); + let result = lab.as_mut().unwrap().answer( + &job.method, + &job.input, + job.text, + job.deadline, + job.reply.clone(), + ); before_delivery(); // A withdrawal, changed grant, update or tamper while code runs vetoes // its result and releases the old compiled-module references. @@ -449,12 +494,24 @@ struct Module { bytes: usize, load_ms: f64, from_cache: bool, - program: Program, + code: Code, /// Diagnostic high-water mark only: guest memory is never retained. memory_bytes: usize, invocations: u64, } +enum Code { + /// A core module (ADR 0011): a fresh instance for every call. + Module(Program), + /// A component (ADR 0014): one instance, kept between calls, and the + /// grants it was made with. + Component { + program: ComponentProgram, + live: Option<(ComponentInstance, Grants)>, + instances: u64, + }, +} + /// How one function has run: in the worker, from input to output. #[derive(Default)] struct Stats { @@ -466,9 +523,20 @@ struct Stats { impl Lab { fn load(app: &str, job: &Job) -> Result { - let admission = job.check(app)?; - let bundle = &admission.bundle; + let admission = job.check(app)?.clone(); let runtime = runtime(&job.host_dir)?; + Lab::from_bundle(app, runtime, admission, || job.check(app).map(drop)) + } + + /// The admitted bundle's `fns/*.wasm`, loaded; `check` ends it early (a + /// cancelled request, a changed admission). + fn from_bundle( + app: &str, + runtime: &'static Runtime, + admission: Admission, + check: impl Fn() -> Result<(), String>, + ) -> Result { + let bundle = &admission.bundle; let mut files: Vec = std::fs::read_dir(bundle.join("fns")) .map_err(|_| format!("{app}'s bundle has no fns directory"))? .flatten() @@ -493,7 +561,7 @@ impl Lab { admission: admission.clone(), }; for path in files { - job.check(app)?; + check()?; let file = path .file_name() .unwrap_or_default() @@ -506,9 +574,29 @@ impl Lab { } let bytes = std::fs::read(&path).map_err(|e| format!("{file}: {e}"))?; let started = Instant::now(); - let program = runtime.load(&bytes).map_err(|e| format!("{file}: {e}"))?; + let (code, functions, from_cache) = if component::is_component(&bytes) { + let program = runtime + .load_component(&bytes) + .map_err(|e| format!("{file}: {e}"))?; + let names: Vec = program.exports().iter().map(|e| e.name.clone()).collect(); + let from_cache = program.from_cache(); + ( + Code::Component { + program, + live: None, + instances: 0, + }, + names, + from_cache, + ) + } else { + let program = runtime.load(&bytes).map_err(|e| format!("{file}: {e}"))?; + let names = program.functions().to_vec(); + let from_cache = program.from_cache(); + (Code::Module(program), names, from_cache) + }; let load_ms = started.elapsed().as_secs_f64() * 1e3; - for function in program.functions() { + for function in &functions { if lab .owner .insert(function.clone(), lab.modules.len()) @@ -519,23 +607,27 @@ impl Lab { )); } } - let from_cache = program.from_cache(); makepad_widgets::log!( - "wasm {app}: {file} ({} KiB) {} in {load_ms:.1} ms: {}", + "wasm {app}: {file} ({} KiB{}) {} in {load_ms:.1} ms: {}", bytes.len() / 1024, + if matches!(code, Code::Component { .. }) { + ", a component" + } else { + "" + }, if from_cache { "loaded from the cache" } else { "compiled" }, - program.functions().join(", ") + functions.join(", ") ); lab.modules.push(Module { file, bytes: bytes.len(), load_ms, from_cache, - program, + code, memory_bytes: 0, invocations: 0, }); @@ -543,10 +635,18 @@ impl Lab { Ok(lab) } + /// Whether a component's instance is alive (its state worth keeping). + fn holds_instances(&self) -> bool { + self.modules + .iter() + .any(|m| matches!(&m.code, Code::Component { live: Some(_), .. })) + } + fn answer( &mut self, method: &str, input: &[u8], + text: bool, deadline: Instant, reply: Replier, ) -> Result { @@ -557,14 +657,25 @@ impl Lab { makepad_widgets::log!("wasm {}: {described}", self.app); return Ok(described); } - let Some(&index) = self.owner.get(method) else { + let name = if self.owner.contains_key(method) { + method.to_string() + } else { + component::snake(method) + }; + let Some(&index) = self.owner.get(&name) else { return Err(format!("{} has no function {method:?}", self.app)); }; let module = &mut self.modules[index]; + let program = match &mut module.code { + Code::Module(program) => program, + Code::Component { .. } => { + return self.answer_component(index, &name, input, text, deadline, reply) + } + }; let started = Instant::now(); let mut instance = self .runtime - .instantiate_guarded(&module.program, deadline, move || reply.is_pending()) + .instantiate_guarded(program, deadline, move || reply.is_pending()) .map_err(|error| error.to_string())?; module.invocations += 1; let result = instance.call(method, input); @@ -588,13 +699,97 @@ impl Lab { .unwrap_or_else(|_| json!({"text": String::from_utf8_lossy(&output)}))) } + /// One call to a component's function, on its live instance (made, or + /// remade, when there is none, it was spent, or its grants changed). + fn answer_component( + &mut self, + index: usize, + name: &str, + input: &[u8], + text: bool, + deadline: Instant, + reply: Replier, + ) -> Result { + let args: Value = if text { + Value::String(String::from_utf8_lossy(input).into_owned()) + } else if input.is_empty() { + Value::Null + } else { + serde_json::from_slice(input).map_err(|e| format!("the arguments are not JSON: {e}"))? + }; + // Decided for every call: a sign-out or a revoked grant applies to + // the next call, and the quota is what is left of it now. + let area = areas::app_area(&*area_env(), &self.app, true).ok(); + let grants = Grants { + storage_dir: area.as_ref().map(|a| a.root.clone()), + read_only: false, + }; + let app = self.app.clone(); + let module = &mut self.modules[index]; + let Code::Component { + program, + live, + instances, + } = &mut module.code + else { + unreachable!("answer_component is called for components"); + }; + let started = Instant::now(); + if live + .as_ref() + .is_some_and(|(instance, made)| instance.spent() || *made != grants) + { + *live = None; + } + if live.is_none() { + let pending = reply.clone(); + let instance = self + .runtime + .instantiate_component( + program, + &grants, + Some((deadline, Box::new(move || pending.is_pending()))), + ) + .map_err(|error| error.to_string())?; + *instances += 1; + *live = Some((instance, grants.clone())); + } + let (instance, _) = live.as_mut().expect("made above"); + module.invocations += 1; + // A write past the quota fails inside the component, as a full disk. + instance.set_storage_budget(area.and_then(|a| a.quota_left)); + let result = instance.call_json_guarded(name, &args, deadline, move || reply.is_pending()); + let us = started.elapsed().as_secs_f64() * 1e6; + for line in instance.take_logs() { + makepad_widgets::log!("wasm {app}: {line}"); + } + if instance.spent() { + *live = None; + } + let stats = self.stats.entry(name.to_string()).or_default(); + stats.calls += 1; + stats.total_us += us; + stats.max_us = stats.max_us.max(us); + result.map_err(|error| { + stats.errors += 1; + error.to_string() + }) + } + fn describe(&self) -> Value { let modules: Vec = self .modules .iter() - .map(|m| { - json!({"file": m.file, "bytes": m.bytes, "load_ms": round(m.load_ms), - "from_cache": m.from_cache, "memory_bytes": m.memory_bytes, "invocations": m.invocations, "renewed": m.invocations.saturating_sub(1), "instance_policy": "fresh-per-call"}) + .map(|m| match &m.code { + Code::Module(_) => json!({"file": m.file, "kind": "module", "bytes": m.bytes, "load_ms": round(m.load_ms), + "from_cache": m.from_cache, "memory_bytes": m.memory_bytes, "invocations": m.invocations, "renewed": m.invocations.saturating_sub(1), "instance_policy": "fresh-per-call"}), + Code::Component { program, live, instances } => json!({"file": m.file, "kind": "component", "bytes": m.bytes, "load_ms": round(m.load_ms), + "from_cache": m.from_cache, "invocations": m.invocations, "instances": instances, "instance_policy": "kept-between-calls", + "live": live.is_some(), "storage": live.as_ref().map(|(_, g)| if g.storage_dir.is_some() { "app folder" } else { "none" }), + "storage_left": live.as_ref().and_then(|(i, _)| i.storage_budget()), + "exports": program.exports().iter().map(|e| json!({"name": e.name, "wit": e.wit_name, + "params": e.params.iter().map(|(n, t)| json!([n, t])).collect::>(), "result": e.result})).collect::>(), + "skipped": program.skipped().iter().map(|(n, why)| json!({"name": n, "why": why})).collect::>()}), }) .collect(); let stats: serde_json::Map = self @@ -738,7 +933,8 @@ mod tests { }); } - fn captured_job(app: &str, method: &str, host_dir: &Path) -> (usize, Job) { + /// A pending reply to `app`'s request, as dispatch hands a service one. + fn pending_reply(app: &str, host_dir: &Path) -> (usize, Replier) { struct Capture(Arc>>); impl HostService for Capture { fn family(&self) -> &'static str { @@ -765,11 +961,17 @@ mod tests { &mut NoSheet, ); let reply = captured.lock().unwrap().take().unwrap(); + (heap, reply) + } + + fn captured_job(app: &str, method: &str, host_dir: &Path) -> (usize, Job) { + let (heap, reply) = pending_reply(app, host_dir); ( heap, Job { method: method.into(), input: Vec::new(), + text: false, host_dir: host_dir.into(), admission: Arc::new(AdmissionEpoch { admitted: OnceLock::from(Ok(Admission::current(app).unwrap())), @@ -1311,4 +1513,209 @@ mod tests { assert!(answer.unwrap_err().contains("no sheet")); let _ = std::fs::remove_dir_all(root); } + + /// The spike's component (ADR 0014): `pulldown-cmark` and friends, built + /// with plain cargo for `wasm32-wasip2`. + const NOTES_COMPONENT: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../wasm-host/tests/fixtures/notes.component.wasm" + ); + + /// A component's functions, in a process of its own (the area env and + /// the registries are process-wide): they answer with typed JSON under + /// either spelling of their names; one instance keeps its state between + /// calls until a trap spends it; and its files are the app's own + /// storage, under what is left of its quota, and absent without the + /// storage capability. + #[test] + fn a_components_instance_keeps_state_and_its_files_stay_in_the_apps_storage() { + const CHILD: &str = "OCTOSENSE_TEST_WASM_COMPONENT"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "wasm_service::tests::a_components_instance_keeps_state_and_its_files_stay_in_the_apps_storage", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + const APP: &str = "os.wasmnotes"; + const PLAIN: &str = "os.wasmplain"; + let root = + std::env::temp_dir().join(format!("octosense-wasm-component-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let bundle = root.join("bundle"); + std::fs::create_dir_all(bundle.join("fns")).unwrap(); + std::fs::copy(NOTES_COMPONENT, bundle.join("fns/notes.wasm")).unwrap(); + // The apps' storage as the shell keeps it: a 4 KiB ceiling for one, + // no storage capability for the other. + let storage = crate::app_storage::Storage::with_file_secrets( + crate::app_storage::Layout::new(&root.join("home")).unwrap(), + ); + let mut env = areas::FixedEnv { + storage: Some(storage.clone()), + ..areas::FixedEnv::default() + }; + env.quotas.insert( + APP.into(), + areas::JailQuota { + bytes: Some(4096), + storage: true, + }, + ); + env.quotas.insert( + PLAIN.into(), + areas::JailQuota { + bytes: None, + storage: false, + }, + ); + *AREA_ENV.lock().unwrap() = Some(Arc::new(env)); + let host_dir = root.join(".host"); + let runtime = runtime(&host_dir).unwrap(); + let admission = Admission { + root: root.clone(), + bundle, + manifest: json!({}), + }; + let mut lab = Lab::from_bundle(APP, runtime, admission.clone(), || Ok(())).unwrap(); + let (_, reply) = pending_reply(APP, &host_dir); + // What the app's script gets from `host.request("wasm." + method, args)`. + let call = |lab: &mut Lab, method: &str, args: Value| { + let text = matches!(args, Value::String(_)); + let input = input_bytes(&args).unwrap(); + lab.answer( + method, + &input, + text, + Instant::now() + REQUEST_TIMEOUT, + reply.clone(), + ) + }; + + // Typed JSON: a bare value for one parameter, an object by name or + // an array in order; a record comes back as an object. + assert_eq!( + call(&mut lab, "to_html", json!("# Hi")).unwrap(), + "

Hi

\n" + ); + assert_eq!( + call(&mut lab, "to-html", json!({"markdown": "*a*"})).unwrap(), + "

a

\n" + ); + assert_eq!( + call(&mut lab, "analyze", json!(["# One\n\ntwo words"])).unwrap(), + json!({"words": 4, "lines": 3, "headings": ["One"]}) + ); + let error = call(&mut lab, "nothing", json!({})).unwrap_err(); + assert!(error.contains("no function"), "{error}"); + + // One instance, kept between calls, until a trap spends it. + assert_eq!(call(&mut lab, "count", json!({})).unwrap(), 1); + assert_eq!(call(&mut lab, "count", Value::Null).unwrap(), 2); + let error = lab + .answer( + "spin", + b"{}", + false, + Instant::now() + Duration::from_millis(100), + reply.clone(), + ) + .unwrap_err(); + assert!(error.contains("deadline"), "{error}"); + assert_eq!(call(&mut lab, "count", json!({})).unwrap(), 1); + let described = lab.describe(); + let notes = &described["modules"][0]; + assert_eq!(notes["kind"], "component", "{described}"); + assert_eq!(notes["instance_policy"], "kept-between-calls"); + assert_eq!(notes["instances"], 2); + assert_eq!(notes["storage"], "app folder"); + assert!( + notes["exports"] + .as_array() + .unwrap() + .iter() + .any(|e| e["name"] == "save_html" && e["wit"] == "save-html"), + "{described}" + ); + assert!(described["functions"] + .as_array() + .unwrap() + .contains(&json!("echo_bytes"))); + assert_eq!(described["stats"]["spin"]["errors"], 1); + + // Its files are the app's own storage, the folder its script's fs.* + // sees, and nothing outside it. + let jail = storage.layout().app(APP).unwrap().jail; + let saved = json!({"markdown": "# Hi", "path": "a.html"}); + assert_eq!(call(&mut lab, "save_html", saved.clone()).unwrap(), 12); + assert_eq!( + std::fs::read_to_string(jail.join("a.html")).unwrap(), + "

Hi

\n" + ); + assert_eq!( + call(&mut lab, "read_file", json!("a.html")).unwrap(), + "

Hi

\n" + ); + std::fs::write(root.join("outside.txt"), "host").unwrap(); + for outside in ["../outside.txt", root.join("outside.txt").to_str().unwrap()] { + assert!( + call(&mut lab, "read_file", json!(outside)).is_err(), + "{outside}" + ); + } + + // The quota (4 KiB here): what is left of it is what a call may add. + // A write past it fails inside the component, which keeps its + // instance and state; a smaller one still goes in. + let before = call(&mut lab, "count", json!({})) + .unwrap() + .as_u64() + .unwrap(); + let big = json!({"markdown": "x".repeat(5000), "path": "big.html"}); + let error = call(&mut lab, "save_html", big).unwrap_err(); + assert!(error.ends_with("the storage budget is used up"), "{error}"); + assert_eq!(std::fs::metadata(jail.join("big.html")).unwrap().len(), 0); + let small = json!({"markdown": "# Hi", "path": "b.html"}); + assert_eq!(call(&mut lab, "save_html", small).unwrap(), 12); + // What the app writes otherwise counts too, from the next call on. + std::fs::write(jail.join("filler"), vec![0u8; 4096 - 24]).unwrap(); + let error = call( + &mut lab, + "save_html", + json!({"markdown": "# Hi", "path": "c.html"}), + ) + .unwrap_err(); + assert!(error.ends_with("the storage budget is used up"), "{error}"); + assert_eq!(call(&mut lab, "count", json!({})).unwrap(), before + 1); + let described = lab.describe(); + assert_eq!(described["modules"][0]["storage"], "app folder"); + assert_eq!(described["modules"][0]["storage_left"], 0); + assert_eq!(described["modules"][0]["instances"], 2); + + // Without the storage capability, no folder at all. + let mut plain = Lab::from_bundle(PLAIN, runtime, admission, || Ok(())).unwrap(); + let (_, plain_reply) = pending_reply(PLAIN, &host_dir); + let error = plain + .answer( + "save_html", + saved.to_string().as_bytes(), + false, + Instant::now() + REQUEST_TIMEOUT, + plain_reply.clone(), + ) + .unwrap_err(); + assert!(!error.is_empty()); + assert_eq!(plain.describe()["modules"][0]["storage"], "none"); + let _ = std::fs::remove_dir_all(root); + } } diff --git a/crates/wasm-host/Cargo.toml b/crates/wasm-host/Cargo.toml index 3db69b248..c93cc3157 100644 --- a/crates/wasm-host/Cargo.toml +++ b/crates/wasm-host/Cargo.toml @@ -10,6 +10,8 @@ wasmtime = { workspace = true } wasmtime-wasi = { workspace = true } serde_json = { workspace = true } base64 = "0.22" +# File writes a component makes (wasmtime-wasi's streams carry `Bytes`). +bytes = "1" sha2 = "0.10" # Not used directly: Wasmtime reads a trap's registers through libc's # ucontext_t, and before 0.2.190 Android aarch64's lacked the kernel's diff --git a/crates/wasm-host/examples/measure_component.rs b/crates/wasm-host/examples/measure_component.rs new file mode 100644 index 000000000..f2e76e72e --- /dev/null +++ b/crates/wasm-host/examples/measure_component.rs @@ -0,0 +1,242 @@ +//! A component's (ADR 0014) startup and per-call latency in the runtime, +//! against Wasm Lab's core module (ADR 0011) and native code doing the same +//! work, and the cost of the storage quota check the shell's `wasm` service +//! makes after a component's call. +//! +//! cargo run --release -p octosense-wasm-host --example measure_component + +use std::path::Path; +use std::time::{Duration, Instant}; + +use octosense_wasm_host::component::Grants; +use octosense_wasm_host::{Limits, Runtime}; +use serde_json::{json, Value}; + +const COMPONENT: &[u8] = include_bytes!("../tests/fixtures/notes.component.wasm"); +const MODULE: &[u8] = include_bytes!("../../../apps/wasmlab/bundle/fns/wasmlab.wasm"); + +/// Median of `runs` timings of `f`, in microseconds. +fn median_us(runs: usize, mut f: impl FnMut()) -> f64 { + let mut times: Vec = (0..runs) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed() + }) + .collect(); + times.sort(); + times[runs / 2].as_secs_f64() * 1e6 +} + +fn markdown() -> String { + let mut text = String::new(); + for i in 0..200 { + text.push_str(&format!( + "## Section {i}\n\nSome *emphasis*, **strong** text, `code` and a [link](https://example.org/{i}).\n\n- [x] done {i}\n- [ ] todo {i}\n\n| key | value |\n|---|---|\n| a{i} | {i} |\n\n" + )); + } + text +} + +/// The walk the shell's `Storage::usage` makes over an app's jail: every +/// entry's metadata, never following a symlink. +fn tree_bytes(dir: &Path) -> u64 { + let mut total = 0; + let mut stack = vec![dir.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(meta) = std::fs::symlink_metadata(entry.path()) else { + continue; + }; + if meta.is_dir() { + stack.push(entry.path()); + } else if meta.is_file() { + total += meta.len(); + } + } + } + total +} + +fn main() { + println!( + "component: {} KiB (module: {} KiB)", + COMPONENT.len() / 1024, + MODULE.len() / 1024 + ); + let scratch = std::env::temp_dir().join(format!( + "octosense-wasm-measure-component-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&scratch); + let cache = scratch.join("cache"); + + let t = Instant::now(); + let runtime = Runtime::new(Limits::default(), Some(cache.clone())).unwrap(); + let program = runtime.load_component(COMPONENT).unwrap(); + println!( + "compile (Cranelift, first load): {:.1} ms", + t.elapsed().as_secs_f64() * 1e3 + ); + let again = Runtime::new(Limits::default(), Some(cache.clone())).unwrap(); + let t = Instant::now(); + let cached = again.load_component(COMPONENT).unwrap(); + println!( + "load from the cache: {:.2} ms (from cache: {})", + t.elapsed().as_secs_f64() * 1e3, + cached.from_cache() + ); + let bare = median_us(50, || { + drop( + runtime + .instantiate_component(&program, &Grants::default(), None) + .unwrap(), + ) + }); + let storage = scratch.join("jail"); + std::fs::create_dir_all(&storage).unwrap(); + let grants = Grants { + storage_dir: Some(storage.clone()), + read_only: false, + }; + let with_files = median_us(50, || { + drop( + runtime + .instantiate_component(&program, &grants, None) + .unwrap(), + ) + }); + println!( + "instantiate: {:.3} ms; with the storage folder: {:.3} ms", + bare / 1e3, + with_files / 1e3 + ); + + let mut component = runtime + .instantiate_component(&program, &grants, None) + .unwrap(); + let module = runtime.load(MODULE).unwrap(); + let mut guest = runtime.instantiate(&module).unwrap(); + let md = markdown(); + let md_json = json!({"markdown": md}); + println!( + "\n{:<46} {:>10} {:>10} {:>10}", + "per call (median of 200)", "component", "module", "native us" + ); + let to_html = median_us(200, || { + drop(component.call_json("to_html", &md_json).unwrap()) + }); + let md_to_html = median_us(200, || { + drop(guest.call("md_to_html", md.as_bytes()).unwrap()) + }); + let native = median_us(200, || { + drop(wasmlab_functions::md_to_html(md.as_bytes()).unwrap()) + }); + println!( + "{:<46} {to_html:>10.1} {md_to_html:>10.1} {native:>10.1}", + format!("Markdown to HTML ({} KiB, pulldown-cmark)", md.len() / 1024) + ); + let analyze = median_us(200, || { + drop(component.call_json("analyze", &md_json).unwrap()) + }); + println!( + "{:<46} {analyze:>10.1} {:>10} {:>10}", + "analyze (a record back)", "-", "-" + ); + // A new file every time: rewriting one file over and over measures the + // filesystem (APFS takes about 6 ms a rewrite, natively too). + let mut n = 0; + let mut saved = || { + n += 1; + json!({"markdown": md, "path": format!("out{n}.html")}) + }; + let save = median_us(200, || { + drop(component.call_json("save_html", &saved()).unwrap()) + }); + println!( + "{:<46} {save:>10.1} {:>10} {:>10}", + "save_html (render, write 49 KiB)", "-", "-" + ); + let html = component.call_json("to_html", &md_json).unwrap(); + let html = html.as_str().unwrap().to_string(); + let mut m = 0; + let native_save = median_us(200, || { + m += 1; + std::fs::write(storage.join(format!("native{m}.html")), &html).unwrap() + }); + println!( + "{:<46} {:>10} {:>10} {native_save:>10.1}", + "write 49 KiB natively", "-", "-" + ); + let mut k = 0; + let save_tiny = median_us(200, || { + k += 1; + drop( + component + .call_json( + "save_html", + &json!({"markdown": "# Hi", "path": format!("tiny{k}.html")}), + ) + .unwrap(), + ) + }); + println!( + "{:<46} {save_tiny:>10.1} {:>10} {:>10}", + "save_html (write 12 bytes)", "-", "-" + ); + let read = json!("out1.html"); + let read_file = median_us(200, || { + drop(component.call_json("read_file", &read).unwrap()) + }); + println!( + "{:<46} {read_file:>10.1} {:>10} {:>10}", + "read_file (49 KiB back)", "-", "-" + ); + let mib: Value = json!(base64_of(&vec![7u8; 1 << 20])); + let echo = median_us(50, || { + drop(component.call_json("echo_bytes", &mib).unwrap()) + }); + println!( + "{:<46} {echo:>10.1} {:>10} {:>10}", + "echo_bytes (1 MiB, base64 both ways)", "-", "-" + ); + let empty = json!({"query": "x", "items": [], "limit": 1}); + let smallest_module = median_us(2000, || { + drop( + guest + .call("fuzzy_rank", &serde_json::to_vec(&empty).unwrap()) + .unwrap(), + ) + }); + let smallest = median_us(2000, || { + drop(component.call_json("count", &Value::Null).unwrap()) + }); + println!( + "\nsmallest call: component `count` {smallest:.1} us; module `fuzzy_rank` over no items {smallest_module:.1} us" + ); + + // The quota check after a call that had the storage folder: one walk of + // the app's jail, as `Storage::usage` makes it. + println!("\nquota check (one walk of the jail, median of 50):"); + for files in [10usize, 1_000, 10_000] { + let jail = scratch.join(format!("jail-{files}")); + for i in 0..files { + let dir = jail.join(format!("d{}", i / 100)); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(format!("f{i}")), b"x").unwrap(); + } + let walk = median_us(50, || { + std::hint::black_box(tree_bytes(&jail)); + }); + println!(" {files:>6} files: {:.2} ms", walk / 1e3); + } + let _ = std::fs::remove_dir_all(&scratch); +} + +fn base64_of(bytes: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(bytes) +} diff --git a/crates/wasm-host/src/component.rs b/crates/wasm-host/src/component.rs index 98e4261a5..d8c7ca0aa 100644 --- a/crates/wasm-host/src/component.rs +++ b/crates/wasm-host/src/component.rs @@ -23,6 +23,11 @@ //! options are `null` or the value, `list` is base64 text, and a //! function's `result` is its value or its error. //! +//! Names face a script in snake_case: WIT's `to-html` is called as +//! `to_html`, and a record's `word-count` comes back as `word_count`, so a +//! script reads `r.data.word_count`. Either spelling is accepted on the +//! way in. +//! //! Unlike a core module's, an instance keeps its state between calls: a //! component may hold a parsed document, a cache or a model. A trap or a //! deadline spends it, as with modules. @@ -30,7 +35,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use base64::Engine as _; use serde_json::{json, Map, Value as Json}; @@ -40,9 +45,9 @@ use wasmtime::{Store, StoreLimits, StoreLimitsBuilder}; use wasmtime_wasi::p2::pipe::MemoryOutputPipe; use wasmtime_wasi::{FsPerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; -use crate::{ - trap, CallError, InvocationGuard, LoadError, Runtime, Ticker, LOG_LINE, LOG_LINES, TICK, -}; +use crate::{trap, CallError, InvocationGuard, LoadError, Runtime, Ticker, LOG_LINE, LOG_LINES}; + +mod files; /// The WASI packages a component may import. Every interface of these is /// linked; what each can reach is set per instance ([`Grants`]). @@ -54,6 +59,9 @@ const ALLOWED_PACKAGES: &[&str] = &[ "wasi:random/", ]; +/// What a call that had a write refused logs, and adds to its error. +const REFUSED: &str = "a write was refused: the storage budget is used up"; + /// How much one stream (stdout or stderr) buffers between reads. const PIPE_BYTES: usize = 64 << 10; @@ -64,18 +72,22 @@ pub fn is_component(bytes: &[u8]) -> bool { } /// What an instance of a component may reach beyond clocks and randomness. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct Grants { /// The app's storage folder, preopened as `/`. `None`: no filesystem. pub storage_dir: Option, + /// Open the storage folder read-only (its quota is used up). + pub read_only: bool, } /// One function an app may call, with its WIT-like signature. #[derive(Clone, Debug, PartialEq)] pub struct Export { - /// `name` for a world's own function, `interface.name` for one in an - /// exported interface. + /// The name a script calls it by, in snake_case: `name` for a world's + /// own function, `interface.name` for one in an exported interface. pub name: String, + /// The same as WIT spells it (`to-html`, `markdown.to-html`). + pub wit_name: String, /// Parameter names and their types, as WIT writes them. pub params: Vec<(String, String)>, /// The result's type, as WIT writes it, if any. @@ -114,6 +126,10 @@ struct State { wasi: WasiCtx, table: ResourceTable, limits: StoreLimits, + /// The running call's deadline and cancellation, read every epoch. + guard: Option>, + /// What the running call may add to the storage folder. + budget: files::Budget, } impl WasiView for State { @@ -135,10 +151,9 @@ pub struct ComponentInstance { /// Bytes of stdout and stderr already turned into log lines. read: [usize; 2], logs: Vec, - deadline_ticks: u64, + deadline: Duration, io_bytes: usize, spent: bool, - guard: Option>, _ticker: Arc, } @@ -212,57 +227,76 @@ impl Runtime { .stderr(stderr.clone()) .allow_tcp(false) .allow_udp(false) - .allow_ip_name_lookup(false); + .allow_ip_name_lookup(false) + // Calls run on their app's own worker thread, synchronously: a + // file operation blocks it directly rather than round-tripping + // through a thread pool. + .allow_blocking_current_thread(true); if let Some(dir) = &grants.storage_dir { - wasi.preopened_dir(dir, "/", FsPerms::ReadWrite) - .map_err(|e| LoadError::Invalid(format!("the app's storage: {e:#}")))?; + wasi.preopened_dir( + dir, + "/", + if grants.read_only { + FsPerms::ReadOnly + } else { + FsPerms::ReadWrite + }, + ) + .map_err(|e| LoadError::Invalid(format!("the app's storage: {e:#}")))?; } let limits = StoreLimitsBuilder::new() .memory_size(self.limits.memory_bytes) .table_elements(self.limits.table_elements) .trap_on_grow_failure(true) .build(); + // Instantiation runs the component's start-up code: it gets the + // same deadline as a call, and the caller's cancellation if any. + let (deadline, pending) = match guard { + Some((deadline, pending)) => (deadline, pending), + None => ( + Instant::now() + self.limits.deadline, + Box::new(|| true) as Box bool + Send + Sync>, + ), + }; + let guard = Arc::new(InvocationGuard { + deadline: deadline.min(Instant::now() + self.limits.deadline), + pending, + }); let mut store = Store::new( &self.engine, State { wasi: wasi.build(), table: ResourceTable::new(), limits, + guard: Some(guard), + // Start-up code may read the folder, not grow it. + budget: files::Budget::default(), }, ); + store.data().budget.set(Some(0)); store.limiter(|state| &mut state.limits); - let guard = guard.map(|(deadline, pending)| { - let deadline = deadline.min(Instant::now() + self.limits.deadline); - Arc::new(InvocationGuard { deadline, pending }) + // Every epoch, the running call's guard decides: a call never runs + // without one, so none outlives its deadline. + store.epoch_deadline_callback(|ctx| { + Ok(match &ctx.data().guard { + Some(guard) if guard.live() => wasmtime::UpdateDeadline::Continue(1), + _ => wasmtime::UpdateDeadline::Interrupt, + }) }); - if let Some(guard) = guard.clone() { - store.epoch_deadline_callback(move |_| { - Ok(if !guard.live() { - wasmtime::UpdateDeadline::Interrupt - } else { - wasmtime::UpdateDeadline::Continue(1) - }) - }); - } else { - store.epoch_deadline_trap(); - } - let deadline_ticks = self - .limits - .deadline - .as_millis() - .div_ceil(TICK.as_millis()) - .max(1) as u64; - store.set_epoch_deadline(if guard.is_some() { 1 } else { deadline_ticks }); + store.set_epoch_deadline(1); let mut linker = Linker::new(&self.engine); wasmtime_wasi::p2::add_to_linker_sync(&mut linker) + .and_then(|()| files::link(&mut linker)) .map_err(|e| LoadError::Invalid(format!("{e:#}")))?; let instance = linker .instantiate(&mut store, &program.component) .map_err(|e| LoadError::Invalid(format!("{e:#}")))?; + store.data_mut().guard = None; + store.data().budget.set(None); let mut funcs = HashMap::new(); for export in &program.exports { - let func = match export.name.split_once('.') { - None => instance.get_func(&mut store, &export.name), + let func = match export.wit_name.split_once('.') { + None => instance.get_func(&mut store, &export.wit_name), Some((iface, name)) => { let full = interface_export(&program.component, &self.engine, iface); full.and_then(|full| instance.get_export_index(&mut store, None, &full)) @@ -280,10 +314,9 @@ impl Runtime { stderr, read: [0, 0], logs: Vec::new(), - deadline_ticks, + deadline: self.limits.deadline, io_bytes: self.limits.io_bytes, spent: false, - guard, _ticker: self.ticker.clone(), }) } @@ -305,6 +338,11 @@ fn interface_export( .map(|(name, _)| name.to_string()) } +/// WIT's kebab-case as a script spells it: `word-count` → `word_count`. +pub fn snake(name: &str) -> String { + name.replace('-', "_") +} + /// `my:pkg/markdown@0.1.0` → `markdown`. fn short_name(full: &str) -> &str { let tail = full.rsplit('/').next().unwrap_or(full); @@ -367,8 +405,9 @@ fn add_export( return; } exports.push(Export { - name, - params: params.iter().map(|(n, t)| (n.clone(), wit(t))).collect(), + name: snake(&name), + wit_name: name, + params: params.iter().map(|(n, t)| (snake(n), wit(t))).collect(), result: match results.len() { 0 => None, 1 => Some(wit(&results[0])), @@ -486,19 +525,47 @@ impl ComponentInstance { self.spent } + /// What the next calls may add to the storage folder (`None`, the + /// default: no ceiling). A write past it fails in the guest as a full + /// disk; overwriting, truncating and deleting give bytes back. + pub fn set_storage_budget(&mut self, left: Option) { + self.store.data().budget.set(left); + } + + /// What is left of the storage budget. + pub fn storage_budget(&self) -> Option { + self.store.data().budget.left() + } + /// Calls `name` with JSON arguments; returns its JSON result. A /// `result` return is `T`, or [`CallError::Guest`] with `E`. pub fn call_json(&mut self, name: &str, args: &Json) -> Result { + self.call_json_guarded(name, args, Instant::now() + self.deadline, || true) + } + + /// [`ComponentInstance::call_json`], ended at `deadline` (no later than + /// the runtime's) or as soon as `pending` answers false. + pub fn call_json_guarded( + &mut self, + name: &str, + args: &Json, + deadline: Instant, + pending: impl Fn() -> bool + Send + Sync + 'static, + ) -> Result { if self.spent { return Err(CallError::Spent); } - if self.guard.as_ref().is_some_and(|guard| !guard.live()) { - self.spent = true; + let guard = Arc::new(InvocationGuard { + deadline: deadline.min(Instant::now() + self.deadline), + pending: Box::new(pending), + }); + if !guard.live() { return Err(CallError::Deadline); } let func = *self .funcs .get(name) + .or_else(|| self.funcs.get(&snake(name))) .ok_or_else(|| CallError::NoSuchFunction(name.into()))?; let size = serde_json::to_vec(args).map(|v| v.len()).unwrap_or(0); if size > self.io_bytes { @@ -509,15 +576,19 @@ impl ComponentInstance { let args = arguments(¶ms, args).map_err(CallError::Guest)?; let results_ty: Vec = fty.results().collect(); let mut results = vec![Val::Bool(false); results_ty.len()]; - self.store.set_epoch_deadline(if self.guard.is_some() { - 1 - } else { - self.deadline_ticks - }); + self.store.data_mut().guard = Some(guard); + self.store.set_epoch_deadline(1); let outcome = func .call(&mut self.store, &args, &mut results) .map_err(trap); + self.store.data_mut().guard = None; self.collect_logs(); + // A refused write reaches the guest as a full disk or, through a + // stream, a bare I/O error: say what it was. + let refused = self.store.data().budget.take_refused(); + if refused { + self.logs.push(REFUSED.into()); + } if let Err(error) = outcome { if matches!(error, CallError::Deadline | CallError::Trap(_)) { self.spent = true; @@ -532,13 +603,16 @@ impl ComponentInstance { .map(|(v, t)| from_val(&t, v)) .unwrap_or(Json::Null), ([Type::Result(res)], [Val::Result(Err(err))]) => { - return Err(CallError::Guest( - match err.as_deref().zip(res.err()).map(|(v, t)| from_val(&t, v)) { - Some(Json::String(text)) => text, - Some(other) => other.to_string(), - None => "the function failed".into(), - }, - )) + let why = match err.as_deref().zip(res.err()).map(|(v, t)| from_val(&t, v)) { + Some(Json::String(text)) => text, + Some(other) => other.to_string(), + None => "the function failed".into(), + }; + return Err(CallError::Guest(if refused { + format!("{why}; {REFUSED}") + } else { + why + })); } ([ty], [one]) => from_val(ty, one), (types, many) => Json::Array( @@ -588,36 +662,50 @@ fn arguments(params: &[(String, Type)], args: &Json) -> Result, String> let named = |map: &Map| -> Result, String> { params .iter() - .map(|(n, t)| to_val(t, &map[n]).map_err(|e| format!("{n}: {e}"))) + .map(|(n, t)| { + let value = field(map, n).unwrap_or(&Json::Null); + to_val(t, value).map_err(|e| format!("{}: {e}", snake(n))) + }) .collect() }; + let has_all = |map: &Map| { + map.len() == params.len() && params.iter().all(|(n, _)| field(map, n).is_some()) + }; match (params, args) { ([], Json::Null) => Ok(Vec::new()), ([], Json::Object(map)) if map.is_empty() => Ok(Vec::new()), ([], Json::Array(items)) if items.is_empty() => Ok(Vec::new()), - ([(name, _)], Json::Object(map)) if map.len() == 1 && map.contains_key(name) => named(map), - ([(name, ty)], value) => Ok(vec![to_val(ty, value).map_err(|e| format!("{name}: {e}"))?]), - (_, Json::Object(map)) - if map.len() == params.len() && params.iter().all(|(n, _)| map.contains_key(n)) => - { - named(map) - } + ([_], Json::Object(map)) if has_all(map) => named(map), + // The value itself or, when it is not one, a one-item array of it. + ([(name, ty)], value) => match (to_val(ty, value), value) { + (Ok(one), _) => Ok(vec![one]), + (Err(_), Json::Array(items)) if items.len() == 1 => Ok(vec![ + to_val(ty, &items[0]).map_err(|e| format!("{}: {e}", snake(name)))? + ]), + (Err(e), _) => Err(format!("{}: {e}", snake(name))), + }, + (_, Json::Object(map)) if has_all(map) => named(map), (_, Json::Array(items)) if items.len() == params.len() => params .iter() .zip(items) - .map(|((n, t), v)| to_val(t, v).map_err(|e| format!("{n}: {e}"))) + .map(|((n, t), v)| to_val(t, v).map_err(|e| format!("{}: {e}", snake(n)))) .collect(), _ => Err(format!( "takes {}: pass an object with those names", params .iter() - .map(|(n, _)| n.as_str()) + .map(|(n, _)| snake(n)) .collect::>() .join(", ") )), } } +/// The value under a WIT name, spelled either way. +fn field<'a>(map: &'a Map, wit_name: &str) -> Option<&'a Json> { + map.get(wit_name).or_else(|| map.get(&snake(wit_name))) +} + /// JSON to a WIT value of type `ty`. pub fn to_val(ty: &Type, v: &Json) -> Result { fn int + TryFrom>(v: &Json, what: &str) -> Result { @@ -686,7 +774,7 @@ pub fn to_val(ty: &Type, v: &Json) -> Result { record .fields() .map(|f| { - let value = map.get(f.name).unwrap_or(&Json::Null); + let value = field(map, f.name).unwrap_or(&Json::Null); to_val(&f.ty, value) .map(|val| (f.name.to_string(), val)) .map_err(|e| format!("{}: {e}", f.name)) @@ -725,23 +813,24 @@ pub fn to_val(ty: &Type, v: &Json) -> Result { }; let found = variant .cases() - .find(|c| c.name == case) + .find(|c| c.name == case || snake(c.name) == case) .ok_or_else(|| format!("no case {case}"))?; let payload = match (&found.ty, payload) { (Some(t), Some(p)) => Some(Box::new(to_val(t, p)?)), (Some(_), None) => return Err(format!("case {case} takes a value")), (None, _) => None, }; - Val::Variant(case.to_string(), payload) + Val::Variant(found.name.to_string(), payload) } Type::Enum(e) => { let name = v .as_str() .ok_or_else(|| format!("expected a name, got {v}"))?; - if !e.names().any(|n| n == name) { - return Err(format!("no case {name}")); - } - Val::Enum(name.to_string()) + let wit_name = e + .names() + .find(|n| *n == name || snake(n) == name) + .ok_or_else(|| format!("no case {name}"))?; + Val::Enum(wit_name.to_string()) } Type::Option(opt) => match v { Json::Null => Val::Option(None), @@ -773,10 +862,11 @@ pub fn to_val(ty: &Type, v: &Json) -> Result { let name = item .as_str() .ok_or_else(|| format!("expected a name, got {item}"))?; - if !f.names().any(|n| n == name) { - return Err(format!("no flag {name}")); - } - out.push(name.to_string()); + let wit_name = f + .names() + .find(|n| *n == name || snake(n) == name) + .ok_or_else(|| format!("no flag {name}"))?; + out.push(wit_name.to_string()); } Val::Flags(out) } @@ -816,7 +906,7 @@ pub fn from_val(ty: &Type, v: &Val) -> Json { (Type::Record(record), Val::Record(fields)) => { let mut map = Map::new(); for (f, (k, val)) in record.fields().zip(fields) { - map.insert(k.clone(), from_val(&f.ty, val)); + map.insert(snake(k), from_val(&f.ty, val)); } Json::Object(map) } @@ -830,11 +920,11 @@ pub fn from_val(ty: &Type, v: &Val) -> Json { (Type::Variant(variant), Val::Variant(case, payload)) => { let ty = variant.cases().find(|c| c.name == case).and_then(|c| c.ty); match (ty, payload) { - (Some(t), Some(p)) => json!({ case.clone(): from_val(&t, p) }), - _ => json!(case), + (Some(t), Some(p)) => json!({ snake(case): from_val(&t, p) }), + _ => json!(snake(case)), } } - (_, Val::Enum(name)) => json!(name), + (_, Val::Enum(name)) => json!(snake(name)), (Type::Option(opt), Val::Option(o)) => o .as_deref() .map(|v| from_val(&opt.ty(), v)) @@ -843,7 +933,7 @@ pub fn from_val(ty: &Type, v: &Val) -> Json { Ok(v) => json!({"ok": v.as_deref().zip(res.ok()).map(|(v, t)| from_val(&t, v))}), Err(e) => json!({"err": e.as_deref().zip(res.err()).map(|(v, t)| from_val(&t, v))}), }, - (_, Val::Flags(names)) => json!(names), + (_, Val::Flags(names)) => json!(names.iter().map(|n| snake(n)).collect::>()), _ => Json::Null, } } diff --git a/crates/wasm-host/src/component/files.rs b/crates/wasm-host/src/component/files.rs new file mode 100644 index 000000000..ff0693c0c --- /dev/null +++ b/crates/wasm-host/src/component/files.rs @@ -0,0 +1,314 @@ +//! A component's storage folder under a byte budget, and its file writes +//! done on the calling thread. +//! +//! wasmtime-wasi gives a component its folder ([`super::Grants`]). This +//! replaces the calls that grow a file — `write`, `set-size`, and the +//! streams `write-via-stream` and `append-via-stream` hand out — with ones +//! that charge the growth against the call's [`Budget`], and the calls that +//! free bytes — `open-at` truncating a file, `unlink-file-at` — with ones +//! that give them back. Growth past the budget fails inside the guest: as a +//! full disk (`ENOSPC`) from a descriptor call, and as an I/O error (`EIO`) +//! from a stream, since wasi-libc reports any failed stream write as one. +//! The budget records the refusal, so the caller can say why. Overwriting, +//! truncating and deleting always work, so a component whose app is at its +//! quota can still free space. +//! +//! What counts is what the shell's storage measure counts: the lengths of +//! the folder's regular files. + +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use wasmtime::component::{Linker, Resource}; +use wasmtime::StoreContextMut; +use wasmtime_wasi::filesystem::WasiFilesystemView; +use wasmtime_wasi::p2::bindings::sync::filesystem::types::{ + self as fs, Host as _, HostDescriptor as _, +}; +use wasmtime_wasi::p2::{DynOutputStream, OutputStream, Pollable, StreamError, StreamResult}; +use wasmtime_wasi::runtime::in_tokio; + +use super::State; + +/// The filesystem interface wasmtime-wasi links; a guest's older 0.2 +/// imports resolve to it. +const TYPES: &str = "wasi:filesystem/types@0.2.12"; + +/// `ENOSPC` on Linux, Android and macOS: a full disk, to the guest. +const ENOSPC: i32 = 28; + +/// What an instance may still add to its storage folder (`None`: no +/// ceiling), shared with the file streams it has open. +#[derive(Clone, Debug, Default)] +pub(crate) struct Budget(Arc>); + +#[derive(Debug, Default)] +struct Ledger { + left: Option, + /// A write was refused since the caller last asked. + refused: bool, +} + +impl Budget { + fn ledger(&self) -> std::sync::MutexGuard<'_, Ledger> { + self.0.lock().unwrap_or_else(|e| e.into_inner()) + } + + pub(crate) fn set(&self, left: Option) { + self.ledger().left = left; + } + + pub(crate) fn left(&self) -> Option { + self.ledger().left + } + + /// Whether a write was refused since the last time this was asked. + pub(crate) fn take_refused(&self) -> bool { + std::mem::take(&mut self.ledger().refused) + } + + /// Spend `bytes`, or refuse them as a full disk. + fn spend(&self, bytes: u64) -> std::io::Result<()> { + let mut ledger = self.ledger(); + match ledger.left { + Some(n) if bytes > n => { + ledger.refused = true; + Err(std::io::Error::from_raw_os_error(ENOSPC)) + } + Some(n) => { + ledger.left = Some(n - bytes); + Ok(()) + } + None => Ok(()), + } + } + + fn refund(&self, bytes: u64) { + if let Some(n) = self.ledger().left.as_mut() { + *n = n.saturating_add(bytes); + } + } +} + +type Answer = wasmtime::Result<(Result,)>; + +/// Shadow the linker's growing and freeing filesystem calls with charged +/// ones. Call after `wasmtime_wasi::p2::add_to_linker_sync`. +pub(crate) fn link(linker: &mut Linker) -> wasmtime::Result<()> { + linker.allow_shadowing(true); + let mut types = linker.instance(TYPES)?; + types.func_wrap( + "[method]descriptor.write-via-stream", + |mut store: StoreContextMut<'_, State>, (fd, offset): (Resource, u64)| { + charged_stream(store.data_mut(), fd, Some(offset)) + }, + )?; + types.func_wrap( + "[method]descriptor.append-via-stream", + |mut store: StoreContextMut<'_, State>, (fd,): (Resource,)| { + charged_stream(store.data_mut(), fd, None) + }, + )?; + types.func_wrap( + "[method]descriptor.write", + |mut store: StoreContextMut<'_, State>, + (fd, buffer, offset): (Resource, Vec, u64)| + -> Answer { + let state = store.data_mut(); + let budget = state.budget.clone(); + let mut files = state.filesystem(); + let size = match files.stat(borrow(&fd)) { + Ok(stat) => stat.size, + Err(e) => return Ok((Err(files.convert_error_code(e)?),)), + }; + let grows = offset + .saturating_add(buffer.len() as u64) + .saturating_sub(size); + if budget.spend(grows).is_err() { + return Ok((Err(fs::ErrorCode::InsufficientSpace),)); + } + match files.write(fd, buffer, offset) { + Ok(written) => Ok((Ok(written),)), + Err(e) => { + budget.refund(grows); + Ok((Err(files.convert_error_code(e)?),)) + } + } + }, + )?; + types.func_wrap( + "[method]descriptor.set-size", + |mut store: StoreContextMut<'_, State>, + (fd, size): (Resource, u64)| + -> Answer<()> { + let state = store.data_mut(); + let budget = state.budget.clone(); + let mut files = state.filesystem(); + let was = match files.stat(borrow(&fd)) { + Ok(stat) => stat.size, + Err(e) => return Ok((Err(files.convert_error_code(e)?),)), + }; + let grows = size.saturating_sub(was); + if budget.spend(grows).is_err() { + return Ok((Err(fs::ErrorCode::InsufficientSpace),)); + } + match files.set_size(fd, size) { + Ok(()) => { + budget.refund(was.saturating_sub(size)); + Ok((Ok(()),)) + } + Err(e) => { + budget.refund(grows); + Ok((Err(files.convert_error_code(e)?),)) + } + } + }, + )?; + types.func_wrap( + "[method]descriptor.open-at", + |mut store: StoreContextMut<'_, State>, + (fd, path_flags, path, open_flags, flags): ( + Resource, + fs::PathFlags, + String, + fs::OpenFlags, + fs::DescriptorFlags, + )| + -> Answer> { + let state = store.data_mut(); + let budget = state.budget.clone(); + let mut files = state.filesystem(); + let freed = if open_flags.contains(fs::OpenFlags::TRUNCATE) { + file_size_at(&mut files, &fd, path_flags, &path) + } else { + 0 + }; + match files.open_at(fd, path_flags, path, open_flags, flags) { + Ok(opened) => { + budget.refund(freed); + Ok((Ok(opened),)) + } + Err(e) => Ok((Err(files.convert_error_code(e)?),)), + } + }, + )?; + types.func_wrap( + "[method]descriptor.unlink-file-at", + |mut store: StoreContextMut<'_, State>, + (fd, path): (Resource, String)| + -> Answer<()> { + let state = store.data_mut(); + let budget = state.budget.clone(); + let mut files = state.filesystem(); + let freed = file_size_at(&mut files, &fd, fs::PathFlags::empty(), &path); + match files.unlink_file_at(fd, path) { + Ok(()) => { + budget.refund(freed); + Ok((Ok(()),)) + } + Err(e) => Ok((Err(files.convert_error_code(e)?),)), + } + }, + )?; + linker.allow_shadowing(false); + Ok(()) +} + +/// Another handle on the descriptor `fd` borrows, for a call before the +/// one it is passed to. +fn borrow(fd: &Resource) -> Resource { + Resource::new_borrow(fd.rep()) +} + +/// The bytes the regular file at `path` holds, as the storage measure +/// counts them (0 for anything else, or nothing there). +fn file_size_at( + files: &mut V, + fd: &Resource, + path_flags: fs::PathFlags, + path: &str, +) -> u64 { + match files.stat_at(borrow(fd), path_flags, path.to_string()) { + Ok(stat) if stat.type_ == fs::DescriptorType::RegularFile => stat.size, + _ => 0, + } +} + +/// The stream `write-via-stream` (`offset`) or `append-via-stream` +/// (`None`) hands out, charged. +fn charged_stream( + state: &mut State, + fd: Resource, + offset: Option, +) -> Answer> { + let budget = state.budget.clone(); + let mut files = state.filesystem(); + let size = match files.stat(borrow(&fd)) { + Ok(stat) => stat.size, + Err(e) => return Ok((Err(files.convert_error_code(e)?),)), + }; + let opened = match offset { + Some(offset) => files.write_via_stream(fd, offset), + None => files.append_via_stream(fd), + }; + let stream = match opened { + Ok(stream) => stream, + Err(e) => return Ok((Err(files.convert_error_code(e)?),)), + }; + let inner = files.table.delete(stream)?; + let charged: DynOutputStream = Box::new(Charged { + inner, + budget, + position: offset, + size, + }); + Ok((Ok(files.table.push(charged)?),)) +} + +/// A file's output stream that charges what it adds to the file, and +/// writes on the calling thread: the plain stream hands every write to a +/// thread pool and waits for it on the next call. +struct Charged { + inner: DynOutputStream, + budget: Budget, + /// Where the next write lands; `None` when appending. + position: Option, + /// The file's size as far as this stream knows. + size: u64, +} + +#[wasmtime_wasi::async_trait] +impl Pollable for Charged { + async fn ready(&mut self) { + self.inner.ready().await + } +} + +#[wasmtime_wasi::async_trait] +impl OutputStream for Charged { + fn write(&mut self, bytes: Bytes) -> StreamResult<()> { + let len = bytes.len() as u64; + let end = self.position.unwrap_or(self.size).saturating_add(len); + let grows = end.saturating_sub(self.size); + self.budget + .spend(grows) + .map_err(|e| StreamError::LastOperationFailed(e.into()))?; + if let Err(e) = in_tokio(self.inner.blocking_write_and_flush(bytes)) { + self.budget.refund(grows); + return Err(e); + } + if let Some(position) = &mut self.position { + *position = end; + } + self.size = self.size.max(end); + Ok(()) + } + + fn flush(&mut self) -> StreamResult<()> { + self.inner.flush() + } + + fn check_write(&mut self) -> StreamResult { + self.inner.check_write() + } +} diff --git a/crates/wasm-host/tests/component-guest/notes/src/lib.rs b/crates/wasm-host/tests/component-guest/notes/src/lib.rs index dde29f7a1..625de1745 100644 --- a/crates/wasm-host/tests/component-guest/notes/src/lib.rs +++ b/crates/wasm-host/tests/component-guest/notes/src/lib.rs @@ -84,6 +84,43 @@ impl Guest for Notes { fn echo_bytes(data: Vec) -> Vec { data } + + fn append_text(path: String, text: String) -> Result { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(&path) + .map_err(|e| e.to_string())?; + file.write_all(text.as_bytes()).map_err(|e| e.to_string())?; + Ok(file.metadata().map_err(|e| e.to_string())?.len()) + } + + fn set_len(path: String, len: u64) -> Result<(), String> { + std::fs::OpenOptions::new() + .write(true) + .open(&path) + .and_then(|file| file.set_len(len)) + .map_err(|e| e.to_string()) + } + + fn delete_file(path: String) -> Result<(), String> { + std::fs::remove_file(&path).map_err(|e| e.to_string()) + } + + fn file_info(path: String) -> Result { + let meta = std::fs::metadata(&path).map_err(|e| e.to_string())?; + Ok(FileInfo { + byte_count: meta.len(), + kind: if meta.is_file() { + EntryKind::RegularFile + } else if meta.is_dir() { + EntryKind::Directory + } else { + EntryKind::Other + }, + }) + } } export!(Notes); diff --git a/crates/wasm-host/tests/component-guest/notes/wit/notes.wit b/crates/wasm-host/tests/component-guest/notes/wit/notes.wit index ba41e55df..573fac2f3 100644 --- a/crates/wasm-host/tests/component-guest/notes/wit/notes.wit +++ b/crates/wasm-host/tests/component-guest/notes/wit/notes.wit @@ -25,4 +25,19 @@ world notes { export grow: func(megabytes: u32) -> u32; /// Bytes in, bytes out. export echo-bytes: func(data: list) -> list; + + /// Appends to a file (append-via-stream); its new length. + export append-text: func(path: string, text: string) -> result; + /// Sets a file's length (set-size). + export set-len: func(path: string, len: u64) -> result<_, string>; + /// Deletes a file (unlink-file-at). + export delete-file: func(path: string) -> result<_, string>; + + enum entry-kind { regular-file, directory, other } + record file-info { + byte-count: u64, + kind: entry-kind, + } + /// Hyphenated names, which scripts read in snake_case. + export file-info: func(path: string) -> result; } diff --git a/crates/wasm-host/tests/component.rs b/crates/wasm-host/tests/component.rs index f17dbeb35..a3c2c4bab 100644 --- a/crates/wasm-host/tests/component.rs +++ b/crates/wasm-host/tests/component.rs @@ -53,15 +53,19 @@ fn exports_are_listed_with_their_wit_signatures() { names, [ "analyze", + "append_text", "count", - "echo-bytes", + "delete_file", + "echo_bytes", + "file_info", "grow", - "now-ms", - "random-u64", - "read-file", - "save-html", + "now_ms", + "random_u64", + "read_file", + "save_html", + "set_len", "spin", - "to-html" + "to_html" ] ); let analyze = &program.exports()[0]; @@ -83,7 +87,7 @@ fn an_unmodified_crate_runs_and_records_come_back_as_objects() { .instantiate_component(&program, &Grants::default(), None) .unwrap(); let html = instance - .call_json("to-html", &json!({"markdown": "# Hi\n\nThere *you*"})) + .call_json("to_html", &json!({"markdown": "# Hi\n\nThere *you*"})) .unwrap(); assert_eq!(html, json!("

Hi

\n

There you

\n")); // A one-parameter function also takes the bare value. @@ -94,6 +98,9 @@ fn an_unmodified_crate_runs_and_records_come_back_as_objects() { stats, json!({"words": 7, "lines": 3, "headings": ["Title", "Part"]}) ); + // And an array in parameter order, as every other function does. + let stats = instance.call_json("analyze", &json!(["# One"])).unwrap(); + assert_eq!(stats, json!({"words": 2, "lines": 1, "headings": ["One"]})); } #[test] @@ -118,19 +125,24 @@ fn clocks_randomness_and_bytes_work() { let mut instance = rt .instantiate_component(&program, &Grants::default(), None) .unwrap(); - let now = instance.call_json("now-ms", &json!(null)).unwrap(); + let now = instance.call_json("now_ms", &json!(null)).unwrap(); assert!(now.as_u64().unwrap() > 1_700_000_000_000, "{now}"); - let a = instance.call_json("random-u64", &json!(null)).unwrap(); - let b = instance.call_json("random-u64", &json!(null)).unwrap(); + let a = instance.call_json("random_u64", &json!(null)).unwrap(); + let b = instance.call_json("random_u64", &json!(null)).unwrap(); assert_ne!(a, b); // list travels as base64. let echoed = instance - .call_json("echo-bytes", &json!("AAEC/w==")) + .call_json("echo_bytes", &json!("AAEC/w==")) .unwrap(); assert_eq!(echoed, json!("AAEC/w==")); + // An array is the list itself, unless only as its one argument. + for args in [json!([0, 1, 2, 255]), json!([[0, 1, 2, 255]])] { + let echoed = instance.call_json("echo_bytes", &args).unwrap(); + assert_eq!(echoed, json!("AAEC/w=="), "{args}"); + } assert_eq!( instance - .call_json("echo-bytes", &json!([0, 1, 2, 255])) + .call_json("echo_bytes", &json!([0, 1, 2, 255])) .unwrap(), json!("AAEC/w==") ); @@ -143,11 +155,12 @@ fn files_live_only_in_the_granted_storage_folder() { let dir = storage(); let grants = Grants { storage_dir: Some(dir.clone()), + read_only: false, }; let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); let written = instance .call_json( - "save-html", + "save_html", &json!({"markdown": "# Saved", "path": "out.html"}), ) .unwrap(); @@ -158,28 +171,122 @@ fn files_live_only_in_the_granted_storage_folder() { ); assert_eq!( instance - .call_json("read-file", &json!("/out.html")) + .call_json("read_file", &json!("/out.html")) .unwrap(), json!("

Saved

\n") ); // Nothing outside the folder: escaping it fails inside the guest. for path in ["../../../../etc/hosts", "/../etc/hosts"] { - match instance.call_json("read-file", &json!(path)) { + match instance.call_json("read_file", &json!(path)) { Err(CallError::Guest(_)) => {} other => panic!("{path}: {other:?}"), } } + // Read-only (a used-up quota): its files read, nothing writes. + let read_only = Grants { + storage_dir: Some(dir.clone()), + read_only: true, + }; + let mut reader = rt + .instantiate_component(&program, &read_only, None) + .unwrap(); + assert_eq!( + reader.call_json("read_file", &json!("out.html")).unwrap(), + json!("

Saved

\n") + ); + for path in ["out.html", "new.html"] { + assert!(matches!( + reader.call_json("save_html", &json!({"markdown": "x", "path": path})), + Err(CallError::Guest(_)) + )); + } + assert!(!dir.join("new.html").exists()); + assert_eq!( + std::fs::read_to_string(dir.join("out.html")).unwrap(), + "

Saved

\n" + ); // Without a storage grant there is no filesystem at all. let mut bare = rt .instantiate_component(&program, &Grants::default(), None) .unwrap(); assert!(matches!( - bare.call_json("save-html", &json!({"markdown": "x", "path": "x.html"})), + bare.call_json("save_html", &json!({"markdown": "x", "path": "x.html"})), Err(CallError::Guest(_)) )); let _ = std::fs::remove_dir_all(dir); } +/// The storage budget: growth past it fails in the guest as a full disk, +/// while rewriting (truncating first), shrinking and deleting give bytes +/// back. A guest's error does not spend the instance. +#[test] +fn the_storage_budget_refuses_growth_and_returns_freed_bytes() { + let rt = runtime(); + let program = rt.load_component(NOTES).unwrap(); + let dir = storage(); + let grants = Grants { + storage_dir: Some(dir.clone()), + read_only: false, + }; + let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); + assert_eq!(instance.storage_budget(), None); + let full = |result: Result| match result { + Err(CallError::Guest(why)) => { + assert!(why.ends_with("the storage budget is used up"), "{why}") + } + other => panic!("expected a full disk, got {other:?}"), + }; + // "# Saved" renders to 15 bytes. + let save = |path: &str| json!({"markdown": "# Saved", "path": path}); + instance.set_storage_budget(Some(20)); + assert_eq!( + instance.call_json("save_html", &save("a.html")).unwrap(), + json!(15) + ); + assert_eq!(instance.storage_budget(), Some(5)); + // Another 15 bytes do not fit (write-via-stream); the file stays empty. + full(instance.call_json("save_html", &save("b.html"))); + assert_eq!(std::fs::metadata(dir.join("b.html")).unwrap().len(), 0); + assert!(!instance.spent()); + // Rewriting a.html truncates it first: its 15 bytes come back. + assert_eq!( + instance.call_json("save_html", &save("a.html")).unwrap(), + json!(15) + ); + assert_eq!(instance.storage_budget(), Some(5)); + // Appending (append-via-stream) charges every byte. + assert_eq!( + instance + .call_json("append_text", &json!(["a.html", "12345"])) + .unwrap(), + json!(20) + ); + full(instance.call_json("append_text", &json!(["a.html", "x"]))); + // Growing a file's length (set-size) is growth; shrinking gives back. + full(instance.call_json("set_len", &json!(["a.html", 1 << 30]))); + assert_eq!(std::fs::metadata(dir.join("a.html")).unwrap().len(), 20); + instance + .call_json("set_len", &json!(["a.html", 10])) + .unwrap(); + assert_eq!(instance.storage_budget(), Some(10)); + // Deleting (unlink-file-at) gives a file's bytes back. + instance.call_json("delete_file", &json!("a.html")).unwrap(); + assert_eq!(instance.storage_budget(), Some(20)); + assert_eq!( + instance.call_json("file_info", &json!("b.html")).unwrap(), + json!({"byte_count": 0, "kind": "regular_file"}) + ); + // Without a ceiling, anything. + instance.set_storage_budget(None); + assert_eq!( + instance + .call_json("append_text", &json!(["big.txt", "x".repeat(100_000)])) + .unwrap(), + json!(100_000) + ); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn the_deadline_and_the_memory_cap_end_a_call_and_spend_the_instance() { let rt = runtime(); @@ -212,7 +319,7 @@ fn arguments_that_do_not_fit_are_refused_with_the_parameter_name() { let mut instance = rt .instantiate_component(&program, &Grants::default(), None) .unwrap(); - match instance.call_json("save-html", &json!({"markdown": 3, "path": "x"})) { + match instance.call_json("save_html", &json!({"markdown": 3, "path": "x"})) { Err(CallError::Guest(why)) => assert!(why.starts_with("markdown:"), "{why}"), other => panic!("{other:?}"), } diff --git a/crates/wasm-host/tests/fixtures/notes.component.wasm b/crates/wasm-host/tests/fixtures/notes.component.wasm index 04b7472a2995b1d4c970a7b4281406b960404b58..ced7a5b6a3091f5087a3a4410e572b41fc19fbd5 100644 GIT binary patch delta 82887 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 diff --git a/docs/adr/0014-app-components-in-webassembly.md b/docs/adr/0014-app-components-in-webassembly.md index 3d5e69ce6..e5b41b929 100644 --- a/docs/adr/0014-app-components-in-webassembly.md +++ b/docs/adr/0014-app-components-in-webassembly.md @@ -3,8 +3,10 @@ English | [简体中文](0014-app-components-in-webassembly.zh-CN.md) Status: Proposed (9 Oct 2026). Phase 1, the runtime spike, is in -`crates/wasm-host` (`src/component.rs`, `tests/component.rs`). Phases 2–4 below -are the plan. It extends [ADR 0011](0011-apps-own-functions-in-webassembly.md): +`crates/wasm-host` (`src/component.rs`, `tests/component.rs`). Phase 2's +runtime and service parts are in `crates/wasm-host/src/component/files.rs` and +`crates/shell/src/wasm_service.rs`, with the measurements below; its App Hub +and App Flow parts, and phases 3–4, are the plan. It extends [ADR 0011](0011-apps-own-functions-in-webassembly.md): core modules keep working as they do. How WebAssembly runs on `main`: [WebAssembly in OctoSense](../wasm.md). @@ -60,16 +62,27 @@ component reaches only what its app may already reach. They're listed as skipped, with the reason. 3. **Instances keep their state.** One instance per component lives for as long as the app's worker, so a component can hold a document, a cache or a model. - A trap or a deadline spends it; the next call gets a fresh one. An update, a - grant change or a withdrawal discards it, as for modules. + A worker holding one waits a minute for the next call, not five seconds. A + trap or a deadline spends the instance; the next call gets a fresh one. An + update, a grant change or a withdrawal discards it, as for modules. 4. **WASI scoped to the app's grants.** - **Always:** `wasi:clocks`, `wasi:random`, `wasi:io` and `wasi:cli`. stdout and stderr become the app's log lines (bounded). The environment, arguments and stdin are empty. - - **`wasi:filesystem`:** only with the app's `storage` capability. Its - storage folder is preopened as `/`, read-write, and nothing else of the - host's filesystem is visible. Without the capability there are no - preopens. + - **`wasi:filesystem`:** only with the app's `storage` capability (and a + signed-in account, for an app with accounts), decided per call by the + rules an engine's folder follows. Its storage folder is preopened as `/`, + read-write, and nothing else of the host's filesystem is visible. Without + the capability there are no preopens. + - **The storage quota, per write.** A call may add what is left of the + app's quota when it starts. The runtime replaces the WASI calls that + grow a file (`write`, `set-size`, and the streams `write-via-stream` and + `append-via-stream` hand out) with ones that charge the growth, and the + calls that free bytes (`open-at` truncating, `unlink-file-at`) with ones + that give them back. Past the budget a write fails inside the component, + and the call's error says why. A check after each call instead was + rejected: a call could write gigabytes before it, and it costs two walks + of the folder a call. - **`wasi:http` outgoing, phase 3:** only to the hosts in the manifest's `network.hosts`, through the shell's network policy. - **Never:** `wasi:sockets`, and any import outside these WASI packages. A @@ -132,14 +145,40 @@ component reaches only what its app may already reach. and randomness are new to Wasm but available to every script. - **Persistent instances use memory for longer.** The per-app worker cap (`MAX_WORKERS`) and the memory cap bound it, and idle workers exit as today. -- **Spike sizes:** `notes` (with `pulldown-cmark` and `getrandom`) is 310 KB; - the sockets probe is 123 KB. Timings are not measured yet; phase 2 records - them as ADR 0011 did. +- **Spike sizes:** `notes` (with `pulldown-cmark` and `getrandom`) is 313 KiB; + the sockets probe is 120 KiB. +- **A full disk looks like an I/O error.** wasi-libc reports any failed stream + write as `EIO`, whatever error the host gives, so most refused writes reach + the guest as an I/O error rather than `ENOSPC`. The runtime adds the reason + to the call's error and to the app's log. + +## Measurements + +`cargo run --release -p octosense-wasm-host --example measure_component`, on +an Apple M5 Max with macOS 26.6.2 (9 October 2026). Each figure is a median +of 200 calls unless the row says otherwise. The module is Wasm Lab's +(ADR 0011), which runs the same `pulldown-cmark`. + +| | Component | Module | Native | +| --- | --- | --- | --- | +| Compile with Cranelift, first load | 37.5 ms (313 KiB) | | | +| Load from the cache | 3.6 ms | | | +| Instantiate (with the storage folder) | 0.09 ms (0.10 ms) | | | +| Markdown to HTML, 32 KiB | 468 µs | 400 µs | 183 µs | +| Render and write 49 KiB to a new file | 678 µs | | 156 µs (the write) | +| Write a 12-byte file | 143 µs | | | +| Read 49 KiB back | 59 µs | | | +| 1 MiB of `list`, there and back as base64 | 18.4 ms | | | +| The smallest call | 0.2 µs | 0.3 µs | | + +The quota needs what the folder holds when a call starts: one walk of it, as +for an engine's call. That took 0.12 ms for 10 files, 2.0 ms for 1,000 and +23.8 ms for 10,000 (a median of 50). Rewriting one file over and over is +slow on APFS, natively too (a median of about 6 ms a rewrite), so the +example writes a new file each time. ## Open questions -- Quota for component writes: account each write (by wrapping the filesystem - host) or check after each call. Phase 2 decides by measuring. - Whether a component may call host services that open sheets (sign-in, review). Probably only through the script, never from a component's call. - Async functions (WASI 0.3) and streaming bodies: after phase 3. diff --git a/docs/adr/0014-app-components-in-webassembly.zh-CN.md b/docs/adr/0014-app-components-in-webassembly.zh-CN.md index 53f22a80d..ab94399c7 100644 --- a/docs/adr/0014-app-components-in-webassembly.zh-CN.md +++ b/docs/adr/0014-app-components-in-webassembly.zh-CN.md @@ -3,7 +3,9 @@ [English](0014-app-components-in-webassembly.md) | 简体中文 状态:提议(2026 年 10 月 9 日)。第 1 阶段(运行时验证原型)位于 -`crates/wasm-host`(`src/component.rs`、`tests/component.rs`);第 2–4 阶段是下文的计划。 +`crates/wasm-host`(`src/component.rs`、`tests/component.rs`)。第 2 阶段的运行时和服务 +部分位于 `crates/wasm-host/src/component/files.rs` 和 `crates/shell/src/wasm_service.rs`, +测量结果见下文;其 App Hub 与 App Flow 部分以及第 3–4 阶段是计划。 本 ADR 扩展 [ADR 0011](0011-apps-own-functions-in-webassembly.zh-CN.md):核心模块照旧可用。 `main` 上 WebAssembly 的运行方式见 [OctoSense 中的 WebAssembly](../wasm.zh-CN.md)。 @@ -46,13 +48,19 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 - 函数返回 `result` 时,结果为其值,或以 `E` 作为调用的错误。 - 接收或返回资源(resource)的函数不能从脚本调用,会被列为已跳过并附原因。 3. **实例保留状态。** 每个组件一个实例,在应用的 worker 存活期间一直存在,因此组件可以保存文档、 - 缓存或模型。陷阱(trap)或超时会使实例作废,下一次调用会得到新实例。应用更新、授权变更或撤回 - 时实例会被丢弃,与模块相同。 + 缓存或模型。持有实例的 worker 等待下一次调用一分钟,而不是五秒。陷阱(trap)或超时会使实例 + 作废,下一次调用会得到新实例。应用更新、授权变更或撤回时实例会被丢弃,与模块相同。 4. **WASI 的范围与应用授权一致。** - **始终提供:** `wasi:clocks`、`wasi:random`、`wasi:io` 和 `wasi:cli`。stdout 与 stderr 成为应用的日志行(有上限)。环境变量、参数和 stdin 都为空。 - - **`wasi:filesystem`:** 仅在应用有 `storage` 能力时提供。其存储文件夹以读写方式预打开为 `/`, + - **`wasi:filesystem`:** 仅在应用有 `storage` 能力(有账户的应用还需已登录账户)时提供, + 每次调用都按引擎文件夹所遵循的规则决定。其存储文件夹以读写方式预打开为 `/`, 主机文件系统的其余部分一概不可见。没有该能力时不预打开任何目录。 + - **存储配额逐次写入计量。** 一次调用可以写入的量,是调用开始时应用配额的剩余部分。运行时把使文件 + 变大的 WASI 调用(`write`、`set-size`,以及 `write-via-stream` 与 `append-via-stream` 返回 + 的流)替换为计量增长的版本,把释放字节的调用(截断的 `open-at`、`unlink-file-at`)替换为 + 归还字节的版本。超出预算的写入在组件内部失败,调用的错误会说明原因。我们没有采用"每次调用后 + 检查":一次调用在检查之前就可能写入数 GB,而且每次调用要遍历文件夹两次。 - **`wasi:http` 出站(第 3 阶段):** 只能访问清单 `network.hosts` 中的主机,并经过 shell 的网络策略。 - **从不提供:** `wasi:sockets`,以及上述 WASI 包以外的任何导入。请求这些导入的组件在加载时被拒绝 (`LoadError::Import`),App Hub 的审核闸门也会拒绝。 @@ -99,11 +107,35 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 时钟和随机数对 Wasm 是新增的,但每个脚本本来就能使用。 - **常驻实例会更长时间占用内存。** 每应用 worker 上限(`MAX_WORKERS`)与内存上限约束其用量, 空闲的 worker 照旧退出。 -- **验证原型的大小:** `notes`(含 `pulldown-cmark` 与 `getrandom`)为 310 KB,sockets 探测组件为 123 KB。 - 耗时尚未测量;第 2 阶段会像 ADR 0011 一样记录。 +- **验证原型的大小:** `notes`(含 `pulldown-cmark` 与 `getrandom`)为 313 KiB,sockets 探测组件为 120 KiB。 +- **磁盘已满看起来像 I/O 错误。** 无论宿主给出什么错误,wasi-libc 都把失败的流写入报告为 `EIO`, + 因此大多数被拒绝的写入在客体中表现为 I/O 错误,而不是 `ENOSPC`。运行时会把原因加到调用的错误 + 和应用的日志中。 + +## 测量 + +在 Apple M5 Max、macOS 26.6.2 上运行 +`cargo run --release -p octosense-wasm-host --example measure_component`(2026 年 10 月 9 日)。 +除非该行另有说明,每个数字都是 200 次调用的中位数。模块是 Wasm Lab 的(ADR 0011),运行同一个 +`pulldown-cmark`。 + +| | 组件 | 模块 | 原生 | +| --- | --- | --- | --- | +| Cranelift 编译(首次加载) | 37.5 毫秒(313 KiB) | | | +| 从缓存加载 | 3.6 毫秒 | | | +| 实例化(带存储文件夹) | 0.09 毫秒(0.10 毫秒) | | | +| Markdown 转 HTML,32 KiB | 468 微秒 | 400 微秒 | 183 微秒 | +| 渲染并写入一个 49 KiB 的新文件 | 678 微秒 | | 156 微秒(仅写入) | +| 写入一个 12 字节的文件 | 143 微秒 | | | +| 读回 49 KiB | 59 微秒 | | | +| 1 MiB 的 `list`,以 base64 往返 | 18.4 毫秒 | | | +| 最小的调用 | 0.2 微秒 | 0.3 微秒 | | + +计量配额需要知道调用开始时文件夹里有多少数据:遍历一次文件夹,与引擎的调用相同。10 个文件耗时 +0.12 毫秒,1,000 个 2.0 毫秒,10,000 个 23.8 毫秒(50 次的中位数)。在 APFS 上反复改写同一个 +文件很慢,原生代码也一样(每次改写的中位数约 6 毫秒),因此示例每次都写入新文件。 ## 待定问题 -- 组件写入的配额:逐次计量(包装文件系统宿主实现),还是在每次调用后检查。第 2 阶段通过测量决定。 - 组件能否调用会打开面板(登录、审阅)的主机服务。很可能只允许经由脚本,绝不允许在组件的调用中进行。 - 异步函数(WASI 0.3)和流式内容:第 3 阶段之后。 diff --git a/docs/wasm.md b/docs/wasm.md index 38648c3c5..fb70809ea 100644 --- a/docs/wasm.md +++ b/docs/wasm.md @@ -13,7 +13,7 @@ have it and how it was checked. It describes `main` after #400 (8 October | Where | Whose module | Runtime | Builds | State | | --- | --- | --- | --- | --- | -| [An app's own functions](#an-apps-own-functions-the-wasm-service): the `wasm` service ([ADR 0011](adr/0011-apps-own-functions-in-webassembly.md)) | The app's own bundle, `fns/*.wasm`, under the `wasm` capability | Wasmtime 49, compiled by Cranelift (`crates/wasm-host`) | Every standard desktop and Home build on macOS, Linux and Android (feature `wasm-functions`); none for Windows, iOS or OpenHarmony | Accepted, limited support. Tests pass on macOS and Linux; a OnePlus 6 passed the phone acceptance. No release has it yet | +| [An app's own functions](#an-apps-own-functions-the-wasm-service): the `wasm` service ([ADR 0011](adr/0011-apps-own-functions-in-webassembly.md)) | The app's own bundle, `fns/*.wasm`, under the `wasm` capability | Wasmtime 49, compiled by Cranelift (`crates/wasm-host`); components get WASI 0.2 from `wasmtime-wasi` | Every standard desktop and Home build on macOS, Linux and Android (feature `wasm-functions`); none for Windows, iOS or OpenHarmony | Accepted, limited support. Tests pass on macOS and Linux; a OnePlus 6 passed the phone acceptance. No release has it yet | | [Engine plug-ins](#engine-plug-ins-the-photo-and-vector-services): photocraft behind `photo`, vectorcraft behind `vector` ([ADR 0013](adr/0013-craft-engines-as-pinned-services.md)) | None: both services refuse every `plugin.*` command (#398, #405) | wasmi 2, an interpreter | Linked into every build with App Hub | Closed: no caller can install or run a plug-in | | [Splash's math compiler](#splash-math-and-compute-kernels) (makepad) | Generated by makepad from Splash code | makepad-stitch, an interpreter | Linked into every build | Not used: OctoSense links nothing that calls it | | [The shell in a browser](#the-shell-in-a-browser) | The shell itself, built for `wasm32-unknown-unknown` | The browser | None | Does not build | @@ -28,8 +28,9 @@ conversion. Splash's compute kernels stay the answer for numeric work. ### The module -A module is core WebAssembly at `fns/.wasm`, at most 8 per bundle. There -is no component model and no WASI. +A module is core WebAssembly at `fns/.wasm`, at most 8 per bundle, with +no WASI. A file there may instead be a [component](#components), which has +both. - It exports `memory`, `octo_alloc(len) -> ptr` and `octo_free(ptr, len)`. - Every other export of type `(i32, i32) -> i64` is a function: it takes its @@ -44,11 +45,52 @@ The guest crate [`apps/wasmlab/guest/octosense-guest`](../apps/wasmlab/guest/octosense-guest/src/lib.rs) writes all of this: `export!` for `fn(&[u8]) -> Result, String>`, `export_json!` for serde types, and `log`. Build the crate as a `cdylib` for -`wasm32-unknown-unknown`. Builds for `wasm32-wasip1`, wasm-bindgen output and -components import functions the shell does not provide, so they do not load. +`wasm32-unknown-unknown`. Builds for `wasm32-wasip1` and wasm-bindgen output +import functions the shell does not provide, so they do not load; a build for +`wasm32-wasip2` is a [component](#components), the other kind it runs. App developers start from App Flow's [Run your own Rust code](https://github.com/OctoSense-org/OctoSense-App-Flow/blob/main/docs/RUST.md). +### Components + +[ADR 0014](adr/0014-app-components-in-webassembly.md) (Proposed) adds a second +kind of `fns/.wasm`: a WebAssembly component, built from an ordinary +Rust crate with `cargo build --target wasm32-wasip2`. The service tells the +two kinds apart by the file's preamble, and a bundle may carry both. + +- **No glue.** Every function the component exports is `wasm.`, + named in snake_case: `save-html` is `wasm.save_html`, and either spelling + is accepted. Arguments are an object keyed by parameter name, an array in + parameter order or, for one parameter, the value itself. A record comes + back as an object, an enum as its name, `list` as base64 and a + `result` as its value or the request's error. `wasm.functions` lists + every export with its WIT signature; a function that takes or returns a + resource is listed as skipped, with the reason. +- **State.** Each component's instance lives on in the app's worker between + calls, so it can keep a parsed document or a cache. A trap or the deadline + spends it and the next call gets a new one; an update, a changed grant, a + withdrawal or the worker's exit (a minute without calls) ends it. +- **What it reaches.** Clocks and random numbers. Its stdout and stderr become + the app's log lines, and it gets no environment, arguments or stdin. A + component that imports anything beyond `wasi:cli`, `wasi:clocks`, + `wasi:filesystem`, `wasi:io` and `wasi:random`, such as sockets or HTTP, is + refused when it loads. +- **Files.** With the `storage` capability (and a signed-in account, for an + app with accounts), the app's own storage folder, the one its script's + `fs.*` sees, is the component's `/`. Nothing else on the device is + visible, and without the capability there is no filesystem. +- **The storage quota.** A call may add what is left of the app's quota when + the call starts. A write past it fails inside the component: `ftruncate` + reports a full disk, and an ordinary write an I/O error, because wasi-libc + reports any failed stream write as one. The request's error then ends with + "a write was refused: the storage budget is used up". Rewriting, truncating + and deleting give bytes back, so a component can free space. + +ADR 0014 records what this costs. On an M-series Mac, the 313 KiB test +component compiles in about 40 ms and loads from the cache in about 4 ms; +its Markdown function runs about 2.6 times slower than native Rust, close to +the same code as a module. + ### How a module reaches a device - **A store app.** App Hub's gate (app contract 1.7.0) admits a `.wasm` file @@ -76,10 +118,11 @@ former name). Builds for Windows, iOS and OpenHarmony leave the runtime out - `wasm.functions`: the functions the app's modules export, how each module loaded (compiled, or from the cache, and how long it took) and how each function has run. -- `wasm.`: one call. A string argument goes in as its text, anything - else as JSON. JSON output comes back as data, anything else as - `{"text": …}`. The function's own error, a trap or the deadline is the - request's error. +- `wasm.`: one call. For a module, a string argument goes in as its + text and anything else as JSON; JSON output comes back as data, anything + else as `{"text": …}`. A component's arguments and result are typed + ([Components](#components)). The function's own error, a trap or the + deadline is the request's error. The code always comes from the calling app's own admitted, digest-checked bundle, never from an argument, and no app reaches another app's functions. @@ -87,9 +130,10 @@ The Card runner's gate and the tool executor require the `wasm` capability, and the service checks the admitted manifest's grant again before it loads anything. -Every call gets a fresh instance: memory, globals, tables and log lines from -one call never reach the next, including after a success or the function's -own error. Only compiled code is reused, in the app's worker and in a disk +Every call to a module gets a fresh instance: memory, globals, tables and log +lines from one call never reach the next, including after a success or the +function's own error. A component keeps one instance between calls instead +([Components](#components)). Only compiled code is reused, in the app's worker and in a disk cache (`wasm-cache` in the host directory, keyed by the module's SHA-256 and the engine's compatibility hash). The service checks admission again before a call runs and before its answer is delivered. An update, a changed grant or a @@ -103,14 +147,14 @@ be interrupted, but an expired or cancelled request never goes on to run. | Table elements | 16,384 | | Wasm stack | 512 KiB | | Module | 8 MiB | -| Input or output of one call | 16 MiB in the runtime; 1 MiB of serialized input in the service | +| Input or output of one call | 16 MiB in the runtime; 8 MiB of serialized input in the service | | Log | 64 lines a call, 1 KiB each | | Modules per app | 8 | | Apps running functions at once | 4 workers, one per app | | Queued requests | 4 per app | -| Buffered input, all apps | 16 MiB | +| Buffered input, all apps | 32 MiB | | One request, queueing and loading included | 10 s | -| An idle worker | exits after 5 s | +| An idle worker | exits after 5 s, or after 60 s while it holds a component's instance | A full queue or no free worker fails the request at once instead of making it wait. @@ -119,8 +163,9 @@ A full queue or no free worker fails the request at once instead of making it wa An app's `tools.json` may map a tool to one of its functions with `"host_method": "wasm."`. That needs the `wasm` capability. Unlike shared host methods it has no minimum risk and needs no -`"private_data": true`, because the function sees only the arguments it is -given. Wasm Lab maps `wasmlab.find_slots`, `wasmlab.rank` and `wasmlab.diff` +`"private_data": true`: a module sees only the arguments it is given, and a +component also its own app's folder, as the app's script does. Neither +reaches shared data. Wasm Lab maps `wasmlab.find_slots`, `wasmlab.rank` and `wasmlab.diff` to `wasm.find_slots`, `wasm.fuzzy_rank` and `wasm.text_diff`, all `read`. ### Platforms @@ -186,10 +231,25 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s its concurrent writers, cancellation. - `crates/wasm-host/tests/guest.rs` runs Wasm Lab's module and compares every function with the same Rust run natively, then every `rogue` mode. +- `crates/wasm-host/tests/component.rs` runs two components built from + `crates/wasm-host/tests/component-guest`: an unmodified crate + (pulldown-cmark) called with JSON, records and snake_case names, state + kept on one instance, clocks, randomness and bytes, files only in the + granted folder (also read-only), the storage budget, the deadline and the + memory cap, and a component that imports sockets refused. - The service tests cover the tool mapping, bounded input, a fresh instance per call, revocation after an update, a grant change or a withdrawal, cancellation and the queue bounds, and the app's own functions answering - its tools and its script. + its tools and its script. For a component they cover typed calls, its + instance's state, the app's storage folder and its quota, and an app + without the storage capability. +- `cargo run --release -p octosense-wasm-host --example measure_component` + prints a component's timings against a module and native Rust. + +The test components rebuild byte for byte: with rustc 1.97.1, +`crates/wasm-host/tests/component-guest/build.sh` writes the same +`notes.component.wasm` (SHA-256 `963c965b…`) and `netprobe.component.wasm` +(`98b2402a…`). It needs the target once: `rustup target add wasm32-wasip2`. `phone.yml` runs the first command, and the service's tests as part of Home's (`mobile-apps` includes `wasm-functions`); `tools/ci-local.sh --linux-host @@ -222,9 +282,12 @@ command. evicts. - Compiling at install time (store apps) or build time (system apps), to remove the first compile on a phone. -- Typed interfaces (the component model and WIT) instead of JSON. Any import - beyond `octo.log`, such as a clock or randomness, would be a new - capability. +- Components on a phone: Android builds link the same runtime, but no + component has run on a device yet (**unverified**). +- A component's network (`wasi:http` under the manifest's `network.hosts`) + and the host's services (`octosense:host`): ADR 0014's phase 3. +- Bytes are slow through JSON: a 1 MiB `list` costs about 18 ms there + and back, as base64. ## Engine plug-ins: the `photo` and `vector` services diff --git a/docs/wasm.zh-CN.md b/docs/wasm.zh-CN.md index 84736d8ce..933d67d77 100644 --- a/docs/wasm.zh-CN.md +++ b/docs/wasm.zh-CN.md @@ -10,7 +10,7 @@ OctoSense 中有四处用到 WebAssembly。其中一处专为运行应用自己 | 位置 | 谁的模块 | 运行时 | 构建 | 状态 | | --- | --- | --- | --- | --- | -| 应用自带的函数:`wasm` 服务([ADR 0011](adr/0011-apps-own-functions-in-webassembly.zh-CN.md)) | 应用自己的应用包,`fns/*.wasm`,需要 `wasm` 能力 | Wasmtime 49,由 Cranelift 编译(`crates/wasm-host`) | macOS、Linux 和 Android 上的每个标准桌面版和 Home 构建(特性 `wasm-functions`);Windows、iOS 和 OpenHarmony 不包含 | 已接受,有限支持。测试在 macOS 和 Linux 上通过;一部 OnePlus 6 通过了手机验收。尚无发布版本包含它 | +| 应用自带的函数:`wasm` 服务([ADR 0011](adr/0011-apps-own-functions-in-webassembly.zh-CN.md)) | 应用自己的应用包,`fns/*.wasm`,需要 `wasm` 能力 | Wasmtime 49,由 Cranelift 编译(`crates/wasm-host`);组件的 WASI 0.2 来自 `wasmtime-wasi` | macOS、Linux 和 Android 上的每个标准桌面版和 Home 构建(特性 `wasm-functions`);Windows、iOS 和 OpenHarmony 不包含 | 已接受,有限支持。测试在 macOS 和 Linux 上通过;一部 OnePlus 6 通过了手机验收。尚无发布版本包含它 | | 引擎插件:`photo` 背后的 photocraft、`vector` 背后的 vectorcraft([ADR 0013](adr/0013-craft-engines-as-pinned-services.zh-CN.md)) | 无:两个服务都拒绝所有 `plugin.*` 命令(#398、#405) | wasmi 2,解释器 | 链接进所有带 App Hub 的构建 | 已关闭:任何调用方都不能安装或运行插件 | | Splash 的数学编译器(makepad) | makepad 根据 Splash 代码生成 | makepad-stitch,解释器 | 链接进所有构建 | 未使用:OctoSense 没有链接任何调用它的代码 | | 浏览器中的外壳 | 外壳本身,为 `wasm32-unknown-unknown` 构建 | 浏览器 | 无 | 无法构建 | @@ -23,8 +23,8 @@ OctoSense 中有四处用到 WebAssembly。其中一处专为运行应用自己 ### 模块 -模块是放在 `fns/.wasm` 的核心 WebAssembly,每个应用包最多 8 个。不使用组件模型, -也不使用 WASI。 +模块是放在 `fns/.wasm` 的核心 WebAssembly,每个应用包最多 8 个,不使用 WASI。 +那里的文件也可以是[组件](#组件),组件两者都有。 - 它导出 `memory`、`octo_alloc(len) -> ptr` 和 `octo_free(ptr, len)`。 - 其余类型为 `(i32, i32) -> i64` 的导出都是函数:在 `(ptr, len)` 处取输入,返回 @@ -37,11 +37,39 @@ OctoSense 中有四处用到 WebAssembly。其中一处专为运行应用自己 [`apps/wasmlab/guest/octosense-guest`](../apps/wasmlab/guest/octosense-guest/src/lib.rs) 生成这一切:`export!` 用于 `fn(&[u8]) -> Result, String>`,`export_json!` 用于 serde 类型,另有 `log`。把 crate 构建成面向 `wasm32-unknown-unknown` 的 -`cdylib`。面向 `wasm32-wasip1` 的构建、wasm-bindgen 的输出以及组件都会导入外壳不提供的 -函数,因此无法加载。应用开发者请从 App Flow 的 +`cdylib`。面向 `wasm32-wasip1` 的构建和 wasm-bindgen 的输出会导入外壳不提供的函数, +因此无法加载;面向 `wasm32-wasip2` 的构建是[组件](#组件),即服务运行的另一种文件。应用开发者请从 App Flow 的 [Run your own Rust code](https://github.com/OctoSense-org/OctoSense-App-Flow/blob/main/docs/RUST.md) 开始。 +### 组件 + +[ADR 0014](adr/0014-app-components-in-webassembly.zh-CN.md)(提议)增加了第二种 +`fns/.wasm`:WebAssembly 组件,用 `cargo build --target wasm32-wasip2` 从普通的 +Rust crate 构建。服务按文件头区分两者,一个应用包可以同时携带两种。 + +- **无需胶水代码。** 组件导出的每个函数都是 `wasm.`,名称用 snake_case: + `save-html` 即 `wasm.save_html`,两种写法都接受。参数可以是按参数名作键的对象、按参数 + 顺序排列的数组;只有一个参数时也可以直接传值。记录返回为对象,枚举返回为其名称, + `list` 返回为 base64,`result` 返回为其值,或作为这次请求的错误。 + `wasm.functions` 列出每个导出及其 WIT 签名;接收或返回资源的函数会被列为已跳过,并附原因。 +- **状态。** 每个组件的实例在调用之间留在应用的工作线程里,因此可以保存已解析的文档或缓存。 + 陷阱或超时会使实例作废,下一次调用得到新实例;更新、授权变化、撤回或工作线程退出(一分钟 + 没有调用)都会结束它。 +- **能接触什么。** 时钟和随机数。它的 stdout 和 stderr 成为应用的日志行,没有环境变量、 + 参数和 stdin。导入 `wasi:cli`、`wasi:clocks`、`wasi:filesystem`、`wasi:io` 和 + `wasi:random` 以外任何东西(例如套接字或 HTTP)的组件在加载时被拒绝。 +- **文件。** 应用有 `storage` 能力(有账户的应用还需已登录账户)时,应用自己的存储文件夹, + 即其脚本的 `fs.*` 看到的那个,就是组件的 `/`。设备上的其他东西一概不可见;没有这个能力 + 时没有文件系统。 +- **存储配额。** 一次调用可以写入的量,是调用开始时应用配额的剩余部分。超出的写入会在组件 + 内部失败:`ftruncate` 报告磁盘已满,普通写入报告 I/O 错误,因为 wasi-libc 把任何失败的 + 流写入都报告为 I/O 错误。这时请求的错误以 "a write was refused: the storage budget is + used up" 结尾。改写、截断和删除会归还字节,因此组件可以自己腾出空间。 + +ADR 0014 记录了开销。在 M 系列 Mac 上,313 KiB 的测试组件约 40 毫秒完成编译,从缓存加载 +约 4 毫秒;它的 Markdown 函数比原生 Rust 慢约 2.6 倍,与同一段代码作为模块时接近。 + ### 模块如何到达设备 - **商店应用。** App Hub 的审核(应用契约 1.7.0)只接受放在 `fns/.wasm` 的 @@ -65,16 +93,17 @@ OctoSense 中有四处用到 WebAssembly。其中一处专为运行应用自己 - `wasm.functions`:应用的模块导出了哪些函数,每个模块如何加载(编译,还是取自缓存, 用了多久),以及每个函数的运行情况。 -- `wasm.`:一次调用。字符串参数按原文传入,其他参数按 JSON 传入。JSON 输出 - 作为数据返回,其他输出返回为 `{"text": …}`。函数自己返回的错误、陷阱或超时都是这次 - 请求的错误。 +- `wasm.`:一次调用。对模块而言,字符串参数按原文传入,其他参数按 JSON 传入; + JSON 输出作为数据返回,其他输出返回为 `{"text": …}`。组件的参数和结果带类型 + (见[组件](#组件))。函数自己返回的错误、陷阱或超时都是这次请求的错误。 代码总是来自发起调用的应用自己的、已准入且摘要校验过的应用包,绝不来自参数;任何应用都 接触不到别的应用的函数。Card runner 的审核和工具执行器都要求 `wasm` 能力,服务在加载任何 东西之前还会再次检查已准入清单中的授权。 -每次调用都使用全新的实例:一次调用的内存、全局变量、表和日志行都不会进入下一次调用,成功 -调用或函数自己返回错误之后也一样。只有编译后的代码会被复用:保存在应用的工作线程里,以及 +每次调用模块都使用全新的实例:一次调用的内存、全局变量、表和日志行都不会进入下一次调用, +成功调用或函数自己返回错误之后也一样。组件则在调用之间保留一个实例(见[组件](#组件))。 +只有编译后的代码会被复用:保存在应用的工作线程里,以及 磁盘缓存中(宿主目录下的 `wasm-cache`,以模块的 SHA-256 和引擎兼容性哈希为键)。调用 运行之前、答复交付之前,服务都会再次检查准入。更新、授权变化或签名撤回会丢弃这次答复和 编译后的代码。编译过程无法中断,但已过期或已取消的请求不会继续运行。 @@ -86,14 +115,14 @@ OctoSense 中有四处用到 WebAssembly。其中一处专为运行应用自己 | 表元素 | 16,384 个 | | wasm 栈 | 512 KiB | | 模块 | 8 MiB | -| 一次调用的输入或输出 | 运行时 16 MiB;服务中序列化后的输入 1 MiB | +| 一次调用的输入或输出 | 运行时 16 MiB;服务中序列化后的输入 8 MiB | | 日志 | 每次调用 64 行,每行 1 KiB | | 每个应用的模块 | 8 个 | | 同时运行函数的应用 | 4 个工作线程,每个应用一个 | | 排队的请求 | 每个应用 4 个 | -| 所有应用缓冲的输入 | 16 MiB | +| 所有应用缓冲的输入 | 32 MiB | | 一个请求(包括排队和加载) | 10 秒 | -| 空闲的工作线程 | 5 秒后退出 | +| 空闲的工作线程 | 5 秒后退出;持有组件实例时 60 秒后退出 | 队列已满或没有空闲的工作线程时,请求会立即失败,不会等待。 @@ -101,7 +130,8 @@ OctoSense 中有四处用到 WebAssembly。其中一处专为运行应用自己 应用的 `tools.json` 可以用 `"host_method": "wasm."` 把一个工具映射到自己的 某个函数。这需要 `wasm` 能力。与共享宿主方法不同,它没有最低风险等级,也不需要 -`"private_data": true`,因为函数只能看到传给它的参数。Wasm Lab 把 `wasmlab.find_slots`、 +`"private_data": true`:模块只能看到传给它的参数,组件还能看到自己应用的文件夹,与应用的 +脚本相同。两者都接触不到共享数据。Wasm Lab 把 `wasmlab.find_slots`、 `wasmlab.rank` 和 `wasmlab.diff` 映射到 `wasm.find_slots`、`wasm.fuzzy_rank` 和 `wasm.text_diff`,风险等级都是 `read`。 @@ -160,8 +190,19 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s 并发写入、取消。 - `crates/wasm-host/tests/guest.rs` 运行 Wasm Lab 的模块,把每个函数的结果与同一段 Rust 原生运行的结果比较,然后逐一运行 `rogue` 的各种模式。 +- `crates/wasm-host/tests/component.rs` 运行两个由 `crates/wasm-host/tests/component-guest` + 构建的组件:以 JSON 调用未作修改的 crate(pulldown-cmark)、记录与 snake_case 名称、 + 同一实例保留状态、时钟、随机数与字节、只能访问授予的文件夹(也包括只读)、存储预算、 + 超时与内存上限,以及拒绝导入套接字的组件。 - 服务测试覆盖工具映射、输入上限、每次调用使用全新实例、更新或授权变化或撤回之后的撤销、 - 取消和队列上限,以及应用自己的函数回答它的工具和脚本。 + 取消和队列上限,以及应用自己的函数回答它的工具和脚本。对组件,测试覆盖带类型的调用、 + 实例的状态、应用的存储文件夹及其配额,以及没有存储能力的应用。 +- `cargo run --release -p octosense-wasm-host --example measure_component` 打印组件 + 相对于模块和原生 Rust 的耗时。 + +测试组件可以逐字节重建:用 rustc 1.97.1,`crates/wasm-host/tests/component-guest/build.sh` +写出相同的 `notes.component.wasm`(SHA-256 `963c965b…`)和 `netprobe.component.wasm` +(`98b2402a…`)。只需添加一次目标:`rustup target add wasm32-wasip2`。 `phone.yml` 运行第一条命令,服务的测试则作为 Home 测试的一部分运行(`mobile-apps` 包含 `wasm-functions`);`tools/ci-local.sh --linux-host --offload` 在 Linux 构建主机上运行 @@ -187,8 +228,10 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s - Windows、iOS(Pulley)和 OpenHarmony。 - 应用之间的 CPU 公平调度,以及磁盘缓存的上限:目前没有任何东西会清理它。 - 在安装时(商店应用)或构建时(系统应用)编译,省掉手机上的第一次编译。 -- 用类型化接口(组件模型和 WIT)代替 JSON。`octo.log` 以外的任何导入,例如时钟或随机数, - 都将是新的能力。 +- 手机上的组件:Android 构建链接同一个运行时,但还没有组件在设备上运行过(**未验证**)。 +- 组件的网络(受清单 `network.hosts` 约束的 `wasi:http`)和宿主服务(`octosense:host`): + ADR 0014 的第 3 阶段。 +- 经 JSON 传字节很慢:1 MiB 的 `list` 以 base64 往返约需 18 毫秒。 ## 引擎插件:`photo` 和 `vector` 服务 From 01d114c3638ab0f51f417172bf36c2579982294f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:11:03 -0700 Subject: [PATCH 2/2] Pin App Hub 769e9ee3 (#186): components under wasm-components-v1 App Hub #186 admits WebAssembly components when the manifest requires wasm-components-v1, validates their imports (only the WASI packages a host scopes to the app) and tells reviewers what each reaches. With the store on it, a component shipped as a system app goes through admission, and the new service test calls it as the app's script would: typed JSON, the instance's state from one request to the next, no folder without storage. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 11 ++--- Cargo.toml | 10 ++--- crates/shell/src/wasm_service.rs | 73 ++++++++++++++++++++++++++++++++ native-apps.json | 2 +- 4 files changed, 85 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dc4d0ead9..db4123fba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11383,7 +11383,7 @@ dependencies = [ [[package]] name = "octosense-app-contract" version = "1.10.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=28745c0f6fd71986820e9835183a3045f61baefc#28745c0f6fd71986820e9835183a3045f61baefc" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=769e9ee356299dce3afbf48801e58140645b71f1#769e9ee356299dce3afbf48801e58140645b71f1" dependencies = [ "blake3", "serde", @@ -11394,7 +11394,7 @@ dependencies = [ [[package]] name = "octosense-app-hub" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=28745c0f6fd71986820e9835183a3045f61baefc#28745c0f6fd71986820e9835183a3045f61baefc" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=769e9ee356299dce3afbf48801e58140645b71f1#769e9ee356299dce3afbf48801e58140645b71f1" dependencies = [ "base64 0.22.1", "blake3", @@ -11414,12 +11414,13 @@ dependencies = [ "sigstore-types", "sigstore-verify", "ureq 3.4.2", + "wasmparser 0.258.3", ] [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=28745c0f6fd71986820e9835183a3045f61baefc#28745c0f6fd71986820e9835183a3045f61baefc" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=769e9ee356299dce3afbf48801e58140645b71f1#769e9ee356299dce3afbf48801e58140645b71f1" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -11447,7 +11448,7 @@ dependencies = [ [[package]] name = "octosense-app-policy" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=28745c0f6fd71986820e9835183a3045f61baefc#28745c0f6fd71986820e9835183a3045f61baefc" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=769e9ee356299dce3afbf48801e58140645b71f1#769e9ee356299dce3afbf48801e58140645b71f1" dependencies = [ "makepad-widgets", "octosense-app-contract", @@ -11469,7 +11470,7 @@ dependencies = [ [[package]] name = "octosense-appstore" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=28745c0f6fd71986820e9835183a3045f61baefc#28745c0f6fd71986820e9835183a3045f61baefc" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=769e9ee356299dce3afbf48801e58140645b71f1#769e9ee356299dce3afbf48801e58140645b71f1" dependencies = [ "makepad-app-module", "makepad-widgets", diff --git a/Cargo.toml b/Cargo.toml index 9dfaf2d45..80e4ffa7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ octosense-reference = { path = "apps/reference" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2" } octosense-appcard = { path = "apps/appcard/module" } -octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "28745c0f6fd71986820e9835183a3045f61baefc" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "769e9ee356299dce3afbf48801e58140645b71f1" } makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } @@ -130,12 +130,12 @@ octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepa octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "33dea2f1f3ad3f1346a219aa8cf6e91b31361e23" } # --- App Hub's host service registry the Mail and llm services register # into; the same revision as App Hub's app crate (a native app, above). -octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "28745c0f6fd71986820e9835183a3045f61baefc" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "769e9ee356299dce3afbf48801e58140645b71f1" } # The app policy (resolution, isolate settings, the Splash adapter) and the # Hub client the Card runner resolves an app's policy with; the shell's # glance tiles run cards under it (crates/shell/src/glance_card.rs). -octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "28745c0f6fd71986820e9835183a3045f61baefc" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "28745c0f6fd71986820e9835183a3045f61baefc" } +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "769e9ee356299dce3afbf48801e58140645b71f1" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "769e9ee356299dce3afbf48801e58140645b71f1" } # The app contract (ADR 0005), from crates.io by version: App Hub, the shell # and Rinx all depend on `octosense-app-contract = "1"`, so Cargo links ONE # 1.x for the whole build (Cargo.lock pins the exact version OctoSense ships) @@ -435,7 +435,7 @@ ruma = { git = "https://github.com/project-robius/ruma.git", branch = "tsp" } # -i octos-core` still sees one octos source. [patch.crates-io] # Keep the contract identical to the pinned SDK for this release candidate. -octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "28745c0f6fd71986820e9835183a3045f61baefc" } +octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "769e9ee356299dce3afbf48801e58140645b71f1" } nix = { git = "https://github.com/octos-org/octos.git", rev = "18fcd3f16e527d2b601d7ae244f056fb711bb6b8" } # pdfcraft's bootstrap PDF stack: its workspace patches these five diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index 12356efcf..d7a632bc5 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -1718,4 +1718,77 @@ mod tests { assert_eq!(plain.describe()["modules"][0]["storage"], "none"); let _ = std::fs::remove_dir_all(root); } + + /// A component through the whole path, in a process of its own: shipped + /// as a system app that requires `wasm-components-v1` (App Hub's + /// admission reads the component's imports), its functions answer the + /// app's script with typed JSON, its instance keeps its state from one + /// request to the next, and without `storage` it has no folder. + #[test] + fn a_shipped_component_answers_its_apps_script() { + const CHILD: &str = "OCTOSENSE_TEST_WASM_SHIPPED_COMPONENT"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "wasm_service::tests::a_shipped_component_answers_its_apps_script", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + let root = std::env::temp_dir().join(format!( + "octosense-wasm-shipped-component-{}", + std::process::id() + )); + let host_dir = root.join(".host"); + // This exact test runs alone in its child process. Keep the env + // override and registered root identical even under an isolated suite. + std::env::set_var("OCTOSENSE_APP_DATA", &root); + octosense_appstore::set_data_root(root.clone()); + ship("os.wasmnotes", "component", |dir, manifest| { + manifest["capabilities"] = json!(["wasm"]); + manifest["requires"] = json!(["wasm-components-v1"]); + manifest.as_object_mut().unwrap().remove("agent"); + std::fs::remove_file(dir.join("tools.json")).unwrap(); + std::fs::remove_file(dir.join("AGENT.md")).unwrap(); + std::fs::remove_file(dir.join("fns/wasmlab.wasm")).unwrap(); + std::fs::copy(NOTES_COMPONENT, dir.join("fns/notes.wasm")).unwrap(); + }); + register(); + let call = |method: &str, args: Value| request("os.wasmnotes", method, args, &host_dir); + + assert_eq!( + call("wasm.to_html", json!("# Hi")).unwrap(), + "

Hi

\n" + ); + assert_eq!( + call("wasm.analyze", json!({"markdown": "# One\n\ntwo words"})).unwrap(), + json!({"words": 4, "lines": 3, "headings": ["One"]}) + ); + assert_eq!(call("wasm.count", json!({})).unwrap(), 1); + assert_eq!(call("wasm.count", json!({})).unwrap(), 2); + let error = call( + "wasm.save_html", + json!({"markdown": "# Hi", "path": "a.html"}), + ) + .unwrap_err(); + assert!(!error.is_empty()); + let described = call("wasm.functions", json!({})).unwrap(); + let notes = &described["modules"][0]; + assert_eq!(notes["file"], "notes.wasm", "{described}"); + assert_eq!(notes["kind"], "component"); + assert_eq!(notes["instances"], 1); + assert_eq!(notes["storage"], "none"); + assert_eq!(described["stats"]["count"]["calls"], 2); + let _ = std::fs::remove_dir_all(root); + } } diff --git a/native-apps.json b/native-apps.json index dc2469715..9857ef84e 100644 --- a/native-apps.json +++ b/native-apps.json @@ -114,7 +114,7 @@ "id": "apphub", "feature": "app-hub", "crate": "octosense-app-hub-app", - "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "28745c0f6fd71986820e9835183a3045f61baefc", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "769e9ee356299dce3afbf48801e58140645b71f1", "local": null }, "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"], "bin": null, "default_features": true,