From 2caf307566bc0e9ac34adc419f5cffbd7fa46ce0 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 28 May 2026 14:25:27 +0100 Subject: [PATCH] Generate process snapshot schema metadata --- abi/snapshot.json | 80 +++++++++++ crates/kernel/src/wasm_api.rs | 2 +- crates/shared/src/lib.rs | 15 ++ .../2026-05-20-rust-owned-host-logic-plan.md | 2 +- host/src/generated/abi.ts | 14 ++ host/src/kernel-worker.ts | 32 +++-- host/test/generated-abi.test.ts | 26 ++++ tools/xtask/src/dump_abi.rs | 136 +++++++++++++++++- 8 files changed, 290 insertions(+), 17 deletions(-) diff --git a/abi/snapshot.json b/abi/snapshot.json index dff46ee7d..6264cb1dd 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -842,11 +842,21 @@ "name": "kernel_ioctl", "signature": "(i32,i32,i64,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_ipc_shm_lookup_mapping", + "signature": "(i64,i64) -> (i32)" + }, { "kind": "func", "name": "kernel_ipc_shm_read_chunk", "signature": "(i32,i32,i64,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_ipc_shm_record_mapping", + "signature": "(i64,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_ipc_shm_write_chunk", @@ -862,6 +872,11 @@ "name": "kernel_ipc_shmdt", "signature": "(i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_ipc_shmdt_addr", + "signature": "(i64) -> (i32)" + }, { "kind": "func", "name": "kernel_is_fd_nonblock", @@ -1002,6 +1017,11 @@ "name": "kernel_pick_signal_target_tid", "signature": "(i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_pick_tcp_listener_target", + "signature": "(i32,i32,i64) -> (i32)" + }, { "kind": "func", "name": "kernel_pipe", @@ -1462,6 +1482,11 @@ "name": "kernel_sysconf", "signature": "(i32) -> (i64)" }, + { + "kind": "func", + "name": "kernel_take_process_timer_cleanup", + "signature": "(i32,i64,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_tcgetattr", @@ -2108,6 +2133,61 @@ "__channel_base", "__tls_base" ], + "process_snapshot": { + "count_offset": 0, + "count_size": 4, + "record_fields": [ + { + "name": "pid", + "offset": 0, + "size": 4, + "type": "u32" + }, + { + "name": "ppid", + "offset": 4, + "size": 4, + "type": "u32" + }, + { + "name": "uid", + "offset": 8, + "size": 4, + "type": "u32" + }, + { + "name": "gid", + "offset": 12, + "size": 4, + "type": "u32" + }, + { + "name": "vsizeBytes", + "offset": 16, + "size": 8, + "type": "u64" + }, + { + "name": "state", + "offset": 24, + "size": 4, + "type": "u32_ascii" + }, + { + "name": "commLen", + "offset": 28, + "size": 4, + "type": "u32" + }, + { + "name": "cmdlineLen", + "offset": 32, + "size": 4, + "type": "u32" + } + ], + "record_fixed_size": 36 + }, "syscall_arg_descriptors": { "1": [ { diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index dad3572bf..f57b83af0 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -1790,7 +1790,7 @@ pub extern "C" fn kernel_enum_procs(out_ptr: *mut u8, out_len: u32) -> i32 { // First pass: compute total bytes we need to write so we can fail fast // on a too-small buffer rather than partial-writing. Skip zombies on // the count too so the size estimate matches what we actually emit. - const HDR_BYTES: usize = 4 + 4 + 4 + 4 + 4 + 8 + 4 + 4 + 4; // 40 bytes per record + const HDR_BYTES: usize = wasm_posix_shared::process_snapshot::RECORD_FIXED_SIZE; let mut need: usize = 4; // count u32 for pid in &pids { let proc = match table.get(*pid) { diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index a64f7a580..896b0c3d3 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -2,6 +2,21 @@ pub mod host_abi; +pub mod process_snapshot { + pub const COUNT_OFFSET: usize = 0; + pub const COUNT_SIZE: usize = 4; + + pub const RECORD_PID_OFFSET: usize = 0; + pub const RECORD_PPID_OFFSET: usize = 4; + pub const RECORD_UID_OFFSET: usize = 8; + pub const RECORD_GID_OFFSET: usize = 12; + pub const RECORD_VSIZE_BYTES_OFFSET: usize = 16; + pub const RECORD_STATE_OFFSET: usize = 24; + pub const RECORD_COMM_LEN_OFFSET: usize = 28; + pub const RECORD_CMDLINE_LEN_OFFSET: usize = 32; + pub const RECORD_FIXED_SIZE: usize = 36; +} + /// Kernel ABI version. /// /// This number is baked into every compiled user program (wasm custom section diff --git a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md index 643033c86..634ed933f 100644 --- a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md +++ b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md @@ -118,7 +118,7 @@ path. | In progress / stacked PR | IPC/resource cleanup in Rust | Rust `Process` now records `shmat` address -> segment metadata, inherits it through fork, clears it across exec setup, and detaches live mappings from `remove_process()`. TS still copies bytes between guest memory and kernel SysV segments because only the host can address guest `Memory`. | `remove_process()` owns IPC attachment cleanup; JS only handles guest-memory transfer and host primitive wake/schedule work. | SysV IPC and mqueue Rust tests plus host integration/e2e coverage for blocking and cleanup. | | Planned | Readiness metadata improvements | Replace broad host inference with kernel-emitted readiness events for pipe/socket/poll/select cases where the kernel already knows state changes. | JS still owns timers/retry queues/`Atomics.waitAsync`, but readiness decisions are less inferred from syscall numbers. No extra Wasm round trip per syscall. | Pipe/socket/poll/select/ppoll/pselect tests; browser bridge smoke for affected wake paths; performance comparison before removing broad fallback logic. | | Planned | VFS policy split | Keep backend I/O, OPFS/IndexedDB/fetch, Node `fs`, and lazy archive materialization in JS. Move permission and policy decisions into Rust where process uid/gid/umask/fd context is authoritative. | Guest-visible policy is enforced in Rust; host adapters only perform platform operations requested through a checked contract. | VFS unit tests, uid/gid/permission tests, host-fs metadata tests, default mount tests, Node/browser parity tests. | -| Planned | Procfs/process snapshot schema metadata | Generate binary process snapshot schema/constants consumed by TS UI decoding, or replace TS decoding with a Rust-exported stable formatter if that does not add hot-path cost. | TS no longer hand-decodes undocumented offsets for kernel process snapshot data. Procfs text formatting remains Rust-owned. | Rust procfs/process snapshot tests; generated ABI vitest; UI/kernel-host tests that consume snapshots. | +| Done / stacked PR | Procfs/process snapshot schema metadata | `crates/shared` owns the binary process snapshot layout, `dump-abi` publishes the schema in `abi/snapshot.json` and generated TS bindings, and `parseProcSnapshots` consumes those generated offsets and sizes. | TS no longer hand-decodes undocumented offsets for kernel process snapshot data. Procfs text formatting remains Rust-owned. | Rust procfs/process snapshot tests; generated ABI vitest; UI/kernel-host tests that consume snapshots. | Deferral rule: if a chunk would move browser/Node primitives, add runtime JS evaluation, or add a Wasm call to every syscall without removing meaningful diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index e13a55a7a..1c0c5cccb 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -83,6 +83,20 @@ export const CH_SIG_HANDLER = 65564 as const; export const CH_SIG_FLAGS = 65568 as const; export const CH_SIG_OLD_MASK = 65576 as const; +export const PROC_SNAPSHOT_COUNT_OFFSET = 0 as const; +export const PROC_SNAPSHOT_COUNT_SIZE = 4 as const; +export const PROC_SNAPSHOT_RECORD_FIXED_SIZE = 36 as const; +export const PROC_SNAPSHOT_RECORD_FIELDS = { + pid: { offset: 0, size: 4, type: "u32" }, + ppid: { offset: 4, size: 4, type: "u32" }, + uid: { offset: 8, size: 4, type: "u32" }, + gid: { offset: 12, size: 4, type: "u32" }, + vsizeBytes: { offset: 16, size: 8, type: "u64" }, + state: { offset: 24, size: 4, type: "u32_ascii" }, + commLen: { offset: 28, size: 4, type: "u32" }, + cmdlineLen: { offset: 32, size: 4, type: "u32" }, +} as const; + export const STRUCT_SIZE_WASM_STAT = 88 as const; export const STRUCT_SIZE_WASM_DIRENT = 16 as const; export const STRUCT_SIZE_WASM_TIMESPEC = 16 as const; diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index c3149fc54..118514680 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -53,6 +53,10 @@ import { CH_SYSCALL, CH_TOTAL_SIZE, HOST_INTERCEPTED_SYSCALLS, + PROC_SNAPSHOT_COUNT_OFFSET, + PROC_SNAPSHOT_COUNT_SIZE, + PROC_SNAPSHOT_RECORD_FIELDS, + PROC_SNAPSHOT_RECORD_FIXED_SIZE, SYSCALL_ARGS, type SyscallArgDesc, } from "./generated/abi"; @@ -291,22 +295,26 @@ export interface ProcessSnapshot { } function parseProcSnapshots(mem: Uint8Array): ProcessSnapshot[] { - if (mem.byteLength < 4) return []; + if (mem.byteLength < PROC_SNAPSHOT_COUNT_SIZE) return []; const dv = new DataView(mem.buffer, mem.byteOffset, mem.byteLength); - const count = dv.getUint32(0, true); - let off = 4; + const count = dv.getUint32(PROC_SNAPSHOT_COUNT_OFFSET, true); + let off = PROC_SNAPSHOT_COUNT_SIZE; const out: ProcessSnapshot[] = []; const dec = new TextDecoder("utf-8", { fatal: false }); for (let i = 0; i < count; i++) { - if (off + 36 > mem.byteLength) break; - const pid = dv.getUint32(off, true); off += 4; - const ppid = dv.getUint32(off, true); off += 4; - const uid = dv.getUint32(off, true); off += 4; - const gid = dv.getUint32(off, true); off += 4; - const vsizeBytes = Number(dv.getBigUint64(off, true)); off += 8; - const state = String.fromCharCode(dv.getUint32(off, true)) as ProcessSnapshot["state"]; off += 4; - const commLen = dv.getUint32(off, true); off += 4; - const cmdLen = dv.getUint32(off, true); off += 4; + if (off + PROC_SNAPSHOT_RECORD_FIXED_SIZE > mem.byteLength) break; + const record = off; + const pid = dv.getUint32(record + PROC_SNAPSHOT_RECORD_FIELDS.pid.offset, true); + const ppid = dv.getUint32(record + PROC_SNAPSHOT_RECORD_FIELDS.ppid.offset, true); + const uid = dv.getUint32(record + PROC_SNAPSHOT_RECORD_FIELDS.uid.offset, true); + const gid = dv.getUint32(record + PROC_SNAPSHOT_RECORD_FIELDS.gid.offset, true); + const vsizeBytes = Number(dv.getBigUint64(record + PROC_SNAPSHOT_RECORD_FIELDS.vsizeBytes.offset, true)); + const state = String.fromCharCode( + dv.getUint32(record + PROC_SNAPSHOT_RECORD_FIELDS.state.offset, true), + ) as ProcessSnapshot["state"]; + const commLen = dv.getUint32(record + PROC_SNAPSHOT_RECORD_FIELDS.commLen.offset, true); + const cmdLen = dv.getUint32(record + PROC_SNAPSHOT_RECORD_FIELDS.cmdlineLen.offset, true); + off += PROC_SNAPSHOT_RECORD_FIXED_SIZE; if (off + commLen + cmdLen > mem.byteLength) break; const comm = dec.decode(mem.subarray(off, off + commLen)); off += commLen; diff --git a/host/test/generated-abi.test.ts b/host/test/generated-abi.test.ts index 165bc557d..00431d780 100644 --- a/host/test/generated-abi.test.ts +++ b/host/test/generated-abi.test.ts @@ -34,6 +34,10 @@ import { HOST_ADAPTER_VERSION, HOST_ADAPTER_WORKER_FEATURES, HOST_INTERCEPTED_SYSCALLS, + PROC_SNAPSHOT_COUNT_OFFSET, + PROC_SNAPSHOT_COUNT_SIZE, + PROC_SNAPSHOT_RECORD_FIELDS, + PROC_SNAPSHOT_RECORD_FIXED_SIZE, STRUCT_SIZE_WASM_DIRENT, STRUCT_SIZE_WASM_POLL_FD, STRUCT_SIZE_WASM_STAT, @@ -79,6 +83,12 @@ function hostAdapterManifestField(name: string): { offset: number; size: number return { offset: field.offset, size: field.size }; } +function processSnapshotField(name: string): { offset: number; size: number; type: string } { + const field = snapshot.process_snapshot.record_fields.find((f: { name: string }) => f.name === name); + if (!field) throw new Error(`missing process_snapshot field ${name}`); + return { offset: field.offset, size: field.size, type: field.type }; +} + describe("generated host ABI bindings", () => { it("match the ABI version and channel layout snapshot", () => { expect(ABI_VERSION).toBe(snapshot.abi_version); @@ -166,4 +176,20 @@ describe("generated host ABI bindings", () => { ).toEqual(hostAdapterManifestField(fieldName)); } }); + + it("match Rust-owned process snapshot schema metadata", () => { + expect(PROC_SNAPSHOT_COUNT_OFFSET).toBe(snapshot.process_snapshot.count_offset); + expect(PROC_SNAPSHOT_COUNT_SIZE).toBe(snapshot.process_snapshot.count_size); + expect(PROC_SNAPSHOT_RECORD_FIXED_SIZE).toBe( + snapshot.process_snapshot.record_fixed_size, + ); + + for (const fieldName of Object.keys(PROC_SNAPSHOT_RECORD_FIELDS)) { + expect( + PROC_SNAPSHOT_RECORD_FIELDS[ + fieldName as keyof typeof PROC_SNAPSHOT_RECORD_FIELDS + ], + ).toEqual(processSnapshotField(fieldName)); + } + }); }); diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index f08a4f668..4ebaf14f1 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -32,10 +32,10 @@ use std::collections::BTreeMap; use std::mem::{offset_of, size_of}; use std::path::PathBuf; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use wasm_posix_shared as shared; -use crate::{JsonMap, repo_root}; +use crate::{repo_root, JsonMap}; pub fn run(args: Vec) -> Result<(), String> { let mut out_path: Option = None; @@ -348,6 +348,27 @@ fn render_ts_module() -> String { channel::SIG_OLD_MASK )); + out.push_str(&format!( + "export const PROC_SNAPSHOT_COUNT_OFFSET = {} as const;\n", + shared::process_snapshot::COUNT_OFFSET + )); + out.push_str(&format!( + "export const PROC_SNAPSHOT_COUNT_SIZE = {} as const;\n", + shared::process_snapshot::COUNT_SIZE + )); + out.push_str(&format!( + "export const PROC_SNAPSHOT_RECORD_FIXED_SIZE = {} as const;\n", + shared::process_snapshot::RECORD_FIXED_SIZE + )); + out.push_str("export const PROC_SNAPSHOT_RECORD_FIELDS = {\n"); + for field in process_snapshot_fields() { + out.push_str(&format!( + " {}: {{ offset: {}, size: {}, type: {:?} }},\n", + field.name, field.offset, field.size, field.ty + )); + } + out.push_str("} as const;\n\n"); + out.push_str(&format!( "export const STRUCT_SIZE_WASM_STAT = {} as const;\n", size_of::() @@ -583,6 +604,7 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { root.insert("channel_header".into(), channel_header()); root.insert("channel_signal_area".into(), channel_signal_area()); root.insert("channel_buffers".into(), channel_buffers()); + root.insert("process_snapshot".into(), process_snapshot()); root.insert("marshalled_structs".into(), marshalled_structs()); root.insert("syscalls".into(), syscalls()); @@ -692,6 +714,89 @@ fn channel_signal_area() -> Value { Value::Object(m.into_iter().collect()) } +struct ProcessSnapshotField { + name: &'static str, + offset: usize, + size: usize, + ty: &'static str, +} + +fn process_snapshot_fields() -> [ProcessSnapshotField; 8] { + use shared::process_snapshot::*; + [ + ProcessSnapshotField { + name: "pid", + offset: RECORD_PID_OFFSET, + size: 4, + ty: "u32", + }, + ProcessSnapshotField { + name: "ppid", + offset: RECORD_PPID_OFFSET, + size: 4, + ty: "u32", + }, + ProcessSnapshotField { + name: "uid", + offset: RECORD_UID_OFFSET, + size: 4, + ty: "u32", + }, + ProcessSnapshotField { + name: "gid", + offset: RECORD_GID_OFFSET, + size: 4, + ty: "u32", + }, + ProcessSnapshotField { + name: "vsizeBytes", + offset: RECORD_VSIZE_BYTES_OFFSET, + size: 8, + ty: "u64", + }, + ProcessSnapshotField { + name: "state", + offset: RECORD_STATE_OFFSET, + size: 4, + ty: "u32_ascii", + }, + ProcessSnapshotField { + name: "commLen", + offset: RECORD_COMM_LEN_OFFSET, + size: 4, + ty: "u32", + }, + ProcessSnapshotField { + name: "cmdlineLen", + offset: RECORD_CMDLINE_LEN_OFFSET, + size: 4, + ty: "u32", + }, + ] +} + +fn process_snapshot() -> Value { + use shared::process_snapshot::*; + let fields = process_snapshot_fields() + .iter() + .map(|field| { + let mut m: JsonMap = BTreeMap::new(); + m.insert("name".into(), json!(field.name)); + m.insert("offset".into(), json!(field.offset)); + m.insert("size".into(), json!(field.size)); + m.insert("type".into(), json!(field.ty)); + Value::Object(m.into_iter().collect()) + }) + .collect(); + + let mut m: JsonMap = BTreeMap::new(); + m.insert("count_offset".into(), json!(COUNT_OFFSET)); + m.insert("count_size".into(), json!(COUNT_SIZE)); + m.insert("record_fixed_size".into(), json!(RECORD_FIXED_SIZE)); + m.insert("record_fields".into(), Value::Array(fields)); + Value::Object(m.into_iter().collect()) +} + fn marshalled_structs() -> Value { use shared::fbdev::{FbBitfield, FbFixScreenInfo, FbVarScreenInfo}; use shared::{WasmDirent, WasmFlock, WasmPollFd, WasmStat, WasmStatfs, WasmTimespec}; @@ -1421,7 +1526,10 @@ fn classify_compat_change(old: &Value, new: &Value) -> Result bool { - matches!(section, "host_adapter" | "syscall_arg_descriptors") + matches!( + section, + "host_adapter" | "process_snapshot" | "syscall_arg_descriptors" + ) } fn classify_additive_object_by_key( @@ -1629,6 +1737,14 @@ mod tests { "marshalled_structs": { "WasmStat": {"size": 96, "fields": []} }, + "process_snapshot": { + "count_offset": 0, + "count_size": 4, + "record_fixed_size": 36, + "record_fields": [ + {"name": "pid", "offset": 0, "size": 4, "type": "u32"} + ] + }, "syscalls": [ {"number": 1, "name": "Open"}, {"number": 2, "name": "Close"} @@ -1709,6 +1825,20 @@ mod tests { ); } + #[test] + fn adding_process_snapshot_section_is_compatible() { + let mut old = base_snapshot(); + old.as_object_mut().unwrap().remove("process_snapshot"); + let new = base_snapshot(); + + let report = classify_compat_change(&old, &new).unwrap(); + assert!(report.breaking.is_empty(), "{report:?}"); + assert_eq!( + report.additive, + vec!["added top-level section \"process_snapshot\""] + ); + } + #[test] fn changed_existing_export_is_breaking() { let old = base_snapshot();