diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index 14a493b7..8bbcf6d0 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -56,6 +56,55 @@ use crate::host_tools::areas::{self, AreaEnv}; use crate::host_tools::script_apps; const MAX_MODULES: usize = 8; + +/// A shared component an app pins (App Hub ADR 0003, ADR 0014 phase 4): a +/// component App Hub publishes on its own, whose functions the app's script +/// calls as `wasm..`. The app gets its own instance, under +/// its own grants, exactly as for a component in its `fns/`. +#[derive(Clone, Debug, PartialEq)] +struct Shared { + alias: String, + id: String, + version: String, + blake3: String, + path: PathBuf, +} + +/// Resolves the shared components an app pins, by its apps root and id. +type SharedResolver = Arc Result, String> + Send + Sync>; + +/// Tests only: shared components without App Hub's store. +static SHARED_RESOLVER: Mutex> = Mutex::new(None); + +/// The shared components `app` pins, verified: App Hub resolves a system +/// app's from its bundle and an installed app's from the shared store, +/// against the verified catalog, and checks every file's BLAKE3 digest. +/// None for a manifest that pins none, without asking the store. +fn shared_components(root: &Path, app: &str, manifest: &Value) -> Result, String> { + if !manifest["components"] + .as_array() + .is_some_and(|pins| !pins.is_empty()) + { + return Ok(Vec::new()); + } + let resolver = SHARED_RESOLVER + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + if let Some(resolve) = resolver { + return resolve(root, app); + } + Ok(octosense_appstore::components::resolved_in(root, app)? + .into_iter() + .map(|resolved| Shared { + alias: resolved.alias, + id: resolved.id, + version: resolved.version, + blake3: resolved.blake3, + path: resolved.path, + }) + .collect()) +} const MAX_WORKERS: usize = 4; const MAX_QUEUED_PER_APP: usize = 4; const MAX_INPUT_BYTES: usize = 8 << 20; @@ -654,18 +703,20 @@ fn warm_now(app: &str) -> Result<(usize, usize, f64), String> { let admission = Admission::current(app)?; let runtime = runtime(&admission.root.join(".host"))?; let started = Instant::now(); - let mut files: Vec = std::fs::read_dir(admission.bundle.join("fns")) - .map_err(|_| format!("{app}'s bundle has no fns directory"))? - .flatten() - .map(|entry| entry.path()) - .filter(|path| { - path.extension().is_some_and(|ext| ext == "wasm") - && std::fs::symlink_metadata(path) - .is_ok_and(|m| m.is_file() && m.len() <= runtime.limits().module_bytes as u64) - }) - .collect(); + let shared = shared_components(&admission.root, app, &admission.manifest)?; + let mut files: Vec = match std::fs::read_dir(admission.bundle.join("fns")) { + Ok(entries) => entries.flatten().map(|entry| entry.path()).collect(), + Err(_) if !shared.is_empty() => Vec::new(), + Err(_) => return Err(format!("{app}'s bundle has no fns directory")), + }; + files.retain(|path| { + path.extension().is_some_and(|ext| ext == "wasm") + && std::fs::symlink_metadata(path) + .is_ok_and(|m| m.is_file() && m.len() <= runtime.limits().module_bytes as u64) + }); files.sort(); files.truncate(MAX_MODULES); + files.extend(shared.into_iter().map(|component| component.path)); let mut compiled = 0; for path in &files { let bytes = std::fs::read(path).map_err(|e| e.to_string())?; @@ -682,6 +733,9 @@ struct Module { load_ms: f64, from_cache: bool, code: Code, + /// The shared component this is, when it is not one of the app's own + /// `fns/`: its functions are named `.`. + shared: Option, /// Diagnostic high-water mark only: guest memory is never retained. memory_bytes: usize, invocations: u64, @@ -724,21 +778,28 @@ impl Lab { check: impl Fn() -> Result<(), String>, ) -> Result { let bundle = &admission.bundle; - let mut files: Vec = std::fs::read_dir(bundle.join("fns")) - .map_err(|_| format!("{app}'s bundle has no fns directory"))? - .flatten() - .map(|entry| entry.path()) - .filter(|path| { - path.extension().is_some_and(|ext| ext == "wasm") - && std::fs::symlink_metadata(path).is_ok_and(|m| m.is_file()) - }) - .collect(); + let shared = shared_components(&admission.root, app, &admission.manifest)?; + let mut files: Vec = match std::fs::read_dir(bundle.join("fns")) { + Ok(entries) => entries.flatten().map(|entry| entry.path()).collect(), + // An app may call only shared components. + Err(_) if !shared.is_empty() => Vec::new(), + Err(_) => return Err(format!("{app}'s bundle has no fns directory")), + }; + files.retain(|path| { + path.extension().is_some_and(|ext| ext == "wasm") + && std::fs::symlink_metadata(path).is_ok_and(|m| m.is_file()) + }); files.sort(); - if files.is_empty() || files.len() > MAX_MODULES { + if (files.is_empty() && shared.is_empty()) || files.len() > MAX_MODULES { return Err(format!( "{app}'s bundle must carry 1 to {MAX_MODULES} fns/*.wasm modules" )); } + let sources = files.into_iter().map(|path| (path, None)).chain( + shared + .into_iter() + .map(|component| (component.path.clone(), Some(component))), + ); let mut lab = Lab { app: app.to_string(), runtime, @@ -751,13 +812,19 @@ impl Lab { }), admission: admission.clone(), }; - for path in files { + for (path, shared) in sources { check()?; - let file = path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .into_owned(); + let file = match &shared { + Some(component) => format!( + "{} ({} {})", + component.alias, component.id, component.version + ), + None => path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + }; if std::fs::metadata(&path).map_err(|e| e.to_string())?.len() > runtime.limits().module_bytes as u64 { @@ -765,11 +832,23 @@ impl Lab { } let bytes = std::fs::read(&path).map_err(|e| format!("{file}: {e}"))?; let started = Instant::now(); + if shared.is_some() && !component::is_component(&bytes) { + return Err(format!( + "{file}: a shared component is not a WebAssembly component" + )); + } let (code, functions, from_cache) = if component::is_component(&bytes) { let program = runtime .load_component(&bytes) .map_err(|e| format!("{file}: {e}"))?; - let names: Vec = program.exports().iter().map(|e| e.name.clone()).collect(); + let names: Vec = program + .exports() + .iter() + .map(|e| match &shared { + Some(component) => format!("{}.{}", component.alias, e.name), + None => e.name.clone(), + }) + .collect(); let from_cache = program.from_cache(); ( Code::Component { @@ -819,6 +898,7 @@ impl Lab { load_ms, from_cache, code, + shared, memory_bytes: 0, invocations: 0, }); @@ -950,9 +1030,16 @@ impl Lab { } let (instance, _) = live.as_mut().expect("made above"); module.invocations += 1; + let export = match &module.shared { + Some(component) => name + .strip_prefix(&format!("{}.", component.alias)) + .unwrap_or(name), + None => name, + }; // A write past the quota fails inside the component, as a full disk. instance.set_storage_budget(area.and_then(|a| a.quota_left)); - let result = instance.call_json_guarded(name, &args, deadline, move || reply.is_pending()); + let result = + instance.call_json_guarded(export, &args, deadline, move || reply.is_pending()); let us = started.elapsed().as_secs_f64() * 1e6; for line in instance.take_logs() { makepad_widgets::log!("wasm {app}: {line}"); @@ -982,6 +1069,7 @@ impl Lab { "live": live.is_some(), "storage": live.as_ref().map(|(_, g)| if g.storage_dir.is_some() { "app folder" } else { "none" }), "storage_left": live.as_ref().and_then(|(i, _)| i.storage_budget()), "network": program.uses_network(), + "shared": m.shared.as_ref().map(|s| json!({"alias": s.alias, "id": s.id, "version": s.version, "blake3": s.blake3})), "exports": program.exports().iter().map(|e| json!({"name": e.name, "wit": e.wit_name, "params": e.params.iter().map(|(n, t)| json!([n, t])).collect::>(), "result": e.result})).collect::>(), "skipped": program.skipped().iter().map(|(n, why)| json!({"name": n, "why": why})).collect::>()}), @@ -1722,6 +1810,121 @@ mod tests { /// calls until a trap spends it; and its files are the app's own /// storage, under what is left of its quota, and absent without the /// storage capability. + /// A shared component the app pins (App Hub ADR 0003, ADR 0014 phase 4) + /// loads beside the app's own functions, here none: its functions answer + /// as `.`, from an instance of the app's own, and + /// `wasm.functions` says which component they come from. + #[test] + fn a_pinned_shared_component_answers_as_alias_and_function() { + const CHILD: &str = "OCTOSENSE_TEST_WASM_SHARED"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "wasm_service::tests::a_pinned_shared_component_answers_as_alias_and_function", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + const APP: &str = "os.wasmshared"; + let root = + std::env::temp_dir().join(format!("octosense-wasm-shared-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + // No fns/: the app calls only the component it pins. + let bundle = root.join("bundle"); + std::fs::create_dir_all(&bundle).unwrap(); + let mut env = areas::FixedEnv::default(); + env.quotas.insert( + APP.into(), + areas::JailQuota { + bytes: None, + storage: false, + }, + ); + *AREA_ENV.lock().unwrap() = Some(Arc::new(env)); + let pinned = Shared { + alias: "md".into(), + id: "org.example.markdown".into(), + version: "1.0.0".into(), + blake3: "0".repeat(64), + path: PathBuf::from(NOTES_COMPONENT), + }; + let resolver: SharedResolver = { + let pinned = pinned.clone(); + Arc::new(move |_: &Path, app: &str| { + Ok(if app == APP { + vec![pinned.clone()] + } else { + Vec::new() + }) + }) + }; + *SHARED_RESOLVER.lock().unwrap() = Some(resolver); + let host_dir = root.join(".host"); + let runtime = runtime(&host_dir).unwrap(); + let admission = Admission { + root: root.clone(), + bundle, + manifest: json!({ + "requires": ["wasm-shared-components-v1"], + "capabilities": ["wasm"], + "components": [{"as": "md", "id": "org.example.markdown", "version": "1.0.0", "blake3": "0".repeat(64)}], + }), + }; + let mut lab = Lab::from_bundle(APP, runtime, admission, || Ok(())).unwrap(); + let (_, reply) = pending_reply(APP, &host_dir); + let call = |lab: &mut Lab, method: &str, args: Value| { + let text = matches!(args, Value::String(_)); + let input = input_bytes(&args).unwrap(); + lab.answer( + method, + &input, + text, + Instant::now() + REQUEST_TIMEOUT, + reply.clone(), + ) + }; + assert_eq!( + call(&mut lab, "md.to_html", json!("# Hi")).unwrap(), + "

