From f576666fbead693f557c6a44b7f11197899ed857 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:21:14 -0700 Subject: [PATCH] Support bounded cancellable WASI TCP UDP and DNS components --- Cargo.lock | 12 +- Cargo.toml | 10 +- crates/shell/src/apps.rs | 6 + crates/shell/src/wasm_service.rs | 380 ++++++++++++-- crates/wasm-host/Cargo.toml | 4 +- crates/wasm-host/README.md | 11 + crates/wasm-host/README.zh-CN.md | 7 + crates/wasm-host/src/component.rs | 48 +- crates/wasm-host/src/component/dns.rs | 325 ++++++++++++ crates/wasm-host/src/component/net.rs | 5 +- crates/wasm-host/src/component/sockets.rs | 470 ++++++++++++++++++ crates/wasm-host/src/lib.rs | 2 +- .../tests/component-guest/netprobe/src/lib.rs | 73 ++- .../component-guest/netprobe/wit/netprobe.wit | 12 +- crates/wasm-host/tests/component.rs | 10 +- crates/wasm-host/tests/component_sockets.rs | 211 ++++++++ .../tests/fixtures/netprobe.component.wasm | Bin 122816 -> 140480 bytes .../adr/0014-app-components-in-webassembly.md | 17 +- ...014-app-components-in-webassembly.zh-CN.md | 12 +- docs/wasm.md | 50 +- docs/wasm.zh-CN.md | 38 +- native-apps.json | 2 +- 22 files changed, 1607 insertions(+), 98 deletions(-) create mode 100644 crates/wasm-host/src/component/dns.rs create mode 100644 crates/wasm-host/src/component/sockets.rs create mode 100644 crates/wasm-host/tests/component_sockets.rs diff --git a/Cargo.lock b/Cargo.lock index 96ba9993..320fa914 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11395,7 +11395,7 @@ dependencies = [ [[package]] name = "octosense-app-contract" version = "1.12.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d86101d7206162b73ea873e2a31b48364600a110#d86101d7206162b73ea873e2a31b48364600a110" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=c317f787ea2f620543000561c2470106644c1fc7#c317f787ea2f620543000561c2470106644c1fc7" dependencies = [ "blake3", "serde", @@ -11406,7 +11406,7 @@ dependencies = [ [[package]] name = "octosense-app-hub" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d86101d7206162b73ea873e2a31b48364600a110#d86101d7206162b73ea873e2a31b48364600a110" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=c317f787ea2f620543000561c2470106644c1fc7#c317f787ea2f620543000561c2470106644c1fc7" dependencies = [ "base64 0.22.1", "blake3", @@ -11433,7 +11433,7 @@ dependencies = [ [[package]] name = "octosense-app-hub-app" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d86101d7206162b73ea873e2a31b48364600a110#d86101d7206162b73ea873e2a31b48364600a110" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=c317f787ea2f620543000561c2470106644c1fc7#c317f787ea2f620543000561c2470106644c1fc7" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -11461,7 +11461,7 @@ dependencies = [ [[package]] name = "octosense-app-policy" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d86101d7206162b73ea873e2a31b48364600a110#d86101d7206162b73ea873e2a31b48364600a110" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=c317f787ea2f620543000561c2470106644c1fc7#c317f787ea2f620543000561c2470106644c1fc7" dependencies = [ "makepad-widgets", "octosense-app-contract", @@ -11483,7 +11483,7 @@ dependencies = [ [[package]] name = "octosense-appstore" version = "0.1.0" -source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d86101d7206162b73ea873e2a31b48364600a110#d86101d7206162b73ea873e2a31b48364600a110" +source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=c317f787ea2f620543000561c2470106644c1fc7#c317f787ea2f620543000561c2470106644c1fc7" dependencies = [ "makepad-app-module", "makepad-widgets", @@ -12007,6 +12007,8 @@ dependencies = [ "rustls", "serde_json", "sha2 0.10.9", + "tokio", + "url", "wasmlab-functions", "wasmtime", "wasmtime-wasi", diff --git a/Cargo.toml b/Cargo.toml index 0b8eaca4..d63cbb9e 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 = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19" } octosense-appcard = { path = "apps/appcard/module" } -octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "d86101d7206162b73ea873e2a31b48364600a110" } +octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "c317f787ea2f620543000561c2470106644c1fc7" } makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false } @@ -131,12 +131,12 @@ makepad-d3 = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "6cf2d90aa33ff4ae603f49c7557f3ecf986207f0" } # --- 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 = "d86101d7206162b73ea873e2a31b48364600a110" } +octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "c317f787ea2f620543000561c2470106644c1fc7" } # 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 = "d86101d7206162b73ea873e2a31b48364600a110" } -octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "d86101d7206162b73ea873e2a31b48364600a110" } +octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "c317f787ea2f620543000561c2470106644c1fc7" } +octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "c317f787ea2f620543000561c2470106644c1fc7" } # 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) @@ -439,7 +439,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 = "d86101d7206162b73ea873e2a31b48364600a110" } +octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "c317f787ea2f620543000561c2470106644c1fc7" } 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/apps.rs b/crates/shell/src/apps.rs index d2fef4c8..22c0e6ec 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -931,6 +931,12 @@ mod tests { register_host_services(); octosense_appstore::host_api::check_manifest(&manifest) .expect("compatible card installs and reopens without warming a module first"); + #[cfg(wasm_functions)] + assert_eq!( + octosense_appstore::host_api::available_versions().get("wasm.sockets"), + Some(&1), + "socket components must be installable before the first app calls wasm" + ); } #[test] diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index a91b73b8..2de61692 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -30,24 +30,26 @@ //! 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 +//! or the worker's exit (its last calling isolate closes, or a minute without +//! calls) ends it. Socket handles share a per-app budget across components. +//! 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 an available app storage area or required signed-in account; 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::collections::{BTreeMap, HashMap, HashSet}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError}; +use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender, TrySendError}; 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, HostCalls, + self, ComponentInstance, ComponentProgram, Grants, HostCalls, SocketBudget, }; use octosense_wasm_host::{Limits, Program, Runtime}; use serde_json::{json, Value}; @@ -114,6 +116,8 @@ 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); +/// Closing an app must not leave its sockets open for the component idle grace. +const RETIRE_POLL: Duration = Duration::from_millis(100); /// Where an app's component works: the shell's own rules, or a test's. static AREA_ENV: Mutex>> = Mutex::new(None); @@ -136,13 +140,14 @@ static RUNTIME: OnceLock> = OnceLock::new(); struct HostCall { call: ServiceCall, deadline: Instant, + parent: Replier, 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)>; +type Waiting = HashMap>, Instant, Replier)>; static HOST_WAITING: Mutex> = Mutex::new(None); /// Component calls are dispatched under keys of their own, apart from any /// isolate's. @@ -156,6 +161,7 @@ static NEXT_HOST_KEY: AtomicUsize = AtomicUsize::new(1 << 50); struct AppHostCalls { app: String, host_dir: PathBuf, + parent: Replier, } impl HostCalls for AppHostCalls { @@ -169,6 +175,11 @@ impl HostCalls for AppHostCalls { } let args: Value = serde_json::from_str(args) .map_err(|e| format!("{service}: the arguments are not JSON: {e}"))?; + let cancelled = || format!("{service}: wasm invocation cancelled or past its deadline"); + let live = || self.parent.is_pending() && Instant::now() < deadline; + if !live() { + return Err(cancelled()); + } let (done, answer) = sync_channel(1); HOST_CALLS .lock() @@ -183,16 +194,24 @@ impl HostCalls for AppHostCalls { host_dir: self.host_dir.clone(), }, deadline, + parent: self.parent.clone(), 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" - )) - }) + loop { + if !live() { + return Err(cancelled()); + } + match answer.recv_timeout( + deadline + .saturating_duration_since(Instant::now()) + .min(RETIRE_POLL), + ) { + Ok(result) if live() => return result, + Ok(_) | Err(RecvTimeoutError::Disconnected) => return Err(cancelled()), + Err(RecvTimeoutError::Timeout) => {} + } + } } } @@ -210,14 +229,15 @@ pub fn pump_host_calls() { for HostCall { call, deadline, + parent, done, } in queued { - if Instant::now() >= deadline { + if Instant::now() >= deadline || !parent.is_pending() { continue; } let key = NEXT_HOST_KEY.fetch_add(1, Ordering::Relaxed); - waiting.insert(key, (done, deadline)); + waiting.insert(key, (done, deadline, parent)); octosense_appstore::services::dispatch(call, key, 1, &mut NoSheets); } if waiting.is_empty() { @@ -225,15 +245,17 @@ pub fn pump_host_calls() { } 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); + if let Some((done, deadline, parent)) = waiting.remove(&key) { + if Instant::now() < deadline && parent.is_pending() { + 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) + .filter(|(_, (_, deadline, parent))| *deadline <= now || !parent.is_pending()) .map(|(key, _)| *key) .collect(); for key in expired { @@ -248,6 +270,23 @@ struct Worker { jobs: SyncSender, retired: Arc, admission: Arc, + /// Every isolate whose call reached this worker. A second window keeps + /// the app's components alive when the first window closes. + isolates: HashSet, +} + +/// The dispatcher has already cancelled this isolate's replies. Do not call +/// services from this callback: only signal retirement; the worker drops its +/// instances and then releases its registry slot on its own thread. +fn isolate_closed(key: usize) { + let mut registry = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(workers) = registry.as_mut() { + for worker in workers.values_mut() { + if worker.isolates.remove(&key) && worker.isolates.is_empty() { + worker.retired.store(true, Ordering::Release); + } + } + } } /// Enqueue only clones this host-owned identity. Bundle IO, signature checks @@ -449,6 +488,12 @@ impl HostService for WasmService { return; } let mut registry = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + // A close may have happened after the first live check, before this + // lock. It must not be followed by a newly retained owner for that key. + if !job.live() { + return; + } + let isolate = job.reply.isolate_key(); let workers = registry.get_or_insert_with(HashMap::new); if let Some(worker) = workers.get_mut(&app) { if worker.retired.load(Ordering::Acquire) { @@ -461,7 +506,10 @@ impl HostService for WasmService { } job.admission = worker.admission.clone(); match worker.jobs.try_send(job) { - Ok(()) => return, + Ok(()) => { + worker.isolates.insert(isolate); + return; + } Err(TrySendError::Full(job)) => { job.reply .send(Err("wasm app queue is full; try again later".into())); @@ -495,6 +543,7 @@ impl HostService for WasmService { jobs, retired: retired.clone(), admission, + isolates: HashSet::from([isolate]), }, ); // Keep ownership outside the spawn closure too, so a spawn failure @@ -545,12 +594,20 @@ impl WasmService { } pub fn register() { + // Compatibility metadata for the WASI component linker, not a JSON + // wasm.sockets endpoint or a permission check. + octosense_appstore::host_api::register_runtime_feature("wasm.sockets", 1); + octosense_appstore::services::on_isolate_closed(isolate_closed); octosense_appstore::services::register_host_service(Box::new(WasmService)); } fn work(app: String, jobs: Receiver, retired: &Arc) { let mut lab: Option = None; + let mut idle_since = Instant::now(); loop { + if retired.load(Ordering::Acquire) { + return; + } // 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) { @@ -558,8 +615,10 @@ fn work(app: String, jobs: Receiver, retired: &Arc) { } else { WORKER_IDLE }; - let job = match jobs.recv_timeout(idle) { + let remaining = idle.saturating_sub(idle_since.elapsed()); + let job = match jobs.recv_timeout(remaining.min(RETIRE_POLL)) { Ok(job) => job, + Err(RecvTimeoutError::Timeout) if idle_since.elapsed() < idle => continue, Err(_) => { // Serialize idle retirement with enqueue. A sender that won // the timeout race still gets its accepted request processed. @@ -574,12 +633,16 @@ fn work(app: String, jobs: Receiver, retired: &Arc) { } } }; + if retired.load(Ordering::Acquire) { + return; + } let result = process(&app, &mut lab, &job); // Cancellation can win while admission is being checked. Replier's // pending-request check remains the final isolate boundary. if job.live() { job.reply.send(result); } + idle_since = Instant::now(); } } @@ -649,8 +712,8 @@ struct Lab { owner: BTreeMap, stats: BTreeMap, admission: Admission, - /// Its host services, as its components reach them (`octosense:host`). - host_calls: Arc, + /// Own and shared components consume the same app-wide socket budget. + socket_budget: SocketBudget, } /// Compile an installed or updated app's functions into the disk cache in @@ -803,11 +866,8 @@ impl Lab { modules: Vec::new(), owner: BTreeMap::new(), stats: BTreeMap::new(), - host_calls: Arc::new(AppHostCalls { - app: app.to_string(), - host_dir: admission.root.join(".host"), - }), admission: admission.clone(), + socket_budget: SocketBudget::default(), }; for (path, shared) in sources { check()?; @@ -993,7 +1053,13 @@ impl Lab { read_only: false, }; let app = self.app.clone(); - let host_calls = self.host_calls.clone(); + // Rebind the host bridge for every call: a kept component must not + // wait on a previous invocation's reply or outlive the current one. + let host_calls = Arc::new(AppHostCalls { + app: app.clone(), + host_dir: self.admission.root.join(".host"), + parent: reply.clone(), + }); let module = &mut self.modules[index]; let Code::Component { program, @@ -1014,18 +1080,18 @@ impl Lab { let pending = reply.clone(); let instance = self .runtime - .instantiate_component( + .instantiate_component_with_sockets( program, &grants, Some((deadline, Box::new(move || pending.is_pending()))), + self.socket_budget.clone(), ) .map_err(|error| error.to_string())?; - let mut instance = instance; - instance.set_host_calls(Some(host_calls)); *instances += 1; *live = Some((instance, grants.clone())); } let (instance, _) = live.as_mut().expect("made above"); + instance.set_host_calls(Some(host_calls)); module.invocations += 1; let export = match &module.shared { Some(component) => name @@ -1317,6 +1383,7 @@ mod tests { jobs: cold_sender, retired: Arc::new(AtomicBool::new(false)), admission: cold_epoch.clone(), + isolates: HashSet::new(), }, ); let cold_heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); @@ -1444,6 +1511,7 @@ mod tests { admitted: OnceLock::from(Ok(Admission::current("os.wasmstate").unwrap())), obsolete: AtomicBool::new(false), }), + isolates: HashSet::new(), }, ); let mut heaps = Vec::new(); @@ -1530,6 +1598,7 @@ mod tests { // release a slot merely because retirement has started. retired: Arc::new(AtomicBool::new(n == 0)), admission: Arc::default(), + isolates: HashSet::new(), }, ); } @@ -1799,6 +1868,259 @@ mod tests { "/../wasm-host/tests/fixtures/notes.component.wasm" ); + /// Real dispatch and isolate close: retaining one window preserves the + /// app's listeners, while closing the last frees both idle and blocked + /// components without waiting for the 60-second idle grace. + #[test] + fn closing_the_last_isolate_releases_idle_and_blocked_sockets() { + const CHILD: &str = "OCTOSENSE_TEST_WASM_SOCKET_CLOSE"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "wasm_service::tests::closing_the_last_isolate_releases_idle_and_blocked_sockets", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + use octosense_appstore::services; + use std::io::{ErrorKind, Read}; + use std::net::TcpListener; + + const APP: &str = "os.wasmsockets"; + let root = std::env::temp_dir().join(format!("wasm-socket-close-{}", uuid::Uuid::new_v4())); + let host_dir = root.join(".host"); + std::env::set_var("OCTOSENSE_APP_DATA", &root); + octosense_appstore::set_data_root(root.clone()); + register(); + ship(APP, "socket-close", |dir, manifest| { + manifest["capabilities"] = json!([]); + manifest["requires"] = json!(["host-api-v1", "wasm-components-v1"]); + manifest["host_api"] = json!({"required":{"wasm.sockets":1}}); + 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( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../wasm-host/tests/fixtures/netprobe.component.wasm" + ), + dir.join("fns/netprobe.wasm"), + ) + .unwrap(); + }); + let dispatch = |heap, method: &str, args| { + services::dispatch( + ServiceCall { + app_id: APP.into(), + service: format!("wasm.{method}"), + args, + from_sheet: false, + may_prompt: false, + host_dir: host_dir.clone(), + }, + heap, + 1, + &mut NoSheet, + ); + }; + let receive = |heap| { + let deadline = Instant::now() + REQUEST_TIMEOUT; + loop { + if let Some((_, _, answer)) = services::take_replies_for(&[heap]).pop() { + return serde_json::from_str::(&answer.unwrap()).unwrap(); + } + assert!(Instant::now() < deadline, "socket fixture did not answer"); + std::thread::sleep(Duration::from_millis(5)); + } + }; + let wait_closed = || { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if !WORKERS.lock().unwrap().as_ref().unwrap().contains_key(APP) { + break; + } + assert!( + Instant::now() < deadline, + "last close must release the worker promptly" + ); + std::thread::sleep(Duration::from_millis(5)); + } + }; + + let first = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + let second = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + dispatch(first, "hold_listener", json!(null)); + let first_port = receive(first).as_u64().unwrap() as u16; + dispatch(second, "hold_listener", json!(null)); + let second_port = receive(second).as_u64().unwrap() as u16; + services::cancel_heap(first); + { + let registry = WORKERS.lock().unwrap(); + let worker = ®istry.as_ref().unwrap()[APP]; + assert_eq!(worker.isolates, HashSet::from([second])); + assert!(!worker.retired.load(Ordering::Acquire)); + } + assert!(TcpListener::bind(("127.0.0.1", first_port)).is_err()); + assert!(TcpListener::bind(("127.0.0.1", second_port)).is_err()); + services::cancel_heap(second); + wait_closed(); + TcpListener::bind(("127.0.0.1", first_port)).expect("first listener released"); + TcpListener::bind(("127.0.0.1", second_port)).expect("second listener released"); + + // A replacement reusing an old heap address gets a fresh worker. + dispatch(first, "hold_listener", json!(null)); + let retained_port = receive(first).as_u64().unwrap() as u16; + let server = TcpListener::bind("127.0.0.1:0").unwrap(); + server.set_nonblocking(true).unwrap(); + dispatch( + first, + "read_forever", + json!(server.local_addr().unwrap().to_string()), + ); + let deadline = Instant::now() + Duration::from_secs(2); + let mut peer = loop { + match server.accept() { + Ok((peer, _)) => break peer, + Err(error) if error.kind() == ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "guest did not connect"); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept: {error}"), + } + }; + services::cancel_heap(first); + wait_closed(); + peer.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + assert_eq!( + peer.read(&mut [0]).unwrap(), + 0, + "cancelled read releases TCP stream" + ); + TcpListener::bind(("127.0.0.1", retained_port)) + .expect("blocked invocation releases retained listener"); + assert!(services::take_replies_for(&[first, second]).is_empty()); + + // The shell must pass one budget to its own and its pinned shared + // components. Separate budgets here would silently double the limit. + let (_, bundle) = script_apps::admitted_bundle(APP).unwrap(); + let shared = Shared { + alias: "other".into(), + id: "org.example.socket-fixture".into(), + version: "1.0.0".into(), + blake3: "0".repeat(64), + path: bundle.join("fns/netprobe.wasm"), + }; + *SHARED_RESOLVER.lock().unwrap() = Some(Arc::new(move |_, _| Ok(vec![shared.clone()]))); + let admission = Admission { + root: root.clone(), + bundle, + manifest: json!({"components":[{"as":"other","id":"org.example.socket-fixture", + "version":"1.0.0","blake3":"0".repeat(64)}]}), + }; + let mut lab = + Lab::from_bundle(APP, runtime(&host_dir).unwrap(), admission, || Ok(())).unwrap(); + let (heap, reply) = pending_reply(APP, &host_dir); + let call = |lab: &mut Lab, method| { + lab.answer( + method, + b"null", + false, + Instant::now() + REQUEST_TIMEOUT, + reply.clone(), + ) + }; + let mut ports = Vec::new(); + for _ in 0..16 { + ports.push(call(&mut lab, "hold_listener").unwrap().as_u64().unwrap() as u16); + ports.push( + call(&mut lab, "other.hold_listener") + .unwrap() + .as_u64() + .unwrap() as u16, + ); + } + assert!( + call(&mut lab, "other.hold_listener").is_err(), + "shared component cannot exceed its app's 32 socket handles" + ); + call(&mut lab, "close_listeners").unwrap(); + ports.push( + call(&mut lab, "other.hold_listener") + .unwrap() + .as_u64() + .unwrap() as u16, + ); + drop(lab); + for port in ports { + TcpListener::bind(("127.0.0.1", port)) + .expect("dropping the Lab releases every component's listener"); + } + services::cancel_heap(heap); + + // A socket-owning component can also wait on another host service. + // Cancellation must release that wait even if the UI is not pumping, + // and a later pump must not execute the cancelled request. + struct Count(Arc); + impl HostService for Count { + fn family(&self) -> &'static str { + "socketclosehost" + } + fn call(&mut self, _: ServiceCall, reply: Replier, _: &mut dyn ServiceHost) { + self.0.fetch_add(1, Ordering::Relaxed); + reply.send(Ok(json!(null))); + } + } + let dispatched = Arc::new(AtomicUsize::new(0)); + services::register_host_service(Box::new(Count(dispatched.clone()))); + let (heap, parent) = pending_reply(APP, &host_dir); + let bridge = AppHostCalls { + app: APP.into(), + host_dir: host_dir.clone(), + parent, + }; + let (finished, answer) = sync_channel(1); + let waiter = std::thread::spawn(move || { + finished + .send(bridge.request( + "socketclosehost.get", + "{}", + Instant::now() + REQUEST_TIMEOUT, + )) + .unwrap(); + }); + let deadline = Instant::now() + Duration::from_secs(1); + while HOST_CALLS.lock().unwrap().is_empty() { + assert!(Instant::now() < deadline, "host call was not queued"); + std::thread::sleep(Duration::from_millis(5)); + } + services::cancel_heap(heap); + assert!(answer + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .unwrap_err() + .contains("cancelled")); + waiter.join().unwrap(); + pump_host_calls(); + assert_eq!( + dispatched.load(Ordering::Relaxed), + 0, + "late pump must not dispatch cancelled work" + ); + assert!(HOST_CALLS.lock().unwrap().is_empty()); + std::fs::remove_dir_all(root).unwrap(); + } + /// 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 diff --git a/crates/wasm-host/Cargo.toml b/crates/wasm-host/Cargo.toml index b01e394b..e995c7d7 100644 --- a/crates/wasm-host/Cargo.toml +++ b/crates/wasm-host/Cargo.toml @@ -9,7 +9,9 @@ publish = false wasmtime = { workspace = true } wasmtime-wasi = { workspace = true } wasmtime-wasi-http = { workspace = true } -# The requests a component's `wasi:http` sends, checked against its hosts. +tokio = { workspace = true } +url = { workspace = true } +# Outgoing WASI HTTP requests; network destinations are not allowlisted. http = "1" http-body-util = "0.1" # Wasmtime's default HTTPS client uses Rustls's process provider. Other shell diff --git a/crates/wasm-host/README.md b/crates/wasm-host/README.md index a550ebbf..df7fdd33 100644 --- a/crates/wasm-host/README.md +++ b/crates/wasm-host/README.md @@ -38,3 +38,14 @@ The regression links both providers and drives a real component against an isolated TLS endpoint, including the case where the host selected `aws-lc` first. Public-network and full-shell acceptance are separate from that deterministic test; a unit-test pass alone does not establish either. + +Standard WASI TCP/UDP/DNS support includes a shared per-app socket budget and +cancellable host waits. The default is 32 live sockets/DNS streams per app +and 512 resource-table entries per component. Embedders with multiple +components must share one `SocketBudget` through +`instantiate_component_with_sockets`; the shell does this for bundled and +shared components. Each instance's sockets close when it traps, expires or +is dropped. The shell also releases an app's worker when its last owning +isolate closes. Compatibility is advertised as `wasm.sockets@1`; RC5 does +not provide it. See the [guide](../../docs/wasm.md) for publishing declarations +and platform evidence. diff --git a/crates/wasm-host/README.zh-CN.md b/crates/wasm-host/README.zh-CN.md index caf5fc3d..ef23e3a3 100644 --- a/crates/wasm-host/README.zh-CN.md +++ b/crates/wasm-host/README.zh-CN.md @@ -27,3 +27,10 @@ 会被保留。证书验证、信任根和请求期限均不改变。回归测试同时链接两种提供程序, 使用真实 Wasm 组件访问隔离的 TLS 测试端点,也覆盖宿主已先选择 `aws-lc` 的情况。 公网请求和完整 shell 的验收独立于该确定性测试;单元测试通过不代表这两项已通过。 + +标准 WASI TCP/UDP/DNS 支持包含按应用共享的套接字额度以及可取消的宿主等待。 +默认每个应用最多 32 个活跃套接字/DNS 查询流,每个组件最多 512 项宿主资源。 +嵌入方如有多个组件,必须通过 `instantiate_component_with_sockets` 共享同一个 +`SocketBudget`;外壳对应用内组件和共享组件都这样处理。实例陷阱、超时或销毁时关闭套接字; +最后一个所属应用隔离实例关闭时,外壳也会释放工作线程。兼容性以 `wasm.sockets@1` +标记,RC5 不提供此标记。发布声明与平台验证证据见[指南](../../docs/wasm.zh-CN.md)。 diff --git a/crates/wasm-host/src/component.rs b/crates/wasm-host/src/component.rs index fdf7c0e9..c77f07b7 100644 --- a/crates/wasm-host/src/component.rs +++ b/crates/wasm-host/src/component.rs @@ -10,9 +10,11 @@ //! the host's filesystem. //! - stdout and stderr, which become its log lines. //! -//! No sockets, environment, arguments or stdin: a component that imports -//! `wasi:sockets`, `wasi:http` or anything outside WASI is refused when it -//! loads ([`LoadError::Import`]). +//! - Outgoing HTTP, ordinary TCP/UDP sockets and DNS through WASI. +//! - Reviewed app host services through `octosense:host`. +//! +//! No host environment, arguments or stdin. Imports outside the listed +//! WASI packages and `octosense:host` are refused ([`LoadError::Import`]). //! //! # How a function is called //! @@ -47,11 +49,14 @@ use wasmtime_wasi::{FsPerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; use crate::{trap, CallError, InvocationGuard, LoadError, Runtime, Ticker, LOG_LINE, LOG_LINES}; +mod dns; mod files; mod host; mod net; +mod sockets; pub use host::HostCalls; +pub use sockets::SocketBudget; /// The WASI packages a component may import. Every interface of these is /// linked; what each can reach is set per instance ([`Grants`]). @@ -62,6 +67,7 @@ const ALLOWED_PACKAGES: &[&str] = &[ "wasi:http/", "wasi:io/", "wasi:random/", + "wasi:sockets/", // The app's host services, as its script reaches them (`host`). "octosense:host/", ]; @@ -130,7 +136,7 @@ impl ComponentProgram { self.from_cache } - /// Whether it imports `wasi:http`: it may reach any host, and its calls + /// Whether it imports HTTP or sockets: it may reach the device network; calls /// get the network deadline. pub fn uses_network(&self) -> bool { self.uses_network @@ -151,6 +157,7 @@ struct State { /// Its app's host services (`octosense:host`), when the embedder gives /// them. host_calls: host::Calls, + sockets: sockets::Resources, } impl WasiView for State { @@ -217,7 +224,7 @@ impl Runtime { let uses_network = component .component_type() .imports(&self.engine) - .any(|(name, _)| name.starts_with("wasi:http/")); + .any(|(name, _)| name.starts_with("wasi:http/") || name.starts_with("wasi:sockets/")); Ok(ComponentProgram { component, exports, @@ -250,6 +257,17 @@ impl Runtime { program: &ComponentProgram, grants: &Grants, guard: Option<(Instant, Box bool + Send + Sync>)>, + ) -> Result { + self.instantiate_component_with_sockets(program, grants, guard, SocketBudget::default()) + } + + /// Instantiate with the app's socket budget, shared by all its components. + pub fn instantiate_component_with_sockets( + &self, + program: &ComponentProgram, + grants: &Grants, + guard: Option<(Instant, Box bool + Send + Sync>)>, + socket_budget: SocketBudget, ) -> Result { if guard .as_ref() @@ -264,9 +282,10 @@ impl Runtime { let mut wasi = WasiCtxBuilder::new(); wasi.stdout(stdout.clone()) .stderr(stderr.clone()) - .allow_tcp(false) - .allow_udp(false) - .allow_ip_name_lookup(false) + .allow_tcp(true) + .allow_udp(true) + .allow_ip_name_lookup(true) + .inherit_network() // Calls run on their app's own worker thread, synchronously: a // file operation blocks it directly rather than round-tripping // through a thread pool. @@ -297,7 +316,7 @@ impl Runtime { Box::new(|| true) as Box bool + Send + Sync>, ), }; - // A component that imports wasi:http waits for replies: its calls + // A component that imports HTTP or sockets waits for replies: its calls // get the longer deadline. let call_deadline = if program.uses_network { self.limits.network_deadline.max(self.limits.deadline) @@ -311,11 +330,13 @@ impl Runtime { let network = net::Network { deadline: Some(guard.deadline), }; + let mut table = ResourceTable::new(); + table.set_max_capacity(sockets::MAX_RESOURCES); let mut store = Store::new( &self.engine, State { wasi: wasi.build(), - table: ResourceTable::new(), + table, limits, guard: Some(guard), // Start-up code may read the folder, not grow it. @@ -323,6 +344,7 @@ impl Runtime { http: wasmtime_wasi_http::WasiHttpCtx::new(), network, host_calls: None, + sockets: sockets::Resources::new(socket_budget), }, ); store.data().budget.set(Some(0)); @@ -341,6 +363,8 @@ impl Runtime { .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)) + .and_then(|()| sockets::link(&mut linker)) + .and_then(|()| dns::link(&mut linker)) .map_err(|e| LoadError::Invalid(format!("{e:#}")))?; let instance = linker .instantiate(&mut store, &program.component) @@ -653,6 +677,10 @@ impl ComponentInstance { if let Err(error) = outcome { if matches!(error, CallError::Deadline | CallError::Trap(_)) { self.spent = true; + // A spent instance cannot be called again. Release sockets, + // DNS tasks and streams now, even if its owner keeps it alive. + self.store.data_mut().table = ResourceTable::new(); + self.store.data_mut().sockets.clear(); } return Err(error); } diff --git a/crates/wasm-host/src/component/dns.rs b/crates/wasm-host/src/component/dns.rs new file mode 100644 index 00000000..72ebcdc4 --- /dev/null +++ b/crates/wasm-host/src/component/dns.rs @@ -0,0 +1,325 @@ +//! WASI DNS with resolver work bounded until native resolution really ends. +//! +//! Cancelling a guest cannot cancel a running getaddrinfo call. Each resolver +//! therefore keeps both a process-wide worker permit and its app's socket +//! permit until it returns. Dropping a guest resource never opens an unbounded +//! queue of detached resolver tasks. +use super::sockets::Permit; +use super::State; +use std::collections::VecDeque; +use std::net::{IpAddr, ToSocketAddrs}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, +}; +use tokio::sync::Notify; +use wasmtime::component::{Linker, Resource, ResourceType}; +use wasmtime::{StoreContextMut, Trap}; +use wasmtime_wasi::p2::bindings::sockets::network::{ErrorCode, IpAddress, Network}; +use wasmtime_wasi::p2::{subscribe, DynPollable, Pollable}; + +const MAX_RESOLVERS: usize = 8; +const MAX_ADDRESSES: usize = 256; +static RESOLVERS: AtomicUsize = AtomicUsize::new(0); + +type Answer = Result, ErrorCode>; +struct Shared { + answer: Mutex>, + ready: Notify, +} +impl Shared { + fn new(answer: Option) -> Self { + Self { + answer: Mutex::new(answer), + ready: Notify::new(), + } + } + fn is_ready(&self) -> bool { + self.answer + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some() + } + fn complete(&self, answer: Answer) { + *self.answer.lock().unwrap_or_else(|e| e.into_inner()) = Some(answer); + self.ready.notify_waiters(); + } +} + +/// Kept by the native worker, not just its guest-facing handle. +struct WorkerPermit; +impl WorkerPermit { + fn acquire() -> Option { + RESOLVERS + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + (used < MAX_RESOLVERS).then_some(used + 1) + }) + .ok() + .map(|_| Self) + } +} +impl Drop for WorkerPermit { + fn drop(&mut self) { + RESOLVERS.fetch_sub(1, Ordering::AcqRel); + } +} + +struct DnsLookup { + shared: Arc, + // The worker holds another Arc. A finished lookup keeps its permit while + // the resource lives; an abandoned lookup keeps it while its worker runs. + _permit: Arc, +} + +#[wasmtime_wasi::async_trait] +impl Pollable for DnsLookup { + async fn ready(&mut self) { + loop { + let notified = self.shared.ready.notified(); + tokio::pin!(notified); + // Register before checking completion: no lost wakeup if the + // resolver completes between the check and awaiting Notify. + notified.as_mut().enable(); + if self.shared.is_ready() { + return; + } + notified.await; + } + } +} + +impl DnsLookup { + fn literal(address: IpAddr, permit: Permit) -> Self { + Self { + shared: Arc::new(Shared::new(Some(Ok(VecDeque::from([address]))))), + _permit: Arc::new(permit), + } + } + + fn start( + permit: Permit, + resolve: impl FnOnce() -> Answer + Send + 'static, + ) -> Result { + let worker_permit = WorkerPermit::acquire().ok_or(ErrorCode::NewSocketLimit)?; + let permit = Arc::new(permit); + let shared = Arc::new(Shared::new(None)); + let worker_state = shared.clone(); + let worker_socket_permit = permit.clone(); + std::thread::Builder::new() + .name("wasm-dns".into()) + .spawn(move || { + let _worker_permit = worker_permit; + let _socket_permit = worker_socket_permit; + worker_state.complete(resolve()); + }) + .map_err(|_| ErrorCode::NewSocketLimit)?; + Ok(Self { + shared, + _permit: permit, + }) + } + + fn next(&mut self) -> Result, ErrorCode> { + let mut answer = self.shared.answer.lock().unwrap_or_else(|e| e.into_inner()); + match answer.as_mut() { + None => Err(ErrorCode::WouldBlock), + Some(Err(error)) => Err(*error), + Some(Ok(addresses)) => Ok(addresses.pop_front().map(Into::into)), + } + } +} + +enum Name { + Literal(IpAddr), + Domain(String), +} + +fn name(value: &str) -> Result { + if value.is_empty() || value.len() > 4096 || value.contains('\0') { + return Err(ErrorCode::InvalidArgument); + } + // Preserve Wasmtime's IDNA and numeric-host handling, including bare IPv6. + match url::Host::parse(value) { + Ok(url::Host::Ipv4(address)) => Ok(Name::Literal(address.into())), + Ok(url::Host::Ipv6(address)) => Ok(Name::Literal(address.into())), + Ok(url::Host::Domain(domain)) if !domain.is_empty() && domain.len() <= 253 => { + Ok(Name::Domain(domain)) + } + Err(_) => value + .parse::() + .map(|address| Name::Literal(address.into())) + .map_err(|_| ErrorCode::InvalidArgument), + _ => Err(ErrorCode::InvalidArgument), + } +} + +fn resolve(domain: String) -> Answer { + (domain.as_str(), 0) + .to_socket_addrs() + .map(|addresses| { + addresses + .map(|address| address.ip().to_canonical()) + .take(MAX_ADDRESSES) + .collect() + }) + .map_err(|error| match error.kind() { + std::io::ErrorKind::InvalidInput => ErrorCode::InvalidArgument, + std::io::ErrorKind::PermissionDenied => ErrorCode::AccessDenied, + std::io::ErrorKind::TimedOut + | std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::Interrupted => ErrorCode::TemporaryResolverFailure, + std::io::ErrorKind::OutOfMemory => ErrorCode::OutOfMemory, + _ => ErrorCode::NameUnresolvable, + }) +} + +pub(crate) fn link(linker: &mut Linker) -> wasmtime::Result<()> { + linker.allow_shadowing(true); + let mut interface = linker.instance("wasi:sockets/ip-name-lookup@0.2.12")?; + interface.resource( + "resolve-address-stream", + ResourceType::host::(), + |mut store, rep| { + // Unlike getaddrinfo, dropping the guest handle must never block. + store + .data_mut() + .table + .delete(Resource::::new_own(rep))?; + Ok(()) + }, + )?; + interface.func_wrap( + "resolve-addresses", + |mut store: StoreContextMut<'_, State>, (network, value): (Resource, String)| { + let state = store.data_mut(); + state.table.get(&network)?; + if !state.guard.as_ref().is_some_and(|guard| guard.live()) { + return Err(Trap::Interrupt.into()); + } + let name = match name(&value) { + Ok(name) => name, + Err(error) => return Ok((Err(error),)), + }; + let Some(permit) = state.sockets.budget.acquire() else { + return Ok((Err(ErrorCode::NewSocketLimit),)); + }; + let lookup = match name { + Name::Literal(address) => DnsLookup::literal(address, permit), + Name::Domain(domain) => match DnsLookup::start(permit, move || resolve(domain)) { + Ok(lookup) => lookup, + Err(error) => return Ok((Err(error),)), + }, + }; + Ok((Ok(state.table.push(lookup)?),)) + }, + )?; + interface.func_wrap( + "[method]resolve-address-stream.resolve-next-address", + |mut store: StoreContextMut<'_, State>, (lookup,): (Resource,)| { + Ok((store.data_mut().table.get_mut(&lookup)?.next(),)) + }, + )?; + interface.func_wrap( + "[method]resolve-address-stream.subscribe", + |mut store: StoreContextMut<'_, State>, + (lookup,): (Resource,)| + -> wasmtime::Result<(Resource,)> { + Ok((subscribe(&mut store.data_mut().table, lookup)?,)) + }, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::component::sockets::SocketBudget; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + #[test] + fn resolver_work_stays_bounded_after_all_guest_handles_are_dropped() { + let budget = SocketBudget::default(); + let mut controls = Vec::new(); + for _ in 0..MAX_RESOLVERS { + let (release, wait) = mpsc::channel(); + let lookup = DnsLookup::start(budget.acquire().unwrap(), move || { + wait.recv().unwrap(); + Ok(VecDeque::from([IpAddr::from([127, 0, 0, 1])])) + }) + .unwrap(); + controls.push((release, lookup.shared.clone())); + drop(lookup); + } + assert!(matches!( + DnsLookup::start(budget.acquire().unwrap(), || unreachable!()), + Err(ErrorCode::NewSocketLimit) + )); + // Eight abandoned native lookups still consume eight per-app permits. + let mut held = Vec::new(); + while let Some(permit) = budget.acquire() { + held.push(permit); + } + assert_eq!(held.len(), 32 - MAX_RESOLVERS); + for (release, _) in &controls { + release.send(()).unwrap(); + } + let deadline = Instant::now() + Duration::from_secs(2); + while controls.iter().any(|(_, result)| !result.is_ready()) + || RESOLVERS.load(Ordering::Acquire) != 0 + { + assert!( + Instant::now() < deadline, + "native resolver workers did not finish" + ); + std::thread::yield_now(); + } + let mut freed = Vec::new(); + while let Some(permit) = budget.acquire() { + freed.push(permit); + } + assert_eq!(freed.len(), MAX_RESOLVERS); + drop(held); + drop(freed); + } + + #[test] + fn literal_lookup_is_ready_and_retains_the_app_permit_until_drop() { + let budget = SocketBudget::default(); + let mut lookup = + DnsLookup::literal(IpAddr::from([127, 0, 0, 1]), budget.acquire().unwrap()); + assert!(lookup.shared.is_ready()); + assert!(matches!( + lookup.next(), + Ok(Some(IpAddress::Ipv4((127, 0, 0, 1)))) + )); + assert!(matches!(lookup.next(), Ok(None))); + let mut permits = Vec::new(); + while let Some(permit) = budget.acquire() { + permits.push(permit); + } + assert_eq!(permits.len(), 31); + drop(lookup); + assert!(budget.acquire().is_some()); + } + + #[test] + fn host_names_reject_ports_and_invalid_input_and_preserve_idna_and_literals() { + for value in [ + "", + "bad\0host", + "example.test:443", + "https://example.test", + "host name", + "example.test/path", + ] { + assert!(name(value).is_err(), "{value:?}"); + } + for value in ["127.0.0.1", "::1", "[::1]"] { + assert!(matches!(name(value), Ok(Name::Literal(_))), "{value}"); + } + assert!( + matches!(name("bücher.example"), Ok(Name::Domain(domain)) if domain == "xn--bcher-kva.example") + ); + } +} diff --git a/crates/wasm-host/src/component/net.rs b/crates/wasm-host/src/component/net.rs index e717e39b..61b517ee 100644 --- a/crates/wasm-host/src/component/net.rs +++ b/crates/wasm-host/src/component/net.rs @@ -3,9 +3,8 @@ //! 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.) +//! host's API surface are the boundary. The same disclosure rule applies +//! to the app's Splash requests and its component's ordinary WASI sockets. //! //! 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 diff --git a/crates/wasm-host/src/component/sockets.rs b/crates/wasm-host/src/component/sockets.rs new file mode 100644 index 00000000..fecfef46 --- /dev/null +++ b/crates/wasm-host/src/component/sockets.rs @@ -0,0 +1,470 @@ +//! WASI sockets use the device's network. Declarations are disclosures, not +//! network grants. The host bounds resources and every blocking wait instead. +use super::{super::InvocationGuard, State}; +use std::collections::HashMap; +use std::future::Future; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use std::time::{Duration, Instant}; +use wasmtime::component::{Linker, Resource, ResourceType}; +use wasmtime::{StoreContextMut, Trap}; +use wasmtime_wasi::p2::bindings::{ + io::{poll, streams}, + sockets::{network, tcp, tcp_create_socket, udp, udp_create_socket}, +}; +use wasmtime_wasi::p2::{ + DynInputStream, DynOutputStream, DynPollable, SocketError, StreamError, TcpSocket, UdpSocket, +}; +use wasmtime_wasi::runtime::in_tokio; +use wasmtime_wasi::sockets::WasiSocketsView; + +/// Bounds pollables, streams and all other host resources in one component. +pub(crate) const MAX_RESOURCES: usize = 512; +/// Live TCP/UDP sockets and DNS lookup streams across one app's components. +const MAX_SOCKETS: usize = 32; +const TICK: Duration = Duration::from_millis(10); + +/// Share one budget between every component belonging to an app. A resource's +/// permit follows its real host handle; closing it, trapping or dropping the +/// instance releases the permit. Different apps have independent budgets. +#[derive(Clone, Debug, Default)] +pub struct SocketBudget(Arc); +impl SocketBudget { + pub(super) fn acquire(&self) -> Option { + self.0 + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| { + (n < MAX_SOCKETS).then_some(n + 1) + }) + .ok() + .map(|_| Permit(self.clone())) + } +} +pub(super) struct Permit(SocketBudget); +impl Drop for Permit { + fn drop(&mut self) { + self.0 .0.fetch_sub(1, Ordering::AcqRel); + } +} + +pub(crate) struct Resources { + pub(super) budget: SocketBudget, + live: HashMap, +} +impl Resources { + pub(crate) fn new(budget: SocketBudget) -> Self { + Self { + budget, + live: HashMap::new(), + } + } + pub(crate) fn clear(&mut self) { + self.live.clear(); + } +} + +/// Epoch interrupts only run while guest code executes. Polling a socket or +/// DNS task waits in the host instead, so observe cancellation here too. +fn wait(guard: Option>, future: F) -> wasmtime::Result { + let guard = guard.ok_or_else(|| wasmtime::Error::from(Trap::Interrupt))?; + if !guard.live() { + return Err(Trap::Interrupt.into()); + } + in_tokio(async { + tokio::pin!(future); + loop { + let remaining = guard.deadline.saturating_duration_since(Instant::now()); + tokio::select! { + biased; + _ = tokio::time::sleep(TICK.min(remaining)) => { + if !guard.live() { return Err(Trap::Interrupt.into()); } + } + result = &mut future => { + return if guard.live() { Ok(result) } else { Err(Trap::Interrupt.into()) }; + } + } + } + }) +} + +fn socket_result( + state: &mut State, + result: Result, +) -> wasmtime::Result<(Result,)> { + use network::Host; + Ok((match result { + Ok(v) => Ok(v), + Err(e) => Err(state.sockets().convert_error_code(e)?), + },)) +} +fn stream_result( + state: &mut State, + result: Result, +) -> wasmtime::Result<(Result,)> { + use streams::Host; + Ok((match result { + Ok(v) => Ok(v), + Err(e) => Err(state.table.convert_stream_error(e)?), + },)) +} +fn full() -> wasmtime::Result<(Result,)> { + Ok((Err(network::ErrorCode::NewSocketLimit),)) +} + +pub(crate) fn link(linker: &mut Linker) -> wasmtime::Result<()> { + linker.allow_shadowing(true); + linker + .instance("wasi:sockets/tcp-create-socket@0.2.12")? + .func_wrap( + "create-tcp-socket", + |mut store: StoreContextMut<'_, State>, (family,): (network::IpAddressFamily,)| { + use tcp_create_socket::Host; + let state = store.data_mut(); + let Some(permit) = state.sockets.budget.acquire() else { + return full(); + }; + let result = state.sockets().create_tcp_socket(family); + if let Ok(socket) = &result { + state.sockets.live.insert(socket.rep(), permit); + } + socket_result(state, result) + }, + )?; + linker + .instance("wasi:sockets/udp-create-socket@0.2.12")? + .func_wrap( + "create-udp-socket", + |mut store: StoreContextMut<'_, State>, (family,): (network::IpAddressFamily,)| { + use udp_create_socket::Host; + let state = store.data_mut(); + let Some(permit) = state.sockets.budget.acquire() else { + return full(); + }; + let guard = state.guard.clone(); + let result = wait(guard, state.sockets().create_udp_socket(family))?; + if let Ok(socket) = &result { + state.sockets.live.insert(socket.rep(), permit); + } + socket_result(state, result) + }, + )?; + let mut types = linker.instance("wasi:sockets/tcp@0.2.12")?; + types.resource( + "tcp-socket", + ResourceType::host::(), + |mut store, rep| { + use tcp::HostTcpSocket; + let state = store.data_mut(); + state.sockets().drop(Resource::new_own(rep))?; + state.sockets.live.remove(&rep); + Ok(()) + }, + )?; + types.func_wrap( + "[method]tcp-socket.accept", + |mut store: StoreContextMut<'_, State>, (socket,): (Resource,)| { + use tcp::HostTcpSocket; + let state = store.data_mut(); + let Some(permit) = state.sockets.budget.acquire() else { + return full(); + }; + let result = state.sockets().accept(socket); + if let Ok((socket, _, _)) = &result { + state.sockets.live.insert(socket.rep(), permit); + } + socket_result(state, result) + }, + )?; + types.func_wrap( + "[method]tcp-socket.start-bind", + |mut store: StoreContextMut<'_, State>, + (socket, net, addr): ( + Resource, + Resource, + network::IpSocketAddress, + )| { + use tcp::HostTcpSocket; + let state = store.data_mut(); + let result = wait( + state.guard.clone(), + state.sockets().start_bind(socket, net, addr), + )?; + socket_result(state, result) + }, + )?; + types.func_wrap( + "[method]tcp-socket.start-listen", + |mut store: StoreContextMut<'_, State>, (socket,): (Resource,)| { + use tcp::HostTcpSocket; + let state = store.data_mut(); + let result = wait(state.guard.clone(), state.sockets().start_listen(socket))?; + socket_result(state, result) + }, + )?; + let mut types = linker.instance("wasi:sockets/udp@0.2.12")?; + types.resource( + "incoming-datagram-stream", + ResourceType::host::(), + |mut store, rep| { + use udp::HostIncomingDatagramStream; + let state = store.data_mut(); + wait( + state.guard.clone(), + state.sockets().drop(Resource::new_own(rep)), + )??; + Ok(()) + }, + )?; + types.resource( + "outgoing-datagram-stream", + ResourceType::host::(), + |mut store, rep| { + use udp::HostOutgoingDatagramStream; + let state = store.data_mut(); + wait( + state.guard.clone(), + state.sockets().drop(Resource::new_own(rep)), + )??; + Ok(()) + }, + )?; + types.resource( + "udp-socket", + ResourceType::host::(), + |mut store, rep| { + use udp::HostUdpSocket; + let state = store.data_mut(); + state.sockets().drop(Resource::new_own(rep))?; + state.sockets.live.remove(&rep); + Ok(()) + }, + )?; + types.func_wrap( + "[method]udp-socket.start-bind", + |mut store: StoreContextMut<'_, State>, + (socket, net, addr): ( + Resource, + Resource, + network::IpSocketAddress, + )| { + use udp::HostUdpSocket; + let state = store.data_mut(); + let result = wait( + state.guard.clone(), + state.sockets().start_bind(socket, net, addr), + )?; + socket_result(state, result) + }, + )?; + types.func_wrap( + "[method]udp-socket.stream", + |mut store: StoreContextMut<'_, State>, + (socket, addr): (Resource, Option)| { + use udp::HostUdpSocket; + let state = store.data_mut(); + let result = wait(state.guard.clone(), state.sockets().stream(socket, addr))?; + socket_result(state, result) + }, + )?; + link_waits(linker) +} + +/// Cover all blocking stream operations, including streams produced by HTTP +/// and TCP and pollables produced by DNS, UDP, clocks and TCP connect/listen. +fn link_waits(linker: &mut Linker) -> wasmtime::Result<()> { + let mut types = linker.instance("wasi:io/poll@0.2.12")?; + types.func_wrap( + "poll", + |mut store: StoreContextMut<'_, State>, (items,): (Vec>,)| { + use poll::Host; + let state = store.data_mut(); + Ok((wait(state.guard.clone(), state.table.poll(items))??,)) + }, + )?; + types.func_wrap( + "[method]pollable.block", + |mut store: StoreContextMut<'_, State>, (item,): (Resource,)| { + use poll::HostPollable; + let state = store.data_mut(); + wait(state.guard.clone(), state.table.block(item))??; + Ok(()) + }, + )?; + let mut types = linker.instance("wasi:io/streams@0.2.12")?; + types.resource( + "input-stream", + ResourceType::host::(), + |mut store, rep| { + use streams::HostInputStream; + let state = store.data_mut(); + wait( + state.guard.clone(), + state.table.drop(Resource::new_own(rep)), + )??; + Ok(()) + }, + )?; + types.resource( + "output-stream", + ResourceType::host::(), + |mut store, rep| { + use streams::HostOutputStream; + let state = store.data_mut(); + wait( + state.guard.clone(), + state.table.drop(Resource::new_own(rep)), + )??; + Ok(()) + }, + )?; + macro_rules! input { + ($name:literal, $method:ident) => { + types.func_wrap( + $name, + |mut store: StoreContextMut<'_, State>, + (stream, len): (Resource, u64)| { + use streams::HostInputStream; + let state = store.data_mut(); + let result = wait(state.guard.clone(), state.table.$method(stream, len))?; + stream_result(state, result) + }, + )?; + }; + } + input!("[method]input-stream.blocking-read", blocking_read); + input!("[method]input-stream.blocking-skip", blocking_skip); + types.func_wrap( + "[method]output-stream.blocking-write-and-flush", + |mut store: StoreContextMut<'_, State>, + (stream, bytes): (Resource, Vec)| { + use streams::HostOutputStream; + let state = store.data_mut(); + let result = wait( + state.guard.clone(), + state.table.blocking_write_and_flush(stream, bytes), + )?; + stream_result(state, result) + }, + )?; + types.func_wrap( + "[method]output-stream.blocking-write-zeroes-and-flush", + |mut store: StoreContextMut<'_, State>, (stream, len): (Resource, u64)| { + use streams::HostOutputStream; + let state = store.data_mut(); + let result = wait( + state.guard.clone(), + state.table.blocking_write_zeroes_and_flush(stream, len), + )?; + stream_result(state, result) + }, + )?; + types.func_wrap( + "[method]output-stream.blocking-flush", + |mut store: StoreContextMut<'_, State>, (stream,): (Resource,)| { + use streams::HostOutputStream; + let state = store.data_mut(); + let result = wait(state.guard.clone(), state.table.blocking_flush(stream))?; + stream_result(state, result) + }, + )?; + types.func_wrap( + "[method]output-stream.blocking-splice", + |mut store: StoreContextMut<'_, State>, + (out, input, len): (Resource, Resource, u64)| { + use streams::HostOutputStream; + let state = store.data_mut(); + let result = wait( + state.guard.clone(), + state.table.blocking_splice(out, input, len), + )?; + stream_result(state, result) + }, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + + #[test] + fn cancellation_ends_a_host_future_that_never_wakes() { + // DNS, poll and streams all use this wait. No external DNS server or + // platform resolver timing is needed to prove the cancellation path. + let pending = Arc::new(AtomicBool::new(true)); + let flag = pending.clone(); + let cancel = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(30)); + flag.store(false, Ordering::Release); + }); + let start = Instant::now(); + let guard = Arc::new(InvocationGuard { + deadline: start + Duration::from_secs(5), + pending: Box::new(move || pending.load(Ordering::Acquire)), + }); + let error = wait(Some(guard), std::future::pending::<()>()).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(Trap::Interrupt) + )); + assert!(start.elapsed() < Duration::from_secs(1)); + cancel.join().unwrap(); + } + + #[test] + fn deadline_ends_a_host_future_that_never_wakes() { + let start = Instant::now(); + let guard = Arc::new(InvocationGuard { + deadline: start + Duration::from_millis(30), + pending: Box::new(|| true), + }); + let error = wait(Some(guard), std::future::pending::<()>()).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(Trap::Interrupt) + )); + assert!(start.elapsed() < Duration::from_secs(1)); + } + + struct StalledClose(Arc); + impl Drop for StalledClose { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } + #[wasmtime_wasi::async_trait] + impl wasmtime_wasi::p2::Pollable for StalledClose { + async fn ready(&mut self) {} + } + #[wasmtime_wasi::async_trait] + impl wasmtime_wasi::p2::InputStream for StalledClose { + fn read(&mut self, _: usize) -> Result { + Err(StreamError::Closed) + } + async fn cancel(&mut self) { + std::future::pending::<()>().await; + } + } + + #[test] + fn a_stream_destructor_cannot_outlive_the_call() { + use streams::HostInputStream; + let closed = Arc::new(AtomicBool::new(false)); + let mut table = wasmtime::component::ResourceTable::new(); + let stream = table + .push(Box::new(StalledClose(closed.clone())) as DynInputStream) + .unwrap(); + let guard = Arc::new(InvocationGuard { + deadline: Instant::now() + Duration::from_millis(30), + pending: Box::new(|| true), + }); + let error = wait(Some(guard), table.drop(stream)).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(Trap::Interrupt) + )); + assert!(closed.load(Ordering::Acquire)); + } +} diff --git a/crates/wasm-host/src/lib.rs b/crates/wasm-host/src/lib.rs index 0ddc3e6e..99eb8b57 100644 --- a/crates/wasm-host/src/lib.rs +++ b/crates/wasm-host/src/lib.rs @@ -58,7 +58,7 @@ pub struct Limits { /// How long one call may run. pub deadline: Duration, /// How long one call may run when it may wait for the network (a - /// component that imports `wasi:http`). + /// component that imports `wasi:http` or `wasi:sockets`). pub network_deadline: Duration, /// The most linear memory an instance may have. pub memory_bytes: usize, diff --git a/crates/wasm-host/tests/component-guest/netprobe/src/lib.rs b/crates/wasm-host/tests/component-guest/netprobe/src/lib.rs index a3b43273..3ccbf731 100644 --- a/crates/wasm-host/tests/component-guest/netprobe/src/lib.rs +++ b/crates/wasm-host/tests/component-guest/netprobe/src/lib.rs @@ -1,12 +1,73 @@ -//! A test component that imports wasi:sockets through std::net. +//! Test real std::net calls, including retained sockets across invocations. wit_bindgen::generate!({ world: "netprobe", path: "wit" }); - +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream, ToSocketAddrs, UdpSocket}; +use std::sync::Mutex; +static LISTENERS: Mutex> = Mutex::new(Vec::new()); struct Probe; - impl Guest for Probe { - fn connect(addr: String) -> Result<(), String> { - std::net::TcpStream::connect(addr).map(drop).map_err(|e| e.to_string()) + // Avoid the bare export name `connect`: it collides with WASI libc. + fn tcp_connect(addr: String) -> Result<(), String> { + TcpStream::connect(addr) + .map(drop) + .map_err(|e| e.to_string()) + } + fn tcp_echo(addr: String, text: String) -> Result { + (|| { + let mut stream = TcpStream::connect(addr)?; + stream.write_all(text.as_bytes())?; + let mut result = vec![0; text.len()]; + stream.read_exact(&mut result)?; + Ok(String::from_utf8_lossy(&result).into_owned()) + })() + .map_err(|e: std::io::Error| e.to_string()) + } + fn udp_echo(addr: String, text: String) -> Result { + (|| { + let socket = UdpSocket::bind("127.0.0.1:0")?; + socket.send_to(text.as_bytes(), addr)?; + let mut result = [0; 1024]; + let (n, _) = socket.recv_from(&mut result)?; + Ok(String::from_utf8_lossy(&result[..n]).into_owned()) + })() + .map_err(|e: std::io::Error| e.to_string()) + } + fn resolve(name: String) -> Result, String> { + (name.as_str(), 80) + .to_socket_addrs() + .map(|a| a.map(|a| a.ip().to_string()).collect()) + .map_err(|e| e.to_string()) + } + fn hold_listener() -> Result { + let socket = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; + let port = socket.local_addr().map_err(|e| e.to_string())?.port(); + LISTENERS.lock().unwrap().push(socket); + Ok(port) + } + fn close_listeners() { + LISTENERS.lock().unwrap().clear(); + } + fn accept_echo(text: String) -> Result { + (|| { + let listeners = LISTENERS.lock().unwrap(); + let (mut stream, _) = listeners[0].accept()?; + stream.write_all(text.as_bytes())?; + let mut result = vec![0; text.len()]; + stream.read_exact(&mut result)?; + Ok(String::from_utf8_lossy(&result).into_owned()) + })() + .map_err(|e: std::io::Error| e.to_string()) + } + fn read_forever(addr: String) -> Result<(), String> { + (|| TcpStream::connect(addr)?.read_exact(&mut [0]))().map_err(|e| e.to_string()) + } + fn udp_receive() -> Result<(), String> { + (|| { + UdpSocket::bind("127.0.0.1:0")? + .recv_from(&mut [0]) + .map(|_| ()) + })() + .map_err(|e| e.to_string()) } } - export!(Probe); diff --git a/crates/wasm-host/tests/component-guest/netprobe/wit/netprobe.wit b/crates/wasm-host/tests/component-guest/netprobe/wit/netprobe.wit index c0d2fab1..2a135b72 100644 --- a/crates/wasm-host/tests/component-guest/netprobe/wit/netprobe.wit +++ b/crates/wasm-host/tests/component-guest/netprobe/wit/netprobe.wit @@ -1,6 +1,12 @@ package octosense:netprobe-spike; - world netprobe { - /// Opens a TCP connection: imports wasi:sockets, so the host refuses it. - export connect: func(addr: string) -> result<_, string>; + export tcp-connect: func(addr: string) -> result<_, string>; + export tcp-echo: func(addr: string, text: string) -> result; + export udp-echo: func(addr: string, text: string) -> result; + export resolve: func(name: string) -> result, string>; + export hold-listener: func() -> result; + export close-listeners: func(); + export accept-echo: func(text: string) -> result; + export read-forever: func(addr: string) -> result<_, string>; + export udp-receive: func() -> result<_, string>; } diff --git a/crates/wasm-host/tests/component.rs b/crates/wasm-host/tests/component.rs index f2661319..0bf27c39 100644 --- a/crates/wasm-host/tests/component.rs +++ b/crates/wasm-host/tests/component.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use octosense_wasm_host::component::{is_component, Grants, HostCalls}; -use octosense_wasm_host::{CallError, Limits, LoadError, Runtime}; +use octosense_wasm_host::{CallError, Limits, Runtime}; use serde_json::json; const HOSTCALL: &[u8] = include_bytes!("fixtures/hostcall.component.wasm"); @@ -574,12 +574,8 @@ fn arguments_that_do_not_fit_are_refused_with_the_parameter_name() { } #[test] -fn a_component_that_imports_sockets_is_refused() { - match runtime().load_component(NETPROBE) { - Err(LoadError::Import(name)) => assert!(name.starts_with("wasi:sockets/"), "{name}"), - Err(other) => panic!("{other:?}"), - Ok(_) => panic!("loaded a component that imports sockets"), - } +fn a_component_that_imports_standard_sockets_loads() { + assert!(runtime().load_component(NETPROBE).unwrap().uses_network()); } #[test] diff --git a/crates/wasm-host/tests/component_sockets.rs b/crates/wasm-host/tests/component_sockets.rs new file mode 100644 index 00000000..6055a03d --- /dev/null +++ b/crates/wasm-host/tests/component_sockets.rs @@ -0,0 +1,211 @@ +//! Exercise actual wasm32-wasip2 std::net, using only isolated loopback peers. +use octosense_wasm_host::{ + component::{Grants, SocketBudget}, + CallError, Limits, Runtime, +}; +use serde_json::{json, Value}; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream, UdpSocket}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::time::{Duration, Instant}; + +const GUEST: &[u8] = include_bytes!("fixtures/netprobe.component.wasm"); +fn runtime() -> Runtime { + Runtime::new( + Limits { + network_deadline: Duration::from_secs(2), + ..Limits::default() + }, + None, + ) + .unwrap() +} +fn listener_port(value: Value) -> u16 { + value.as_u64().unwrap() as u16 +} +fn assert_closed(port: u16) { + assert!(TcpStream::connect_timeout( + &format!("127.0.0.1:{port}").parse().unwrap(), + Duration::from_millis(100) + ) + .is_err()); + TcpListener::bind(("127.0.0.1", port)).expect("listener's port should be released"); +} + +#[test] +fn tcp_udp_and_dns_work_without_a_network_grant() { + let rt = runtime(); + let program = rt.load_component(GUEST).unwrap(); + let mut instance = rt + .instantiate_component(&program, &Grants::default(), None) + .unwrap(); + let tcp = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = tcp.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = tcp.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut buf = [0; 5]; + stream.read_exact(&mut buf).unwrap(); + stream.write_all(&buf).unwrap(); + }); + assert_eq!( + instance + .call_json("tcp_echo", &json!({"addr":addr,"text":"hello"})) + .unwrap(), + json!("hello") + ); + server.join().unwrap(); + let udp = UdpSocket::bind("127.0.0.1:0").unwrap(); + let addr = udp.local_addr().unwrap().to_string(); + udp.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + let server = std::thread::spawn(move || { + let mut bytes = [0; 128]; + let (n, peer) = udp.recv_from(&mut bytes).unwrap(); + udp.send_to(&bytes[..n], peer).unwrap(); + }); + assert_eq!( + instance + .call_json("udp_echo", &json!({"addr":addr,"text":"datagram"})) + .unwrap(), + json!("datagram") + ); + server.join().unwrap(); + let addresses = instance.call_json("resolve", &json!("localhost")).unwrap(); + assert!( + addresses + .as_array() + .unwrap() + .iter() + .any(|v| v == "127.0.0.1" || v == "::1"), + "{addresses}" + ); +} + +#[test] +fn tcp_listener_accepts_and_drop_releases_the_port() { + let rt = runtime(); + let program = rt.load_component(GUEST).unwrap(); + let mut instance = rt + .instantiate_component(&program, &Grants::default(), None) + .unwrap(); + let port = listener_port(instance.call_json("hold_listener", &json!(null)).unwrap()); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut buf = [0; 5]; + stream.read_exact(&mut buf).unwrap(); + stream.write_all(&buf).unwrap(); + }); + assert_eq!( + instance.call_json("accept_echo", &json!("hello")).unwrap(), + json!("hello") + ); + client.join().unwrap(); + drop(instance); + // An accepted TCP connection can leave TIME_WAIT, so test release with a + // fresh listener that never accepted a connection. + let mut instance = rt + .instantiate_component(&program, &Grants::default(), None) + .unwrap(); + let port = listener_port(instance.call_json("hold_listener", &json!(null)).unwrap()); + drop(instance); + assert_closed(port); +} + +#[test] +fn socket_limit_is_shared_across_components_and_close_refunds_it() { + let rt = runtime(); + let program = rt.load_component(GUEST).unwrap(); + let budget = SocketBudget::default(); + let mut first = rt + .instantiate_component_with_sockets(&program, &Grants::default(), None, budget.clone()) + .unwrap(); + let mut second = rt + .instantiate_component_with_sockets(&program, &Grants::default(), None, budget) + .unwrap(); + for _ in 0..16 { + first.call_json("hold_listener", &json!(null)).unwrap(); + second.call_json("hold_listener", &json!(null)).unwrap(); + } + assert!(matches!( + second.call_json("hold_listener", &json!(null)), + Err(CallError::Guest(_)) + )); + // Another app is independent. + let mut other = rt + .instantiate_component(&program, &Grants::default(), None) + .unwrap(); + other.call_json("hold_listener", &json!(null)).unwrap(); + first.call_json("close_listeners", &json!(null)).unwrap(); + // More than a budget's worth of open/close cycles also checks resource + // destructors, not just dropping the entire Wasmtime store. + for _ in 0..64 { + let port = listener_port(second.call_json("hold_listener", &json!(null)).unwrap()); + second.call_json("close_listeners", &json!(null)).unwrap(); + assert_closed(port); + } +} + +#[test] +fn cancellation_interrupts_a_blocked_tcp_read_and_closes_retained_sockets() { + let rt = runtime(); + let program = rt.load_component(GUEST).unwrap(); + let mut instance = rt + .instantiate_component(&program, &Grants::default(), None) + .unwrap(); + let retained = listener_port(instance.call_json("hold_listener", &json!(null)).unwrap()); + let tcp = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = tcp.local_addr().unwrap().to_string(); + let pending = Arc::new(AtomicBool::new(true)); + let cancel = pending.clone(); + let server = std::thread::spawn(move || { + let (mut stream, _) = tcp.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + cancel.store(false, Ordering::Release); + assert_eq!( + stream.read(&mut [0]).unwrap(), + 0, + "cancelled guest should close its connection" + ); + }); + let start = Instant::now(); + let result = instance.call_json_guarded( + "read_forever", + &json!(addr), + start + Duration::from_secs(5), + move || pending.load(Ordering::Acquire), + ); + assert!(matches!(result, Err(CallError::Deadline)), "{result:?}"); + assert!(start.elapsed() < Duration::from_secs(2)); + assert_closed(retained); // store is deliberately still alive + server.join().unwrap(); +} + +#[test] +fn deadline_interrupts_an_empty_udp_receive() { + let rt = runtime(); + let program = rt.load_component(GUEST).unwrap(); + let mut instance = rt + .instantiate_component(&program, &Grants::default(), None) + .unwrap(); + let retained = listener_port(instance.call_json("hold_listener", &json!(null)).unwrap()); + let start = Instant::now(); + let result = instance.call_json_guarded( + "udp_receive", + &json!(null), + start + Duration::from_millis(100), + || true, + ); + assert!(matches!(result, Err(CallError::Deadline)), "{result:?}"); + assert!(start.elapsed() < Duration::from_secs(2)); + assert_closed(retained); +} diff --git a/crates/wasm-host/tests/fixtures/netprobe.component.wasm b/crates/wasm-host/tests/fixtures/netprobe.component.wasm index 52060e297945ab9c2281b93754f4a27166c79efc..804ac72bdc23c3ac21ad8c5dc281c990a7cc9bf3 100644 GIT binary patch delta 37149 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 delta 20686 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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 99819434..5a9202c0 100644 --- a/docs/adr/0014-app-components-in-webassembly.md +++ b/docs/adr/0014-app-components-in-webassembly.md @@ -130,9 +130,18 @@ component reaches only what its app may already reach. families the manifest declares. Each service retains its actual app/account, consent, review and resource checks. Calls gain no sheet or 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. + - **Ordinary sockets (unreleased follow-up):** `wasi:sockets` provides TCP, + UDP and DNS. It requires `host_api.required: {"wasm.sockets": 1}` and + `host-api-v1`, so older hosts remain able to read the catalog but cannot + install this app. No new permission gate or network allowlist is added. + The app shares 32 live socket/DNS handles across its components; each + component has 512 host resource slots. Blocking waits observe deadlines + and cancellation. Closing the last owning app isolate releases the worker + and its sockets. See the [Wasm guide](../wasm.md) for limits and release + status. RC5 still refuses sockets. + - **Never:** imports outside the listed packages, including native system + calls and privileged raw IP sockets. The loader and App Hub gate refuse + unsupported imports. 5. **No WIT to write.** - **Guest SDK, phase 2:** a guest crate, `octosense-component`, with an `#[octosense_component::export]` attribute that derives the WIT world from @@ -161,7 +170,7 @@ component reaches only what its app may already reach. | Phase | Scope | | --- | --- | -| 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`. | +| 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, in the original spike, refuse `wasi:sockets` (superseded by the follow-up above). | | 2. Usable by developers | The shell's `wasm` service: loading components from `fns/`, `wasm.` calls, per-app instances, the app's available storage area, 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 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 OpenHarmony until its JIT policy is known; Windows runtime tests in CI. iOS remains outside the current plan. | | 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, account and storage boundaries. | 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 78ee74ec..048a69dd 100644 --- a/docs/adr/0014-app-components-in-webassembly.zh-CN.md +++ b/docs/adr/0014-app-components-in-webassembly.zh-CN.md @@ -90,8 +90,14 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 - **`octosense:host`(第 3 阶段):** `request(service, args)` 像应用脚本的 `host.request` 一样调用 宿主服务,在 UI 线程上分派;自 makepad#118(OctoSense #450)起不检查清单声明的服务族。 各服务保留实际的应用/账户、同意、审阅与资源检查,但不打开面板、不询问用户(只能调用后台界面可以调用的方法),并且绝不调用 `wasm.*`。 - - **从不提供:** `wasi:sockets`,以及上述包以外的任何导入。请求这些导入的组件在加载时被拒绝 - (`LoadError::Import`),App Hub 的审核闸门也会拒绝。 + - **普通套接字(尚未发布的后续实现):** `wasi:sockets` 提供 TCP、UDP 和 DNS。 + 应用必须声明 `host_api.required: {"wasm.sockets": 1}` 和 `host-api-v1`,旧宿主仍能 + 读取目录,但不能安装该应用。不增加权限闸门或网络白名单。应用的所有组件共享 32 个活跃 + 套接字/DNS 句柄,每个组件有 512 个宿主资源槽。阻塞等待检查期限与取消;最后一个所属 + 应用隔离实例关闭时释放工作线程及其套接字。限制及发布状态见 [Wasm 指南](../wasm.zh-CN.md)。 + RC5 仍然拒绝套接字。 + - **从不提供:** 上述包之外的导入,包括本地系统调用和特权原始 IP 套接字。 + 加载器和 App Hub 检查都会拒绝不受支持的导入。 5. **无需编写 WIT。** - **Guest SDK(第 2 阶段):** guest crate `octosense-component` 提供 `#[octosense_component::export]` 属性,从普通的 Rust 签名推导 WIT world。支持的类型包括数字、 @@ -112,7 +118,7 @@ crates.io 上的大多数 crate 要么需要上述缺失能力中的某些(时 | 阶段 | 范围 | | --- | --- | -| 1. 运行时验证原型(本 PR) | `crates/wasm-host::component`:加载、检查导入、列出导出及其 WIT 签名、长期存活的实例、JSON 调用、上述 WASI 子集与存储预打开、超时、内存上限和日志。测试运行一个用普通 cargo 构建的、未作修改的 crate(`pulldown-cmark`),并拒绝导入 `wasi:sockets` 的组件。 | +| 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. 访问能力与平台 | 可访问任何主机的 `wasi:http` 出站(以 `net` 声明,不在运行时强制);`octosense:host` 导入,以与 `host.request` 相同的检查调用主机服务;安装时编译,让手机跳过首次编译;OpenHarmony 在其 JIT 策略明确前使用 Pulley(Wasmtime 的解释器);Windows 运行时测试在 CI 中运行。iOS 不在当前计划内。 | | 4. 共享组件 | App Hub 目录中经过审核、带版本的组件,应用可以像 npm 包一样依赖它们。安装器负责校验,每个应用仍有自己的实例、账户和存储边界。 | diff --git a/docs/wasm.md b/docs/wasm.md index 0c43d847..7b5c57c6 100644 --- a/docs/wasm.md +++ b/docs/wasm.md @@ -77,12 +77,13 @@ two kinds apart by the file's preamble, and a bundle may carry both. - **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. + withdrawal, the last owning app isolate closing, 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:http`, `wasi:io`, `wasi:random` and - `octosense:host`, such as sockets, is refused when it loads. + `wasi:filesystem`, `wasi:http`, `wasi:io`, `wasi:random`, `wasi:sockets` + and `octosense:host` is refused when it loads. - **Files.** The app's available storage folder, the one its script's `fs.*` sees, is the component's `/`. Account-scoped apps need their active account. Nothing else on the device is visible. Missing storage or a signed-out @@ -93,14 +94,24 @@ 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.** Its `wasi:http` requests reach any host, as the device - can: an app's network declarations (`net`, `network.hosts`) are shown when +- **The network.** Its `wasi:http` requests and `wasi:sockets` TCP, UDP and + DNS operations reach the network 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 pinned Splash runtime also no longer enforces the manifest host list. 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. + imports `wasi:http` or `wasi:sockets` gets 10 s a call instead of 2 s. + HTTP timeouts and blocking socket/stream/poll waits end with the call; + cancellation is observed at most every 10 ms while waiting in the host. + TCP bind/listen/accept and UDP bind/send/receive are supported. Privileged + raw IP sockets (`SOCK_RAW`) are not part of this API. An app shares a + 32-handle budget for live TCP/UDP sockets and DNS lookup streams across + its components. Each component has at most 512 host resources, including + streams and pollables. Closing a handle refunds its slot; a trap or + cancellation immediately drops the instance's resources. A cancelled DNS + waiter stops promptly. An OS resolver operation already running may finish + in the background; it retains its budget slot until then. At most eight + native resolvers run across the process, with no waiting queue. - **Host services.** Through `octosense:host` ([`crates/wasm-host/wit/octosense-host.wit`](../crates/wasm-host/wit/octosense-host.wit)), `request(service, args)` uses the admitted app's identity, dispatched on @@ -110,6 +121,18 @@ two kinds apart by the file's preamble, and a bundle may carry both. background surface may call are available. `wasm.*` is refused, since the app's worker is busy with the call, and the call's deadline bounds the wait. +**Socket compatibility:** this support is unreleased; desktop RC5 rejects socket +imports. Socket bundles must keep `host-api-v1` and `wasm-components-v1` in +`requires` and declare `host_api.required: {"wasm.sockets": 1}`. The SDK adds +this automatically when it detects socket imports, and App Hub verifies it +for bundled and shared components. `wasm.sockets` is a runtime compatibility +marker, not a callable JSON method or permission. Older hosts can still read +the catalog and report this individual app as incompatible. Rust crates must +still build for `wasm32-wasip2`; native TLS backends, process creation and +threads do not become portable just because sockets work. Rust's WASI UDP +`set_read_timeout` is unsupported; use nonblocking receive with a deadline, +or rely on the host call deadline. + 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 @@ -204,10 +227,13 @@ be interrupted, but an expired or cancelled request never goes on to run. | Limit | Value | | --- | --- | -| One call, start function included | 2 s of wall-clock time (checked every 10 ms) | +| One call, start function included | 2 s; 10 s with HTTP/socket imports (checked every 10 ms) | | Linear memory | 256 MiB | | Table elements | 16,384 | | Wasm stack | 512 KiB | +| Live sockets and DNS lookup streams | 32 per app, shared across components | +| Host resource table | 512 entries per component | +| Native DNS work | 8 resolvers per process; slots retained until native completion | | Module | 8 MiB | | 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 | @@ -313,7 +339,11 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s memory cap; `fetch`, whose `wasi:http` reaches a local server without a manifest host allowlist, 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. + a component importing ordinary sockets. +- `crates/wasm-host/tests/component_sockets.rs` runs real TCP/UDP echo, + localhost DNS, listener/accept, shared socket limits, close/refund cycles, + blocked-read cancellation and UDP deadlines. Unit tests also cover + stalled stream destruction and abandoned native resolver work. - 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 @@ -332,7 +362,7 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s 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…`), `netprobe.component.wasm` -(`98b2402a…`), `fetch.component.wasm` (`6eaf6ac8…`) and +(`faf1cb5d…`), `fetch.component.wasm` (`6eaf6ac8…`) and `hostcall.component.wasm` (`d39f2a8e…`). It needs the target once: `rustup target add wasm32-wasip2`. diff --git a/docs/wasm.zh-CN.md b/docs/wasm.zh-CN.md index 6cd5ff32..02c10f97 100644 --- a/docs/wasm.zh-CN.md +++ b/docs/wasm.zh-CN.md @@ -60,11 +60,11 @@ Rust crate 构建。服务按文件头区分两者,一个应用包可以同时 `list` 返回为 base64,`result` 返回为其值,或作为这次请求的错误。 `wasm.functions` 列出每个导出及其 WIT 签名;接收或返回资源的函数会被列为已跳过,并附原因。 - **状态。** 每个组件的实例在调用之间留在应用的工作线程里,因此可以保存已解析的文档或缓存。 - 陷阱或超时会使实例作废,下一次调用得到新实例;更新、授权变化、撤回或工作线程退出(一分钟 - 没有调用)都会结束它。 + 陷阱或超时会使实例作废,下一次调用得到新实例;更新、授权变化、撤回、最后一个所属应用隔离实例关闭, + 或工作线程退出(一分钟没有调用)都会结束它。 - **能接触什么。** 时钟和随机数。它的 stdout 和 stderr 成为应用的日志行,没有环境变量、 参数和 stdin。导入 `wasi:cli`、`wasi:clocks`、`wasi:filesystem`、`wasi:http`、`wasi:io`、 - `wasi:random` 和 `octosense:host` 以外任何东西(例如套接字)的组件在加载时被拒绝。 + `wasi:random`、`wasi:sockets` 和 `octosense:host` 以外任何东西的组件在加载时被拒绝。 - **文件。** 应用可用的存储文件夹,即其脚本的 `fs.*` 看到的那个,就是组件的 `/`。 按账户隔离的应用需要当前已连接账户。设备上的其他路径不可见;存储不可用或所需账户已退出时 不预打开目录。省略 `storage` 声明不会取消存储。 @@ -72,11 +72,15 @@ Rust crate 构建。服务按文件头区分两者,一个应用包可以同时 内部失败:`ftruncate` 报告磁盘已满,普通写入报告 I/O 错误,因为 wasi-libc 把任何失败的 流写入都报告为 I/O 错误。这时请求的错误以 "a write was refused: the storage budget is used up" 结尾。改写、截断和删除会归还字节,因此组件可以自己腾出空间。 -- **网络。** 组件的 `wasi:http` 请求可以访问任何主机,与设备本身能访问的范围相同:应用的网络 +- **网络。** 组件的 `wasi:http` 请求以及 `wasi:sockets` TCP、UDP 和 DNS 操作,可以访问设备本身可访问的网络:应用的网络 声明(`net`、`network.hosts`)在安装时展示,运行时不强制。2026 年 10 月 8 日的裁定取消了按应用 的运行时闸门,边界是操作系统和宿主的 API 表面。固定版本的 Splash 运行时也不再强制执行 - 清单中的主机列表。导入 `wasi:http` 的组件每次调用有 10 秒,而不是 2 秒; - 每个请求的连接、首字节和字节间超时都随调用结束。 + 清单中的主机列表。导入 `wasi:http` 或 `wasi:sockets` 的组件每次调用有 10 秒,而不是 2 秒。 + HTTP 超时以及阻塞的套接字、流和轮询等待都受调用期限约束;在宿主中等待时每隔最多 10 毫秒检查取消。 + 支持 TCP 绑定、监听、接受连接,以及 UDP 绑定、发送和接收,不包含特权原始 IP 套接字(`SOCK_RAW`)。 + 一个应用的所有组件共享 32 个活跃 TCP/UDP 套接字及 DNS 查询流的额度,每个组件的宿主资源表最多 + 512 项(包括流和轮询对象)。关闭句柄归还额度,陷阱或取消立即释放实例资源。DNS 等待可以立即取消, + 但已经运行的操作系统解析调用会在后台完成前继续占用额度;整个进程最多同时执行八个本地解析任务,没有等待队列。 - **宿主服务。** 通过 `octosense:host` ([`crates/wasm-host/wit/octosense-host.wit`](../crates/wasm-host/wit/octosense-host.wit)), 组件通过 `request(service, args)` 以已准入应用的身份在 UI 线程上分派调用。 @@ -84,6 +88,14 @@ Rust crate 构建。服务按文件头区分两者,一个应用包可以同时 或可信用户输入,只能调用允许后台使用的方法。`wasm.*` 被拒绝,因为应用的工作线程正忙于这次调用;调用的截止时间限制等待时长。 +**套接字兼容性:** 此支持尚未发布,桌面 RC5 会拒绝套接字导入。应用包必须在 `requires` 中保留 +`host-api-v1`、`wasm-components-v1`,并声明 `host_api.required: {"wasm.sockets": 1}`。 +SDK 检测到套接字导入时自动添加声明,App Hub 对内置组件和共享组件都进行验证。 +`wasm.sockets` 是运行时兼容性标记,不是可调用的 JSON 方法或权限。旧宿主仍能读取目录, +仅把该应用标为不兼容。Rust crate 仍须能为 `wasm32-wasip2` 构建;套接字支持不会让本地 TLS +后端、进程创建和线程自动变得可移植。Rust WASI 的 UDP `set_read_timeout` 不受支持, +请使用带截止时间的非阻塞接收,或依靠宿主调用期限。 + ADR 0014 记录了开销。在 M 系列 Mac 上,313 KiB 的测试组件约 40 毫秒完成编译,从缓存加载 约 4 毫秒;它的 Markdown 函数比原生 Rust 慢约 2.6 倍,与同一段代码作为模块时接近。在 Pulley 中还要再慢约 32 倍。 @@ -151,10 +163,13 @@ ADR 0014 记录了开销。在 M 系列 Mac 上,313 KiB 的测试组件约 40 | 限制 | 值 | | --- | --- | -| 一次调用(包括启动函数) | 2 秒实际经过时间(每 10 毫秒检查一次) | +| 一次调用(包括启动函数) | 2 秒;导入 HTTP/套接字时 10 秒(每 10 毫秒检查一次) | | 线性内存 | 256 MiB | | 表元素 | 16,384 个 | | wasm 栈 | 512 KiB | +| 活跃套接字及 DNS 查询流 | 每个应用 32 个,由其所有组件共享 | +| 宿主资源表 | 每个组件 512 项 | +| 本地 DNS 工作 | 每个进程 8 个解析器,直到本地解析完成才归还额度 | | 模块 | 8 MiB | | 一次调用的输入或输出 | 运行时 16 MiB;服务中序列化后的输入 8 MiB | | 日志 | 每次调用 64 行,每行 1 KiB | @@ -241,8 +256,11 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s - `crates/wasm-host/tests/component.rs` 运行四个由 `crates/wasm-host/tests/component-guest` 构建的组件:以 JSON 调用未作修改的 crate(pulldown-cmark)、记录与 snake_case 名称、 同一实例保留状态、时钟、随机数与字节、只能访问授予的文件夹(也包括只读)、存储预算、 - 超时与内存上限;`fetch`,它的 `wasi:http` 只有在主机获准时才能访问本地服务器,向从不应答的 - 服务器发出的请求在截止时间结束;`hostcall`,它通过嵌入方调用宿主服务;以及拒绝导入套接字的组件。 + 超时与内存上限;`fetch`,它的 `wasi:http` 不受清单主机白名单限制,可以访问本地服务器,向从不应答的 + 服务器发出的请求在截止时间结束;`hostcall`,它通过嵌入方调用宿主服务;以及导入普通套接字的组件。 +- `crates/wasm-host/tests/component_sockets.rs` 运行真实 TCP/UDP 回显、本地 DNS、监听及接受连接、 + 共享套接字额度、关闭及归还额度、阻塞读取取消和 UDP 截止时间测试。单元测试另覆盖阻塞的流析构, + 以及客体放弃之后仍在运行的本地 DNS 任务。 - 服务测试覆盖工具映射、输入上限、每次调用使用全新实例、更新或授权变化或撤回之后的撤销、 取消和队列上限,以及应用自己的函数回答它的工具和脚本。对组件,测试覆盖带类型的调用、 实例的状态、应用的存储文件夹及其配额、没有可用存储目录的应用、作为系统应用经 App Hub 准入发布的 @@ -254,7 +272,7 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s 测试组件可以逐字节重建:用 rustc 1.97.1,`crates/wasm-host/tests/component-guest/build.sh` 写出相同的 `notes.component.wasm`(SHA-256 `963c965b…`)、`netprobe.component.wasm` -(`98b2402a…`)、`fetch.component.wasm`(`6eaf6ac8…`)和 `hostcall.component.wasm` +(`faf1cb5d…`)、`fetch.component.wasm`(`6eaf6ac8…`)和 `hostcall.component.wasm` (`d39f2a8e…`)。只需添加一次目标:`rustup target add wasm32-wasip2`。 `phone.yml` 运行第一条命令,服务的测试则作为 Home 测试的一部分运行(`mobile-apps` 包含 diff --git a/native-apps.json b/native-apps.json index 597b018c..e0997a9f 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": "d86101d7206162b73ea873e2a31b48364600a110", "local": null }, + "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "c317f787ea2f620543000561c2470106644c1fc7", "local": null }, "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"], "bin": null, "default_features": true,