From 0860b4ace887d764a7c2cbd795a31345708772cc Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:44:09 -0700 Subject: [PATCH 1/3] ADR 0014 phase 4: an app's pinned shared components in the wasm service A shared component (App Hub ADR 0003) is a component App Hub publishes on its own; an app pins it by alias, id, exact version and BLAKE3 digest. The wasm service now loads each one the app pins beside its fns/, from octosense_appstore::components::resolved_in (a system app's from its bundle, an installed app's from the shared store, checked against the verified catalog and its digest on every load), and routes its exports as wasm... The app gets its own instance, under its own grants, as for a component in its fns/; an app may call only shared components. warm compiles them ahead too, and wasm.functions names the component each function comes from. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/wasm_service.rs | 259 +++++++++++++++++++++++++++---- 1 file changed, 231 insertions(+), 28 deletions(-) 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"; From ad912dab623df4f51fe7bffa270f0feb5b267c1f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:44:47 -0700 Subject: [PATCH 2/3] docs: shared components in the wasm service (ADR 0014 phase 4) Co-Authored-By: Claude Opus 5.5 --- docs/wasm.md | 30 ++++++++++++++++++++++++++++++ docs/wasm.zh-CN.md | 20 ++++++++++++++++++++ 2 files changed, 50 insertions(+) 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` 服务 From 5dc972bbede9da78acfce50d26d3ba8f7f3f6aaf Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:44:55 -0700 Subject: [PATCH 3/3] ADR 0014: phase 4's parts, and iOS not planned for now Co-Authored-By: Claude Opus 5.5 --- docs/adr/0014-app-components-in-webassembly.md | 11 ++++++++--- docs/adr/0014-app-components-in-webassembly.zh-CN.md | 6 ++++-- 2 files changed, 12 insertions(+), 5 deletions(-) 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)。