Hi

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

a

\n" + ); + // One instance of the app's own, kept between its calls. + assert_eq!(call(&mut lab, "md.count", json!({})).unwrap(), 1); + assert_eq!(call(&mut lab, "md.count", json!({})).unwrap(), 2); + // A bare name would be one of the app's own functions. + let error = call(&mut lab, "to_html", json!("# Hi")).unwrap_err(); + assert!(error.contains("no function"), "{error}"); + let described = lab.describe(); + assert!( + described["functions"] + .as_array() + .unwrap() + .iter() + .any(|f| f == "md.to_html"), + "{described}" + ); + let module = &described["modules"][0]; + assert_eq!(module["file"], "md (org.example.markdown 1.0.0)"); + assert_eq!(module["shared"]["id"], "org.example.markdown"); + assert_eq!(module["shared"]["version"], "1.0.0"); + assert_eq!(module["storage"], "none", "no storage grant, no folder"); + *SHARED_RESOLVER.lock().unwrap() = None; + let _ = std::fs::remove_dir_all(root); + } + #[test] fn a_components_instance_keeps_state_and_its_files_stay_in_the_apps_storage() { const CHILD: &str = "OCTOSENSE_TEST_WASM_COMPONENT"; diff --git a/docs/adr/0014-app-components-in-webassembly.md b/docs/adr/0014-app-components-in-webassembly.md index 9006b7a0..ed93d0ce 100644 --- a/docs/adr/0014-app-components-in-webassembly.md +++ b/docs/adr/0014-app-components-in-webassembly.md @@ -2,15 +2,20 @@ English | [简体中文](0014-app-components-in-webassembly.zh-CN.md) -Status: Proposed (9 Oct 2026). Phase 1, the runtime spike, is in +Status: Accepted (9 Oct 2026, "do 1 2 3 4"); phases 1 to 3 merged on +10 Oct 2026 in OctoSense (#436, #451), App Hub (#186, #188, #189, #190) and +App Flow (#180, #181). Phase 1, the runtime spike, is in `crates/wasm-host` (`src/component.rs`, `tests/component.rs`). Phase 2's runtime and service parts are in `crates/wasm-host/src/component/files.rs` and `crates/shell/src/wasm_service.rs`, with the measurements below. Phase 3's are `component/net.rs` (`wasi:http`), `component/host.rs` with `wit/octosense-host.wit` (`octosense:host`), `Runtime::precompile` with `wasm_service::warm` (compiling at install), and Pulley on OpenHarmony and in -tests; Windows runs the service, its tests in CI. App Hub's and App Flow's -parts are in review, and iOS and phase 4 are the plan. It extends [ADR 0011](0011-apps-own-functions-in-webassembly.md): +tests; Windows runs the service, its tests in CI. Phase 4's App Hub part is +App Hub #190 (shared components in the catalog, the gate and the store), and +OctoSense's is the `wasm` service loading an app's pinned shared components +(`wasm_service::shared_components`). App Flow's parts are the SDK and +`tools/octo wasm`; iOS is not planned for now. It extends [ADR 0011](0011-apps-own-functions-in-webassembly.md): core modules keep working as they do. How WebAssembly runs on `main`: [WebAssembly in OctoSense](../wasm.md). 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 f781f362..d16d491b 100644 --- a/docs/adr/0014-app-components-in-webassembly.zh-CN.md +++ b/docs/adr/0014-app-components-in-webassembly.zh-CN.md @@ -2,13 +2,15 @@ [English](0014-app-components-in-webassembly.md) | 简体中文 -状态:提议(2026 年 10 月 9 日)。第 1 阶段(运行时验证原型)位于 +状态:已接受(2026 年 10 月 9 日,“do 1 2 3 4”);第 1 至第 3 阶段于 2026 年 10 月 10 日合并到 OctoSense(#436、#451)、 +App Hub(#186、#188、#189、#190)和 App Flow(#180、#181)。第 1 阶段(运行时验证原型)位于 `crates/wasm-host`(`src/component.rs`、`tests/component.rs`)。第 2 阶段的运行时和服务 部分位于 `crates/wasm-host/src/component/files.rs` 和 `crates/shell/src/wasm_service.rs`, 测量结果见下文。第 3 阶段的部分是 `component/net.rs`(`wasi:http`)、`component/host.rs` 与 `wit/octosense-host.wit`(`octosense:host`)、`Runtime::precompile` 与 `wasm_service::warm` (安装时编译),以及 OpenHarmony 上和测试中的 Pulley;Windows 运行这个服务,其测试在 CI 中运行。 -App Hub 与 App Flow 的部分正在审阅,iOS 和第 4 阶段是计划。 +第 4 阶段中 App Hub 的部分是 App Hub #190(目录、审核和商店中的共享组件),OctoSense 的部分是 `wasm` +服务加载应用固定的共享组件(`wasm_service::shared_components`)。App Flow 的部分是 SDK 和 `tools/octo wasm`;iOS 暂不计划。 本 ADR 扩展 [ADR 0011](0011-apps-own-functions-in-webassembly.zh-CN.md):核心模块照旧可用。 `main` 上 WebAssembly 的运行方式见 [OctoSense 中的 WebAssembly](../wasm.zh-CN.md)。 diff --git a/docs/wasm.md b/docs/wasm.md index a2234e8e..73fc41a3 100644 --- a/docs/wasm.md +++ b/docs/wasm.md @@ -108,6 +108,34 @@ its Markdown function runs about 2.6 times slower than native Rust, close to the same code as a module. In Pulley it runs about 32 times slower than that. +### Shared components + +An app can also call a component that App Hub publishes on its own (App Hub +ADR 0003, OctoSense ADR 0014 phase 4). The manifest pins it under +`components` by alias, id, exact version and BLAKE3 digest, and requires +`wasm-shared-components-v1`. The `wasm` service loads each pinned component +beside the app's `fns/` and routes its functions as `wasm..`: +with `{"as": "md", "id": "org.example.markdown", …}`, `wasm.md.to_html` calls +the component's `to-html`. The app gets an instance of its own, under its own +grants, as for a component in its `fns/`. It may carry no `fns/` at all and +call only shared components. `wasm.functions` lists their functions with the +alias, and names the component each comes from (`"shared": {"alias", "id", +"version", "blake3"}`). + +The service asks App Hub for the files on every load +(`octosense_appstore::components::resolved_in`). A system app's are in its +bundle at `components/.wasm`. An installed app's are in the shared, +read-only store at `/.components/.wasm`. Each is checked +against the verified catalog, which must still offer it at that version, and +against its digest. Nothing is fetched there: App Hub's installers fetch an +app's components before the app itself. A missing, changed or withdrawn +component stops the app's launch. + +`wasm_service::tests::a_pinned_shared_component_answers_as_alias_and_function` +runs this path with a stand-in resolver. No catalog has published a component +yet, so installing an app that pins one and calling it in a shell is +**unverified**. + ### How a module reaches a device - **A store app.** App Hub's gate (app contract 1.7.0) admits a `.wasm` file @@ -317,6 +345,8 @@ command. install, a system app's at its first call. - Components on a phone: Android and OpenHarmony builds link the runtime, but no component has run on a device yet (**unverified**). +- Shared components end to end: no App Hub catalog has published one yet, + so no installed app has pinned one (**unverified**). - Bytes are slow through JSON: a 1 MiB `list` costs about 18 ms there and back, as base64. diff --git a/docs/wasm.zh-CN.md b/docs/wasm.zh-CN.md index 597db428..1c043552 100644 --- a/docs/wasm.zh-CN.md +++ b/docs/wasm.zh-CN.md @@ -82,6 +82,25 @@ ADR 0014 记录了开销。在 M 系列 Mac 上,313 KiB 的测试组件约 40 约 4 毫秒;它的 Markdown 函数比原生 Rust 慢约 2.6 倍,与同一段代码作为模块时接近。在 Pulley 中还要再慢约 32 倍。 +### 共享组件 + +应用还可以调用 App Hub 单独发布的组件(App Hub ADR 0003,OctoSense ADR 0014 第 4 阶段):清单在 +`components` 中按别名、id、确切版本和 BLAKE3 摘要固定它,并在 `requires` 中声明 +`wasm-shared-components-v1`。`wasm` 服务把每个固定的组件和应用的 `fns/` 一起加载,并以 +`wasm.<别名>.<函数>` 路由它的函数:固定为 `{"as": "md", "id": "org.example.markdown", …}` 时, +`wasm.md.to_html` 调用该组件的 `to-html`。应用得到一个属于自己的实例,使用自己的授权,与 `fns/` 中的 +组件相同;应用也可以完全没有 `fns/`,只调用共享组件。`wasm.functions` 带别名列出这些函数,并说明每个 +函数来自哪个组件(`"shared": {"alias", "id", "version", "blake3"}`)。 + +服务每次加载时都向 App Hub 查找这些文件(`octosense_appstore::components::resolved_in`):系统应用的 +在其应用包的 `components/.wasm`,已安装应用的在共享的只读存储 +`/.components/.wasm`;每个文件都要对照已验证的目录(它必须仍以该版本提供)和自己的 +摘要检查。这里不会下载任何东西:App Hub 的安装程序会先于应用本身下载它固定的组件。组件缺失、被修改或 +被撤回时,应用无法启动。 + +`wasm_service::tests::a_pinned_shared_component_answers_as_alias_and_function` 用替代的解析器运行这条 +路径。目前还没有目录发布过组件,因此安装固定了组件的应用并在 Shell 中调用它属于**未验证**。 + ### 模块如何到达设备 - **商店应用。** App Hub 的审核(应用契约 1.7.0)只接受放在 `fns/.wasm` 的 @@ -250,6 +269,7 @@ cd phone && cargo test --locked --features mobile-apps -p octosense-shell wasm_s - 应用之间的 CPU 公平调度,以及磁盘缓存的上限:目前没有任何东西会清理它。 - 提前编译系统应用的函数:已安装应用的函数在安装时编译,系统应用的函数在第一次调用时编译。 - 手机上的组件:Android 和 OpenHarmony 构建链接了运行时,但还没有组件在设备上运行过(**未验证**)。 +- 共享组件的完整流程:还没有 App Hub 目录发布过共享组件,因此还没有已安装的应用固定过它(**未验证**)。 - 经 JSON 传字节很慢:1 MiB 的 `list` 以 base64 往返约需 18 毫秒。 ## 引擎插件:`photo` 和 `vector` 服务