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 52060e29..804ac72b 100644 Binary files a/crates/wasm-host/tests/fixtures/netprobe.component.wasm and b/crates/wasm-host/tests/fixtures/netprobe.component.wasm differ 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,