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/9] 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/9] 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, From 98930e33acb4b97e082c6b09c6ad8a8a7426bdc4 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:26:21 -0700 Subject: [PATCH 3/9] ADR 0014 phase 3: a component's wasi:http reaches only its app's hosts Components may import wasi:http (wasmtime-wasi-http 49.0.2, p2, rustls with ring as the shell's). Requests go only to the app's network.hosts under net, by a script's rule (exact host, any case, any port), and over HTTPS except to this device. A request's timeouts are clamped to the call's deadline, which is 10 s for a component that may reach the network. Refused requests are logged with the reason. Tests: a fetch component against a local server, a server that never answers, the service's hosts from the admitted manifest. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 27 +++ Cargo.toml | 4 +- crates/shell/src/wasm_service.rs | 110 ++++++++++++ crates/wasm-host/Cargo.toml | 4 + .../wasm-host/examples/measure_component.rs | 1 + crates/wasm-host/src/component.rs | 39 ++++- crates/wasm-host/src/component/net.rs | 159 ++++++++++++++++++ crates/wasm-host/src/lib.rs | 4 + .../tests/component-guest/Cargo.lock | 39 ++++- .../tests/component-guest/Cargo.toml | 2 +- .../wasm-host/tests/component-guest/build.sh | 2 +- .../tests/component-guest/fetch/Cargo.toml | 13 ++ .../tests/component-guest/fetch/src/lib.rs | 46 +++++ .../tests/component-guest/fetch/wit/fetch.wit | 6 + crates/wasm-host/tests/component.rs | 105 ++++++++++++ .../tests/fixtures/fetch.component.wasm | Bin 0 -> 84774 bytes 16 files changed, 554 insertions(+), 7 deletions(-) create mode 100644 crates/wasm-host/src/component/net.rs create mode 100644 crates/wasm-host/tests/component-guest/fetch/Cargo.toml create mode 100644 crates/wasm-host/tests/component-guest/fetch/src/lib.rs create mode 100644 crates/wasm-host/tests/component-guest/fetch/wit/fetch.wit create mode 100644 crates/wasm-host/tests/fixtures/fetch.component.wasm diff --git a/Cargo.lock b/Cargo.lock index db4123fba..2f12a474a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11986,12 +11986,15 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "bytes", + "http", + "http-body-util", "libc", "serde_json", "sha2 0.10.9", "wasmlab-functions", "wasmtime", "wasmtime-wasi", + "wasmtime-wasi-http", "wat", ] @@ -19039,6 +19042,30 @@ dependencies = [ "winx", ] +[[package]] +name = "wasmtime-wasi-http" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4cd327c70de1309b0c04d49df3633a2ffeae9b29b526d421320b565e198175" +dependencies = [ + "async-trait", + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "pin-project-lite", + "rustls", + "tokio", + "tokio-rustls", + "tracing", + "wasmtime", + "wasmtime-wasi", + "wasmtime-wasi-io", + "webpki-roots 1.0.9", +] + [[package]] name = "wasmtime-wasi-io" version = "49.0.2" diff --git a/Cargo.toml b/Cargo.toml index 80e4ffa7b..7d3359128 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -313,9 +313,11 @@ log = "0.4" # Developers' own functions compiled to WebAssembly, run for their app with # a deadline and a memory cap (crates/wasm-host, ADR 0011). Cranelift compiles; # no GC or threads. Components (ADR 0014) add the component model and a WASI -# 0.2 subset scoped to the app: `wasmtime-wasi`'s `p2` only. +# 0.2 subset scoped to the app: `wasmtime-wasi`'s `p2` only, and outgoing +# `wasi:http` to the app's own network hosts (rustls with ring, as the shell's). wasmtime = { version = "49.0.2", default-features = false, features = ["cranelift", "runtime", "std", "parallel-compilation", "component-model"] } wasmtime-wasi = { version = "49.0.2", default-features = false, features = ["p2"] } +wasmtime-wasi-http = { version = "49.0.2", default-features = false, features = ["p2", "default-send-request"] } # Workspace-wide clippy policy for the crates that opt in (`[lints] # workspace = true`, AppCard's): lints that are real but tracked elsewhere. diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index d7a632bc5..3ed289fe5 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -487,6 +487,26 @@ struct Lab { owner: BTreeMap, stats: BTreeMap, admission: Admission, + /// The hosts its components' `wasi:http` may reach: the admitted + /// policy's network hosts, granted `net`. + http_hosts: Vec, +} + +/// The hosts an app's components may reach over HTTP(S): its policy's +/// `network.hosts` when it is granted `net`, as for its script; none when +/// the manifest does not resolve. +fn http_hosts(manifest: &Value) -> Vec { + let resolved = + octosense_app_contract::AppManifest::parse(&manifest.to_string()).and_then(|m| { + octosense_app_contract::resolve( + &m, + &octosense_app_contract::HostLimits::default().with_require_signature(false), + ) + }); + match resolved { + Ok(policy) if policy.allows("net") => policy.hosts.into_iter().collect(), + _ => Vec::new(), + } } struct Module { @@ -558,6 +578,7 @@ impl Lab { modules: Vec::new(), owner: BTreeMap::new(), stats: BTreeMap::new(), + http_hosts: http_hosts(&admission.manifest), admission: admission.clone(), }; for path in files { @@ -723,6 +744,7 @@ impl Lab { let grants = Grants { storage_dir: area.as_ref().map(|a| a.root.clone()), read_only: false, + http_hosts: self.http_hosts.clone(), }; let app = self.app.clone(); let module = &mut self.modules[index]; @@ -787,6 +809,7 @@ impl Lab { "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()), + "http_hosts": live.as_ref().map(|(_, g)| g.http_hosts.clone()), "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::>()}), @@ -1791,4 +1814,91 @@ mod tests { assert_eq!(described["stats"]["count"]["calls"], 2); let _ = std::fs::remove_dir_all(root); } + + /// A component's requests reach its app's own hosts, as its script's + /// do: the admitted manifest's `network.hosts` with `net`, and nothing + /// for an app without `net`. + #[test] + fn a_components_requests_reach_only_its_apps_hosts() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + for mut stream in listener.incoming().flatten() { + let mut request = Vec::new(); + let mut byte = [0u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + if std::io::Read::read(&mut stream, &mut byte).unwrap_or(0) == 0 { + break; + } + request.push(byte[0]); + } + let _ = std::io::Write::write_all( + &mut stream, + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello", + ); + } + }); + let root = std::env::temp_dir().join(format!( + "octosense-wasm-component-net-{}", + 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( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../wasm-host/tests/fixtures/fetch.component.wasm" + ), + bundle.join("fns/fetch.wasm"), + ) + .unwrap(); + let manifest = |granted: bool| { + let dir = script_apps::tests::stamped_bundle("wasmlab", "net", |_, _| {}); + let mut manifest: Value = + serde_json::from_str(&std::fs::read_to_string(dir.join("manifest.json")).unwrap()) + .unwrap(); + let _ = std::fs::remove_dir_all(&dir); + manifest["requires"] = json!(["wasm-components-v1"]); + if granted { + manifest["capabilities"] = json!(["wasm", "net"]); + manifest["network"] = json!({"hosts": ["127.0.0.1"]}); + } else { + manifest["capabilities"] = json!(["wasm"]); + } + manifest + }; + assert_eq!(http_hosts(&manifest(true)), ["127.0.0.1"]); + assert!(http_hosts(&manifest(false)).is_empty()); + let host_dir = root.join(".host"); + let runtime = runtime(&host_dir).unwrap(); + let url = json!(format!("http://127.0.0.1:{port}/hi")); + for granted in [true, false] { + let admission = Admission { + root: root.clone(), + bundle: bundle.clone(), + manifest: manifest(granted), + }; + let mut lab = Lab::from_bundle("os.wasmnet", runtime, admission, || Ok(())).unwrap(); + let (_, reply) = pending_reply("os.wasmnet", &host_dir); + let answer = lab.answer( + "get", + url.to_string().as_bytes(), + false, + Instant::now() + REQUEST_TIMEOUT, + reply, + ); + if granted { + assert_eq!(answer.unwrap(), "200 hello"); + assert_eq!( + lab.describe()["modules"][0]["http_hosts"], + json!(["127.0.0.1"]) + ); + } else { + let error = answer.unwrap_err(); + assert!(error.contains("HttpRequestDenied"), "{error}"); + } + } + let _ = std::fs::remove_dir_all(root); + } } diff --git a/crates/wasm-host/Cargo.toml b/crates/wasm-host/Cargo.toml index c93cc3157..8c83b1bd9 100644 --- a/crates/wasm-host/Cargo.toml +++ b/crates/wasm-host/Cargo.toml @@ -8,6 +8,10 @@ publish = false [dependencies] wasmtime = { workspace = true } wasmtime-wasi = { workspace = true } +wasmtime-wasi-http = { workspace = true } +# The requests a component's `wasi:http` sends, checked against its hosts. +http = "1" +http-body-util = "0.1" serde_json = { workspace = true } base64 = "0.22" # File writes a component makes (wasmtime-wasi's streams carry `Bytes`). diff --git a/crates/wasm-host/examples/measure_component.rs b/crates/wasm-host/examples/measure_component.rs index f2e76e72e..818a42fb1 100644 --- a/crates/wasm-host/examples/measure_component.rs +++ b/crates/wasm-host/examples/measure_component.rs @@ -101,6 +101,7 @@ fn main() { let grants = Grants { storage_dir: Some(storage.clone()), read_only: false, + http_hosts: Vec::new(), }; let with_files = median_us(50, || { drop( diff --git a/crates/wasm-host/src/component.rs b/crates/wasm-host/src/component.rs index d8c7ca0aa..f885420e0 100644 --- a/crates/wasm-host/src/component.rs +++ b/crates/wasm-host/src/component.rs @@ -48,6 +48,7 @@ use wasmtime_wasi::{FsPerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; use crate::{trap, CallError, InvocationGuard, LoadError, Runtime, Ticker, LOG_LINE, LOG_LINES}; mod files; +mod net; /// The WASI packages a component may import. Every interface of these is /// linked; what each can reach is set per instance ([`Grants`]). @@ -55,6 +56,7 @@ const ALLOWED_PACKAGES: &[&str] = &[ "wasi:cli/", "wasi:clocks/", "wasi:filesystem/", + "wasi:http/", "wasi:io/", "wasi:random/", ]; @@ -78,6 +80,9 @@ pub struct Grants { pub storage_dir: Option, /// Open the storage folder read-only (its quota is used up). pub read_only: bool, + /// The hosts its `wasi:http` requests may reach, by a script's rule + /// (`host`, or `host:port`). Empty: none. + pub http_hosts: Vec, } /// One function an app may call, with its WIT-like signature. @@ -130,6 +135,9 @@ struct State { guard: Option>, /// What the running call may add to the storage folder. budget: files::Budget, + http: wasmtime_wasi_http::WasiHttpCtx, + /// The hosts its requests may reach, and what it was refused. + hosts: net::Hosts, } impl WasiView for State { @@ -141,6 +149,16 @@ impl WasiView for State { } } +impl wasmtime_wasi_http::WasiHttpView for State { + fn http(&mut self) -> wasmtime_wasi_http::WasiHttpCtxView<'_> { + wasmtime_wasi_http::WasiHttpCtxView { + ctx: &mut self.http, + table: &mut self.table, + hooks: &mut self.hosts, + } + } +} + /// One app's instance of a [`ComponentProgram`]. It keeps its state between /// calls until a trap or a deadline spends it. pub struct ComponentInstance { @@ -258,10 +276,19 @@ impl Runtime { Box::new(|| true) as Box bool + Send + Sync>, ), }; + // A component that may reach the network waits for replies: its + // calls get the longer deadline. + let call_deadline = if grants.http_hosts.is_empty() { + self.limits.deadline + } else { + self.limits.network_deadline.max(self.limits.deadline) + }; let guard = Arc::new(InvocationGuard { - deadline: deadline.min(Instant::now() + self.limits.deadline), + deadline: deadline.min(Instant::now() + call_deadline), pending, }); + let mut hosts = net::Hosts::new(grants.http_hosts.clone()); + hosts.deadline = Some(guard.deadline); let mut store = Store::new( &self.engine, State { @@ -271,6 +298,8 @@ impl Runtime { guard: Some(guard), // Start-up code may read the folder, not grow it. budget: files::Budget::default(), + http: wasmtime_wasi_http::WasiHttpCtx::new(), + hosts, }, ); store.data().budget.set(Some(0)); @@ -286,6 +315,7 @@ impl Runtime { store.set_epoch_deadline(1); let mut linker = Linker::new(&self.engine); wasmtime_wasi::p2::add_to_linker_sync(&mut linker) + .and_then(|()| wasmtime_wasi_http::p2::add_only_http_to_linker_sync(&mut linker)) .and_then(|()| files::link(&mut linker)) .map_err(|e| LoadError::Invalid(format!("{e:#}")))?; let instance = linker @@ -314,7 +344,7 @@ impl Runtime { stderr, read: [0, 0], logs: Vec::new(), - deadline: self.limits.deadline, + deadline: call_deadline, io_bytes: self.limits.io_bytes, spent: false, _ticker: self.ticker.clone(), @@ -576,6 +606,7 @@ 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.data_mut().hosts.deadline = Some(guard.deadline); self.store.data_mut().guard = Some(guard); self.store.set_epoch_deadline(1); let outcome = func @@ -583,6 +614,10 @@ impl ComponentInstance { .map_err(trap); self.store.data_mut().guard = None; self.collect_logs(); + for (host, why) in self.store.data_mut().hosts.take_refused() { + self.logs + .push(format!("a request to {host} was refused: {why}")); + } // 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(); diff --git a/crates/wasm-host/src/component/net.rs b/crates/wasm-host/src/component/net.rs new file mode 100644 index 000000000..95ed8d0fc --- /dev/null +++ b/crates/wasm-host/src/component/net.rs @@ -0,0 +1,159 @@ +//! A component's outgoing `wasi:http`: only to its app's own network hosts. +//! +//! The rule is a script's (makepad's `splash_policy::url_allowed`): a +//! request's host must be listed exactly, without regard to ASCII case, as +//! the app contract compares hosts; a listed `host` matches any port, and a +//! listed `host:port` only that port. Nothing else is reachable, and with no +//! hosts nothing at all, so a component may import `wasi:http` and still +//! reach nothing until its app is granted hosts. The request is HTTPS, as +//! the contract has it for an app's hosts, except to this device itself +//! (`localhost`, `127.0.0.1`, `[::1]`), where plain HTTP is allowed too. +//! +//! A request waits for the network outside the guest, where the deadline's +//! epoch check cannot end it, so its timeouts (connect, first byte, between +//! bytes) are clamped to what is left of the call. + +use std::future::Future; +use std::time::{Duration, Instant}; + +use http_body_util::BodyExt; +use wasmtime_wasi_http::{Error, RequestOptions, WasiBody, WasiHttpHooks}; + +/// The hosts an instance may reach, and the running call's deadline. +#[derive(Debug, Default)] +pub(crate) struct Hosts { + hosts: Vec, + /// The running call's deadline, set for every call. + pub(crate) deadline: Option, + /// The requests refused since the caller last asked: the host, and why. + refused: Vec<(String, &'static str)>, +} + +impl Hosts { + pub(crate) fn new(hosts: Vec) -> Hosts { + Hosts { + hosts, + ..Hosts::default() + } + } + + /// The requests refused since the last time this was asked: the host, + /// and why. + pub(crate) fn take_refused(&mut self) -> Vec<(String, &'static str)> { + std::mem::take(&mut self.refused) + } + + /// Whether a request to `uri` may go out, or why not. + fn allows(&self, uri: &http::Uri) -> Result<(), &'static str> { + const NOT_LISTED: &str = "it is not one of the app's network hosts"; + let host = uri.host().ok_or(NOT_LISTED)?.to_ascii_lowercase(); + let host_port = uri.port_u16().map(|port| format!("{host}:{port}")); + let listed = self.hosts.iter().any(|listed| { + listed.eq_ignore_ascii_case(&host) + || host_port + .as_ref() + .is_some_and(|hp| listed.eq_ignore_ascii_case(hp)) + }); + if !listed { + return Err(NOT_LISTED); + } + let this_device = matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]"); + if uri.scheme() != Some(&http::uri::Scheme::HTTPS) && !this_device { + return Err("it is plain HTTP; a component's requests use HTTPS"); + } + Ok(()) + } +} + +type Response = ( + http::Response, + Box> + Send>, +); + +impl WasiHttpHooks for Hosts { + fn send_request( + &mut self, + request: http::Request, + options: Option, + fut: Box> + Send>, + ) -> Box> + Send> { + _ = fut; + let allowed = self.allows(request.uri()); + if let Err(why) = allowed { + let uri = request.uri(); + let host = uri + .authority() + .map(|a| a.to_string()) + .unwrap_or_else(|| uri.to_string()); + self.refused.push((host, why)); + } + let allowed = allowed.is_ok(); + let left = self + .deadline + .map(|deadline| deadline.saturating_duration_since(Instant::now())); + Box::new(async move { + if !allowed { + return Err(Error::HttpRequestDenied); + } + let clamp = |asked: Option| match (asked, left) { + (Some(asked), Some(left)) => Some(asked.min(left)), + (asked, left) => asked.or(left), + }; + let options = options.unwrap_or_default(); + let options = RequestOptions { + connect_timeout: clamp(options.connect_timeout), + first_byte_timeout: clamp(options.first_byte_timeout), + between_bytes_timeout: clamp(options.between_bytes_timeout), + }; + let (response, io) = + wasmtime_wasi_http::default_send_request(request, Some(options)).await?; + Ok(( + response.map(BodyExt::boxed_unsync), + Box::new(io) as Box> + Send>, + )) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn allows(hosts: &[&str], uri: &str) -> bool { + Hosts::new(hosts.iter().map(|h| h.to_string()).collect()) + .allows(&uri.parse().unwrap()) + .is_ok() + } + + /// A script's rule: a listed host matches any port and any case, a + /// listed host:port only that port, and nothing else matches. + #[test] + fn a_request_reaches_only_a_listed_host() { + assert!(allows(&["api.example.com"], "https://api.example.com/v1")); + assert!(allows( + &["api.example.com"], + "https://API.Example.com:8443/" + )); + assert!(allows(&["127.0.0.1:8141"], "http://127.0.0.1:8141/x")); + assert!(!allows(&["127.0.0.1:8141"], "http://127.0.0.1:8142/x")); + assert!(!allows(&["127.0.0.1:8141"], "http://127.0.0.1/x")); + assert!(!allows(&["api.example.com"], "https://evil.example.com/")); + assert!(!allows(&["example.com"], "https://api.example.com/")); + assert!(!allows( + &["api.example.com"], + "https://api.example.com.evil.net/" + )); + assert!(!allows(&[], "https://api.example.com/")); + assert!(!allows(&["api.example.com"], "/relative")); + } + + /// HTTPS, except to this device. + #[test] + fn plain_http_reaches_only_this_device() { + assert!(!allows(&["api.example.com"], "http://api.example.com/")); + assert!(allows(&["localhost"], "http://localhost:8080/")); + assert!(allows(&["127.0.0.1"], "http://127.0.0.1:8080/")); + assert!(allows(&["[::1]"], "http://[::1]:8080/")); + assert!(!allows(&["192.168.1.2"], "http://192.168.1.2/")); + } +} diff --git a/crates/wasm-host/src/lib.rs b/crates/wasm-host/src/lib.rs index ee83f29f3..403fb6359 100644 --- a/crates/wasm-host/src/lib.rs +++ b/crates/wasm-host/src/lib.rs @@ -56,6 +56,9 @@ const LOG_LINES: usize = 64; pub struct Limits { /// How long one call may run. pub deadline: Duration, + /// How long one call may run when it may reach the network (a + /// component granted hosts): it waits for replies. + pub network_deadline: Duration, /// The most linear memory an instance may have. pub memory_bytes: usize, /// The most elements in an instance's single table (including growth). @@ -72,6 +75,7 @@ impl Default for Limits { fn default() -> Self { Limits { deadline: Duration::from_secs(2), + network_deadline: Duration::from_secs(10), memory_bytes: 256 << 20, table_elements: 16_384, module_bytes: 8 << 20, diff --git a/crates/wasm-host/tests/component-guest/Cargo.lock b/crates/wasm-host/tests/component-guest/Cargo.lock index f98eb5a23..e6fe5a3a2 100644 --- a/crates/wasm-host/tests/component-guest/Cargo.lock +++ b/crates/wasm-host/tests/component-guest/Cargo.lock @@ -26,6 +26,14 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fetch" +version = "0.1.0" +dependencies = [ + "wasi", + "wit-bindgen 0.62.0", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -121,7 +129,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" name = "netprobe" version = "0.1.0" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.62.0", ] [[package]] @@ -130,7 +138,7 @@ version = "0.1.0" dependencies = [ "getrandom", "pulldown-cmark", - "wit-bindgen", + "wit-bindgen 0.62.0", ] [[package]] @@ -256,6 +264,24 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + [[package]] name = "wasm-encoder" version = "0.259.0" @@ -290,6 +316,15 @@ dependencies = [ "semver", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags", +] + [[package]] name = "wit-bindgen" version = "0.62.0" diff --git a/crates/wasm-host/tests/component-guest/Cargo.toml b/crates/wasm-host/tests/component-guest/Cargo.toml index 3c5598b37..58dbbaf6c 100644 --- a/crates/wasm-host/tests/component-guest/Cargo.toml +++ b/crates/wasm-host/tests/component-guest/Cargo.toml @@ -3,7 +3,7 @@ # ../fixtures. Its own workspace: none of this enters the shell's graph. [workspace] resolver = "2" -members = ["notes", "netprobe"] +members = ["notes", "netprobe", "fetch"] [profile.release] opt-level = "s" diff --git a/crates/wasm-host/tests/component-guest/build.sh b/crates/wasm-host/tests/component-guest/build.sh index b689bc75b..b71d297aa 100755 --- a/crates/wasm-host/tests/component-guest/build.sh +++ b/crates/wasm-host/tests/component-guest/build.sh @@ -5,7 +5,7 @@ set -euo pipefail cd "$(dirname "$0")" cargo build --locked --target wasm32-wasip2 --release mkdir -p ../fixtures -for c in notes netprobe; do +for c in notes netprobe fetch; do cp "target/wasm32-wasip2/release/$c.wasm" "../fixtures/$c.component.wasm" done ls -l ../fixtures/*.component.wasm diff --git a/crates/wasm-host/tests/component-guest/fetch/Cargo.toml b/crates/wasm-host/tests/component-guest/fetch/Cargo.toml new file mode 100644 index 000000000..e299f5f45 --- /dev/null +++ b/crates/wasm-host/tests/component-guest/fetch/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "fetch" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "0.62" +# WASI 0.2's own bindings: the HTTP client a component uses. +wasi = "0.14.7" diff --git a/crates/wasm-host/tests/component-guest/fetch/src/lib.rs b/crates/wasm-host/tests/component-guest/fetch/src/lib.rs new file mode 100644 index 000000000..d8aa08896 --- /dev/null +++ b/crates/wasm-host/tests/component-guest/fetch/src/lib.rs @@ -0,0 +1,46 @@ +//! A test component that reaches the network over `wasi:http`, the way a +//! crate built for wasm32-wasip2 does, with WASI 0.2's own bindings. +use wasi::http::outgoing_handler; +use wasi::http::types::{Fields, Method, OutgoingRequest, Scheme}; +use wasi::io::streams::StreamError; + +wit_bindgen::generate!({ world: "fetch", path: "wit" }); + +struct Fetch; + +impl Guest for Fetch { + fn get(url: String) -> Result { + let (scheme, rest) = url.split_once("://").ok_or("the URL has no scheme")?; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + let request = OutgoingRequest::new(Fields::new()); + request.set_method(&Method::Get).map_err(|()| "method")?; + let scheme = if scheme == "https" { Scheme::Https } else { Scheme::Http }; + request.set_scheme(Some(&scheme)).map_err(|()| "scheme")?; + request.set_authority(Some(authority)).map_err(|()| "authority")?; + request.set_path_with_query(Some(path)).map_err(|()| "path")?; + let pending = outgoing_handler::handle(request, None).map_err(|e| format!("{e:?}"))?; + pending.subscribe().block(); + let response = pending + .get() + .ok_or("no response")? + .map_err(|()| "the response was taken")? + .map_err(|e| format!("{e:?}"))?; + let status = response.status(); + let body = response.consume().map_err(|()| "no body")?; + let stream = body.stream().map_err(|()| "no body stream")?; + let mut bytes = Vec::new(); + loop { + match stream.blocking_read(64 << 10) { + Ok(chunk) => bytes.extend_from_slice(&chunk), + Err(StreamError::Closed) => break, + Err(e) => return Err(format!("{e:?}")), + } + } + Ok(format!("{status} {}", String::from_utf8_lossy(&bytes))) + } +} + +export!(Fetch); diff --git a/crates/wasm-host/tests/component-guest/fetch/wit/fetch.wit b/crates/wasm-host/tests/component-guest/fetch/wit/fetch.wit new file mode 100644 index 000000000..b080fca0d --- /dev/null +++ b/crates/wasm-host/tests/component-guest/fetch/wit/fetch.wit @@ -0,0 +1,6 @@ +package octosense:fetch-spike; + +world fetch { + /// GET `url` over wasi:http: " ", or the error. + export get: func(url: string) -> result; +} diff --git a/crates/wasm-host/tests/component.rs b/crates/wasm-host/tests/component.rs index a3c2c4bab..6e22e75d6 100644 --- a/crates/wasm-host/tests/component.rs +++ b/crates/wasm-host/tests/component.rs @@ -11,6 +11,7 @@ use octosense_wasm_host::component::{is_component, Grants}; use octosense_wasm_host::{CallError, Limits, LoadError, Runtime}; use serde_json::json; +const FETCH: &[u8] = include_bytes!("fixtures/fetch.component.wasm"); const NOTES: &[u8] = include_bytes!("fixtures/notes.component.wasm"); const NETPROBE: &[u8] = include_bytes!("fixtures/netprobe.component.wasm"); @@ -156,6 +157,7 @@ fn files_live_only_in_the_granted_storage_folder() { let grants = Grants { storage_dir: Some(dir.clone()), read_only: false, + http_hosts: Vec::new(), }; let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); let written = instance @@ -186,6 +188,7 @@ fn files_live_only_in_the_granted_storage_folder() { let read_only = Grants { storage_dir: Some(dir.clone()), read_only: true, + http_hosts: Vec::new(), }; let mut reader = rt .instantiate_component(&program, &read_only, None) @@ -227,6 +230,7 @@ fn the_storage_budget_refuses_growth_and_returns_freed_bytes() { let grants = Grants { storage_dir: Some(dir.clone()), read_only: false, + http_hosts: Vec::new(), }; let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); assert_eq!(instance.storage_budget(), None); @@ -287,6 +291,107 @@ fn the_storage_budget_refuses_growth_and_returns_freed_bytes() { let _ = std::fs::remove_dir_all(dir); } +/// A local HTTP server: every connection gets `reply` (or, with `None`, an +/// accepted connection that never answers). Its `host:port`. +fn serve(reply: Option<&'static str>) -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + std::thread::spawn(move || { + let mut held = Vec::new(); + for stream in listener.incoming().flatten() { + let Some(body) = reply else { + held.push(stream); + continue; + }; + let mut stream = stream; + let mut request = Vec::new(); + let mut byte = [0u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + if std::io::Read::read(&mut stream, &mut byte).unwrap_or(0) == 0 { + break; + } + request.push(byte[0]); + } + let answer = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = std::io::Write::write_all(&mut stream, answer.as_bytes()); + } + }); + address +} + +/// `wasi:http` reaches exactly the app's hosts, by a script's rule; anything +/// else is refused inside the component and logged. +#[test] +fn http_reaches_only_the_apps_hosts() { + let rt = runtime(); + let program = rt.load_component(FETCH).unwrap(); + let server = serve(Some("hello")); + let port = server.rsplit(':').next().unwrap().to_string(); + let grants = Grants { + http_hosts: vec![server.clone()], + ..Grants::default() + }; + let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); + assert_eq!( + instance + .call_json("get", &json!(format!("http://{server}/hi"))) + .unwrap(), + json!("200 hello") + ); + // The same server under another name is another host. + match instance.call_json("get", &json!(format!("http://localhost:{port}/hi"))) { + Err(CallError::Guest(why)) => assert!(why.contains("HttpRequestDenied"), "{why}"), + other => panic!("{other:?}"), + } + assert!( + instance.take_logs().iter().any(|l| l == &format!( + "a request to localhost:{port} was refused: it is not one of the app's network hosts" + )), + "the refusal is logged" + ); + // No hosts, no network. + let mut offline = rt + .instantiate_component(&program, &Grants::default(), None) + .unwrap(); + match offline.call_json("get", &json!(format!("http://{server}/hi"))) { + Err(CallError::Guest(why)) => assert!(why.contains("HttpRequestDenied"), "{why}"), + other => panic!("{other:?}"), + } +} + +/// A request waits outside the guest, where the epoch check cannot end it: +/// its timeouts are clamped to the call's deadline, so a server that never +/// answers ends the call instead of holding the worker. +#[test] +fn a_request_that_never_answers_ends_at_the_deadline() { + let rt = Runtime::new( + Limits { + network_deadline: Duration::from_millis(800), + ..Limits::default() + }, + None, + ) + .unwrap(); + let program = rt.load_component(FETCH).unwrap(); + let server = serve(None); + let grants = Grants { + http_hosts: vec![server.clone()], + ..Grants::default() + }; + let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); + let started = std::time::Instant::now(); + let result = instance.call_json("get", &json!(format!("http://{server}/"))); + assert!(result.is_err(), "{result:?}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "ended after {:?}", + started.elapsed() + ); +} + #[test] fn the_deadline_and_the_memory_cap_end_a_call_and_spend_the_instance() { let rt = runtime(); diff --git a/crates/wasm-host/tests/fixtures/fetch.component.wasm b/crates/wasm-host/tests/fixtures/fetch.component.wasm new file mode 100644 index 0000000000000000000000000000000000000000..3356c178d1316aecdafa3c5bee7323b990f31772 GIT binary patch literal 84774 zcmeFa4ZK}fS?9mk-skn6bCZ*|HtkK@eNIA~N<#vaqz2LK3>0cn5vw0&)Q`PQZku~= znw#8vleQx?w{Hj{S``(Mmm)9atxymYr05ljG72Nk;D`!Zv>zxde+*}-IM)QRC>`;lq>H9iB>@ zI~2KnuCg$7_y{8mLaq&(F;#ZtOf4ZOpN?XL-q_c21l`aVS{E`<}$@Kfe;Sr;i*xzPMvy zaeiv@#>5G8u*=IkHyyiU@utI*3yVAEj!yX=?znz(`ta02=uiW#g{gxPByM{UJ3LD2 z;`rS0#bMah0jktBPDfwlX55&ox_z+?STi|1b>ya~Rf&4}sFiW_BR75Ih8Tc zUEkMS*KdlV+eQ~P1{0dsA3nZt$gOwNjefNamFS|;)e5$78^zJmBs3sBl4ISNK9V=ku3dl#^d)vh zV?Vvl?SFP9s$D-lb@<>yf?U-P!CF)E3skE_9q4q!oM*UW$EOw+Q7^~F5*p{N3UWC* zx4^FiZjYkc!KuSji&Iflhog>69pF)Y?x-}wLKIaO=O+*Fu6lHG@xURjbBl+j=G_AA z)VzuUJ$EdrJm;Egp2urlzHhMD0!)14R1zUdo3B5jZsTvxKHEwpn&HkvHkE`>l z+G$Q7xqi-|tfR4Ke)sozZLs6$6uen3D#m zT)05|+)`mF!G4!Em)@AJ8vW|<^o`St(x0#r*sd4NL4R@Xz}#U!WKpz94-YU*)7MWQ z0Ga%`6&@TQcTTiHj0!{(kR1o7j!X;imVu|J^&1usS)u89y*_8)wK#2uM$VldO$@y8 zf&rJGm^?g9z0Ct}zyJAH^XO@M{bCsbl{yA@f`~Ys7AsXK6eOoUPeew zMBRb<2r@=+ei*2g%qq2+oN7tk z4KwBV;kmh^u-77(i-R`M%JtJX1kWpH(KXXFJ%3~}@G`hz^vo{_8g5G^TATA~P|Yay zX?(^gx~LLu_(8y8aE~8YoSXk)Zx&oC!-F@p&*j#k8}%yD1p{S=O@-U)4#oHxK=#hf zwxH!J(Gx1s){+Y_U}2uLfQ9y4?=CK?uEk~?+Hun~{~%`bw{!&KvQxqss^Kniv$XaL#Aq5U36j5 z=HT1B^11bMt@7I9H68V}x4Gz|m9_ory!z(a20ZH4P}B8qcl&D^i1Sur<-O`zlp9Vg zUX&HUEdd)o?sLTxS>x&ZEqNEn_@IRy5ED-l`W6a z`UPjux~4CuXuUL#xzPRm8!HhJ2^Z0&L!E1rlP6EQlh?-iztOesWOQwfr~XC6OQ#Z* z@SvK%u?GY~3p3+;0;*n9ejYF-O>zTJeJaI6VGci=U9PG*~t&r_4D=LKufP9%%Yh5x_Yc(fi zRRBe-YqgqBLVf3>u%f4@5zmXp3MMnEnPu6v3PIPKB`gPMcdgEjJ~}jn)2>q!e{`K; z+IvG5La0%4!AkpVL+ZP7!O##C=FrMS$q3)T!}rxMT&=n<6eZ^utq=P%yM`U0T%ZSh zmtE%CM9B!+uBsPCl(jH=)EZ5|LXZb2R}Rib@#vbT3d=^#(RB)*9c*ndKD%0jj}@hj zYF-0~hJ9CPS3fivRXtXr066%8ECpeQisU}UuDl2jqUs2)Dz!JlV;jjzC5LI(C{Js( zN(D|UDhav7Tf0-NS?n6I+!3{t2i=F=cdw!i-~n>k?Cb z;J;|x)oKxiYWkml_%Eh*ty-Z=s-9tvKU{bP4udB8t;AY7Mz%l}?ldN69$cU1@r+&DLXOB{_Kn7nTKM-)%~ z5vkH|vpDlJ+V+$Q>S5M}qL;bm2`A|PZpSaXH@kPZx46mdTmRiX?wTL?{+ZduAHL1q z_y+ez_a^sxcdtA8R`)h{pZlKYeed(X`vw30KfKH>x!c_x?oM}?d$Id5_Y(K>?ibuE z-7mUVxnFXxc0cWY#{H~&g?p*{arblXweEH9lkN}Pe{uiSeaiiz8@uffk`L4A);*Bn z*}{q8!-#Jl6oX_UI`wRbKrMKfufZJz18Q?Jvw^DbT6u9ecv?l@Ap?QvyyTxn4* zo$;u&9@^=W9K&4$7z)d?^(T$!?V3yAtE5t`)f>&RR(pKS+RnQ58#Zn_=iJTbojFw%aw#hoNJ#Mcvde}dEqwS*C?syv=i}psB>asgd zt(%=MiVR)XzBmV(sIY)0*{^$`8y+uE@Tts%ak0^v>3l)W6pcM0Z>-dVPx!_ze7ubn zK(~zm`ZpeE_l$vV9Rc(w-eI)voiWhf2%zu$MuB!m0R5x~y5)>9O-BIzk_URx83Vm& z1kjh=XEZ%?OfMV(^j;4%J!4ES7y|sVyUbKKzXWofrZ1rSCMFPMmS~&mRHw zjUMQki|Khe(2}D0@EKxSRy3yehZW7{ys?s^`K)j3%vtT+5kUXR1KoDUv_2;Xnh1on z?`z&=!#9f3-HLSh-e}w__D1HqNxUzuU@fDpG1HCsL9lx)TXM(7qhKv6D&>J!viH5h zfV&a+j=MBUUFYp+?x>q!AxG8!RbSssMP<<5jN?<#P@K8F(M8y28<^~??^A&ML#WleX7e5qkg*XcJF^v4%` zQ`^5etV_|^9M-jcN*BZW@^_mEbw|+pl%if9);mWH>zcf=lCpZ|88%jE!0{Ytp#py2 zgZw71AX#UVSK56_7rgSn&T#l1lUG{(#)1lX<$H`RN4e^!)a>7MAx`Z=jIE&buX##e zIzj=QlG5L_p{J&Fvrp+lfFAO+?v0@JH>PYG{lk2%$c`?#Cm5zTX4A;EaJ5^X$SY`$G?M^9YdN6p1qz2?Ymsh4_Bz zT0fh(S{~u=%=VK08bmp|uYkg_XK6Jeb0@kLts4+vA$9Xv<1UN3)zo!sT3lVeL%k6Z#dkI=}6&nM*63_xXE*JIVIAA)wq@I7lI{PQL7N zjCG-`(slU|_yv-S6Rp7&|K&f}>cAqJEza3H5N16E8eIwQS-Yz>p483sw8+wPeo@Jp zH_e02dxTM|^=Oi?=5}lCqzzm(J2}&aI!l75J_p^wgYraumH}zq{jkVjPz?(&|i)K5tBG zX#Bl;qN~stKKQzHZMW$`3Ut~@oA6H-QLQ%7U7wD3oA!Kt+PtdYLX#FKT+PrJJVC1H-rrTT6W|!X912hQL(whV-`BFxlsC8pZ8r`k^#mJxt z;C3R3Z7yB}!m`#~LUZQ6{ppK)2m~?L`|jM6Y?f&7t8KqJ0kaR@woFmuZbamFegAH7 zaY^Zv7e1MPf*0fmjD=sNa)d#w$S&TpVG_mr6bWscN`eQC!#ZD(zbO4y2-2Hee}#2e zyD`E@g-c_2ng{4;0BYiXnXJ)8@u^+9Iiu$hcftUKiUHXo9MF;WuJcjP4V|DOU%+4qU5Xg_DI{!@+3&LQ z1l2pgVno;Dz#JGLkT-ygM4HT`E{(r7hT^4lY7k3rv~HC?wM7Df_NL4?gGuSdo{*v- z?}Fw}(BmpB$jCqrGxq9~aWvJfarB*BtM(}77ueTgPq+{2Ug|tZ7ED;;b{cSS6HT}@ z`Y!8s9$Ln)u|t;>y9NbUOIMJ6lK48@;hgEHYPndX3jznRoxPBlj3Sx&%XBP zqmR~)@m}z#+ALM&-dAl%e>Y5>)$s?g`AymU3gxTax-=~AMYinhpQ|Q%En(PJFVj{Q zFZRZ>N9xCVV}@f>w5+FNh>e;|>xS_dgCLtpOAA^31fEFk#C&$!?RPegfkfjJ?<%Xm zTMb}yhW}`K3|Z-orBy5)in^AzX6acy9S1dxO5;TK=xtxCALG`pGudrRmBU%*gmpcY zjJMJ@-NVMz>F3w*y5hw?CCAh}*4_G3E%gW6rM4j;mM(jtr|e@k*sgfL3MoNGs2baN zjT~xkZ^zk*UgCXqeS+oE94+F-wm+&xPG=#5G2YT{F8@zQ~xoMjJMe z&JrHaT|L1Q?0Pyji`WCX&mlC_Ks~07*{1sf z`WBQL+47P=BRN3#Gl$S-cwv5EPrfC^aSNr-Jmkbw3ZYRh9$lyyI=P;^gW21X^%0Bomqr(igT#|mjG$#Ld{zw(-@729zqSww6&j2-? zdVW{a_Dqj%%dREiokwec8z@4dnY7*c6ItBeIP8qxX)|SJKEmItCyZ#>$!=x8#CmDz zo~256P1?pJp6oUlZU#~oGT|yKiMBDNC#R){K4UcCnv)A@G7Fa`GZ?XE_M)rC_lBXy zYSB(@MsF$ntsY5~d|=ml!N>Ih%>8`E_!uI<#cBr56%N9sf%>)iw=@(pqS~xSQy2%v zZfb^fWv?#ArdnE`ki9|KGN&^`HPp4Sa@W?b-nGg=*T$@CnuI;xt}&pic5O`F?ONY8 zrmi*ukE?45brN_*{8GDmVz%4d&twxv53cF1HJ%wuTljFoA^&4O5I$I)K@RMK6r?Sia%G=3WKjFalxF1U zO!G=nz+!YRv)(2kY7(>^sQR5(;u@9*f*+0SW}MioF~zY)l2I%d&-v7vf$95Wh^ov{ z(0pEC%$=yBqO&DjMZggDU;-#_=3RgGz(2(cvuShQze{kq^d>3ZB=dyHa4~NV7xl8r zK*?}03+k_NGDu$dKqkk)h`H0ElhVsA}F# zdo}J`^hL8VR3|tYHDP7FLN3z86iFB*MfZ$QEgUV`lroyFjx;2MhC9eVzLuG%IYgGo z1wUMi^|SQnO6PVJH1Wz=LRUcJ_GD4#4!xE#t|5Q5z_3*@Y!Msr4BOlpRh}L}o3yz- zigrg)d#`%6Hj@f7kP**m*F0;&kTBEcoBgkO9zXK%AAF=HAAqSOuk7bl6Fy?7j9(Gs zVH5^q^Sl+n`mPm9Et6x7QmJm|vr(=N^^|ncGoBiH0N&(J`1DqXEV=qEU zZ@p0Z-U<$nIxo)gsQ{WG+( z$y5I7lY_6GfFrlPm2Km4Nh{mR<>FS>>S0-tS5{_+=XK)c7ZiWje=NFN4bwt>sKR<@o?XB9H9 z^Y1!b*0i#jTi5Cavd}>At1=DN8V-;jPwipOa}5DF&Uw`32Jo$w{^sA zYkVW)T$pgSxs0{478eF(j7y`1Ua4Y7vj!I!q|OC`*0?a$uX2I-6)spwVtsOZVnTi` z`B*lb5WY|P)r0{2;-|V>_jlAYAu5(!fh?0b;GYh>VWGNMQAp4F&HF?Sp~QR_B_3?b zOC;}lOe_SI?tLh^Qf#!rm6Fms$OUif+7(-D8o|C{y1W|H%-6eBNq?&i8Mp8B+R-%S zyZpTz9qQp-DGK9T=GP0#-jZ5Xlo@xfVsy1&JqGfng3(JlKdvjdCmur5d6^zSQ?5KS&UyFb7B3WtW0SA@%$7Ut6R_B8%37yrMOXI_e;&Zn>UL*(E+PHP7=1=*W z=agzn@uNt##|NcAbd!4j6cw_0PkfkX^htEZofTanFLdpDqMdgL3st7e9?2CFHE|7umyxbE)%xTL|B@(>Jcckt+f}N&0%ys7{ z3yKIG^q_~xjS%kql!qwNu7vpx4|7e4IM%D(O7fNiemUR(u+-TTUzPwO&|v#aXW7G5 zaqGJ|C05z^lfk)WPjW@Ujb^|JZa-&$8?WHWGl7a{U*g3FLHpMPo;Ttu;l4e-LU6ao z-D;X{aKiTZGX1m5FAnNgG$zGX$)PNx}F4voOn9c#oSzIe#wta!+wM|45`aJsk^3zldq z-hCGkI_VL|# z|4yL}eqAneuZI`gLuL;6qwTHoo{~0eQV40gE6*yy$;Qu0+84>|O61ixxLhEQEWumG zKQj-s#Rbo^&E>pSHqK=;4Lv!!H28@y49)pgFB_k*R;j&~g6Fid4wp^xKv7&2lcQgg zqrZz;;5z?qJ(mq~Srd7#8@b@QZpypy1kHNR$$xF4H;OLR^z_{D^jYKE=%DYs^PS%a z8dWuX9^ja0oL>|rhh(tm1}nO86&$xvAD8ulqW*=!ufMz-np$u5HyF_EvX6)cS{yL3ccySpo7)Blvb26&cm;*3&m@w_4CsM2} z^pUha7C_FV`OeExuurWQ_4mB@m+MWf;kqGTc+s7oE2}nbJcRTOKNjs?63iz`PZDM$ zgh_?_u*jex#XitJFpCU{Kd<+jFR4|!=O?aOXH>#T9t@>vuX3a8Rd18Z&qi-p$E8!3 z%@5rh?Vw4l-!?AW`g;S5Bus3gw0I@^=eHWjRKJJU3q=ZcVXpIlSy~S}Fj*Qv>nlO< zjCWwhGR+%$^wIfAKZZbvO)io^Sa&I8q_Lud=%rwP8DKoJ9ed~_#_69`GS*jEdb@;=+`L+j;cE)Yv}p{7+0 zNI^%A?TyG^l>75VPdB|Tlnc2zw^v;yPY2pnWZdE?L>WW8HDcq zjv5v8wvxJ-8sIQ+*=V{obpok>3Ei+DiIeJl#+4RkSlxmcO2K3lX0`U`;X=%$54Qj) zEv-8FM(G#-q{OIK*%8HiBX}#VkSzT42p9rDIo}16W-EK*&93R!=rF%I^Np(Gh0iTn>}}WvMCI=Xu0( z(&CKu03yQ}bN zdER+g({77s_R*`hsOFg4|0kgywVpuMj%O%_?xiL|*;XifzfTJeS+}oqDrQ5Sm9t?b z1N}@`R;%(fbtxdPjzJNdOOx?x>98O393(C!y+A=j?$Bp+yARJhN%RqaO%ysFbdYgc*ax$vE<31;;S6fD&ECa4;9;IHc{1X((dwzZy#fduBI^?Z0 zYm&%u_7~Vxb7{EC_(JOEstK~|vVGq@E`E(!ved+2^^&BB)UtbkxrS{9{r_St-(ys| zn@ZIRRjQP2WY-?c?m78vTYKWy>)0I9V0i(m)H(h_Q<|(TRZ!1vJ(eAL_IQIrAgMC` zuFeF2w`+Y_OiRr_xAc`p_6W|efb1ftU((aaeu^fjqI4{D?y8g^DU}39mC~yAYV`7@ zSS}U80RLD}K^+wcNU<4nj!4D9LffGo|MYF{7L)+$=IZ{q7vRAi$V+azboZPA>F%Mh|T!RfiK}aiXQn z1(4Nc+sEVAX-H#Oa*C8UNv?giZJ01I#x4l)%Km#`_}U(GWmtk;grM@?38>oP93z9kg%ukC^R@zJ>jJz{)|eP2j$5kH}$_uhdk>c^yMZe!@vs6^0Vzw1I)^D#UAr zDn@S0@Pc#{0Fnwc^8yVO7y+TKzOW(5a3Hn9TTv*s(^U*SFVpand|&0BhL>bsk&pOv zw}wmW=}y7RF&TU~NNnVX0^kY4a;g!Kg4B{8#Y7=IRnIk%h@dsJLN%w=)-RR}BC61N zuEU=GHwIq3%cYS9zXmh(@;6B#c?OTNPn#tYLpwip=*5{WWZ5nfpyYc<97MDQt?)we zSZ3|n&)sSI&4CiX9v(1kI}7v2=t!>uBcHyQ#vSDOR-Th;-h&KVo|F0?0Hk4sc4qMq zL<^6Xb|84ySdWQEpao>o?O!X4IUBVDA&XkClFMb_y0JCjkRxq9!N&p)E%KnF2Z|74 zp)kIBv{WI<#PV;jHrBO(4D#$9SA<)(S$ezxEH&z~?;wll(j101t`)$A?)x8l; z35tE$&9fRH9pSR}38l{G^?U^wPri=TFqh6(tY;4u7402rDy_+9CmwMLeo>zY;LSAq-4JZ08N0|mT<3UR#ra)d%6;PYJDUyDAQaaF0R}q=$WlNdS2B5 zi+Zu$UX++vj-_6twDc!FE+1!1@S^PV?^nC|V!{^VAx6$dR7{H#5>#P}m&V%sgMf<@ zH0*xUVX@&OJoFnrt|(IuhtCC(jfe(FP`vlz(s+;LSCh@3@=nheE9?|8@bb}ViueeZ zhNaS3YBl;nX=Nx%3l7IawAi>@ftq05wxf|IWsgwFlcMeKNwmZ1H1ES$pjwYj3DHO0isAZK2atr`ra2 zt4PsnDy>c3WK+nGzv3;Qj7ZTN4=H-e?9!X4coU6C(Zipd>9O;|QuN||G)#UXy`)LB ztKhRsGA0=h5?C&DWP}^<_f6?trM`ulk!sRa;)TEucA1WA&sv`{G^Me?#*FjVFV3b7 zW!+ISt+S;fQ<7g@H~`HCL%DnMLPKg}_P1{oBcgKTu$L_`B8x}nv8JkiXz`R$nqjU5 z6h;PG@}Lf?DPx3qvp2Jjq(^jW2%fa13^NhmT1(piJ)3!LYuSHdv|bd@%agGR^oYxp z(W48Q^LwwrLoI7=MWONri?V+42eGM4keq+1#0?g*jK7&}B5!yhwQQ4Zsi}Kf>8Z>- zRMxgDF1Sb1{Ln!e0=8X<&7ktCZG#i{8Z7PlU_QzwBM8Kf9NF0HiP=f*U-iRK_j^%l z43?S#s-a*m19RtD&i%c z?f5`smsGOX4Y;%a;{wyiY9KGOby!dk>*026XsEitaL|Ka*I zz|&)%z!tXMkf~3^&aYsHL$rq+Vm5-;_Y>$@>Q!~Q^+orRjcwkj;xq7h2>8Gx%sUxu zk#=Rw2h4#^llA?NzT+<|KOmW)W7Yl57a&F+1M05~-rU<0i?#0&Gy)Z z|B|Y6mHbEf+SHGF1m|R*Vbi(IbMXFZa^#ScW8;`fm!(%RrDvrMMjj(+FOA4ea`In3 z^AOojk!)%I(ZfU~A}y`;AAQ~)VFmk-K4XuVCG{V@-yUJO;%q5q0)iqh+WF7cj-18L zpG(C3aR)36tbx+JBN`Go=oj;Nq02U&kfGQct@YfSRZe7UkNHRJfEPn+XJh_oeR$OF zKUx>qETat9S;&>Me|TDa-TRCE367 zWK)2hH@{IOcDjvEkIY|)VLf)EqWLOUp0Tocrs1;EJ)M|OihZDYKcS(~Znf{TS`filQGj_>7gYJ6vrb>q-R_G$(CJ#<`4g5p{idh_e7 znVP2K7%4?-UY*Bz9~wxnmJ*^#@CDf`$Lr#GO|@AxWii0p&!T-zR%C>SyCzM5t*;f;JYIp-MT4Qmz4g`kcnjKoyLL(H4JmQDuA;~uy zi3rRrDj(!f6?h>ikI3VYW2Z0xEEw&(o% z@EbHx2z{JInIbN4e+{9Bp2FFUc`k|{#*3N^WvxYCw-$Q|2VoFy$|~%S;XOgt;(h)q zUi}plxz%c|_Nw0icqUg0r>nGsw`WaTq!G`(i_T_sNh%7QE<$cC_^3CGgmFR@Uqn6e zjP}brrv?w!$ytBqMW+aLD@WaI3gwHMwPkKTqwJ zK?~e6XjKF@n?^Y1lW}hGPdouJp3LSQZuk&+=}u=I?t%`AT0HZlWnTu%hJ4krcAb>C6?erT1MRvk{5cGG3)WH z^lAm3dL7y(QtQV!Fl9X)YoAuckJv$u)mni({K%0gX?Kc@n{q< zagg?u83*Mmvd=*zgai(NKMm80D;VCe_u7pKCpV#o+`;t!Kf?^sB#7`Je?X z9_)ztD{tQK8;eW1@Z@v_GlOn~!O>4X-vLdD-W1Kte*mSK1@O2S;Hx!v(yW^|-=oPl z>m(tvsE0CL_#^1P;;9Zf!kX4F-gqmumc1@l0&d}-q;hl@`G|fU zE(K|RY0+Y{XRqwPG&*9Uwx@U}W)hTY zUaA{Rfcqv$>vcTlBM~()hG=SKn}qQM72EXYm!zu1Nc@r%r83*yl)u;*mZbEe&jW*F{)rzS zHB=G+2n!cH>;)jzX=&nM9fSz#1!MB?G}txpbHAFS0rVa2wI*;WDmL^|BWpH;qXm!_ zQESw=Qd~nLpIbRyq%bA@zeS zEte1S=_rh$Mn;PIZPk8;JFAlMHdtz zZg|tfNeDJYi&0*R;NF@Cv3&>Lm`Gp7jZ;ja z({m+vSsL7!AiFK@{8`Sr3SW-*5@JhIGMH()mX@oPI7pVA;_V*h6d!PQczMasR!m13 z8l}cp^yJV$mTKf>iILwT$0PXjocjB7_e$F3Uq!*}m(mj=SqM^h^4;r^B*0~#8pus} zdpyumklH|so>G9sXgRBml3y}n0UNkYvBSq;vuLH*!qF#sk=caYE6F8cq}W>UVzp)h zf1Qg*cK0v-?jy-4wdd7I^cb{L!n3@DsE52jW1llEe$De2EX z@@=n61jegg+V)p7`_j}LSIkdF&GX-a?}C^pghDb>8b+LK8lv{`zEJ;@R-X>$ zGhkr&XPRQm!Th*yfkjzb@bF=XTJ|Y>?pxKV>p1Gm=B__l9QyE?H!q_Evi})%{$4DZ zk^{0&y62gl|Eb?fl@!J-_|`~W?R?4et<4Eil-c%p^SayhjK8XFYAfSL2HQ>Sjvv%i z2Rq5}w7cVFet1=b`Y9|Qe)Lb5{^bKF@8WQ_4&!6b*HV9=g>+}tRQJ20?=&+--G#1 z+T8!Qw|)6%KJeN5Ui=EcHTHk#rN8~s_7Rv|RKAhEA*JoI_%|-wWI7E6Po}tL;qr0+??j z9pjxa<$8rDJS!thm9G)nuj){ckHLQY7UO$P?{Xr7FX;Inbo-p&gQVGZ7_ryflRWH2 zWq|v)=^br7{sQ<_B@3MnYfzP6EF)BW>H@gox&)#cbi)er^&a4fP z+vBegg0)e%f%AEE@{9ZyHh5v(9U6E3E-5QbK9g;3i$Xds&cYXNnq(pdt<=(_&=BkVjQ-;M6>d~kzwD^S94@}`$6v}M}frdl=&h&$ag==fSPzmBs-kvnqn5KvX z1jxbas&QBoCuKzIe`D${`_t78YUiIslBm`BXMfGqp`|?O@+)3?e#5^j1ng69U6!5x zgvu^|K=-+Tb%HPXHAkX)e+8kk4G6Hg#81Rx0a}g*697fuNuO6Df9Tg|ydoenQ%HYh zN5sJpVR&s@ryXrDAoi7z&o8mB42!)lZeovq-)OnCX5RAbSxxkMK3|gH@u1Z6&ZIsF zI}T-E>gmd*31=mthAUZ~iPd@mGZDt5P&UwMq?)nKbvT+s4X&nUTCF#7&PV@HlYR!B z;8*aqtr^YU6a(n_@AzW&f}0bS!e~7?Liu4bC>?%#uWEFfWxwdZPf$z^RX~Z$6 zY$5)*h86uB7oAUCi|p)rKKM%<799D}FMP;9Fy=;j6ce_DnoI{tX9C2(O4SMz!|dJ0 zz`gj=sJ6$|Qu=Z!C%7h2_FvxiiC?ZNXzBlC&XT$z^bBwn?8D6ixLkLOhcKD*;K>8= zXPd^eX`JU!Yx%)`4<2rMC7;o-07Zg;<)G_c13PPcc43gzq_9ymtFoPk zU;OKLf3AiFHsLCFm|uh?)}F#BTL718wxwJ8fLa>eJYhkFnR(0lUKaa)5pzJzGtuXmIRs ztZFuKrM9LaLw$j6xp=B~7NmXO2WdSYL!ro0OsWv}tJXhu_PfXnW(JR#fg#(1H*+B2R1>goOMqJq>WA_+(5 z5$HR;FH`lOYhhDE{^F(nMJ0U4-?)aB~97r+J)Bazye?g zjciMD2%6S6mF}T9gHGllDfJ{mh!)3F3EIrgO=cl8e-JdFP|5%i&YN=z$Mfrbo}ih0 zs{DsXP$v(uYRrapn&~x7d!@}G^{xaOd*d->c*0Fi z+hQ-xd*8B*1G=caApRd zRRuLtm7~_^)%KH6_vqR*T(6n9cUId6^i?;mBob@ydCxutqCfy^Zq@tr$$_*^XldWGRuw5+E*APjePXk>Xe(s0Wj zu#=f6n<@U*UnCU?Ug_+?{@MZZfGD-a3vlX^tpXGqFmZ0Q0TTh!J8klW7Nm7(Pgo_h z4VZki$4os1*n|l=!xqX=i3~eiI#w-xT(9CQSN$eT4z2c;4eDC|Ud5^biZ5Mk#B660 zQVc$_Ojf8kP5v8+1^J*#M>D921{JZfK@C?M)QZSx*`Kg73#(yJsfKL%LFHQ$HmJ6> zBaACaa{Y0o-l%c)Qo72xng5ziH-cYVxWh{Rh+32PjYGLb2W#6*|@TNUJ{{N zd8b$&F~PSoH@x+wxI8=PTX7q6l8K<8&IRx~RR7|>IBI+Mg-mvdWa8ZyE^=S&7l&l@ zwJgyjxhc3W2&deaiqs{pjGk+r)puX2kf8!dj#l*!j1`49v`>LynL%UlYgPd`(9;Op zfx@=@6vS!JVG+QxXrU>KP7SLS5ndAnko6;>q46UD-wlm~=1&@l;1L(jYe~Y?%f2$J z$W0uvME7rA%u=D#pe7YBL%Yoc(aWc+b2#T@Q|H_O^Ehf|J} zQsB6g3)|Wk#V{cw#566>(1^pSP2v?i8kRi)s;wzTEqUKJ%&b zllqm%A%31ulA;lVBhsHR2kv^rW}>|WgVbe$YfBix)xAp%nyVWQJrO78u0^Un3E?x6 z+~H`%91s$K{=Q!d5ZQtk%+$sfTpNBJz(DtQ3n1O4uB#Q}+7HK3!w=UJIKdLSo71cl&XB%iw&= zZG-Yl5o}>W40>JZF*0_}gjr4L4j!D)0y4~@6^+~xeYHjA%&pKr4QGGTX3@XKW63gqkvWBw39{NOVHe{Yl>HPbhC6#9}Nr73?>Q7dsZ1K z9u;g$IjO@Qsu!r2#pS%cKKDvwDC!k9cWr_Ku_TYv0rrb}M5J`aTM<*c(|1kQ&p!2H zQyed~=fl*Hb+f+2gQG3s?DQ&Hbb!h>U*K3u#kXjO6D6P@?J$c#h7lQ;Hz(PkI0)D& zme`hd_-F^hopIL>`oV-sVGhyejbjEy1&{T(C*)De2Q)7QtlgzyuXg%u?}{^sS;O7JvJ^zaiH@w3lL zk#0nq%-0wDByvoLzbTsiA3D*)kijgfVS;0oc%Qg2TN-pI6;J%Pa-v7z2S^y~*fvHt zNMaCTe&S9HY-#>JW%28|lnAA5#?cty`9_)Ny|#j{`do9*5Vx0&>xy@6>Y3DO%`X_f zpYRG4>E!=c4yw`EtI?q75)D|?4IN9e|KT&Q;p-JWL#ojw!S~meEHiqVgDdTu^kI=kxl}g_P(Xj{wuY55yxgb38`kA90Yhb&^SUtxT%>h(Yzq9Gw2wO*oq` zO7?k_9!E0AqZb`pHJgLZ0f}&9bvT=2uti*>a*L0V&2g$0J*1%)z0D4!i(2nF^`MIJ z7?b zU(G1&ZaY8l_UC-d|2>R$zTTE|ZIt~(zF%Mj*J7bcOc%9&>=alqqCUPC(<`0v;~jtk zk)J=Ci2PyQjhDj7fgC1}GWfEoTq?!J8jUGQH9Z?lZHnmh8l?m~zo4KfZze&AY@W=e z$=|Uw=`wZcwvmmto#~?YqP#s|L1Yb{ZZFDigX?fOv)7}w9W?0KsW!H;-=*@Aw06R5 zkke=cW-QDa(=|#ZX9Sf>zJ@~GHkFl8ZcE?*w~TT{pUEgE*1(Nrl(&^c-kN75*Jv|$ zTV5rn$uFi<@rmWkN-1oK8i+zr0<_FIOpMl6*qm>HEkIbw&S(w*8p~ z5vcgM<30O25$D?h3?0VkmPwLX}1yzOB_|2qO8IoKGCJIqbG7I_QifyTeFNjN^ zf?PtXKJHMdAQ2+f6#Q8`1*F#;44^3o1>;Rix%;~0ms>+VvthR;^9h@NsV5!ags^9w zV|!*8r!sWI6HFuQG9ir2b#0mm;pbtZX zJOD!vvN8-927>65iR1e6XbU`4gJWh04tZWo2g#f;VtrCK`L%-F{u9_rZ8m&ha*>@2 zD<~j9g|UL{JokCRPGA4@)Ex_c-`xY$wc&ACG`7;NIHZNJ8Uk1*wiaJX`lWiBn)!FV z_B{9TYv#Z3HJQDjcMIkG3tlsQP8XE(U-?4gqWXa19&LzEr4vyMeVwSRz#*OZ%Co5x zPtUB~_I5LCY`}ZGPRdXFnqv^J(I?)JW&5R~5XAe%5@8=d@nqJdFHQL@xr*@?tUuy%x zdTM8Yc;bGiB^Mcv#>%n#rL#G9?;PA88Jq#HXT?N1Y$A8r(b`xPc>~0``+FrN3_Y@U z`HVx!p7=A?Q*x5ReqpPf?{9wRplx;9;xfRMUx1Yk<}7${UB?8rkU3-Hc1ZD zkF5><)$VLgIn@-0(5lVbgU;>mv}G0%Hc2wA4*P4l(>xo}GhGOXg!CpEa_x>BJmG$5 zW36S_g_erlk!2Ju-W}-aX-PErR9+-O-Z;xc4r!(Ju7LiS6uG6MKgG!;rLZhlJdjmmDpo6by#ADJAV^`^ zTX>`MD}rR>3nYL%$7j80TvFa=xEmQ}eBGTQKYv4EzmSpbg-HdhG}H;v!I>g6A^Y?P zmzN^T365=ywGhsB8jpXz?40caj?P#E2O(t%KKRA>T%n-{ndVrDRMkIgNXtuV{cD^k zGMyjin#+p?q_M@h8pytCPb@Os`F&N-EP3Ae&-s}{|70A^B?!#HE&7Bjp}2d!`%h25 z7DC;kIYfr)*sIWLc&FSUg)u;t+qSI+p%dL=h8Mzz=(F}=f7rVgXY(|~P?svQdO()= zgB^?QS{A~4Ic%qpKo#kSM4^SDc71s(Ktmu%)j`ZiV9~e+V3hEnP&_oK_@vxe=wg(( zLxXK2qd9>>#-+s`IMK*Y(AkszbO=O7j7d|C@ntJiQc&YmO{`M#YqX3_sdZz&D^1&=+{2Jb<^gY^@AjSCki zAyL+6NPxFx$yoSWRcQUAR+>=`rDI=Or*SC?&k{=LC$@qC1CT#mTREAw0ItM}y5Z3I zZNUo6gx8Tciedo3OYh9~!K4EXjRgxhBA28~`a+}k7UWq5z8}*eEoTBGD zNv_g|Rh4qTYFAjT-K!I0H7N<(gSA~ovqt4OFf#gV1j&n(N|Z}d>J?yt_-?5}YOd0MX};Hrk@E*is^QBz?=u(|u)o5C7uFu8^q>vuz1qP8M~Y%>wGhr^Gobl7 z=3Hyy^*cyWuvyx&5iJZ1PR3(2AxwA8gfLXC^`jx7Rm#KiZ>_z!8ySOzBwD!6@W>al zbrM}1Y2=x~fSgQ~U~QRj{M&71O@H``4%(6B3y?!79ITHnB!D-9Ym7I;hcNq8!9+VKPj zAiU)phVa(YgJ6Aqn{|l3en~2#^`5w&u!_$W`L229AoFZv=fA6%#wX}0c`edL^i&Em zH^-kbmq!{BVq6+;CNUos=1ev_?>22D^B3Nj(HM#2aYWX{fAV1Qp@(qke9XTQJAi}o z5<3r+aF7vwEF92*Aer6m*m?#^8=?#jU1C*mX(=^@H_B*4|=;ph2EE)B8 zg@mQ@Gf15e=~w1ZXXH4WPX0ar-bp1jI=@l8hX?Jf_>cSdpa!W+?+e%L=^71Fe|>e1 zI(gz~F^7MmXKSYGlsCRC;PNC3zwQBZuu+k=EZ_OG3bb(ZT|4*O=yv2~)4PIVQ;EQD zs;H-gXGlhoR(|3`%gjK+k}uw|7>&+ml_1YJ;Q)}Qjir{Vf|zt&vd;i`Znlavql0UERpy~6b^nIZpps>z z@|BLX>C+=QQhuh_%$|WE;H{-oHtB(qCi4PgGd!@#0Kl>R*ef|!7C}&FtG4`L^m(t5 zGPuv4%wd{Q7#92X45YN8*>6Xk|K`W%moy#i{E>b@9|tl~_KeP->Nk50ILA0jWvU{4 z>KSZkH{N=ubgR(dmF4dF%u7+tR@^<7!L>Fs*Lg_bD30+X%?U8sGdO^U@UsShNm%HY zKaN!TF?jY@p|-&)0V{zPOmiiAgerg{5@@_t9JIO6ga!cA5Mzb6L&iUuf0kJrc*wTY zcx=hDC2=NJzXdT-f1wrLzLr4*Uq5;1r>-+zHHrQSH7T8V{~=LrD53DYRmBq)P?+d+ zOG2B+h6zNNK$|W7CLP!ttY+PC~2U3 zs{n)%AgI)(KTFdnpgyC3pixdPWBk@X$L0=;+9@v=jK-9qydAx#$KjcO+G7(%#8dH< z(OZM5VYh|0xS(_z`>a=bqPA5_oB@Ao)y!9b;HOoAl`~&KJb5}Ti0&Z71%qh>lLGPE zqK1htMwPLkJ2qaSJolEeKB7_}u(p#JCB%M#8ph%Du|#>_e?1Tu3XM4%J})1_Nb(_s&;_PUq;E?DYiuBH zU-=LMuY4#*+9(n2{;V^1=9y*-5}Mlh~OX2nebh$9H^#9|#3jgbT9 zo7F;roukT1z$I{vB7sJ5fZ_MhAn zbCwtRW3mU!Ok_zY&pbdCd4ObY2-KL#jvmWC{kA`RTNTxVJhZmnW%i@G^~JhsgO_e$0Yzhd2#O$b7w${rhT;px1J^>Qqx6zgIuG-mfdoZ)th4L;wA*vKgxfoED-%%H@g*cM-4{@9^j zQwZBogRq@e%Lmtmuug3skP9MMx2DaTI2|~k%=TJkWfC-p!s05lzIWH%q1foNzB`nWOV3Dz^mo8-h|uZdb1s3YeQ%E;qT27-^hv4+nU zN)0$*gdAAt7dZkpQ_*RV`Ej#BI%(TPm>oJ4fvN+oXwo%?E+2T9jveyncHbXsVGaH} zMrZK>Yh9?Gz#lp-rz6K#HygEy0QU~EhN3#mRI++ikMHK-m5GKE=-Vm*PV+0!LDIY4 zyXUp}oyJB!Z0@gj1GCw;^Gya?F{0&qcmODaZYQscz-E;!$wbSI3Dd^&c*#G*=Cw^89!Q3gdL#O5sji9l#*VPqHyPR{rWQRNtuoldWHMMi(by<0dLo3fk40J=1{3WP;aMYX%?@=DxmI^-v---L;jZDRbnCQA zjhb)7XVKC<`W#+TsWt20p0DEpsG^2ACW2Hx^V+L`5dY|#OAwj-#zE`o9TJTymiPe% zrmc10}?b3(YD?3$8kzhf@gT=blSXgNP+*&&sq?AFwx0qIOMe?XJ!vWM>AK$;GY{R|E_q*!4eP2YnJ0MmmY0DLim|c0rtmRUng2#0 zZQ{4nW*uifw9v>~z^iGC7x;J{`>+f1pzl%2Nse#{1`t4l+oFP~&1dvT{PYMoNk)Bm zP(f(mksnI%);B8$-gw>-rzQJ5_ds9qbip?kZ`;9EXwrM(JXSh#ffEvbx zkeMy7Ue{q6Wby~bf(%}b1+K2XV`O6imQCL|LCN-@ZIl_5Wj1Soy*e{YWt!Pm=oF|r zmQ$64*g>-4j(e}!s7EuG#venHGVq7uExNsuvW~&gh69P@Ia<&Ksn&vWnXoE+JJhdm__Sw z+ITT~(tvwplLk^o%!NSA@e&|A92yw4!LfvN1^r1}>mtiTqV?d=*0YO&J><~NXWz~@ zr0}if1#IE00H*()K~y1}JX#5E7d>vss^bzRO~_gn^$-?=eS{z_oBuJWaI$blq2dAK zjo<o^Zr$3z~7K}Ag+YdOpJn7Kr&c;_`F;NOj#_^Z<#~GO*jrR zNP>8A3v+lzN)x;L_4y-2he4C)jGMb+BIxJvy)m!VY*)!2-16i&Il*qFbt1~~m&b1O!bFt?%-#vsh-27O?)OUF%R2OYuB zP#O~X@c|2+A+$oakm5qNs@AMBs)&Wid`q_U`Pcy-*Oc32tLnLiF}1`37)P>YL#Sv$ z$opbk_2m#G4DiAsNLVGim|v2xs-H3vU0E~{i?)_!IPL# z4xW*i*?H8nemb3MS@7m|3pQ#1hpK69Z!<6GH&G zA7TC_dITosS;53BV@ez=+K(y0j2r#F{H%y5F((*iVde+M!Yg7*#vR?4D(X59V$;D?o7)J)}}pKs^=v*?LYTBd{TJu4k61Atgp&qu#<|*0GO{ zB`=i`>8K>OxA*!@1@l*YU;~XsSe|c4=At)9P_?iH&s3rZ{k#QnWl0P=m1|wp3$RAQqR|r0N`OEtRa8W>?pP~jvq)z~QK~6?^fvLv} zLGm=Cpmim?XqnbKVJM-XtGLTuFz!Ye3CH8y0SkjIBf&xtFJvU56%xi9lQ41tH6V08 zIAA7W#=^#Ifpz_eVk7`B9D0`;U@JJ*L$(5f%U0CQR@B7_W-Dxp z5X)8w5o`tJ@aJSJY!vHaG;yY3G0>m{S?!8w5=<}c+BW2|@wUQHR8c5^`cd|1%htud zXzOBktl_hbA|%WU{ceUwx!8vz5rCVI@%@H8x@&$fILd%RGbS;Pw$v+B64m;#5h8XV zVJsEz^MBGoCP$_utW-9gfSEL262Gb^uz8*w9ww3|6w(JcH$fjriAwx2zp5ADQCVGW z^T_;(u)IS_j$u$I$!7D`QnhW?&g!E?yDHJ=D~TydRaVA}z8%_P@4e_N&?Jvr>JIl( zMxXL1J2rcgF$`(E&?IKv2lTO?=p2Y3L#$VNBoQ^Cj3eqbiQmgWw+4wOgty?`Nt3`F zUX$3&U)4{gNvZ>yL@N>cLXc)uR5W+g?n;^j-&2-6Cy_J>ho5BMb z-55uFnD&u{8yqPb3mT9y^KvV7Nhdw~27lQY(YtLUS^!-!uzY22&|u&J?PjN71%t*L zHm6z>!yKKMIaA|-qmzQ8d1f3<&FxvyFM7CwD@Coe_bXIu_hw7{hI3S^grDKBS|Hvg!M4Wo8QcJa)!5Ff4^2o7wL;@nF z_O@w!q>00)Wm^K{0Shl?SC88A1%s zr=NhPCPPLi9zq#ZCEG73UVMGQn@9{%AfHbs7*Y??T&WfS*e75g@km>(!?CjDMGz$k zNe9cOM)#%ezFT{Vc0n;1q>r)5o!1;DoF>{GMKb3Ej-RByV_7D)D~-W_;mnQ*7O?dN zO~`lzbCTE!(AMjkWTN<%$U?Gu#$ztq2lUcniBfS$J#ehDvWml%%mYvncsqfKQNytg zBPq4w4f9g$t#3@|TxNeZ5Fn3&5*=LpuJF}A*?Z=wxVa!iSrD))5C$;%~mPm&aRa-jasA22g?@%iS7JO<2`l(+>`p*)E}O86J-~AH)uGDf~tg zk`<=M+L!53-b68%%H2ehDdA3STP22ofRgd?@VwCZ;*<+|gLjt2Kq;9Y^Fnz>ii7+@ za%OlYhpN$Kc;P3N@m>QG?;S4tZ8;G-uLEr-|KEtykH!r~TvFwr;GSZQd~toJ-}$6g$*S-=3l#`}Q4L*v-N!6H;AQ5)vi!2#E?0FMN?x&&B%{&W^y+xtUfQ2o3mOJ)Eulr}?f=Gl2tB+Fgk$Ll`UX^a`fzQ^>frV9Nez_WQ-%O2=OE8Jw;WF)RhX@)#B?2eAbX zy<@LvRjN3fu^x%U>q>O1(hqdD;oz7STrW^nLr0L{S{*BTP^qu-vSUP3L|lW+xc8M>v# z4l0;_pk%9cb7|v5){zMh&bL3sYzxgR^o$R}Za4UUE)RmhCMXs~8bBKwhnP zV^oA7v3>n9_efA<#v4T;p!gLULQQ!go_M!K)DTFsrkWkmvtu@^=#cIsOyJxCl-fgW z%O`h48G~0u1Dc3{YYv`a58$CEF;3Q*Tc7+k6ti;gTmO3bjZ=$<<_=CC=imA1#ak8+ zO{Mcw3rFXUEKH?0O)jL1le1Gtj?AUk%^kcYT+)Tb`Kiep7Y-bnx^Zgn&Yi0B{m*}H zdWhl_@y~a1mjhaMd-Z>G&cfm815@erk%Ln=17vc3aUq>tOqa*g>y9s`Q%4T^pV6l4 zZdnA4$s;#RovK&>G)LXJ>p}210QRQB=J3>!bb29OUT=W)#J8zu(K>56wKn|YV;9-g`$I(O3f=^G9$UYlM&IemEQ;NEn(IhNA5>lgj4z2!Dqj_qH^?auk* z3yTMKUUA*zbx+xS{pHv1zG7yKc|7?%s9X)Ki~&`N5}M_q3<%nwq@)X;0aC zc>22e$@yD$P98oycVOqj{DGa<-?;ds`GqqEw6J*4z%1Oduyf&-BL{Zgczki{X4vaT z77xLO2N%vN@cElSd-g0rKQK2x<#At}-+6R$adB$?$l1X5=;V><1GBK^Hv+6c?l{2a z4o>Yn3bQXxUUzuvtUzVdciuR6@Ed`ZkNou9HwvlGjPsK>{m6+anCR@F=2>v(wD0FA z)pD^^%aOUobTU0~XmZ}GDFo!{>GYyrO&?j9J~)*wH`49WNGPb56pmLtm#)+|-ypnA zH%{F+H-AfN6qz*TKx@m-Hl4N*6vNoU?DWy2QhnD=9++L6pG0XJcj(dLTsnDTZu($) zbZ&9#$l~CJE1?9x3{s5B}rsgK1M{O|Uz1jw!8On1LqPxsvSXvX95*dA$gOEY5| zpMxhjw()@h8$9xi#ukjFi8KRtC}P=|WW(iB@D1OTn!>LkjbK^tctc$I6TgTS=_vj^lsmB)`22{OW}j4F7b zAfn}4xPIjFt+dkhRG=VE6?!J+aI|?3?-N+ULk7DY4dGSP8?N+88G;{Sad z0t`V;qIODnBDC6gfpwIqLOrmKwpU z3VgT8g2)XgMNtJbF{*Ifbv!~)GHZ_Kj;|#2yB&nTnvS@Ll;DU!RTsCJBj~3<8#$4F zg=?y^(r%^EOldFgMc=?A?V@WSdk!&Zh4#?^1i*13dcK5H=vz#1lu+VuI0z{HlK#C& zc=S`~;RX6HXk(Q~xKBz$W}k2g4cix^uPW~YU70TDyF{w2z<*){O^}+v(cL^JKrr-1 z$}6>+boG(2$-KCRLP$&@cmbWWaoZKv$$BPhy&T%|8*nV)-ppzNe1RsVIQtZ+NKBOb z6IBI)egW+Z2wU|?+UQ9f@4+A(2W?89mLV*(OV) zjty6yh=VerrwM05s~`anS5HJZ6TXc9wG;dbwb43}+KEh^8dY(O-VB`O0ueso z{=mpX_C($4Y*vFsJ&`Wk9z6iUe~3gm`3mp zk8GM`Gs(cfbn@bKY_ezZhzmwX%r&wxm5h(LE{aV|$Coendc8ic-y84-y&-Sd8}UZH z{XVbH=kxmlzMwDU3;QCzsITAe_51vOf50E~hx}oG#2@wd2fP7az#o9E5eNmsfk+@4 z=nr~>zMwxC2nK_pU^o~FMuYt!Z^#$&hXSEsC=?2ZBB5vqMoQQh_J;%EU^o;Gha=%= zxIf~J_#*yDAQFs(BH>6R5{>jny-{D(9}Pr<(NHuTjYOl-{(i97kLLS9x*ur$D1!L^ zrWZNoi@imaFTUgw*j3mj^b&6>C(1IXC?-x7&74heK&h(4>XsT>%hhv@))uiao%=8PrtoX-E!R83|8Rd1-=mgAgDW;} zI(E}d|8&WfU;2lye)GZ4-Y3bX@bVRB{o%Rig_?$N>YKlP`^1yS1=oUw-93Tm!1_;Y*t~W7S!bWS>->?? z`1pmH1D77U;?_Iw{l??Z-E(g;_0%t3`O^8Fmx#RJ=Er&3=gA&v<$d-Rq07`JE)~}b zwk6p+r7odM=uv{^jeO8@IAW?(m4?A}{rsq6^45tRe6!fx9Tm>`_#yn{QRP>~mWjl49!cC`;;^x?5`MO&ihJT5F@MO6!$HrfKu) z6-%T+QI*b=sOaED^EnFFx~FZuqGoS9zjK{gm8{hRk}B1-^a}Oa2bPU(wX8R(L+hH? zD_gC@vU-+scBf61hSXO6^x+6^L&N=`-7BcscN^tFzI8jb`>mJVFg|V0e(#D6qt>Hd zN1b}?2Jy)0*FP}u+2cq0ZI{t%ao_*t2_?^Jq3p2>xnDfWtN3Xr%#;2co*6Lh5Ft~1b$IkP2kHxRP z_6}e?`Sh!A{OTR6bKUS*JbUz;4?p_Y3opI%yUQ-W^48lPeeB7no_+ae>#uv{N5`Le zc6j5a9p{|C`}5ZvyYK$*Jod!#r(SZ_)$iQ(?w>z6k=_5PS6;QXCsQr0yD$CBJ@>xy z>L(w1xURmfedzR!o7hY9nM2=x^2aay{GH$ZDV;esHQnv$``W$VdF-iYUwZX=vhpj} zd5^U}b7JGBb9c&$-La_et+$h@$nw)xuiLtJ`uk5m_xw+O`sN2Gh--J}kynKyYn5gJ zKW_SUTlOw|C-ra(->6WbPY4P!Pi0AVs+;Xq@^+aQT2vFS@G}0tisvnY$eSfr4$Mrg9hHMQM`G zk(Y`?YA^nSjq*Nouh1l!`RvzGwV@{aM`amr=U2cAS}Gnn;cQU)oNm6;)?v?HB^~`?~4(H}MhE;axS^e^au*s9DCVQdAjIEYg&@ zjXzg7$CSOSp+&7TZ4k0oNO#?8sTX{=2!~(pmMx-qG&Bvf22WFO_5dAk+UH&4iA z2pU~0j*#=JZzfgsu6B!Sr2XyQk)=z#uPsg8c5^RxyUX47p36f%7zy8W;^y%Cv?D^* zSftC^5xvWHOMhQO$G{)jI?h-byk%=!?DcK$ToBv7DfRmH8y}9HMV^iAh(CYJ4)Sux z+2plb&bjB89Xo&f#_PMD`*rL*7kTUacj%$r9*+x`_rQ#4v1;|2wd;n)V-uP9RC+od zV_}#5aFjJiBOPNd(*DT_gqh%(YR*bJJ~f?AGQT0|il@^E(p> ze{;X(%a0uO(cixCkaIjX#k`|wcuD)?I=g6Ii!iuYm;v2j5ub|@cmvnHhG(PZVDg>_ z;8>(X!^spgJsLxBZYuAAT@;@HkE5TOPNd^5El#pJbzm6+cdk{*gDY|l)@W=x1B%I1 z(hYkJ5hW(ES-zDXB4T}K>>(DoF9lKTnI0dHrx8FKy>Lbn1FA0Atts?!EIx(c5(0VP zAhkb%n8#ReWs#3hPfQ>j1tG*-aJ%-T5Op*p>``Z!*c(sA(=qUYXvL?P6Q{*~T*GSz zTze8z8Mtl`@PU2k8XC-rPmf1Vx%R}NAqDbgvP7S^YEjH}Oo;{tHpDVhnwHfR%qmBocx zuCOScfl_JR*_fIdV!_e!wcC@ii_l-D>E!{2CpV{4`w|dyyaI{g$?eHBI4YsBCY4Oa zN0~sk#K)P=70{ol+ZIpnXN*>=p4HhI)GZju*}|Bq-@!C@E`SXQXeg*4l3mCgMiO0~ zN{yx_G?okaM%5Y?R2@%@g5@%`MJiB$xPEGCa!VYlo0(c0PbP}mE2!I`1)3QkUg1KX z3Sd?v6wefKvje2CUo?NF_6v zY|$A^RC)JYFaA3y`{4%JGws z2&pZ|f2AaUT}l2TFw;{2&+=8sKLL8%afi`BmKNlj@Ek^}M}N-1{c{5_eyFMg8QUEE46hd+!u{zd|2vQWOAL%$`eID*ieyq&|^1E=aMlvC>F=?oAXk%E9 zXLg}0wjhMk*jF-l%fDC+TQ(E0LFRv7U3=nr@j%j{`!DUQU8-V_1{#)9zDN`y`yF)abeq@<=KH)CndRGUDO~r22p^)v%Fcu zSS2{w#=ZV57CcMrxd2CY8vkMG;#Mh6A^(q-$eEm?K|?LUGdm@t&cf z>7FCj4<(cuD1z+OJ4bQE0zU8PR4RR*X6a`X5jBKJoeZG02mr?Q-W1FLcUm_gHBitp zQ=Z6D4xOibyX3}-gJzgUGx@Mj2ne46Kssj80gg-RV+}6ZsUHP7Z+sdqcHEs{HkIC_ zZfkfl(|aBOG6);dR|g$&P?$18&Xr+r%Q7Eh!WOzz+#2tJ&%U==XZAvNeAA%Iv7 z5O(r}Rd-6QthWu7_+u~&BpD(^D`D;tfl@~~F=O?ZQ?`WG?!?Iz%}{ZVrBjo+;UV0D zd{;}w=t20xt&5NFb3^H;V~Ef_CoSVV4iS6j1jMO=eQp-baKp7oA0$$H3Gbz>p431S zW!}*xrj9&=#)#$5{dky;Ps7YpT_s%20wv~_S!CG2d-PBav9wg8z@UhAQAu?rB_X!1 zd^?puJbU9)#GY3q{8%Y=dnDagCk_d_s4$uGG$vCMn&<4WMA7DS zEZ1Bu;O%%n-35VK{PrF8?TISH41zLu{aL z>fmHNvl6Dd$LFu29Wxc+MYlur`7oV_;i@$loYZ3PT)vdA$(L#qRK8fyTLQ2?M;R@N z29+6jZ`&b!a^F}Dy3B`ADu|_8FJS2RcqW^WonyUuj^S3Ky5Mr3Un-2 zY1SEJ)S0s7N-cUfS*bRpxnir%Vy+mC+ay++K$DLVAuetfPL0u(LeCObb=| zNRG0#DuW|%2-~M{_|L5sHg0kz5TP}e(b%5EZbaJI zF;GPT6$^(&QR>0dJVbO0NyD*fI7=DMhqRY8G@FLzV`wL3J!!~x4Y`{k8*}h1b0u%t zC^(Tr9GKsv4u&wN4Wq%b6bjD3tn&Jxios&f^KP{DtQ zg5MR+Gs;}fe&%rw zFo$!Hg$$PAZ6j5+vK`07GBmT2^3&;w3~@`%%o(cTu^h0wRePZ(Q<KLRh;C-B654k(bF?^L~(r`9b^|wpob7 zo>OjZj-0hrxC`u&B0>fJk#K56^vOXSIwejF?@HQmN_0w`FS%G;%1LWWF$7iSwPu-VAI;Zg946&h4tH?x15iU~CrXIoaz!Y{Iq491J6 z-vY0qb>JgW(0q=zl6SjAZr%#57f=4}^Eo80;x2a-XFCJM{A;$_%&89D%5gIryTH6# zpt%ZNGv2_$ym1dXb?2Y49xUpgP2I3F3yd|bf#Qmu!MRb|@WA8Ar|pBfQ;QulmJlX~CBI;ne4ectt*(nRb3Sf{CLS%uJmR<_2rY0;cxGVdmJQwQ~MOQA1_%_w7kkz1IDm?N<3^Yit2 zFc3mJxdjW0p1!*%8U&@r0?0!55ffI)c9{V=YbG=mL#m~aGw-~)Xv5OdkZ(Lk(wHo* z27|UD47)*kmha-2b>P8i&z+!fr?l@ZW~wTlC#Y66DQRcBYr67WX>DNa%58(xw&kH1 z?*;y4Zo02ChCr*&?;!a6^&N$X#^hT#IX85c1<@5VN58RTkFKda*}^HixpZHyrF=U@ z(|BtsQd>o&nPG0v9aC}58k3IM$3Z%akAEzvI7nTFQY_TfxVuO<7U_jO2GruB>LtZW ay0k=oy`}x6|8HXG#^?WG6XT?hZT&Cveg04Y literal 0 HcmV?d00001 From 555422b69181b6b0320a24063c3926b576427882 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:37:30 -0700 Subject: [PATCH 4/9] ADR 0014 phase 3: octosense:host, a component calls its app's host services A component may import octosense:host/services@0.1.0 (wit/octosense-host.wit): request(service, args) -> result. The runtime knows no services; the shell's wasm service gives each instance its app's: the families its admitted manifest grants (a system app's own namespace too), dispatched on the UI thread (host_tools::pump) as its script's host.request would be, but never with a sheet or a prompt, so only the methods a background surface may call. wasm.* is refused (the app's worker is busy with the call). The call's deadline bounds the wait. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/host_tools/mod.rs | 3 + crates/shell/src/wasm_service.rs | 231 +++++++++++++++++- crates/wasm-host/src/component.rs | 16 ++ crates/wasm-host/src/component/host.rs | 53 ++++ .../tests/component-guest/Cargo.lock | 7 + .../tests/component-guest/Cargo.toml | 2 +- .../wasm-host/tests/component-guest/build.sh | 2 +- .../tests/component-guest/hostcall/Cargo.toml | 11 + .../tests/component-guest/hostcall/src/lib.rs | 13 + .../hostcall/wit/deps/octosense-host/host.wit | 14 ++ .../component-guest/hostcall/wit/hostcall.wit | 7 + crates/wasm-host/tests/component.rs | 67 ++++- .../tests/fixtures/hostcall.component.wasm | Bin 0 -> 42284 bytes crates/wasm-host/wit/octosense-host.wit | 14 ++ 14 files changed, 433 insertions(+), 7 deletions(-) create mode 100644 crates/wasm-host/src/component/host.rs create mode 100644 crates/wasm-host/tests/component-guest/hostcall/Cargo.toml create mode 100644 crates/wasm-host/tests/component-guest/hostcall/src/lib.rs create mode 100644 crates/wasm-host/tests/component-guest/hostcall/wit/deps/octosense-host/host.wit create mode 100644 crates/wasm-host/tests/component-guest/hostcall/wit/hostcall.wit create mode 100644 crates/wasm-host/tests/fixtures/hostcall.component.wasm create mode 100644 crates/wasm-host/wit/octosense-host.wit diff --git a/crates/shell/src/host_tools/mod.rs b/crates/shell/src/host_tools/mod.rs index 53fe65497..0ae73914d 100644 --- a/crates/shell/src/host_tools/mod.rs +++ b/crates/shell/src/host_tools/mod.rs @@ -121,6 +121,9 @@ pub fn pump() { let Ok(_pump) = PUMP.try_lock() else { return }; #[cfg(any(feature = "app-hub", native_mobile))] script_apps::poll(); + // Components' calls to their apps' host services (ADR 0014). + #[cfg(wasm_functions)] + crate::wasm_service::pump_host_calls(); for _ in 0..8 { let events = std::mem::take(&mut *INBOX.lock().unwrap_or_else(|e| e.into_inner())); if events.is_empty() { diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index 3ed289fe5..72a0c65a3 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -37,7 +37,7 @@ //! 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::collections::{BTreeMap, BTreeSet, HashMap}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -46,7 +46,9 @@ 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::component::{ + self, ComponentInstance, ComponentProgram, Grants, HostCalls, +}; use octosense_wasm_host::{Limits, Program, Runtime}; use serde_json::{json, Value}; @@ -76,6 +78,130 @@ fn area_env() -> Arc { } static RUNTIME: OnceLock> = OnceLock::new(); + +// ------------------------------------------------- a component's host calls + +/// A component's call to one of its app's host services (`octosense:host`), +/// waiting for the UI thread ([`pump_host_calls`]), where its app's script +/// would make it. +struct HostCall { + call: ServiceCall, + deadline: Instant, + done: SyncSender>, +} + +static HOST_CALLS: Mutex> = Mutex::new(Vec::new()); +/// Dispatched calls by their request key, until answered or past their +/// deadline. +type Waiting = HashMap>, Instant)>; +static HOST_WAITING: Mutex> = Mutex::new(None); +/// Component calls are dispatched under keys of their own, apart from any +/// isolate's. +static NEXT_HOST_KEY: AtomicUsize = AtomicUsize::new(1 << 50); + +/// An app's host services as its components reach them: the families its +/// admitted manifest grants (a system app's own namespace too), dispatched +/// on the UI thread as its script's `host.request` would be, but never with +/// a sheet or a prompt, so only the methods a background surface may call. +struct AppHostCalls { + app: String, + host_dir: PathBuf, + families: BTreeSet, +} + +impl HostCalls for AppHostCalls { + fn request(&self, service: &str, args: &str, deadline: Instant) -> Result { + let family = service.split('.').next().unwrap_or(""); + // The app's worker is busy running this very call. + if family == "wasm" { + return Err( + "a component cannot call wasm.*: its app's functions are already running it".into(), + ); + } + let own = self + .app + .strip_prefix(octosense_appstore::system::SYSTEM_ID_PREFIX) + == Some(family); + if !self.families.contains(family) && !own { + return Err(format!( + "{} was not granted the {family} service, which {service} needs", + self.app + )); + } + let args: Value = serde_json::from_str(args) + .map_err(|e| format!("{service}: the arguments are not JSON: {e}"))?; + let (done, answer) = sync_channel(1); + HOST_CALLS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(HostCall { + call: ServiceCall { + app_id: self.app.clone(), + service: service.to_string(), + args, + from_sheet: false, + may_prompt: false, + host_dir: self.host_dir.clone(), + }, + deadline, + done, + }); + makepad_widgets::makepad_platform::thread::SignalToUI::set_ui_signal(); + answer + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .unwrap_or_else(|_| { + Err(format!( + "{service} did not answer before the call's deadline" + )) + }) + } +} + +/// Dispatch the components' queued host calls and deliver their answers; +/// on the UI thread ([`crate::host_tools::pump`]). +pub fn pump_host_calls() { + struct NoSheets; + impl ServiceHost for NoSheets { + fn open_sheet(&mut self, _body: String) {} + fn close_sheet(&mut self) {} + } + let queued = std::mem::take(&mut *HOST_CALLS.lock().unwrap_or_else(|e| e.into_inner())); + let mut waiting = HOST_WAITING.lock().unwrap_or_else(|e| e.into_inner()); + let waiting = waiting.get_or_insert_with(HashMap::new); + for HostCall { + call, + deadline, + done, + } in queued + { + if Instant::now() >= deadline { + continue; + } + let key = NEXT_HOST_KEY.fetch_add(1, Ordering::Relaxed); + waiting.insert(key, (done, deadline)); + octosense_appstore::services::dispatch(call, key, 1, &mut NoSheets); + } + if waiting.is_empty() { + return; + } + let keys: Vec = waiting.keys().copied().collect(); + for (key, _, result) in octosense_appstore::services::take_replies_for(&keys) { + if let Some((done, _)) = waiting.remove(&key) { + let _ = done.try_send(result); + } + } + // A call whose component stopped waiting goes nowhere. + let now = Instant::now(); + let expired: Vec = waiting + .iter() + .filter(|(_, (_, deadline))| *deadline <= now) + .map(|(key, _)| *key) + .collect(); + for key in expired { + waiting.remove(&key); + octosense_appstore::services::cancel_heap(key); + } +} static WORKERS: Mutex>> = Mutex::new(None); static BUFFERED_BYTES: AtomicUsize = AtomicUsize::new(0); @@ -490,6 +616,8 @@ struct Lab { /// The hosts its components' `wasi:http` may reach: the admitted /// policy's network hosts, granted `net`. http_hosts: Vec, + /// Its host services, as its components reach them (`octosense:host`). + host_calls: Arc, } /// The hosts an app's components may reach over HTTP(S): its policy's @@ -579,6 +707,16 @@ impl Lab { owner: BTreeMap::new(), stats: BTreeMap::new(), http_hosts: http_hosts(&admission.manifest), + host_calls: Arc::new(AppHostCalls { + app: app.to_string(), + host_dir: admission.root.join(".host"), + families: admission.manifest["capabilities"] + .as_array() + .into_iter() + .flatten() + .filter_map(|c| c.as_str().map(str::to_string)) + .collect(), + }), admission: admission.clone(), }; for path in files { @@ -747,6 +885,7 @@ impl Lab { http_hosts: self.http_hosts.clone(), }; let app = self.app.clone(); + let host_calls = self.host_calls.clone(); let module = &mut self.modules[index]; let Code::Component { program, @@ -773,6 +912,8 @@ impl Lab { Some((deadline, Box::new(move || pending.is_pending()))), ) .map_err(|error| error.to_string())?; + let mut instance = instance; + instance.set_host_calls(Some(host_calls)); *instances += 1; *live = Some((instance, grants.clone())); } @@ -1901,4 +2042,90 @@ mod tests { } let _ = std::fs::remove_dir_all(root); } + + /// A component's `octosense:host` calls reach its app's granted host + /// services, dispatched on the UI thread as its script's would be (a + /// thread here plays the UI's part); nothing else, and never `wasm.*`. + #[test] + fn a_components_host_calls_reach_only_its_apps_granted_services() { + struct Echo; + impl HostService for Echo { + fn family(&self) -> &'static str { + "wasmhostecho" + } + fn call(&mut self, call: ServiceCall, reply: Replier, _: &mut dyn ServiceHost) { + reply.send(Ok(json!({"app": call.app_id, "method": call.method(), "args": call.args, "may_prompt": call.may_prompt}))); + } + } + octosense_appstore::services::register_host_service(Box::new(Echo)); + let stop = Arc::new(AtomicBool::new(false)); + let pumping = stop.clone(); + let pump = std::thread::spawn(move || { + while !pumping.load(Ordering::Relaxed) { + pump_host_calls(); + std::thread::sleep(Duration::from_millis(2)); + } + }); + let root = std::env::temp_dir().join(format!( + "octosense-wasm-component-host-{}", + 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( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../wasm-host/tests/fixtures/hostcall.component.wasm" + ), + bundle.join("fns/hostcall.wasm"), + ) + .unwrap(); + let host_dir = root.join(".host"); + let admission = Admission { + root: root.clone(), + bundle, + manifest: json!({"capabilities": ["wasm", "wasmhostecho"]}), + }; + let mut lab = Lab::from_bundle( + "org.example.hostcalls", + runtime(&host_dir).unwrap(), + admission, + || Ok(()), + ) + .unwrap(); + let mut call = |service: &str, args: &str| { + let (_, reply) = pending_reply("org.example.hostcalls", &host_dir); + lab.answer( + "call", + json!([service, args]).to_string().as_bytes(), + false, + Instant::now() + REQUEST_TIMEOUT, + reply, + ) + }; + let answer: Value = serde_json::from_str( + call("wasmhostecho.get", r#"{"id":1}"#) + .unwrap() + .as_str() + .unwrap(), + ) + .unwrap(); + assert_eq!( + answer, + json!({"app": "org.example.hostcalls", "method": "get", "args": {"id": 1}, "may_prompt": false}) + ); + let error = call("mail.list", "{}").unwrap_err(); + assert!( + error.contains("was not granted the mail service"), + "{error}" + ); + let error = call("wasm.functions", "{}").unwrap_err(); + assert!(error.contains("cannot call wasm.*"), "{error}"); + let error = call("wasmhostecho.get", "not json").unwrap_err(); + assert!(error.contains("not JSON"), "{error}"); + stop.store(true, Ordering::Relaxed); + pump.join().unwrap(); + let _ = std::fs::remove_dir_all(root); + } } diff --git a/crates/wasm-host/src/component.rs b/crates/wasm-host/src/component.rs index f885420e0..0d7c7651d 100644 --- a/crates/wasm-host/src/component.rs +++ b/crates/wasm-host/src/component.rs @@ -48,8 +48,11 @@ use wasmtime_wasi::{FsPerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; use crate::{trap, CallError, InvocationGuard, LoadError, Runtime, Ticker, LOG_LINE, LOG_LINES}; mod files; +mod host; mod net; +pub use host::HostCalls; + /// The WASI packages a component may import. Every interface of these is /// linked; what each can reach is set per instance ([`Grants`]). const ALLOWED_PACKAGES: &[&str] = &[ @@ -59,6 +62,8 @@ const ALLOWED_PACKAGES: &[&str] = &[ "wasi:http/", "wasi:io/", "wasi:random/", + // The app's host services, as its script reaches them (`host`). + "octosense:host/", ]; /// What a call that had a write refused logs, and adds to its error. @@ -138,6 +143,9 @@ struct State { http: wasmtime_wasi_http::WasiHttpCtx, /// The hosts its requests may reach, and what it was refused. hosts: net::Hosts, + /// Its app's host services (`octosense:host`), when the embedder gives + /// them. + host_calls: host::Calls, } impl WasiView for State { @@ -300,6 +308,7 @@ impl Runtime { budget: files::Budget::default(), http: wasmtime_wasi_http::WasiHttpCtx::new(), hosts, + host_calls: None, }, ); store.data().budget.set(Some(0)); @@ -317,6 +326,7 @@ impl Runtime { wasmtime_wasi::p2::add_to_linker_sync(&mut linker) .and_then(|()| wasmtime_wasi_http::p2::add_only_http_to_linker_sync(&mut linker)) .and_then(|()| files::link(&mut linker)) + .and_then(|()| host::link(&mut linker)) .map_err(|e| LoadError::Invalid(format!("{e:#}")))?; let instance = linker .instantiate(&mut store, &program.component) @@ -567,6 +577,12 @@ impl ComponentInstance { self.store.data().budget.left() } + /// The app's host services its `octosense:host` calls reach (`None`, + /// the default: none). + pub fn set_host_calls(&mut self, calls: Option>) { + self.store.data_mut().host_calls = calls; + } + /// 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 { diff --git a/crates/wasm-host/src/component/host.rs b/crates/wasm-host/src/component/host.rs new file mode 100644 index 000000000..4d936e606 --- /dev/null +++ b/crates/wasm-host/src/component/host.rs @@ -0,0 +1,53 @@ +//! `octosense:host`: a component calls its app's host services, as the +//! app's script does with `host.request` (`wit/octosense-host.wit`). +//! +//! The runtime knows no services: the embedder hands an instance its app's +//! [`HostCalls`], which decides what the app may call and makes the call. +//! Without one, every call answers that this host has none. A call waits +//! outside the guest, where the deadline's epoch check cannot end it, so +//! [`HostCalls::request`] gets the call's deadline and must answer by then. + +use std::sync::Arc; +use std::time::Instant; + +use wasmtime::component::Linker; +use wasmtime::StoreContextMut; + +use super::State; + +/// The interface a component imports (older 0.1 patch versions resolve to +/// it). +pub(crate) const INTERFACE: &str = "octosense:host/services@0.1.0"; + +/// An app's host services, as a component reaches them. +pub trait HostCalls: Send + Sync { + /// Calls `service` (`family.method`) with JSON `args`: its JSON answer, + /// or why there is none. Answers by `deadline`. + fn request(&self, service: &str, args: &str, deadline: Instant) -> Result; +} + +/// Links `octosense:host/services`. +pub(crate) fn link(linker: &mut Linker) -> wasmtime::Result<()> { + linker.instance(INTERFACE)?.func_wrap( + "request", + |store: StoreContextMut<'_, State>, + (service, args): (String, String)| + -> wasmtime::Result<(Result,)> { + let state = store.data(); + let deadline = state + .guard + .as_ref() + .map(|guard| guard.deadline) + .unwrap_or_else(Instant::now); + let answer = match &state.host_calls { + Some(host) => host.request(&service, &args, deadline), + None => Err("this host gives a component no host services".into()), + }; + Ok((answer,)) + }, + )?; + Ok(()) +} + +/// The calls an instance's host services get, set by the embedder. +pub(crate) type Calls = Option>; diff --git a/crates/wasm-host/tests/component-guest/Cargo.lock b/crates/wasm-host/tests/component-guest/Cargo.lock index e6fe5a3a2..69024f09f 100644 --- a/crates/wasm-host/tests/component-guest/Cargo.lock +++ b/crates/wasm-host/tests/component-guest/Cargo.lock @@ -66,6 +66,13 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hostcall" +version = "0.1.0" +dependencies = [ + "wit-bindgen 0.62.0", +] + [[package]] name = "id-arena" version = "2.3.0" diff --git a/crates/wasm-host/tests/component-guest/Cargo.toml b/crates/wasm-host/tests/component-guest/Cargo.toml index 58dbbaf6c..57fd43c62 100644 --- a/crates/wasm-host/tests/component-guest/Cargo.toml +++ b/crates/wasm-host/tests/component-guest/Cargo.toml @@ -3,7 +3,7 @@ # ../fixtures. Its own workspace: none of this enters the shell's graph. [workspace] resolver = "2" -members = ["notes", "netprobe", "fetch"] +members = ["notes", "netprobe", "fetch", "hostcall"] [profile.release] opt-level = "s" diff --git a/crates/wasm-host/tests/component-guest/build.sh b/crates/wasm-host/tests/component-guest/build.sh index b71d297aa..e4144ae9d 100755 --- a/crates/wasm-host/tests/component-guest/build.sh +++ b/crates/wasm-host/tests/component-guest/build.sh @@ -5,7 +5,7 @@ set -euo pipefail cd "$(dirname "$0")" cargo build --locked --target wasm32-wasip2 --release mkdir -p ../fixtures -for c in notes netprobe fetch; do +for c in notes netprobe fetch hostcall; do cp "target/wasm32-wasip2/release/$c.wasm" "../fixtures/$c.component.wasm" done ls -l ../fixtures/*.component.wasm diff --git a/crates/wasm-host/tests/component-guest/hostcall/Cargo.toml b/crates/wasm-host/tests/component-guest/hostcall/Cargo.toml new file mode 100644 index 000000000..801462285 --- /dev/null +++ b/crates/wasm-host/tests/component-guest/hostcall/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "hostcall" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "0.62" diff --git a/crates/wasm-host/tests/component-guest/hostcall/src/lib.rs b/crates/wasm-host/tests/component-guest/hostcall/src/lib.rs new file mode 100644 index 000000000..9f4329c27 --- /dev/null +++ b/crates/wasm-host/tests/component-guest/hostcall/src/lib.rs @@ -0,0 +1,13 @@ +//! A test component that calls its app's host services through +//! `octosense:host` (crates/wasm-host/wit/octosense-host.wit). +wit_bindgen::generate!({ world: "hostcall", path: "wit", generate_all }); + +struct HostCall; + +impl Guest for HostCall { + fn call(service: String, args: String) -> Result { + octosense::host::services::request(&service, &args) + } +} + +export!(HostCall); diff --git a/crates/wasm-host/tests/component-guest/hostcall/wit/deps/octosense-host/host.wit b/crates/wasm-host/tests/component-guest/hostcall/wit/deps/octosense-host/host.wit new file mode 100644 index 000000000..e3bbf27cb --- /dev/null +++ b/crates/wasm-host/tests/component-guest/hostcall/wit/deps/octosense-host/host.wit @@ -0,0 +1,14 @@ +package octosense:host@0.1.0; + +/// The app's host services, as its script's `host.request` reaches them: +/// only the families its app is granted, and never a sheet or a prompt. +interface services { + /// Calls `service` ("family.method") with JSON `args`: the service's JSON + /// answer, or why there is none. + request: func(service: string, args: string) -> result; +} + +/// What a component imports to reach them. +world component-host { + import services; +} diff --git a/crates/wasm-host/tests/component-guest/hostcall/wit/hostcall.wit b/crates/wasm-host/tests/component-guest/hostcall/wit/hostcall.wit new file mode 100644 index 000000000..f53a89b36 --- /dev/null +++ b/crates/wasm-host/tests/component-guest/hostcall/wit/hostcall.wit @@ -0,0 +1,7 @@ +package octosense:hostcall-spike; + +world hostcall { + import octosense:host/services@0.1.0; + /// Forwards to the app's host services. + export call: func(service: string, args: string) -> result; +} diff --git a/crates/wasm-host/tests/component.rs b/crates/wasm-host/tests/component.rs index 6e22e75d6..19b8c7045 100644 --- a/crates/wasm-host/tests/component.rs +++ b/crates/wasm-host/tests/component.rs @@ -5,12 +5,14 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicU32, Ordering}; -use std::time::Duration; +use std::sync::Arc; +use std::time::{Duration, Instant}; -use octosense_wasm_host::component::{is_component, Grants}; +use octosense_wasm_host::component::{is_component, Grants, HostCalls}; use octosense_wasm_host::{CallError, Limits, LoadError, Runtime}; use serde_json::json; +const HOSTCALL: &[u8] = include_bytes!("fixtures/hostcall.component.wasm"); const FETCH: &[u8] = include_bytes!("fixtures/fetch.component.wasm"); const NOTES: &[u8] = include_bytes!("fixtures/notes.component.wasm"); const NETPROBE: &[u8] = include_bytes!("fixtures/netprobe.component.wasm"); @@ -347,7 +349,8 @@ fn http_reaches_only_the_apps_hosts() { other => panic!("{other:?}"), } assert!( - instance.take_logs().iter().any(|l| l == &format!( + instance.take_logs().iter().any(|l| l + == &format!( "a request to localhost:{port} was refused: it is not one of the app's network hosts" )), "the refusal is logged" @@ -392,6 +395,64 @@ fn a_request_that_never_answers_ends_at_the_deadline() { ); } +/// An embedder's host services for the `hostcall` component: it records +/// each call, answers `notes.get`, refuses the rest, and makes `slow.wait` +/// wait out the call's deadline. +#[derive(Default)] +struct Services(std::sync::Mutex>); + +impl HostCalls for Services { + fn request(&self, service: &str, args: &str, deadline: Instant) -> Result { + self.0.lock().unwrap().push((service.into(), args.into())); + match service { + "notes.get" => Ok(format!(r#"{{"echo":{args}}}"#)), + "slow.wait" => { + std::thread::sleep(deadline.saturating_duration_since(Instant::now())); + Err("past the call's deadline".into()) + } + _ => Err(format!("this app was not granted {service}")), + } + } +} + +/// `octosense:host`: a component calls its app's host services through the +/// embedder, which decides what it may call; without one it reaches none, +/// and a call's deadline reaches the embedder. +#[test] +fn a_component_calls_its_apps_host_services_through_the_embedder() { + let rt = runtime(); + let program = rt.load_component(HOSTCALL).unwrap(); + let mut instance = rt + .instantiate_component(&program, &Grants::default(), None) + .unwrap(); + match instance.call_json("call", &json!(["notes.get", "{}"])) { + Err(CallError::Guest(why)) => assert!(why.contains("no host services"), "{why}"), + other => panic!("{other:?}"), + } + let services = Arc::new(Services::default()); + instance.set_host_calls(Some(services.clone())); + assert_eq!( + instance + .call_json("call", &json!(["notes.get", r#"{"id":1}"#])) + .unwrap(), + json!(r#"{"echo":{"id":1}}"#) + ); + match instance.call_json("call", &json!(["mail.send", "{}"])) { + Err(CallError::Guest(why)) => assert!(why.contains("not granted mail.send"), "{why}"), + other => panic!("{other:?}"), + } + let started = Instant::now(); + assert!(instance + .call_json("call", &json!(["slow.wait", "{}"])) + .is_err()); + assert!( + started.elapsed() < Duration::from_secs(3), + "{:?}", + started.elapsed() + ); + assert_eq!(services.0.lock().unwrap().len(), 3); +} + #[test] fn the_deadline_and_the_memory_cap_end_a_call_and_spend_the_instance() { let rt = runtime(); diff --git a/crates/wasm-host/tests/fixtures/hostcall.component.wasm b/crates/wasm-host/tests/fixtures/hostcall.component.wasm new file mode 100644 index 0000000000000000000000000000000000000000..c0ce9239bf9c7b9912597fdc4ab9dfe906b45161 GIT binary patch literal 42284 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 literal 0 HcmV?d00001 diff --git a/crates/wasm-host/wit/octosense-host.wit b/crates/wasm-host/wit/octosense-host.wit new file mode 100644 index 000000000..e3bbf27cb --- /dev/null +++ b/crates/wasm-host/wit/octosense-host.wit @@ -0,0 +1,14 @@ +package octosense:host@0.1.0; + +/// The app's host services, as its script's `host.request` reaches them: +/// only the families its app is granted, and never a sheet or a prompt. +interface services { + /// Calls `service` ("family.method") with JSON `args`: the service's JSON + /// answer, or why there is none. + request: func(service: string, args: string) -> result; +} + +/// What a component imports to reach them. +world component-host { + import services; +} From d3327b30366a0de02a69d6756252ff68e67fb625 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:42:26 -0700 Subject: [PATCH 5/9] ADR 0014 phase 3: compile an installed app's functions before its first call When App Hub completes an install or an update, the shell compiles the app's admitted fns/*.wasm (modules and components) into the disk cache in the background (wasm_service::warm, Runtime::precompile): one app at a time, on a thread of its own, never in a worker's place, and only with the wasm grant. The first call then loads from the cache instead of waiting for Cranelift (ADR 0011 measured about 0.4 s for a 433 KiB module on a mid-range phone). Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/lib.rs | 3 + crates/shell/src/wasm_service.rs | 135 +++++++++++++++++++++++++++++++ crates/wasm-host/src/lib.rs | 16 ++++ 3 files changed, 154 insertions(+) diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index e468154d4..baf5ecb6a 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -6319,6 +6319,9 @@ impl MatchEvent for App { self.installed_app_changed(cx, &id); // Its tools, grants and kernel tools, as installed (ADR 0004 §7). host_tools::script_app_installed(&id); + // Its own functions, compiled before its first call (ADR 0014). + #[cfg(wasm_functions)] + wasm_service::warm(&id); } self.drain_hub(cx); self.drain_client_lines(cx); diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index 72a0c65a3..c05181cc7 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -620,6 +620,75 @@ struct Lab { host_calls: Arc, } +/// Compile an installed or updated app's functions into the disk cache in +/// the background, so that its first call does not wait for Cranelift (ADR +/// 0014 phase 3; a phone takes about 0.4 s for a 433 KiB module). From its +/// admitted bundle only, and only with the `wasm` grant; one app at a time, +/// on a thread of its own, never in a worker's place. A call that comes +/// first compiles the same code itself: the cache takes either. +pub fn warm(app: &str) { + static QUEUE: Mutex<(Vec, bool)> = Mutex::new((Vec::new(), false)); + { + let mut queue = QUEUE.lock().unwrap_or_else(|e| e.into_inner()); + if !queue.0.iter().any(|queued| queued == app) { + queue.0.push(app.to_string()); + } + if std::mem::replace(&mut queue.1, true) { + return; + } + } + let spawned = std::thread::Builder::new() + .name("wasm-warm".into()) + .spawn(|| loop { + let next = { + let mut queue = QUEUE.lock().unwrap_or_else(|e| e.into_inner()); + if queue.0.is_empty() { + queue.1 = false; + return; + } + queue.0.remove(0) + }; + match warm_now(&next) { + Ok((0, _, _)) => {} + Ok((compiled, files, ms)) => makepad_widgets::log!( + "wasm {next}: compiled {compiled} of {files} function files ahead of its first call in {ms:.0} ms" + ), + Err(error) => makepad_widgets::log!("wasm {next}: not compiled ahead: {error}"), + } + }); + if spawned.is_err() { + QUEUE.lock().unwrap_or_else(|e| e.into_inner()).1 = false; + } +} + +/// [`warm`]'s work for one app: how many files it compiled, of how many, and +/// how long it took. +fn warm_now(app: &str) -> Result<(usize, usize, f64), String> { + let admission = Admission::current(app)?; + let runtime = runtime(&admission.root.join(".host"))?; + let started = Instant::now(); + let mut files: Vec = std::fs::read_dir(admission.bundle.join("fns")) + .map_err(|_| format!("{app}'s bundle has no fns directory"))? + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.extension().is_some_and(|ext| ext == "wasm") + && std::fs::symlink_metadata(path) + .is_ok_and(|m| m.is_file() && m.len() <= runtime.limits().module_bytes as u64) + }) + .collect(); + files.sort(); + files.truncate(MAX_MODULES); + let mut compiled = 0; + for path in &files { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + if runtime.precompile(&bytes).map_err(|e| e.to_string())? { + compiled += 1; + } + } + Ok((compiled, files.len(), started.elapsed().as_secs_f64() * 1e3)) +} + /// The hosts an app's components may reach over HTTP(S): its policy's /// `network.hosts` when it is granted `net`, as for its script; none when /// the manifest does not resolve. @@ -2128,4 +2197,70 @@ mod tests { pump.join().unwrap(); let _ = std::fs::remove_dir_all(root); } + + /// An installed app's functions are compiled before its first call, in + /// a process of its own (the apps root and the registries are + /// process-wide): the first call loads them from the cache. + #[test] + fn an_installed_apps_first_call_loads_from_the_cache() { + const CHILD: &str = "OCTOSENSE_TEST_WASM_WARM"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "wasm_service::tests::an_installed_apps_first_call_loads_from_the_cache", + "--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-warm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + 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.wasmlab", "warm", |_, _| {}); + register(); + warm("os.wasmlab"); + let cache = host_dir.join("wasm-cache"); + let cached = || { + std::fs::read_dir(&cache) + .map(|entries| { + entries + .flatten() + .any(|e| e.path().extension().is_some_and(|x| x == "cwasm")) + }) + .unwrap_or(false) + }; + for _ in 0..3000 { + if cached() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!(cached(), "warm compiled nothing into {}", cache.display()); + let ranked = request( + "os.wasmlab", + "wasm.fuzzy_rank", + json!({"query": "m", "items": ["Mail"]}), + &host_dir, + ) + .unwrap(); + assert_eq!(ranked["ranked"][0]["item"], "Mail"); + let described = request("os.wasmlab", "wasm.functions", json!({}), &host_dir).unwrap(); + assert_eq!(described["modules"][0]["from_cache"], true, "{described}"); + // Warming again finds it compiled. + assert_eq!(warm_now("os.wasmlab").unwrap().0, 0); + let _ = std::fs::remove_dir_all(root); + } } diff --git a/crates/wasm-host/src/lib.rs b/crates/wasm-host/src/lib.rs index 403fb6359..8e905d967 100644 --- a/crates/wasm-host/src/lib.rs +++ b/crates/wasm-host/src/lib.rs @@ -261,6 +261,22 @@ impl Runtime { }) } + /// Compiles `bytes`, a module or a component, into the cache unless it + /// is there already; whether it compiled. An app's install calls this so + /// that its first call loads from the cache instead of waiting for + /// Cranelift (ADR 0014 phase 3). Without a cache it only checks. + pub fn precompile(&self, bytes: &[u8]) -> Result { + if self.cache_path(bytes).is_some_and(|path| path.is_file()) { + return Ok(false); + } + if component::is_component(bytes) { + self.load_component(bytes) + .map(|program| !program.from_cache()) + } else { + self.load(bytes).map(|program| !program.from_cache()) + } + } + fn compile(&self, bytes: &[u8], cache: Option<&PathBuf>) -> Result { let module = Module::new(&self.engine, bytes).map_err(|e| LoadError::Invalid(format!("{e:#}")))?; From 13649a46b03221201ca165699212a93aa76cbf29 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:15:04 -0700 Subject: [PATCH 6/9] ADR 0014 phase 3: Pulley on OpenHarmony and in tests, the service on Windows, docs Pulley, Wasmtime's interpreter, runs app functions where an app may not generate code: wasm-host's pulley feature, on by itself for iOS and OpenHarmony, selected elsewhere with OCTOSENSE_WASM_PULLEY for tests (phone.yml runs the runtime's tests in it too; about 32 times Cranelift's code). The wasm service now runs on OpenHarmony (Home's OpenHarmony release build compiled with it; no device run) and on Windows, whose new wasm-windows.yml runs the runtime's tests and compiles the shell with the service on windows-2022. iOS stays out until a build with it is compiled. A full disk is ENOSPC on Unix and StorageFull elsewhere. Docs (en, zh-CN): the components' network and host services, compiling at install, the platforms, the tests; ADR 0014's phase 3 status and the Pulley figures. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/phone.yml | 5 +- .github/workflows/wasm-windows.yml | 50 +++++++++++++ crates/shell/Cargo.toml | 13 ++-- crates/shell/build.rs | 5 +- crates/wasm-host/Cargo.toml | 10 +++ crates/wasm-host/src/component/files.rs | 19 ++++- crates/wasm-host/src/lib.rs | 17 +++++ .../adr/0014-app-components-in-webassembly.md | 32 +++++++-- ...014-app-components-in-webassembly.zh-CN.md | 23 ++++-- docs/wasm.md | 71 +++++++++++++------ docs/wasm.zh-CN.md | 53 +++++++++----- tools/check-shell-graph.sh | 6 +- tools/ci_local.py | 2 + 13 files changed, 244 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/wasm-windows.yml diff --git a/.github/workflows/phone.yml b/.github/workflows/phone.yml index ac3bdd90c..c9c6ced1e 100644 --- a/.github/workflows/phone.yml +++ b/.github/workflows/phone.yml @@ -180,6 +180,7 @@ jobs: timeout-minutes: 60 run: | cargo test --locked -p octosense-wasm-host --no-run + cargo test --locked -p octosense-wasm-host --features pulley --no-run cargo test --locked -p makepad-widgets --no-run cargo test --locked -p makepad-script-std --no-run - name: Test Wasm and runtime policy @@ -192,7 +193,9 @@ jobs: # Apps' WebAssembly functions: the runtime's own tests. The `wasm` # service's (fresh state per call; resource, cancellation and # admission bounds) run with the shell's above: `mobile-apps` - # includes `wasm-functions`. + # includes `wasm-functions`. Then the same tests in Pulley, the + # interpreter OpenHarmony and iOS use (ADR 0014). cargo test --locked -p octosense-wasm-host + OCTOSENSE_WASM_PULLEY=1 cargo test --locked -p octosense-wasm-host --features pulley cargo test --locked -p makepad-widgets splash_policy cargo test --locked -p makepad-script-std gate::tests diff --git a/.github/workflows/wasm-windows.yml b/.github/workflows/wasm-windows.yml new file mode 100644 index 000000000..e03bca931 --- /dev/null +++ b/.github/workflows/wasm-windows.yml @@ -0,0 +1,50 @@ +name: WebAssembly on Windows + +# Apps' own functions on Windows (ADR 0014): the runtime's tests +# (crates/wasm-host) and the shell compiled with the `wasm` service, which +# Windows desktop builds include. ci-local cannot run these (tools/ci_local.py, +# NOT_LOCAL), so a pull request that touches them merges on this run. + +on: + push: + branches: [main] + pull_request: + paths: + - 'crates/wasm-host/**' + - 'crates/shell/src/wasm_service.rs' + - 'crates/shell/build.rs' + - 'crates/shell/Cargo.toml' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/wasm-windows.yml' + +permissions: + contents: read + +concurrency: + group: wasm-windows-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows: + runs-on: windows-2022 + timeout-minutes: 55 + env: + PYTHONUTF8: '1' + steps: + - name: Preserve runtime patch bytes + run: git config --global core.autocrlf false + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + - run: python tools/setup.py + - uses: Swatinem/rust-cache@v2 + with: + shared-key: wasm-windows + cache-on-failure: true + - name: The runtime's tests + run: cargo test --locked -p octosense-wasm-host + - name: The shell with the wasm service + run: cargo check --locked -p octosense-shell --features wasm-functions diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 19838f13a..588f400e8 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -148,11 +148,12 @@ octosense-calendar-service = { workspace = true } octosense-ai-host = { workspace = true, features = ["llm"] } # Apps' own WebAssembly functions (the `wasm` service, ADR 0011), compiled by -# Cranelift: only where an app may generate code and the runtime has been -# checked. Windows (not yet checked), iOS (no code generation for apps), -# OpenHarmony (its policy is unknown) and the browser leave it out; build.rs -# sets `wasm_functions` for the same targets. -[target.'cfg(any(target_os = "macos", target_os = "android", all(target_os = "linux", not(target_env = "ohos"))))'.dependencies] +# Cranelift where an app may generate code, and on OpenHarmony, until its +# policy is known, run in Pulley, Wasmtime's interpreter (ADR 0014; the +# runtime selects it there). Windows runs its tests in CI +# (.github/workflows/wasm-windows.yml). iOS (not yet built with it) and the +# browser leave it out; build.rs sets `wasm_functions` for the same targets. +[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "android", target_os = "linux"))'.dependencies] octosense-wasm-host = { workspace = true, optional = true } [features] @@ -192,7 +193,7 @@ dev-mode = [] # The `wasm` host service (ADR 0011), which runs an app's own # WebAssembly functions (crates/wasm-host). On in every standard shell # build (tools/native_apps.py's BASE_DEFAULT); it runs where build.rs sets -# `wasm_functions`: macOS, Linux and Android. +# `wasm_functions`: macOS, Windows, Linux, Android and OpenHarmony. wasm-functions = ["app-hub", "dep:octosense-wasm-host"] # The feature's former name. wasm-lab = ["wasm-functions"] diff --git a/crates/shell/build.rs b/crates/shell/build.rs index b9a57230b..b158882b4 100644 --- a/crates/shell/build.rs +++ b/crates/shell/build.rs @@ -8,7 +8,8 @@ //! `cfg!(native_mobile)`, never as the feature or the targets directly. //! //! `wasm_functions` marks a build that runs the `wasm` service (ADR 0011): -//! feature `wasm-functions` on macOS, Linux or Android, the targets +//! feature `wasm-functions` on macOS, Windows, Linux, Android or OpenHarmony +//! (there in Pulley, Wasmtime's interpreter: ADR 0014), the targets //! Cargo.toml links its runtime for. Code reads `#[cfg(wasm_functions)]`. //! //! `dev_mode` marks a development build (ADR 0004 §13, `src/dev_mode.rs`): @@ -35,7 +36,7 @@ fn main() { println!("cargo:rustc-cfg=mobile_only"); } println!("cargo:rustc-check-cfg=cfg(wasm_functions)"); - let wasm_target = target_os == "macos" || target_os == "android" || (target_os == "linux" && target_env != "ohos"); + let wasm_target = matches!(target_os.as_str(), "macos" | "windows" | "android" | "linux"); if wasm_target && std::env::var_os("CARGO_FEATURE_WASM_FUNCTIONS").is_some() { println!("cargo:rustc-cfg=wasm_functions"); } diff --git a/crates/wasm-host/Cargo.toml b/crates/wasm-host/Cargo.toml index 8c83b1bd9..e012f253b 100644 --- a/crates/wasm-host/Cargo.toml +++ b/crates/wasm-host/Cargo.toml @@ -23,6 +23,16 @@ sha2 = "0.10" # recognised as wasm and every trap ended the app. libc = "0.2.190" +# Pulley, Wasmtime's interpreter (ADR 0014 phase 3): how code runs on iOS, +# where an app may not generate code, and on OpenHarmony, until its policy is +# known. Elsewhere only for its tests (OCTOSENSE_WASM_PULLEY with this +# feature); desktop and Android builds leave it out. +[features] +pulley = ["wasmtime/pulley"] + +[target.'cfg(any(target_os = "ios", target_env = "ohos"))'.dependencies] +wasmtime = { workspace = true, features = ["pulley"] } + [dev-dependencies] wat = "1" # The sample functions natively, to check the module against them. diff --git a/crates/wasm-host/src/component/files.rs b/crates/wasm-host/src/component/files.rs index ff0693c0c..081f81389 100644 --- a/crates/wasm-host/src/component/files.rs +++ b/crates/wasm-host/src/component/files.rs @@ -34,8 +34,21 @@ use super::State; /// 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; +/// A full disk, to the guest: `ENOSPC` where WASI maps it (Linux, Android, +/// macOS, iOS); elsewhere an error of that kind. +fn full() -> std::io::Error { + #[cfg(unix)] + { + std::io::Error::from_raw_os_error(28) + } + #[cfg(not(unix))] + { + std::io::Error::new( + std::io::ErrorKind::StorageFull, + "the storage budget is used up", + ) + } +} /// What an instance may still add to its storage folder (`None`: no /// ceiling), shared with the file streams it has open. @@ -73,7 +86,7 @@ impl Budget { match ledger.left { Some(n) if bytes > n => { ledger.refused = true; - Err(std::io::Error::from_raw_os_error(ENOSPC)) + Err(full()) } Some(n) => { ledger.left = Some(n - bytes); diff --git a/crates/wasm-host/src/lib.rs b/crates/wasm-host/src/lib.rs index 8e905d967..3af33125d 100644 --- a/crates/wasm-host/src/lib.rs +++ b/crates/wasm-host/src/lib.rs @@ -219,6 +219,11 @@ impl Runtime { // Components (ADR 0014) share the engine, its epoch and its cache. config.wasm_component_model(true); config.max_wasm_stack(limits.stack_bytes); + if Runtime::interpreted() { + config + .target("pulley64") + .map_err(|e| format!("Pulley: {e:#}"))?; + } let engine = Engine::new(&config).map_err(|e| format!("{e:#}"))?; let ticker = Arc::new(Ticker::start(engine.clone())); Ok(Runtime { @@ -233,6 +238,18 @@ impl Runtime { &self.limits } + /// Whether code runs in Pulley, Wasmtime's interpreter, rather than as + /// native code Cranelift generates (ADR 0014 phase 3): on iOS, where an + /// app may not generate code, and on OpenHarmony, until its policy is + /// known. With the `pulley` feature, `OCTOSENSE_WASM_PULLEY` selects it + /// on any host, so that its tests run anywhere. Cranelift then compiles + /// to Pulley's bytecode, and the cache keeps it apart (the engine's + /// compatibility hash differs). + pub fn interpreted() -> bool { + cfg!(any(target_os = "ios", target_env = "ohos")) + || (cfg!(feature = "pulley") && std::env::var_os("OCTOSENSE_WASM_PULLEY").is_some()) + } + /// Checks and compiles `bytes`, or takes its compiled code from the cache. pub fn load(&self, bytes: &[u8]) -> Result { if bytes.len() > self.limits.module_bytes { diff --git a/docs/adr/0014-app-components-in-webassembly.md b/docs/adr/0014-app-components-in-webassembly.md index e5b41b929..39ef8dd7c 100644 --- a/docs/adr/0014-app-components-in-webassembly.md +++ b/docs/adr/0014-app-components-in-webassembly.md @@ -5,8 +5,12 @@ 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`). 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): +`crates/shell/src/wasm_service.rs`, with the measurements below. Phase 3's are +`component/net.rs` (`wasi:http`), `component/host.rs` with +`wit/octosense-host.wit` (`octosense:host`), `Runtime::precompile` with +`wasm_service::warm` (compiling at install), and Pulley on OpenHarmony and in +tests; Windows runs the service, its tests in CI. App Hub's and App Flow's +parts are in review, and iOS and phase 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). @@ -84,8 +88,16 @@ component reaches only what its app may already reach. 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 + `network.hosts` under `net`, by a script's rule (the host listed exactly, + any case, any port), over HTTPS except to the device itself. A request + waits outside the guest, where the epoch check cannot end it, so its + timeouts are clamped to the call's deadline, which is 10 s for a + component that may reach the network. + - **`octosense:host`, phase 3:** `request(service, args)` reaches the host + services the app is granted, dispatched on the UI thread as the app's + script's `host.request` is, but with no sheet and no prompt (only + methods a background surface may call), and never `wasm.*`. + - **Never:** `wasi:sockets`, and any import outside these packages. A component that asks for one is refused when it loads (`LoadError::Import`) and by App Hub's gate. 5. **No WIT to write.** @@ -171,6 +183,11 @@ of 200 calls unless the row says otherwise. The module is Wasm Lab's | 1 MiB of `list`, there and back as base64 | 18.4 ms | | | | The smallest call | 0.2 µs | 0.3 µs | | +In Pulley (`OCTOSENSE_WASM_PULLEY=1`, `--features pulley`, same machine), the +Markdown function takes 15.1 ms as a component and 14.1 ms as a module: about +32 times Cranelift's code. The smallest component call is 0.4 µs. OpenHarmony +runs this until its code-generation policy is known. + 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 @@ -179,6 +196,9 @@ example writes a new file each time. ## Open questions -- Whether a component may call host services that open sheets (sign-in, - review). Probably only through the script, never from a component's call. +Settled in phase 3: a component never calls a host service that opens a +sheet or asks the person; such a call belongs to the script. + - Async functions (WASI 0.3) and streaming bodies: after phase 3. +- iOS: a Home build with the service in Pulley; the Rust target is not + installed on the build machine yet. 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 ab94399c7..56f528def 100644 --- a/docs/adr/0014-app-components-in-webassembly.zh-CN.md +++ b/docs/adr/0014-app-components-in-webassembly.zh-CN.md @@ -5,7 +5,10 @@ 状态:提议(2026 年 10 月 9 日)。第 1 阶段(运行时验证原型)位于 `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 阶段是计划。 +测量结果见下文。第 3 阶段的部分是 `component/net.rs`(`wasi:http`)、`component/host.rs` 与 +`wit/octosense-host.wit`(`octosense:host`)、`Runtime::precompile` 与 `wasm_service::warm` +(安装时编译),以及 OpenHarmony 上和测试中的 Pulley;Windows 运行这个服务,其测试在 CI 中运行。 +App Hub 与 App Flow 的部分正在审阅,iOS 和第 4 阶段是计划。 本 ADR 扩展 [ADR 0011](0011-apps-own-functions-in-webassembly.zh-CN.md):核心模块照旧可用。 `main` 上 WebAssembly 的运行方式见 [OctoSense 中的 WebAssembly](../wasm.zh-CN.md)。 @@ -61,8 +64,14 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 的流)替换为计量增长的版本,把释放字节的调用(截断的 `open-at`、`unlink-file-at`)替换为 归还字节的版本。超出预算的写入在组件内部失败,调用的错误会说明原因。我们没有采用"每次调用后 检查":一次调用在检查之前就可能写入数 GB,而且每次调用要遍历文件夹两次。 - - **`wasi:http` 出站(第 3 阶段):** 只能访问清单 `network.hosts` 中的主机,并经过 shell 的网络策略。 - - **从不提供:** `wasi:sockets`,以及上述 WASI 包以外的任何导入。请求这些导入的组件在加载时被拒绝 + - **`wasi:http` 出站(第 3 阶段):** 只能在有 `net` 时访问清单 `network.hosts` 中的主机,规则与 + 脚本相同(主机完全列出,不区分大小写,端口不限),并使用 HTTPS,只有访问设备本身时例外。请求在客体 + 之外等待,epoch 检查无法结束它,因此请求的超时被限制在调用的截止时间内;可以访问网络的组件 + 每次调用有 10 秒。 + - **`octosense:host`(第 3 阶段):** `request(service, args)` 可以调用应用已获授权的宿主服务, + 像应用脚本的 `host.request` 一样在 UI 线程上分派,但不打开面板、不询问用户(只能调用后台界面 + 可以调用的方法),并且绝不调用 `wasm.*`。 + - **从不提供:** `wasi:sockets`,以及上述包以外的任何导入。请求这些导入的组件在加载时被拒绝 (`LoadError::Import`),App Hub 的审核闸门也会拒绝。 5. **无需编写 WIT。** - **Guest SDK(第 2 阶段):** guest crate `octosense-component` 提供 @@ -131,11 +140,17 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 | 1 MiB 的 `list`,以 base64 往返 | 18.4 毫秒 | | | | 最小的调用 | 0.2 微秒 | 0.3 微秒 | | +在 Pulley 中(`OCTOSENSE_WASM_PULLEY=1`、`--features pulley`,同一台机器),Markdown 函数作为组件 +耗时 15.1 毫秒,作为模块耗时 14.1 毫秒,约为 Cranelift 代码的 32 倍;最小的组件调用为 0.4 微秒。 +在 OpenHarmony 的代码生成策略明确之前,OpenHarmony 以这种方式运行。 + 计量配额需要知道调用开始时文件夹里有多少数据:遍历一次文件夹,与引擎的调用相同。10 个文件耗时 0.12 毫秒,1,000 个 2.0 毫秒,10,000 个 23.8 毫秒(50 次的中位数)。在 APFS 上反复改写同一个 文件很慢,原生代码也一样(每次改写的中位数约 6 毫秒),因此示例每次都写入新文件。 ## 待定问题 -- 组件能否调用会打开面板(登录、审阅)的主机服务。很可能只允许经由脚本,绝不允许在组件的调用中进行。 +第 3 阶段已定:组件绝不调用会打开面板或询问用户的宿主服务,这类调用属于脚本。 + - 异步函数(WASI 0.3)和流式内容:第 3 阶段之后。 +- iOS:在 Pulley 中带这个服务构建 Home;构建机器上还没有安装 Rust 的 iOS 目标。 diff --git a/docs/wasm.md b/docs/wasm.md index fb70809ea..9a1ba492f 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`); 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 | +| [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, Windows, Linux, Android and OpenHarmony (feature `wasm-functions`; on OpenHarmony in Pulley, Wasmtime's interpreter); none for iOS | 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 | @@ -73,8 +73,8 @@ two kinds apart by the file's preamble, and a bundle may carry both. - **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. + `wasi:filesystem`, `wasi:http`, `wasi:io`, `wasi:random` and + `octosense:host`, such as sockets, 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 @@ -85,11 +85,28 @@ two kinds apart by the file's preamble, and a bundle may carry both. 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. +- **The network.** With the `net` capability, its `wasi:http` requests reach + the app's `network.hosts` by a script's rule: the host listed exactly, + without regard to case, on any port. Requests use HTTPS, except to this + device itself (`localhost`, `127.0.0.1`, `[::1]`). A component that may + reach the network gets 10 s a call instead of 2 s, and each request's + connect, first-byte and between-bytes timeouts end with the call. A refused + request fails inside the component (`HttpRequestDenied`), and the app's log + says why: "a request to … was refused: it is not one of the app's network + hosts". +- **Host services.** Through `octosense:host` + ([`crates/wasm-host/wit/octosense-host.wit`](../crates/wasm-host/wit/octosense-host.wit)), + `request(service, args)` calls one of the host services its app is + granted, as the app's script does: the same families, dispatched on the UI + thread, but never with a sheet or a prompt, so only methods a background + surface may call. `wasm.*` is refused, since the app's worker is busy with + the call, and the call's deadline bounds the wait. 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. +the same code as a module. In Pulley it runs about 32 times slower than +that. ### How a module reaches a device @@ -158,6 +175,11 @@ be interrupted, but an expired or cancelled request never goes on to run. A full queue or no free worker fails the request at once instead of making it wait. +When App Hub installs or updates an app, the shell compiles its functions into +the cache in the background (`wasm_service::warm`): one app at a time, on a +thread of its own, from the admitted bundle and only with the `wasm` grant. The +app's first call then loads from the cache instead of waiting for Cranelift. + ### Agent tools An app's `tools.json` may map a tool to one of its functions with @@ -175,9 +197,9 @@ to `wasm.find_slots`, `wasm.fuzzy_rank` and `wasm.text_diff`, all `read`. | macOS | Runs. Validated in a hidden desktop shell (ADR 0011). Wasmtime catches traps with Mach exception ports, not signals. | | Linux | Runs. The runtime and service tests pass on x86_64, with traps caught by signals, and Wasm Lab ran in a headless desktop (8 October 2026). | | Android | Runs in Home's default build. Needs libc 0.2.190 or later, which `crates/wasm-host` requires: with 0.2.189 every trap ended the process. Validated on a Redmi Note 12 (ADR 0011) and by the [phone acceptance](#phone-acceptance) on a OnePlus 6. The runtime adds about 7.5 MiB of code; compiling a 433 KiB module takes about 0.4 s on a Snapdragon 685, and loading it from the cache 5–11 ms. | -| Windows | Not included. The runtime stays out until it has been checked on Windows. | -| iOS | Not included. Apps may not JIT there; the option is Pulley, Wasmtime's interpreter, about 17 times slower than Cranelift. | -| OpenHarmony | Not included. Its JIT policy is **unverified**. | +| Windows | Included (ADR 0014). [`wasm-windows.yml`](../.github/workflows/wasm-windows.yml) runs the runtime's tests and compiles the shell with the service on GitHub's `windows-2022`. A desktop run on Windows is **unverified**. | +| iOS | Not included. Apps may not generate code there, so the runtime would use Pulley, as on OpenHarmony; no iOS build with the service has been compiled yet. | +| OpenHarmony | Included, in Pulley, Wasmtime's interpreter, until OpenHarmony's code-generation policy is known: Cranelift compiles to Pulley's bytecode and nothing runs as native code. Home's OpenHarmony release build compiled with the service (9 October 2026, `cargo-makepad makepad ohos … deveco -p octosense-home --release`); no device has run it (**unverified**). Pulley is about 32 times slower than Cranelift (ADR 0014). | On a managed Mac, Microsoft Defender holds the first open of a freshly written cache file for about a second; ADR 0011 has the measurements. @@ -231,25 +253,36 @@ 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.rs` runs four 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. + memory cap; `fetch`, whose `wasi:http` reaches a local server only under a + granted host, and whose request to a server that never answers ends at the + deadline; `hostcall`, which calls host services through the embedder; 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. 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. + instance's state, the app's storage folder and its quota, an app without + the storage capability, a component shipped as a system app through App + Hub's admission, requests that reach only the app's hosts, host calls that + reach only its granted services, and an installed app's first call + loading from the cache. - `cargo run --release -p octosense-wasm-host --example measure_component` prints a component's timings against a module and native Rust. +- `OCTOSENSE_WASM_PULLEY=1 cargo test --locked -p octosense-wasm-host --features pulley` + runs the same tests in Pulley, as OpenHarmony runs them; `phone.yml` runs + it too. 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`. +`notes.component.wasm` (SHA-256 `963c965b…`), `netprobe.component.wasm` +(`98b2402a…`), `fetch.component.wasm` (`6eaf6ac8…`) and +`hostcall.component.wasm` (`d39f2a8e…`). 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 @@ -277,15 +310,13 @@ command. - No release includes the service yet: the first desktop and Home releases built from `main` after #400 will. -- Windows, iOS (Pulley) and OpenHarmony. +- iOS: building Home for iOS with the service (in Pulley). - CPU fairness between apps, and a bound on the disk cache, which nothing evicts. -- Compiling at install time (store apps) or build time (system apps), to - remove the first compile on a phone. -- 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. +- Compiling system apps' functions ahead: an installed app's are compiled at + install, a system app's at its first call. +- Components on a phone: Android and OpenHarmony builds link the runtime, + but no component has run on a device yet (**unverified**). - Bytes are slow through JSON: a 1 MiB `list` costs about 18 ms there and back, as base64. diff --git a/docs/wasm.zh-CN.md b/docs/wasm.zh-CN.md index 933d67d77..191279b82 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`);组件的 WASI 0.2 来自 `wasmtime-wasi` | 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、Windows、Linux、Android 和 OpenHarmony 上的每个标准桌面版和 Home 构建(特性 `wasm-functions`;OpenHarmony 上在 Wasmtime 的解释器 Pulley 中运行);iOS 不包含 | 已接受,有限支持。测试在 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` 构建 | 浏览器 | 无 | 无法构建 | @@ -57,8 +57,8 @@ Rust crate 构建。服务按文件头区分两者,一个应用包可以同时 陷阱或超时会使实例作废,下一次调用得到新实例;更新、授权变化、撤回或工作线程退出(一分钟 没有调用)都会结束它。 - **能接触什么。** 时钟和随机数。它的 stdout 和 stderr 成为应用的日志行,没有环境变量、 - 参数和 stdin。导入 `wasi:cli`、`wasi:clocks`、`wasi:filesystem`、`wasi:io` 和 - `wasi:random` 以外任何东西(例如套接字或 HTTP)的组件在加载时被拒绝。 + 参数和 stdin。导入 `wasi:cli`、`wasi:clocks`、`wasi:filesystem`、`wasi:http`、`wasi:io`、 + `wasi:random` 和 `octosense:host` 以外任何东西(例如套接字)的组件在加载时被拒绝。 - **文件。** 应用有 `storage` 能力(有账户的应用还需已登录账户)时,应用自己的存储文件夹, 即其脚本的 `fs.*` 看到的那个,就是组件的 `/`。设备上的其他东西一概不可见;没有这个能力 时没有文件系统。 @@ -66,9 +66,21 @@ Rust crate 构建。服务按文件头区分两者,一个应用包可以同时 内部失败:`ftruncate` 报告磁盘已满,普通写入报告 I/O 错误,因为 wasi-libc 把任何失败的 流写入都报告为 I/O 错误。这时请求的错误以 "a write was refused: the storage budget is used up" 结尾。改写、截断和删除会归还字节,因此组件可以自己腾出空间。 +- **网络。** 应用有 `net` 能力时,组件的 `wasi:http` 请求按脚本的规则访问应用的 + `network.hosts`:主机必须完全列出,不区分大小写,端口不限。请求使用 HTTPS,只有访问设备本身 + (`localhost`、`127.0.0.1`、`[::1]`)时才允许普通 HTTP。可以访问网络的组件每次调用有 10 秒, + 而不是 2 秒;每个请求的连接、首字节和字节间超时都随调用结束。被拒绝的请求在组件内部失败 + (`HttpRequestDenied`),应用的日志会说明原因:"a request to … was refused: it is not one + of the app's network hosts"。 +- **宿主服务。** 通过 `octosense:host` + ([`crates/wasm-host/wit/octosense-host.wit`](../crates/wasm-host/wit/octosense-host.wit)), + 组件可以用 `request(service, args)` 调用应用已获授权的宿主服务,与应用的脚本相同:同样的服务族, + 在 UI 线程上分派,但绝不打开面板、不询问用户,因此只能调用后台界面可以调用的方法。`wasm.*` + 被拒绝,因为应用的工作线程正忙于这次调用;调用的截止时间限制等待时长。 ADR 0014 记录了开销。在 M 系列 Mac 上,313 KiB 的测试组件约 40 毫秒完成编译,从缓存加载 -约 4 毫秒;它的 Markdown 函数比原生 Rust 慢约 2.6 倍,与同一段代码作为模块时接近。 +约 4 毫秒;它的 Markdown 函数比原生 Rust 慢约 2.6 倍,与同一段代码作为模块时接近。在 Pulley +中还要再慢约 32 倍。 ### 模块如何到达设备 @@ -126,6 +138,10 @@ ADR 0014 记录了开销。在 M 系列 Mac 上,313 KiB 的测试组件约 40 队列已满或没有空闲的工作线程时,请求会立即失败,不会等待。 +App Hub 安装或更新应用时,外壳会在后台把它的函数编译进缓存(`wasm_service::warm`):一次一个 +应用,在单独的线程上,只编译已准入的应用包,并且只在应用有 `wasm` 授权时进行。这样应用的第一次 +调用就从缓存加载,不必等待 Cranelift 编译。 + ### Agent 工具 应用的 `tools.json` 可以用 `"host_method": "wasm."` 把一个工具映射到自己的 @@ -142,9 +158,9 @@ ADR 0014 记录了开销。在 M 系列 Mac 上,313 KiB 的测试组件约 40 | macOS | 运行。已在隐藏的桌面外壳中验证(ADR 0011)。Wasmtime 在这里用 Mach 异常端口而不是信号捕获陷阱。 | | Linux | 运行。运行时和服务的测试在 x86_64 上通过,陷阱由信号捕获;Wasm Lab 在无头桌面中运行过(2026 年 10 月 8 日)。 | | Android | 在 Home 的默认构建中运行。需要 libc 0.2.190 或更高版本,`crates/wasm-host` 已要求这一点:在 0.2.189 下,每个陷阱都会结束进程。已在 Redmi Note 12 上验证(ADR 0011),并在一部 OnePlus 6 上通过[手机验收](#手机验收)。运行时增加约 7.5 MiB 代码;在 Snapdragon 685 上编译一个 433 KiB 的模块约需 0.4 秒,从缓存加载需 5–11 毫秒。 | -| Windows | 不包含。在 Windows 上检查过之前,运行时不进入构建。 | -| iOS | 不包含。那里的应用不允许 JIT;可选方案是 Wasmtime 的解释器 Pulley,比 Cranelift 慢约 17 倍。 | -| OpenHarmony | 不包含。其 JIT 策略**未验证**。 | +| Windows | 包含(ADR 0014)。[`wasm-windows.yml`](../.github/workflows/wasm-windows.yml) 在 GitHub 的 `windows-2022` 上运行运行时的测试,并编译带这个服务的外壳。在 Windows 上运行桌面版**未验证**。 | +| iOS | 不包含。那里的应用不能生成代码,所以运行时会像在 OpenHarmony 上一样使用 Pulley;但还没有编译过带这个服务的 iOS 构建。 | +| OpenHarmony | 包含,在 OpenHarmony 的代码生成策略明确之前于 Wasmtime 的解释器 Pulley 中运行:Cranelift 编译为 Pulley 字节码,没有任何东西以原生代码运行。Home 的 OpenHarmony 发布构建已带着这个服务编译通过(2026 年 10 月 9 日,`cargo-makepad makepad ohos … deveco -p octosense-home --release`);还没有在设备上运行过(**未验证**)。Pulley 比 Cranelift 慢约 32 倍(ADR 0014)。 | 在受管理的 Mac 上,Microsoft Defender 会把新写入的缓存文件的第一次打开拦住约一秒;具体 测量见 ADR 0011。 @@ -190,19 +206,24 @@ 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` +- `crates/wasm-host/tests/component.rs` 运行四个由 `crates/wasm-host/tests/component-guest` 构建的组件:以 JSON 调用未作修改的 crate(pulldown-cmark)、记录与 snake_case 名称、 同一实例保留状态、时钟、随机数与字节、只能访问授予的文件夹(也包括只读)、存储预算、 - 超时与内存上限,以及拒绝导入套接字的组件。 + 超时与内存上限;`fetch`,它的 `wasi:http` 只有在主机获准时才能访问本地服务器,向从不应答的 + 服务器发出的请求在截止时间结束;`hostcall`,它通过嵌入方调用宿主服务;以及拒绝导入套接字的组件。 - 服务测试覆盖工具映射、输入上限、每次调用使用全新实例、更新或授权变化或撤回之后的撤销、 取消和队列上限,以及应用自己的函数回答它的工具和脚本。对组件,测试覆盖带类型的调用、 - 实例的状态、应用的存储文件夹及其配额,以及没有存储能力的应用。 + 实例的状态、应用的存储文件夹及其配额、没有存储能力的应用、作为系统应用经 App Hub 准入发布的 + 组件、只能访问应用主机的请求、只能到达已授权服务的宿主调用,以及已安装应用的第一次调用从缓存加载。 - `cargo run --release -p octosense-wasm-host --example measure_component` 打印组件 相对于模块和原生 Rust 的耗时。 +- `OCTOSENSE_WASM_PULLEY=1 cargo test --locked -p octosense-wasm-host --features pulley` + 在 Pulley 中运行同样的测试,与 OpenHarmony 的运行方式相同;`phone.yml` 也运行它。 测试组件可以逐字节重建:用 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`。 +写出相同的 `notes.component.wasm`(SHA-256 `963c965b…`)、`netprobe.component.wasm` +(`98b2402a…`)、`fetch.component.wasm`(`6eaf6ac8…`)和 `hostcall.component.wasm` +(`d39f2a8e…`)。只需添加一次目标:`rustup target add wasm32-wasip2`。 `phone.yml` 运行第一条命令,服务的测试则作为 Home 测试的一部分运行(`mobile-apps` 包含 `wasm-functions`);`tools/ci-local.sh --linux-host --offload` 在 Linux 构建主机上运行 @@ -225,12 +246,10 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s - 还没有任何发布版本包含这个服务:#400 之后从 `main` 构建的第一个桌面版和 Home 发布版 将会包含。 -- Windows、iOS(Pulley)和 OpenHarmony。 +- iOS:带这个服务(在 Pulley 中)构建 Home。 - 应用之间的 CPU 公平调度,以及磁盘缓存的上限:目前没有任何东西会清理它。 -- 在安装时(商店应用)或构建时(系统应用)编译,省掉手机上的第一次编译。 -- 手机上的组件:Android 构建链接同一个运行时,但还没有组件在设备上运行过(**未验证**)。 -- 组件的网络(受清单 `network.hosts` 约束的 `wasi:http`)和宿主服务(`octosense:host`): - ADR 0014 的第 3 阶段。 +- 提前编译系统应用的函数:已安装应用的函数在安装时编译,系统应用的函数在第一次调用时编译。 +- 手机上的组件:Android 和 OpenHarmony 构建链接了运行时,但还没有组件在设备上运行过(**未验证**)。 - 经 JSON 传字节很慢:1 MiB 的 `list` 以 base64 往返约需 18 毫秒。 ## 引擎插件:`photo` 和 `vector` 服务 diff --git a/tools/check-shell-graph.sh b/tools/check-shell-graph.sh index 9fdb87541..8700fdad4 100755 --- a/tools/check-shell-graph.sh +++ b/tools/check-shell-graph.sh @@ -52,7 +52,7 @@ fail() { echo "::error::$*"; exit 1; } # The targets the `wasm` service runs on (crates/shell/Cargo.toml, build.rs). wasm_runs_on() { case $1 in - *-apple-darwin | *-linux-android* | *-unknown-linux-gnu* | *-unknown-linux-musl*) return 0 ;; + *-apple-darwin | *-pc-windows-* | *-linux-android* | *-unknown-linux-gnu* | *-unknown-linux-musl* | *-unknown-linux-ohos*) return 0 ;; *) return 1 ;; esac } @@ -145,12 +145,12 @@ for features in "${feature_sets[@]}"; do done # Where the wasm service does not run, its runtime is not even built. -for target in aarch64-apple-ios aarch64-unknown-linux-ohos x86_64-pc-windows-msvc; do +for target in aarch64-apple-ios; do if linked octosense-wasm-host --locked ${manifest[@]+"${manifest[@]}"} ${package[@]+"${package[@]}"} --target "$target"; then fail "octosense-wasm-host is linked for $target, where the wasm service does not run" fi done -echo "ok: no wasm runtime for iOS, OpenHarmony or Windows" +echo "ok: no wasm runtime for iOS" # One octos: every octos-* crate in the lock from one octos-org/octos rev # (the `nix` patch taken from the octos repo is not an octos crate). diff --git a/tools/ci_local.py b/tools/ci_local.py index 2ca6c6d53..e60f61a00 100644 --- a/tools/ci_local.py +++ b/tools/ci_local.py @@ -82,6 +82,8 @@ "compilation or another OS is not native account evidence", "embedded-browser.yml": "native Windows WebView2 acceptance needs a Windows desktop and installed engine; " "ci-local-merge requires a successful GitHub run on the exact PR head", + "wasm-windows.yml": "apps' WebAssembly functions on Windows need a Windows runner; " + "ci-local-merge requires a successful GitHub run on the exact PR head", "release-desktop.yml": "a release workflow (tag push or manual run): signed packages for three OSes, " "with a matrix, environments, secrets and artifacts; nothing a pull request merges " "on depends on it, and its packaging scripts' tests run in desktop.yml", From 9d069bb842a2c4dfa37ecfd9cdb4d66b18ec1e19 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:44:29 -0700 Subject: [PATCH 7/9] Components: HTTPS to public hosts only, never this device or its network A component's wasi:http no longer reaches the device itself, even when the app lists localhost or 127.0.0.1: an app that lists one would otherwise reach every local service (the shell's remote control, a developer's servers), over plain HTTP too. Loopback, private, link-local and similar addresses, localhost, single-label and .local/.lan/.internal names are refused, as makepad's public_https_host refuses them. Grants::http_local lets tests and a developer's runs use a local server; the shell never sets it (its own tests do). Scripts still allow a listed loopback host; that is reported, not changed here. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/wasm_service.rs | 7 + .../wasm-host/examples/measure_component.rs | 1 + crates/wasm-host/src/component.rs | 8 +- crates/wasm-host/src/component/net.rs | 130 +++++++++++++++--- crates/wasm-host/tests/component.rs | 7 + .../adr/0014-app-components-in-webassembly.md | 4 +- ...014-app-components-in-webassembly.zh-CN.md | 3 +- docs/wasm.md | 7 +- docs/wasm.zh-CN.md | 6 +- 9 files changed, 149 insertions(+), 24 deletions(-) diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index c05181cc7..2e2e5e2af 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -78,6 +78,8 @@ fn area_env() -> Arc { } static RUNTIME: OnceLock> = OnceLock::new(); +/// Tests only: let components reach a local server (see [`Grants::http_local`]). +static HTTP_LOCAL_FOR_TESTS: AtomicBool = AtomicBool::new(false); // ------------------------------------------------- a component's host calls @@ -952,6 +954,9 @@ impl Lab { storage_dir: area.as_ref().map(|a| a.root.clone()), read_only: false, http_hosts: self.http_hosts.clone(), + // This device and its network are no app's to reach; only the + // service's own tests use a local server. + http_local: cfg!(test) && HTTP_LOCAL_FOR_TESTS.load(Ordering::Relaxed), }; let app = self.app.clone(); let host_calls = self.host_calls.clone(); @@ -2082,6 +2087,8 @@ mod tests { assert!(http_hosts(&manifest(false)).is_empty()); let host_dir = root.join(".host"); let runtime = runtime(&host_dir).unwrap(); + // The server is on this device, which only a test may reach. + HTTP_LOCAL_FOR_TESTS.store(true, Ordering::Relaxed); let url = json!(format!("http://127.0.0.1:{port}/hi")); for granted in [true, false] { let admission = Admission { diff --git a/crates/wasm-host/examples/measure_component.rs b/crates/wasm-host/examples/measure_component.rs index 818a42fb1..17909be4a 100644 --- a/crates/wasm-host/examples/measure_component.rs +++ b/crates/wasm-host/examples/measure_component.rs @@ -102,6 +102,7 @@ fn main() { storage_dir: Some(storage.clone()), read_only: false, http_hosts: Vec::new(), + http_local: false, }; let with_files = median_us(50, || { drop( diff --git a/crates/wasm-host/src/component.rs b/crates/wasm-host/src/component.rs index 0d7c7651d..1e7e5cbf7 100644 --- a/crates/wasm-host/src/component.rs +++ b/crates/wasm-host/src/component.rs @@ -86,8 +86,12 @@ pub struct Grants { /// Open the storage folder read-only (its quota is used up). pub read_only: bool, /// The hosts its `wasi:http` requests may reach, by a script's rule - /// (`host`, or `host:port`). Empty: none. + /// (`host`, or `host:port`), over HTTPS. Empty: none. pub http_hosts: Vec, + /// Let those requests reach this device and its local network too, over + /// plain HTTP as well (still only listed hosts): for tests and a + /// developer's runs. The shell never sets it. + pub http_local: bool, } /// One function an app may call, with its WIT-like signature. @@ -295,7 +299,7 @@ impl Runtime { deadline: deadline.min(Instant::now() + call_deadline), pending, }); - let mut hosts = net::Hosts::new(grants.http_hosts.clone()); + let mut hosts = net::Hosts::new(grants.http_hosts.clone(), grants.http_local); hosts.deadline = Some(guard.deadline); let mut store = Store::new( &self.engine, diff --git a/crates/wasm-host/src/component/net.rs b/crates/wasm-host/src/component/net.rs index 95ed8d0fc..9b6a1dfcc 100644 --- a/crates/wasm-host/src/component/net.rs +++ b/crates/wasm-host/src/component/net.rs @@ -6,8 +6,13 @@ //! listed `host:port` only that port. Nothing else is reachable, and with no //! hosts nothing at all, so a component may import `wasi:http` and still //! reach nothing until its app is granted hosts. The request is HTTPS, as -//! the contract has it for an app's hosts, except to this device itself -//! (`localhost`, `127.0.0.1`, `[::1]`), where plain HTTP is allowed too. +//! the contract has it for an app's hosts, and it never goes to this device +//! or its local network — loopback, private, link-local and similar +//! addresses, `localhost` and `.local`-style or single-label names — even +//! when the app lists one: the services there (the shell's own, a +//! developer's) are no app's to reach. A test or a developer's run may allow +//! them ([`super::Grants::http_local`]); the shell never does. A public name +//! that resolves to a local address is not caught here (as for scripts). //! //! A request waits for the network outside the guest, where the deadline's //! epoch check cannot end it, so its timeouts (connect, first byte, between @@ -23,6 +28,9 @@ use wasmtime_wasi_http::{Error, RequestOptions, WasiBody, WasiHttpHooks}; #[derive(Debug, Default)] pub(crate) struct Hosts { hosts: Vec, + /// This device and its local network too, over plain HTTP as well: + /// tests and developers' runs only. + local: bool, /// The running call's deadline, set for every call. pub(crate) deadline: Option, /// The requests refused since the caller last asked: the host, and why. @@ -30,9 +38,10 @@ pub(crate) struct Hosts { } impl Hosts { - pub(crate) fn new(hosts: Vec) -> Hosts { + pub(crate) fn new(hosts: Vec, local: bool) -> Hosts { Hosts { hosts, + local, ..Hosts::default() } } @@ -57,14 +66,66 @@ impl Hosts { if !listed { return Err(NOT_LISTED); } - let this_device = matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]"); - if uri.scheme() != Some(&http::uri::Scheme::HTTPS) && !this_device { + let local = is_local(&host); + if local && !self.local { + return Err("it is this device or its local network, which a component never reaches"); + } + if uri.scheme() != Some(&http::uri::Scheme::HTTPS) && !(local && self.local) { return Err("it is plain HTTP; a component's requests use HTTPS"); } Ok(()) } } +/// Whether `host` (lowercased, as a URI writes it) names this device or its +/// local network: makepad's `splash_policy::public_https_host`, inverted. +fn is_local(host: &str) -> bool { + let bare = host.trim_start_matches('[').trim_end_matches(']'); + if let Ok(ip) = bare.parse::() { + return !is_public_ip(ip); + } + // An IP in a form only some resolvers read as one (`0x7f.1`, `127.1`), + // or a single-label name, is not a public host either. + let last = host.rsplit('.').next().unwrap_or(""); + let numeric = last.bytes().all(|b| b.is_ascii_digit()) || last.starts_with("0x"); + numeric + || !host.contains('.') + || host.starts_with('[') + || host == "localhost" + || [".localhost", ".internal", ".local", ".lan", ".home.arpa"] + .iter() + .any(|suffix| host.ends_with(suffix)) +} + +fn is_public_ip(ip: std::net::IpAddr) -> bool { + use std::net::IpAddr; + match ip { + IpAddr::V4(v4) => { + let [a, b, ..] = v4.octets(); + !(v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_unspecified() + || v4.is_broadcast() + || v4.is_documentation() + || v4.is_multicast() + || a == 0 + || (a == 100 && (64..128).contains(&b))) + } + IpAddr::V6(v6) => { + if let Some(v4) = v6.to_ipv4_mapped() { + return is_public_ip(IpAddr::V4(v4)); + } + let first = v6.segments()[0]; + !(v6.is_loopback() + || v6.is_unspecified() + || v6.is_multicast() + || (first & 0xfe00) == 0xfc00 + || (first & 0xffc0) == 0xfe80) + } + } +} + type Response = ( http::Response, Box> + Send>, @@ -119,12 +180,16 @@ impl WasiHttpHooks for Hosts { mod tests { use super::*; - fn allows(hosts: &[&str], uri: &str) -> bool { - Hosts::new(hosts.iter().map(|h| h.to_string()).collect()) + fn allows_with(local: bool, hosts: &[&str], uri: &str) -> bool { + Hosts::new(hosts.iter().map(|h| h.to_string()).collect(), local) .allows(&uri.parse().unwrap()) .is_ok() } + fn allows(hosts: &[&str], uri: &str) -> bool { + allows_with(false, hosts, uri) + } + /// A script's rule: a listed host matches any port and any case, a /// listed host:port only that port, and nothing else matches. #[test] @@ -134,9 +199,14 @@ mod tests { &["api.example.com"], "https://API.Example.com:8443/" )); - assert!(allows(&["127.0.0.1:8141"], "http://127.0.0.1:8141/x")); - assert!(!allows(&["127.0.0.1:8141"], "http://127.0.0.1:8142/x")); - assert!(!allows(&["127.0.0.1:8141"], "http://127.0.0.1/x")); + assert!(allows( + &["api.example.com:8443"], + "https://api.example.com:8443/" + )); + assert!(!allows( + &["api.example.com:8443"], + "https://api.example.com:9443/" + )); assert!(!allows(&["api.example.com"], "https://evil.example.com/")); assert!(!allows(&["example.com"], "https://api.example.com/")); assert!(!allows( @@ -147,13 +217,41 @@ mod tests { assert!(!allows(&["api.example.com"], "/relative")); } - /// HTTPS, except to this device. + /// HTTPS only, and never this device or its local network, even listed. #[test] - fn plain_http_reaches_only_this_device() { + fn a_request_never_reaches_this_device_or_its_network() { assert!(!allows(&["api.example.com"], "http://api.example.com/")); - assert!(allows(&["localhost"], "http://localhost:8080/")); - assert!(allows(&["127.0.0.1"], "http://127.0.0.1:8080/")); - assert!(allows(&["[::1]"], "http://[::1]:8080/")); - assert!(!allows(&["192.168.1.2"], "http://192.168.1.2/")); + for (listed, url) in [ + ("localhost", "https://localhost:8080/"), + ("127.0.0.1", "https://127.0.0.1:47631/"), + ("[::1]", "https://[::1]/"), + ("10.0.0.2", "https://10.0.0.2/"), + ("192.168.1.2", "https://192.168.1.2/"), + ("169.254.169.254", "https://169.254.169.254/"), + ("[fd00::1]", "https://[fd00::1]/"), + ("printer.local", "https://printer.local/"), + ("router.lan", "https://router.lan/"), + ("db.internal", "https://db.internal/"), + ("intranet", "https://intranet/"), + ("0x7f.1", "https://0x7f.1/"), + ("127.1", "https://127.1/"), + ] { + assert!(!allows(&[listed], url), "{url}"); + } + assert!(allows(&["93.184.215.14"], "https://93.184.215.14/")); + } + + /// A test or a developer's run may reach this device, over plain HTTP + /// too; still only the listed hosts. + #[test] + fn local_runs_may_reach_this_device() { + assert!(allows_with(true, &["127.0.0.1"], "http://127.0.0.1:8080/")); + assert!(allows_with(true, &["localhost"], "http://localhost:8080/")); + assert!(!allows_with(true, &["127.0.0.1"], "http://localhost:8080/")); + assert!(!allows_with( + true, + &["api.example.com"], + "http://api.example.com/" + )); } } diff --git a/crates/wasm-host/tests/component.rs b/crates/wasm-host/tests/component.rs index 19b8c7045..32836178a 100644 --- a/crates/wasm-host/tests/component.rs +++ b/crates/wasm-host/tests/component.rs @@ -160,6 +160,7 @@ fn files_live_only_in_the_granted_storage_folder() { storage_dir: Some(dir.clone()), read_only: false, http_hosts: Vec::new(), + http_local: false, }; let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); let written = instance @@ -191,6 +192,7 @@ fn files_live_only_in_the_granted_storage_folder() { storage_dir: Some(dir.clone()), read_only: true, http_hosts: Vec::new(), + http_local: false, }; let mut reader = rt .instantiate_component(&program, &read_only, None) @@ -233,6 +235,7 @@ fn the_storage_budget_refuses_growth_and_returns_freed_bytes() { storage_dir: Some(dir.clone()), read_only: false, http_hosts: Vec::new(), + http_local: false, }; let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); assert_eq!(instance.storage_budget(), None); @@ -334,6 +337,8 @@ fn http_reaches_only_the_apps_hosts() { let port = server.rsplit(':').next().unwrap().to_string(); let grants = Grants { http_hosts: vec![server.clone()], + // A local test server: this device, which only a test may reach. + http_local: true, ..Grants::default() }; let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); @@ -382,6 +387,8 @@ fn a_request_that_never_answers_ends_at_the_deadline() { let server = serve(None); let grants = Grants { http_hosts: vec![server.clone()], + // A local test server: this device, which only a test may reach. + http_local: true, ..Grants::default() }; let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); diff --git a/docs/adr/0014-app-components-in-webassembly.md b/docs/adr/0014-app-components-in-webassembly.md index 39ef8dd7c..e3b478d75 100644 --- a/docs/adr/0014-app-components-in-webassembly.md +++ b/docs/adr/0014-app-components-in-webassembly.md @@ -89,7 +89,9 @@ component reaches only what its app may already reach. of the folder a call. - **`wasi:http` outgoing, phase 3:** only to the hosts in the manifest's `network.hosts` under `net`, by a script's rule (the host listed exactly, - any case, any port), over HTTPS except to the device itself. A request + any case, any port), over HTTPS, and never to the device itself or its + local network, even listed (stricter than a script's rule, which lets a + listed `127.0.0.1` reach every local service). A request waits outside the guest, where the epoch check cannot end it, so its timeouts are clamped to the call's deadline, which is 10 s for a component that may reach the network. 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 56f528def..3a4eeb8b7 100644 --- a/docs/adr/0014-app-components-in-webassembly.zh-CN.md +++ b/docs/adr/0014-app-components-in-webassembly.zh-CN.md @@ -65,7 +65,8 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 归还字节的版本。超出预算的写入在组件内部失败,调用的错误会说明原因。我们没有采用"每次调用后 检查":一次调用在检查之前就可能写入数 GB,而且每次调用要遍历文件夹两次。 - **`wasi:http` 出站(第 3 阶段):** 只能在有 `net` 时访问清单 `network.hosts` 中的主机,规则与 - 脚本相同(主机完全列出,不区分大小写,端口不限),并使用 HTTPS,只有访问设备本身时例外。请求在客体 + 脚本相同(主机完全列出,不区分大小写,端口不限),使用 HTTPS,并且即使列出也绝不访问设备本身或其本地网络 + (比脚本的规则更严格:脚本列出 `127.0.0.1` 就能访问本机的所有服务)。请求在客体 之外等待,epoch 检查无法结束它,因此请求的超时被限制在调用的截止时间内;可以访问网络的组件 每次调用有 10 秒。 - **`octosense:host`(第 3 阶段):** `request(service, args)` 可以调用应用已获授权的宿主服务, diff --git a/docs/wasm.md b/docs/wasm.md index 9a1ba492f..5a58f94b9 100644 --- a/docs/wasm.md +++ b/docs/wasm.md @@ -87,8 +87,11 @@ two kinds apart by the file's preamble, and a bundle may carry both. and deleting give bytes back, so a component can free space. - **The network.** With the `net` capability, its `wasi:http` requests reach the app's `network.hosts` by a script's rule: the host listed exactly, - without regard to case, on any port. Requests use HTTPS, except to this - device itself (`localhost`, `127.0.0.1`, `[::1]`). A component that may + without regard to case, on any port. Requests use HTTPS, and never reach + this device or its local network, even when the app lists such a host: + not loopback, private or link-local addresses, nor `localhost`, + single-label or `.local`, `.lan`, `.internal` names. A public name that + resolves to a local address is not caught yet, as for scripts. A component that may reach the network gets 10 s a call instead of 2 s, and each request's connect, first-byte and between-bytes timeouts end with the call. A refused request fails inside the component (`HttpRequestDenied`), and the app's log diff --git a/docs/wasm.zh-CN.md b/docs/wasm.zh-CN.md index 191279b82..05f626c61 100644 --- a/docs/wasm.zh-CN.md +++ b/docs/wasm.zh-CN.md @@ -67,8 +67,10 @@ Rust crate 构建。服务按文件头区分两者,一个应用包可以同时 流写入都报告为 I/O 错误。这时请求的错误以 "a write was refused: the storage budget is used up" 结尾。改写、截断和删除会归还字节,因此组件可以自己腾出空间。 - **网络。** 应用有 `net` 能力时,组件的 `wasi:http` 请求按脚本的规则访问应用的 - `network.hosts`:主机必须完全列出,不区分大小写,端口不限。请求使用 HTTPS,只有访问设备本身 - (`localhost`、`127.0.0.1`、`[::1]`)时才允许普通 HTTP。可以访问网络的组件每次调用有 10 秒, + `network.hosts`:主机必须完全列出,不区分大小写,端口不限。请求使用 HTTPS,并且即使应用列出了 + 这样的主机,也绝不访问设备本身或其本地网络:不访问环回、私有或链路本地地址,也不访问 + `localhost`、单标签名称或 `.local`、`.lan`、`.internal` 名称。解析为本地地址的公共名称目前还 + 检查不到,脚本也是如此。可以访问网络的组件每次调用有 10 秒, 而不是 2 秒;每个请求的连接、首字节和字节间超时都随调用结束。被拒绝的请求在组件内部失败 (`HttpRequestDenied`),应用的日志会说明原因:"a request to … was refused: it is not one of the app's network hosts"。 From 0c43acc25d92541a0e8bbbdb9b0b9c5e1db838ec Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:32:33 -0700 Subject: [PATCH 8/9] Components reach any host: no per-app network gate The ruling of 8 October 2026 removes the per-app runtime gates: an app's network declarations (net, network.hosts) are shown at install and not enforced while it runs, and the OS and the host's API surface are the boundary. A component's wasi:http therefore reaches any host, and the public-HTTPS rule of the previous commit goes with the host list. What stays: a request's timeouts are clamped to the call's deadline, and a component that imports wasi:http gets the network deadline (decided by its imports now, not by a grant). wasm.describe says "network": true for such a component instead of listing hosts. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/wasm_service.rs | 93 ++----- .../wasm-host/examples/measure_component.rs | 2 - crates/wasm-host/src/component.rs | 49 ++-- crates/wasm-host/src/component/net.rs | 255 ++++-------------- crates/wasm-host/src/lib.rs | 4 +- crates/wasm-host/tests/component.rs | 60 ++--- .../adr/0014-app-components-in-webassembly.md | 25 +- ...014-app-components-in-webassembly.zh-CN.md | 16 +- docs/wasm.md | 20 +- docs/wasm.zh-CN.md | 13 +- 10 files changed, 150 insertions(+), 387 deletions(-) diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index 2e2e5e2af..83dc22633 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -78,8 +78,6 @@ fn area_env() -> Arc { } static RUNTIME: OnceLock> = OnceLock::new(); -/// Tests only: let components reach a local server (see [`Grants::http_local`]). -static HTTP_LOCAL_FOR_TESTS: AtomicBool = AtomicBool::new(false); // ------------------------------------------------- a component's host calls @@ -615,9 +613,6 @@ struct Lab { owner: BTreeMap, stats: BTreeMap, admission: Admission, - /// The hosts its components' `wasi:http` may reach: the admitted - /// policy's network hosts, granted `net`. - http_hosts: Vec, /// Its host services, as its components reach them (`octosense:host`). host_calls: Arc, } @@ -691,23 +686,6 @@ fn warm_now(app: &str) -> Result<(usize, usize, f64), String> { Ok((compiled, files.len(), started.elapsed().as_secs_f64() * 1e3)) } -/// The hosts an app's components may reach over HTTP(S): its policy's -/// `network.hosts` when it is granted `net`, as for its script; none when -/// the manifest does not resolve. -fn http_hosts(manifest: &Value) -> Vec { - let resolved = - octosense_app_contract::AppManifest::parse(&manifest.to_string()).and_then(|m| { - octosense_app_contract::resolve( - &m, - &octosense_app_contract::HostLimits::default().with_require_signature(false), - ) - }); - match resolved { - Ok(policy) if policy.allows("net") => policy.hosts.into_iter().collect(), - _ => Vec::new(), - } -} - struct Module { file: String, bytes: usize, @@ -777,7 +755,6 @@ impl Lab { modules: Vec::new(), owner: BTreeMap::new(), stats: BTreeMap::new(), - http_hosts: http_hosts(&admission.manifest), host_calls: Arc::new(AppHostCalls { app: app.to_string(), host_dir: admission.root.join(".host"), @@ -953,10 +930,6 @@ impl Lab { let grants = Grants { storage_dir: area.as_ref().map(|a| a.root.clone()), read_only: false, - http_hosts: self.http_hosts.clone(), - // This device and its network are no app's to reach; only the - // service's own tests use a local server. - http_local: cfg!(test) && HTTP_LOCAL_FOR_TESTS.load(Ordering::Relaxed), }; let app = self.app.clone(); let host_calls = self.host_calls.clone(); @@ -1024,7 +997,7 @@ impl Lab { "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()), - "http_hosts": live.as_ref().map(|(_, g)| g.http_hosts.clone()), + "network": program.uses_network(), "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::>()}), @@ -2030,11 +2003,12 @@ mod tests { let _ = std::fs::remove_dir_all(root); } - /// A component's requests reach its app's own hosts, as its script's - /// do: the admitted manifest's `network.hosts` with `net`, and nothing - /// for an app without `net`. + /// A component's requests reach any host: an app's network + /// declarations are shown at install, not enforced (the ruling of + /// 8 October 2026), so an app with neither `net` nor `network.hosts` + /// reaches a server on this device. #[test] - fn a_components_requests_reach_only_its_apps_hosts() { + fn a_components_requests_reach_any_host() { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); std::thread::spawn(move || { @@ -2068,36 +2042,25 @@ mod tests { bundle.join("fns/fetch.wasm"), ) .unwrap(); - let manifest = |granted: bool| { - let dir = script_apps::tests::stamped_bundle("wasmlab", "net", |_, _| {}); - let mut manifest: Value = - serde_json::from_str(&std::fs::read_to_string(dir.join("manifest.json")).unwrap()) - .unwrap(); - let _ = std::fs::remove_dir_all(&dir); - manifest["requires"] = json!(["wasm-components-v1"]); - if granted { - manifest["capabilities"] = json!(["wasm", "net"]); - manifest["network"] = json!({"hosts": ["127.0.0.1"]}); - } else { - manifest["capabilities"] = json!(["wasm"]); - } - manifest - }; - assert_eq!(http_hosts(&manifest(true)), ["127.0.0.1"]); - assert!(http_hosts(&manifest(false)).is_empty()); + let dir = script_apps::tests::stamped_bundle("wasmlab", "net", |_, _| {}); + let mut manifest: Value = + serde_json::from_str(&std::fs::read_to_string(dir.join("manifest.json")).unwrap()) + .unwrap(); + let _ = std::fs::remove_dir_all(&dir); + manifest["requires"] = json!(["wasm-components-v1"]); + manifest["capabilities"] = json!(["wasm"]); + assert!(manifest.get("network").is_none()); let host_dir = root.join(".host"); let runtime = runtime(&host_dir).unwrap(); - // The server is on this device, which only a test may reach. - HTTP_LOCAL_FOR_TESTS.store(true, Ordering::Relaxed); - let url = json!(format!("http://127.0.0.1:{port}/hi")); - for granted in [true, false] { - let admission = Admission { - root: root.clone(), - bundle: bundle.clone(), - manifest: manifest(granted), - }; - let mut lab = Lab::from_bundle("os.wasmnet", runtime, admission, || Ok(())).unwrap(); + let admission = Admission { + root: root.clone(), + bundle: bundle.clone(), + manifest, + }; + let mut lab = Lab::from_bundle("os.wasmnet", runtime, admission, || Ok(())).unwrap(); + for host in ["127.0.0.1", "localhost"] { let (_, reply) = pending_reply("os.wasmnet", &host_dir); + let url = json!(format!("http://{host}:{port}/hi")); let answer = lab.answer( "get", url.to_string().as_bytes(), @@ -2105,17 +2068,9 @@ mod tests { Instant::now() + REQUEST_TIMEOUT, reply, ); - if granted { - assert_eq!(answer.unwrap(), "200 hello"); - assert_eq!( - lab.describe()["modules"][0]["http_hosts"], - json!(["127.0.0.1"]) - ); - } else { - let error = answer.unwrap_err(); - assert!(error.contains("HttpRequestDenied"), "{error}"); - } + assert_eq!(answer.unwrap(), "200 hello", "{host}"); } + assert_eq!(lab.describe()["modules"][0]["network"], json!(true)); let _ = std::fs::remove_dir_all(root); } diff --git a/crates/wasm-host/examples/measure_component.rs b/crates/wasm-host/examples/measure_component.rs index 17909be4a..f2e76e72e 100644 --- a/crates/wasm-host/examples/measure_component.rs +++ b/crates/wasm-host/examples/measure_component.rs @@ -101,8 +101,6 @@ fn main() { let grants = Grants { storage_dir: Some(storage.clone()), read_only: false, - http_hosts: Vec::new(), - http_local: false, }; let with_files = median_us(50, || { drop( diff --git a/crates/wasm-host/src/component.rs b/crates/wasm-host/src/component.rs index 1e7e5cbf7..a4c21f6e7 100644 --- a/crates/wasm-host/src/component.rs +++ b/crates/wasm-host/src/component.rs @@ -85,13 +85,6 @@ pub struct Grants { pub storage_dir: Option, /// Open the storage folder read-only (its quota is used up). pub read_only: bool, - /// The hosts its `wasi:http` requests may reach, by a script's rule - /// (`host`, or `host:port`), over HTTPS. Empty: none. - pub http_hosts: Vec, - /// Let those requests reach this device and its local network too, over - /// plain HTTP as well (still only listed hosts): for tests and a - /// developer's runs. The shell never sets it. - pub http_local: bool, } /// One function an app may call, with its WIT-like signature. @@ -117,6 +110,8 @@ pub struct ComponentProgram { /// the reason, so `wasm.functions` can say why they are missing. skipped: Vec<(String, String)>, from_cache: bool, + /// It imports `wasi:http`, so its calls may wait for the network. + uses_network: bool, } impl ComponentProgram { @@ -134,6 +129,12 @@ impl ComponentProgram { pub fn from_cache(&self) -> bool { self.from_cache } + + /// Whether it imports `wasi:http`: it may reach any host, and its calls + /// get the network deadline. + pub fn uses_network(&self) -> bool { + self.uses_network + } } struct State { @@ -145,8 +146,8 @@ struct State { /// What the running call may add to the storage folder. budget: files::Budget, http: wasmtime_wasi_http::WasiHttpCtx, - /// The hosts its requests may reach, and what it was refused. - hosts: net::Hosts, + /// The running call's deadline, for the requests it sends. + network: net::Network, /// Its app's host services (`octosense:host`), when the embedder gives /// them. host_calls: host::Calls, @@ -166,7 +167,7 @@ impl wasmtime_wasi_http::WasiHttpView for State { wasmtime_wasi_http::WasiHttpCtxView { ctx: &mut self.http, table: &mut self.table, - hooks: &mut self.hosts, + hooks: &mut self.network, } } } @@ -210,11 +211,16 @@ impl Runtime { None => self.compile_component(bytes, cached.as_ref())?, }; let (exports, skipped) = check_component(&self.engine, &component)?; + let uses_network = component + .component_type() + .imports(&self.engine) + .any(|(name, _)| name.starts_with("wasi:http/")); Ok(ComponentProgram { component, exports, skipped, from_cache, + uses_network, }) } @@ -288,19 +294,20 @@ impl Runtime { Box::new(|| true) as Box bool + Send + Sync>, ), }; - // A component that may reach the network waits for replies: its - // calls get the longer deadline. - let call_deadline = if grants.http_hosts.is_empty() { - self.limits.deadline - } else { + // A component that imports wasi:http waits for replies: its calls + // get the longer deadline. + let call_deadline = if program.uses_network { self.limits.network_deadline.max(self.limits.deadline) + } else { + self.limits.deadline }; let guard = Arc::new(InvocationGuard { deadline: deadline.min(Instant::now() + call_deadline), pending, }); - let mut hosts = net::Hosts::new(grants.http_hosts.clone(), grants.http_local); - hosts.deadline = Some(guard.deadline); + let network = net::Network { + deadline: Some(guard.deadline), + }; let mut store = Store::new( &self.engine, State { @@ -311,7 +318,7 @@ impl Runtime { // Start-up code may read the folder, not grow it. budget: files::Budget::default(), http: wasmtime_wasi_http::WasiHttpCtx::new(), - hosts, + network, host_calls: None, }, ); @@ -626,7 +633,7 @@ 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.data_mut().hosts.deadline = Some(guard.deadline); + self.store.data_mut().network.deadline = Some(guard.deadline); self.store.data_mut().guard = Some(guard); self.store.set_epoch_deadline(1); let outcome = func @@ -634,10 +641,6 @@ impl ComponentInstance { .map_err(trap); self.store.data_mut().guard = None; self.collect_logs(); - for (host, why) in self.store.data_mut().hosts.take_refused() { - self.logs - .push(format!("a request to {host} was refused: {why}")); - } // 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(); diff --git a/crates/wasm-host/src/component/net.rs b/crates/wasm-host/src/component/net.rs index 9b6a1dfcc..fb8790faf 100644 --- a/crates/wasm-host/src/component/net.rs +++ b/crates/wasm-host/src/component/net.rs @@ -1,22 +1,16 @@ -//! A component's outgoing `wasi:http`: only to its app's own network hosts. +//! A component's outgoing `wasi:http`: to any host. //! -//! The rule is a script's (makepad's `splash_policy::url_allowed`): a -//! request's host must be listed exactly, without regard to ASCII case, as -//! the app contract compares hosts; a listed `host` matches any port, and a -//! listed `host:port` only that port. Nothing else is reachable, and with no -//! hosts nothing at all, so a component may import `wasi:http` and still -//! reach nothing until its app is granted hosts. The request is HTTPS, as -//! the contract has it for an app's hosts, and it never goes to this device -//! or its local network — loopback, private, link-local and similar -//! addresses, `localhost` and `.local`-style or single-label names — even -//! when the app lists one: the services there (the shell's own, a -//! developer's) are no app's to reach. A test or a developer's run may allow -//! them ([`super::Grants::http_local`]); the shell never does. A public name -//! that resolves to a local address is not caught here (as for scripts). +//! An app's network declarations (`net`, `network.hosts`) are shown when it +//! is installed, not enforced while it runs: the architecture ruling of +//! 8 October 2026 removes the per-app runtime gates, and the OS and the +//! host's API surface are the boundary. (The app's Splash script still +//! passes the Splash runtime's URL gate until the Makepad fork drops it; a +//! component never gets one.) //! -//! A request waits for the network outside the guest, where the deadline's -//! epoch check cannot end it, so its timeouts (connect, first byte, between -//! bytes) are clamped to what is left of the call. +//! What the runtime still owes the call: a request waits for the network +//! outside the guest, where the deadline's epoch check cannot end it, so +//! its timeouts (connect, first byte, between bytes) are clamped to what is +//! left of the call. use std::future::Future; use std::time::{Duration, Instant}; @@ -24,106 +18,11 @@ use std::time::{Duration, Instant}; use http_body_util::BodyExt; use wasmtime_wasi_http::{Error, RequestOptions, WasiBody, WasiHttpHooks}; -/// The hosts an instance may reach, and the running call's deadline. +/// The running call's deadline, for the requests it sends. #[derive(Debug, Default)] -pub(crate) struct Hosts { - hosts: Vec, - /// This device and its local network too, over plain HTTP as well: - /// tests and developers' runs only. - local: bool, +pub(crate) struct Network { /// The running call's deadline, set for every call. pub(crate) deadline: Option, - /// The requests refused since the caller last asked: the host, and why. - refused: Vec<(String, &'static str)>, -} - -impl Hosts { - pub(crate) fn new(hosts: Vec, local: bool) -> Hosts { - Hosts { - hosts, - local, - ..Hosts::default() - } - } - - /// The requests refused since the last time this was asked: the host, - /// and why. - pub(crate) fn take_refused(&mut self) -> Vec<(String, &'static str)> { - std::mem::take(&mut self.refused) - } - - /// Whether a request to `uri` may go out, or why not. - fn allows(&self, uri: &http::Uri) -> Result<(), &'static str> { - const NOT_LISTED: &str = "it is not one of the app's network hosts"; - let host = uri.host().ok_or(NOT_LISTED)?.to_ascii_lowercase(); - let host_port = uri.port_u16().map(|port| format!("{host}:{port}")); - let listed = self.hosts.iter().any(|listed| { - listed.eq_ignore_ascii_case(&host) - || host_port - .as_ref() - .is_some_and(|hp| listed.eq_ignore_ascii_case(hp)) - }); - if !listed { - return Err(NOT_LISTED); - } - let local = is_local(&host); - if local && !self.local { - return Err("it is this device or its local network, which a component never reaches"); - } - if uri.scheme() != Some(&http::uri::Scheme::HTTPS) && !(local && self.local) { - return Err("it is plain HTTP; a component's requests use HTTPS"); - } - Ok(()) - } -} - -/// Whether `host` (lowercased, as a URI writes it) names this device or its -/// local network: makepad's `splash_policy::public_https_host`, inverted. -fn is_local(host: &str) -> bool { - let bare = host.trim_start_matches('[').trim_end_matches(']'); - if let Ok(ip) = bare.parse::() { - return !is_public_ip(ip); - } - // An IP in a form only some resolvers read as one (`0x7f.1`, `127.1`), - // or a single-label name, is not a public host either. - let last = host.rsplit('.').next().unwrap_or(""); - let numeric = last.bytes().all(|b| b.is_ascii_digit()) || last.starts_with("0x"); - numeric - || !host.contains('.') - || host.starts_with('[') - || host == "localhost" - || [".localhost", ".internal", ".local", ".lan", ".home.arpa"] - .iter() - .any(|suffix| host.ends_with(suffix)) -} - -fn is_public_ip(ip: std::net::IpAddr) -> bool { - use std::net::IpAddr; - match ip { - IpAddr::V4(v4) => { - let [a, b, ..] = v4.octets(); - !(v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || v4.is_unspecified() - || v4.is_broadcast() - || v4.is_documentation() - || v4.is_multicast() - || a == 0 - || (a == 100 && (64..128).contains(&b))) - } - IpAddr::V6(v6) => { - if let Some(v4) = v6.to_ipv4_mapped() { - return is_public_ip(IpAddr::V4(v4)); - } - let first = v6.segments()[0]; - !(v6.is_loopback() - || v6.is_unspecified() - || v6.is_multicast() - || (first & 0xfe00) == 0xfc00 - || (first & 0xffc0) == 0xfe80) - } - } } type Response = ( @@ -131,7 +30,7 @@ type Response = ( Box> + Send>, ); -impl WasiHttpHooks for Hosts { +impl WasiHttpHooks for Network { fn send_request( &mut self, request: http::Request, @@ -139,33 +38,11 @@ impl WasiHttpHooks for Hosts { fut: Box> + Send>, ) -> Box> + Send> { _ = fut; - let allowed = self.allows(request.uri()); - if let Err(why) = allowed { - let uri = request.uri(); - let host = uri - .authority() - .map(|a| a.to_string()) - .unwrap_or_else(|| uri.to_string()); - self.refused.push((host, why)); - } - let allowed = allowed.is_ok(); let left = self .deadline .map(|deadline| deadline.saturating_duration_since(Instant::now())); Box::new(async move { - if !allowed { - return Err(Error::HttpRequestDenied); - } - let clamp = |asked: Option| match (asked, left) { - (Some(asked), Some(left)) => Some(asked.min(left)), - (asked, left) => asked.or(left), - }; - let options = options.unwrap_or_default(); - let options = RequestOptions { - connect_timeout: clamp(options.connect_timeout), - first_byte_timeout: clamp(options.first_byte_timeout), - between_bytes_timeout: clamp(options.between_bytes_timeout), - }; + let options = clamped(options.unwrap_or_default(), left); let (response, io) = wasmtime_wasi_http::default_send_request(request, Some(options)).await?; Ok(( @@ -176,82 +53,40 @@ impl WasiHttpHooks for Hosts { } } +/// `options` with no timeout longer than `left` (and `left` where it sets +/// none). +fn clamped(options: RequestOptions, left: Option) -> RequestOptions { + let clamp = |asked: Option| match (asked, left) { + (Some(asked), Some(left)) => Some(asked.min(left)), + (asked, left) => asked.or(left), + }; + RequestOptions { + connect_timeout: clamp(options.connect_timeout), + first_byte_timeout: clamp(options.first_byte_timeout), + between_bytes_timeout: clamp(options.between_bytes_timeout), + } +} + #[cfg(test)] mod tests { use super::*; - fn allows_with(local: bool, hosts: &[&str], uri: &str) -> bool { - Hosts::new(hosts.iter().map(|h| h.to_string()).collect(), local) - .allows(&uri.parse().unwrap()) - .is_ok() - } - - fn allows(hosts: &[&str], uri: &str) -> bool { - allows_with(false, hosts, uri) - } - - /// A script's rule: a listed host matches any port and any case, a - /// listed host:port only that port, and nothing else matches. - #[test] - fn a_request_reaches_only_a_listed_host() { - assert!(allows(&["api.example.com"], "https://api.example.com/v1")); - assert!(allows( - &["api.example.com"], - "https://API.Example.com:8443/" - )); - assert!(allows( - &["api.example.com:8443"], - "https://api.example.com:8443/" - )); - assert!(!allows( - &["api.example.com:8443"], - "https://api.example.com:9443/" - )); - assert!(!allows(&["api.example.com"], "https://evil.example.com/")); - assert!(!allows(&["example.com"], "https://api.example.com/")); - assert!(!allows( - &["api.example.com"], - "https://api.example.com.evil.net/" - )); - assert!(!allows(&[], "https://api.example.com/")); - assert!(!allows(&["api.example.com"], "/relative")); - } - - /// HTTPS only, and never this device or its local network, even listed. - #[test] - fn a_request_never_reaches_this_device_or_its_network() { - assert!(!allows(&["api.example.com"], "http://api.example.com/")); - for (listed, url) in [ - ("localhost", "https://localhost:8080/"), - ("127.0.0.1", "https://127.0.0.1:47631/"), - ("[::1]", "https://[::1]/"), - ("10.0.0.2", "https://10.0.0.2/"), - ("192.168.1.2", "https://192.168.1.2/"), - ("169.254.169.254", "https://169.254.169.254/"), - ("[fd00::1]", "https://[fd00::1]/"), - ("printer.local", "https://printer.local/"), - ("router.lan", "https://router.lan/"), - ("db.internal", "https://db.internal/"), - ("intranet", "https://intranet/"), - ("0x7f.1", "https://0x7f.1/"), - ("127.1", "https://127.1/"), - ] { - assert!(!allows(&[listed], url), "{url}"); - } - assert!(allows(&["93.184.215.14"], "https://93.184.215.14/")); - } - - /// A test or a developer's run may reach this device, over plain HTTP - /// too; still only the listed hosts. + /// No timeout outlives the call. #[test] - fn local_runs_may_reach_this_device() { - assert!(allows_with(true, &["127.0.0.1"], "http://127.0.0.1:8080/")); - assert!(allows_with(true, &["localhost"], "http://localhost:8080/")); - assert!(!allows_with(true, &["127.0.0.1"], "http://localhost:8080/")); - assert!(!allows_with( - true, - &["api.example.com"], - "http://api.example.com/" - )); + fn a_requests_timeouts_end_with_the_call() { + let left = Some(Duration::from_secs(3)); + let asked = RequestOptions { + connect_timeout: Some(Duration::from_secs(60)), + first_byte_timeout: Some(Duration::from_secs(1)), + between_bytes_timeout: None, + }; + let got = clamped(asked, left); + assert_eq!(got.connect_timeout, Some(Duration::from_secs(3))); + assert_eq!(got.first_byte_timeout, Some(Duration::from_secs(1))); + assert_eq!(got.between_bytes_timeout, Some(Duration::from_secs(3))); + assert_eq!( + clamped(RequestOptions::default(), None).connect_timeout, + None + ); } } diff --git a/crates/wasm-host/src/lib.rs b/crates/wasm-host/src/lib.rs index 3af33125d..68f6b0312 100644 --- a/crates/wasm-host/src/lib.rs +++ b/crates/wasm-host/src/lib.rs @@ -56,8 +56,8 @@ const LOG_LINES: usize = 64; pub struct Limits { /// How long one call may run. pub deadline: Duration, - /// How long one call may run when it may reach the network (a - /// component granted hosts): it waits for replies. + /// How long one call may run when it may wait for the network (a + /// component that imports `wasi:http`). pub network_deadline: Duration, /// The most linear memory an instance may have. pub memory_bytes: usize, diff --git a/crates/wasm-host/tests/component.rs b/crates/wasm-host/tests/component.rs index 32836178a..5de409b86 100644 --- a/crates/wasm-host/tests/component.rs +++ b/crates/wasm-host/tests/component.rs @@ -159,8 +159,6 @@ fn files_live_only_in_the_granted_storage_folder() { let grants = Grants { storage_dir: Some(dir.clone()), read_only: false, - http_hosts: Vec::new(), - http_local: false, }; let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); let written = instance @@ -191,8 +189,6 @@ fn files_live_only_in_the_granted_storage_folder() { let read_only = Grants { storage_dir: Some(dir.clone()), read_only: true, - http_hosts: Vec::new(), - http_local: false, }; let mut reader = rt .instantiate_component(&program, &read_only, None) @@ -234,8 +230,6 @@ fn the_storage_budget_refuses_growth_and_returns_freed_bytes() { let grants = Grants { storage_dir: Some(dir.clone()), read_only: false, - http_hosts: Vec::new(), - http_local: false, }; let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); assert_eq!(instance.storage_budget(), None); @@ -327,47 +321,35 @@ fn serve(reply: Option<&'static str>) -> String { address } -/// `wasi:http` reaches exactly the app's hosts, by a script's rule; anything -/// else is refused inside the component and logged. +/// `wasi:http` reaches any host, as the app's script does: an app's network +/// declarations are shown at install, not enforced (the ruling of +/// 8 October 2026), and no grant is needed. #[test] -fn http_reaches_only_the_apps_hosts() { +fn http_reaches_any_host() { let rt = runtime(); let program = rt.load_component(FETCH).unwrap(); let server = serve(Some("hello")); let port = server.rsplit(':').next().unwrap().to_string(); - let grants = Grants { - http_hosts: vec![server.clone()], - // A local test server: this device, which only a test may reach. - http_local: true, - ..Grants::default() - }; - let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); + let mut instance = rt + .instantiate_component(&program, &Grants::default(), None) + .unwrap(); assert_eq!( instance .call_json("get", &json!(format!("http://{server}/hi"))) .unwrap(), json!("200 hello") ); - // The same server under another name is another host. - match instance.call_json("get", &json!(format!("http://localhost:{port}/hi"))) { - Err(CallError::Guest(why)) => assert!(why.contains("HttpRequestDenied"), "{why}"), - other => panic!("{other:?}"), - } + // The same server under another name: still reached. + assert_eq!( + instance + .call_json("get", &json!(format!("http://localhost:{port}/hi"))) + .unwrap(), + json!("200 hello") + ); assert!( - instance.take_logs().iter().any(|l| l - == &format!( - "a request to localhost:{port} was refused: it is not one of the app's network hosts" - )), - "the refusal is logged" + instance.take_logs().is_empty(), + "nothing is refused or logged" ); - // No hosts, no network. - let mut offline = rt - .instantiate_component(&program, &Grants::default(), None) - .unwrap(); - match offline.call_json("get", &json!(format!("http://{server}/hi"))) { - Err(CallError::Guest(why)) => assert!(why.contains("HttpRequestDenied"), "{why}"), - other => panic!("{other:?}"), - } } /// A request waits outside the guest, where the epoch check cannot end it: @@ -385,13 +367,9 @@ fn a_request_that_never_answers_ends_at_the_deadline() { .unwrap(); let program = rt.load_component(FETCH).unwrap(); let server = serve(None); - let grants = Grants { - http_hosts: vec![server.clone()], - // A local test server: this device, which only a test may reach. - http_local: true, - ..Grants::default() - }; - let mut instance = rt.instantiate_component(&program, &grants, None).unwrap(); + let mut instance = rt + .instantiate_component(&program, &Grants::default(), None) + .unwrap(); let started = std::time::Instant::now(); let result = instance.call_json("get", &json!(format!("http://{server}/"))); assert!(result.is_err(), "{result:?}"); diff --git a/docs/adr/0014-app-components-in-webassembly.md b/docs/adr/0014-app-components-in-webassembly.md index e3b478d75..429968c99 100644 --- a/docs/adr/0014-app-components-in-webassembly.md +++ b/docs/adr/0014-app-components-in-webassembly.md @@ -87,14 +87,15 @@ component reaches only what its app may already reach. 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` under `net`, by a script's rule (the host listed exactly, - any case, any port), over HTTPS, and never to the device itself or its - local network, even listed (stricter than a script's rule, which lets a - listed `127.0.0.1` reach every local service). A request - waits outside the guest, where the epoch check cannot end it, so its - timeouts are clamped to the call's deadline, which is 10 s for a - component that may reach the network. + - **`wasi:http` outgoing, phase 3:** to any host. The app declares `net`, + so that its install sheet says it uses the network, but neither that + nor `network.hosts` is enforced while it runs: the ruling of + 8 October 2026 removed the per-app runtime gates, and the OS and the + host's API surface are the boundary. Holding a component to its app's + `network.hosts`, and then to public HTTPS hosts, was rejected under that + ruling. A request waits outside the guest, where the epoch check cannot + end it, so its timeouts are clamped to the call's deadline, which is + 10 s for a component that imports `wasi:http`. - **`octosense:host`, phase 3:** `request(service, args)` reaches the host services the app is granted, dispatched on the UI thread as the app's script's `host.request` is, but with no sheet and no prompt (only @@ -120,10 +121,10 @@ component reaches only what its app may already reach. 6. **Admission sees what a component reaches.** App Hub's gate reads each component's imports: - it refuses any outside the allowed set; - - it requires `storage` for `wasi:filesystem` and `network.hosts` for - `wasi:http`; + - it requires `storage` for `wasi:filesystem` and `net` for `wasi:http`, + so that the install sheet shows them; - it tells reviewers what the component reaches, for example "files in its - app folder; HTTPS to api.example.com". + app folder; the network". Components need a new app contract version. ## Phases @@ -132,7 +133,7 @@ component reaches only what its app may already reach. | --- | --- | | 1. Runtime spike (done here) | `crates/wasm-host::component`: load, check imports, list exports with WIT signatures, long-lived instances, JSON calls, the WASI subset above with the storage preopen, the deadline, the memory cap and logs. Tests run an unmodified crate (`pulldown-cmark`) built with plain cargo, and refuse a component that imports `wasi:sockets`. | | 2. Usable by developers | The shell's `wasm` service: loading components from `fns/`, `wasm.` calls, per-app instances, the storage grant from the manifest, and storage quota accounting for component writes. A larger input limit for components. The guest SDK and its macro; `octo wasm new/build/doctor`; App Hub's gate check and contract version; docs and an example app. | -| 3. Reach and platforms | `wasi:http` outgoing under `network.hosts`; an `octosense:host` import for host services under the same checks as `host.request`; compiling at install time, so a phone skips the first compile; Pulley (Wasmtime's interpreter) on iOS and, until its JIT policy is known, OpenHarmony; Windows once its CI runs the runtime's tests. | +| 3. Reach and platforms | `wasi:http` outgoing to any host (declared with `net`, not enforced); an `octosense:host` import for host services under the same checks as `host.request`; compiling at install time, so a phone skips the first compile; Pulley (Wasmtime's interpreter) on iOS and, until its JIT policy is known, OpenHarmony; Windows once its CI runs the runtime's tests. | | 4. Shared components | Reviewed, versioned components in App Hub's catalog that apps depend on, like npm packages. The installer verifies them, and every app still gets its own instance and grants. | ## Alternatives considered 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 3a4eeb8b7..b4153da4e 100644 --- a/docs/adr/0014-app-components-in-webassembly.zh-CN.md +++ b/docs/adr/0014-app-components-in-webassembly.zh-CN.md @@ -64,11 +64,11 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 的流)替换为计量增长的版本,把释放字节的调用(截断的 `open-at`、`unlink-file-at`)替换为 归还字节的版本。超出预算的写入在组件内部失败,调用的错误会说明原因。我们没有采用"每次调用后 检查":一次调用在检查之前就可能写入数 GB,而且每次调用要遍历文件夹两次。 - - **`wasi:http` 出站(第 3 阶段):** 只能在有 `net` 时访问清单 `network.hosts` 中的主机,规则与 - 脚本相同(主机完全列出,不区分大小写,端口不限),使用 HTTPS,并且即使列出也绝不访问设备本身或其本地网络 - (比脚本的规则更严格:脚本列出 `127.0.0.1` 就能访问本机的所有服务)。请求在客体 - 之外等待,epoch 检查无法结束它,因此请求的超时被限制在调用的截止时间内;可以访问网络的组件 - 每次调用有 10 秒。 + - **`wasi:http` 出站(第 3 阶段):** 可以访问任何主机。应用声明 `net`,让安装界面说明它会使用 + 网络,但运行时既不强制 `net`,也不强制 `network.hosts`:2026 年 10 月 8 日的裁定取消了按应用的 + 运行时闸门,边界是操作系统和宿主的 API 表面。把组件限制在应用的 `network.hosts` 内、后来又限制 + 为公共 HTTPS 主机的做法,都已按该裁定否决。请求在客体之外等待,epoch 检查无法结束它,因此 + 请求的超时被限制在调用的截止时间内;导入 `wasi:http` 的组件每次调用有 10 秒。 - **`octosense:host`(第 3 阶段):** `request(service, args)` 可以调用应用已获授权的宿主服务, 像应用脚本的 `host.request` 一样在 UI 线程上分派,但不打开面板、不询问用户(只能调用后台界面 可以调用的方法),并且绝不调用 `wasm.*`。 @@ -86,8 +86,8 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 - **分发:** SDK 发布到 crates.io(需维护者批准);发布前使用 git 依赖。 6. **审核能看到组件的访问范围。** App Hub 的闸门读取每个组件的导入: - 拒绝允许集合以外的导入; - - `wasi:filesystem` 需要 `storage` 能力,`wasi:http` 需要 `network.hosts`; - - 告诉审核者组件能访问什么,例如“其应用文件夹中的文件;访问 api.example.com 的 HTTPS”。 + - `wasi:filesystem` 需要 `storage` 能力,`wasi:http` 需要 `net` 能力,以便安装界面展示它们; + - 告诉审核者组件能访问什么,例如“其应用文件夹中的文件;网络”。 组件需要新的应用合约版本。 ## 阶段 @@ -96,7 +96,7 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 | --- | --- | | 1. 运行时验证原型(本 PR) | `crates/wasm-host::component`:加载、检查导入、列出导出及其 WIT 签名、长期存活的实例、JSON 调用、上述 WASI 子集与存储预打开、超时、内存上限和日志。测试运行一个用普通 cargo 构建的、未作修改的 crate(`pulldown-cmark`),并拒绝导入 `wasi:sockets` 的组件。 | | 2. 开发者可用 | shell 的 `wasm` 服务:从 `fns/` 加载组件、`wasm.` 调用、按应用的实例、来自清单的存储授权,以及组件写入的存储配额计量。提高组件的输入上限。guest SDK 及其宏;`octo wasm new/build/doctor`;App Hub 闸门检查与合约版本;文档与示例应用。 | -| 3. 访问能力与平台 | 受 `network.hosts` 约束的 `wasi:http` 出站;`octosense:host` 导入,以与 `host.request` 相同的检查调用主机服务;安装时编译,让手机跳过首次编译;iOS 使用 Pulley(Wasmtime 的解释器),OpenHarmony 在其 JIT 策略明确前也使用 Pulley;Windows 待其 CI 运行运行时测试后开启。 | +| 3. 访问能力与平台 | 可访问任何主机的 `wasi:http` 出站(以 `net` 声明,不在运行时强制);`octosense:host` 导入,以与 `host.request` 相同的检查调用主机服务;安装时编译,让手机跳过首次编译;iOS 使用 Pulley(Wasmtime 的解释器),OpenHarmony 在其 JIT 策略明确前也使用 Pulley;Windows 待其 CI 运行运行时测试后开启。 | | 4. 共享组件 | App Hub 目录中经过审核、带版本的组件,应用可以像 npm 包一样依赖它们。安装器负责校验,每个应用仍有自己的实例和授权。 | ## 考虑过的替代方案 diff --git a/docs/wasm.md b/docs/wasm.md index 5a58f94b9..debc704b0 100644 --- a/docs/wasm.md +++ b/docs/wasm.md @@ -85,18 +85,14 @@ two kinds apart by the file's preamble, and a bundle may carry both. 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. -- **The network.** With the `net` capability, its `wasi:http` requests reach - the app's `network.hosts` by a script's rule: the host listed exactly, - without regard to case, on any port. Requests use HTTPS, and never reach - this device or its local network, even when the app lists such a host: - not loopback, private or link-local addresses, nor `localhost`, - single-label or `.local`, `.lan`, `.internal` names. A public name that - resolves to a local address is not caught yet, as for scripts. A component that may - reach the network gets 10 s a call instead of 2 s, and each request's - connect, first-byte and between-bytes timeouts end with the call. A refused - request fails inside the component (`HttpRequestDenied`), and the app's log - says why: "a request to … was refused: it is not one of the app's network - hosts". +- **The network.** Its `wasi:http` requests reach any host, as the device + can: an app's network declarations (`net`, `network.hosts`) are shown when + it is installed and not enforced while it runs. The ruling of 8 October + 2026 removed the per-app runtime gates; the OS and the host's API surface + are the boundary. (The app's Splash script still passes the Splash + runtime's URL gate until the Makepad fork drops it.) A component that + imports `wasi:http` gets 10 s a call instead of 2 s, and each request's + connect, first-byte and between-bytes timeouts end with the call. - **Host services.** Through `octosense:host` ([`crates/wasm-host/wit/octosense-host.wit`](../crates/wasm-host/wit/octosense-host.wit)), `request(service, args)` calls one of the host services its app is diff --git a/docs/wasm.zh-CN.md b/docs/wasm.zh-CN.md index 05f626c61..f208b4d97 100644 --- a/docs/wasm.zh-CN.md +++ b/docs/wasm.zh-CN.md @@ -66,14 +66,11 @@ Rust crate 构建。服务按文件头区分两者,一个应用包可以同时 内部失败:`ftruncate` 报告磁盘已满,普通写入报告 I/O 错误,因为 wasi-libc 把任何失败的 流写入都报告为 I/O 错误。这时请求的错误以 "a write was refused: the storage budget is used up" 结尾。改写、截断和删除会归还字节,因此组件可以自己腾出空间。 -- **网络。** 应用有 `net` 能力时,组件的 `wasi:http` 请求按脚本的规则访问应用的 - `network.hosts`:主机必须完全列出,不区分大小写,端口不限。请求使用 HTTPS,并且即使应用列出了 - 这样的主机,也绝不访问设备本身或其本地网络:不访问环回、私有或链路本地地址,也不访问 - `localhost`、单标签名称或 `.local`、`.lan`、`.internal` 名称。解析为本地地址的公共名称目前还 - 检查不到,脚本也是如此。可以访问网络的组件每次调用有 10 秒, - 而不是 2 秒;每个请求的连接、首字节和字节间超时都随调用结束。被拒绝的请求在组件内部失败 - (`HttpRequestDenied`),应用的日志会说明原因:"a request to … was refused: it is not one - of the app's network hosts"。 +- **网络。** 组件的 `wasi:http` 请求可以访问任何主机,与设备本身能访问的范围相同:应用的网络 + 声明(`net`、`network.hosts`)在安装时展示,运行时不强制。2026 年 10 月 8 日的裁定取消了按应用 + 的运行时闸门,边界是操作系统和宿主的 API 表面。(在 Makepad 分支去掉之前,应用的 Splash 脚本 + 仍要经过 Splash 运行时的 URL 闸门。)导入 `wasi:http` 的组件每次调用有 10 秒,而不是 2 秒; + 每个请求的连接、首字节和字节间超时都随调用结束。 - **宿主服务。** 通过 `octosense:host` ([`crates/wasm-host/wit/octosense-host.wit`](../crates/wasm-host/wit/octosense-host.wit)), 组件可以用 `request(service, args)` 调用应用已获授权的宿主服务,与应用的脚本相同:同样的服务族, From 40a5deabbaebb6e67029554698a9cb768263e53e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:35:12 -0700 Subject: [PATCH 9/9] fix(wasm): pin merged HTTP and host-call component admission --- Cargo.lock | 10 +++++----- Cargo.toml | 10 +++++----- native-apps.json | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f12a474a..30ced5e7e 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=769e9ee356299dce3afbf48801e58140645b71f1#769e9ee356299dce3afbf48801e58140645b71f1" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339#371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339" 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=769e9ee356299dce3afbf48801e58140645b71f1#769e9ee356299dce3afbf48801e58140645b71f1" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339#371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339" dependencies = [ "base64 0.22.1", "blake3", @@ -11420,7 +11420,7 @@ dependencies = [ [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=769e9ee356299dce3afbf48801e58140645b71f1#769e9ee356299dce3afbf48801e58140645b71f1" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339#371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -11448,7 +11448,7 @@ dependencies = [ [[package]] name = "octosense-app-policy" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=769e9ee356299dce3afbf48801e58140645b71f1#769e9ee356299dce3afbf48801e58140645b71f1" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339#371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339" dependencies = [ "makepad-widgets", "octosense-app-contract", @@ -11470,7 +11470,7 @@ dependencies = [ [[package]] name = "octosense-appstore" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=769e9ee356299dce3afbf48801e58140645b71f1#769e9ee356299dce3afbf48801e58140645b71f1" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339#371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339" dependencies = [ "makepad-app-module", "makepad-widgets", diff --git a/Cargo.toml b/Cargo.toml index 7d3359128..8b8c6f806 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 = "769e9ee356299dce3afbf48801e58140645b71f1" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339" } 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 = "769e9ee356299dce3afbf48801e58140645b71f1" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339" } # 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 = "769e9ee356299dce3afbf48801e58140645b71f1" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "769e9ee356299dce3afbf48801e58140645b71f1" } +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339" } # 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) @@ -437,7 +437,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 = "769e9ee356299dce3afbf48801e58140645b71f1" } +octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339" } nix = { git = "https://github.com/octos-org/octos.git", rev = "18fcd3f16e527d2b601d7ae244f056fb711bb6b8" } # pdfcraft's bootstrap PDF stack: its workspace patches these five diff --git a/native-apps.json b/native-apps.json index e03d73595..201bb79b2 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": "769e9ee356299dce3afbf48801e58140645b71f1", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "371f51aeb6ba90e9a214c5ddb6ccfc40a2e2a339", "local": null }, "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"], "bin": null, "default_features": true,