diff --git a/abi/snapshot.json b/abi/snapshot.json index 2ca66a07d..e6afa7dfd 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -183,6 +183,7 @@ } ], "optional_kernel_exports": [ + "kernel_get_process_exit_signal", "kernel_reserve_host_region", "kernel_reserve_host_region_at", "kernel_set_cwd", @@ -335,6 +336,11 @@ "name": "kernel_clear_fork_exec", "signature": "() -> (i32)" }, + { + "kind": "func", + "name": "kernel_clear_process_metadata", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_clock_getres", @@ -455,11 +461,21 @@ "name": "kernel_eventfd2", "signature": "(i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_exec_prepare", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_exec_setup", "signature": "(i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_exec_setup_for_thread", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_execve", @@ -515,11 +531,26 @@ "name": "kernel_fcntl_lock", "signature": "(i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_fd_is_open", + "signature": "(i32,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_fd_supports_mmap_writeback", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_fdatasync", "signature": "(i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_find_listener_fd_by_accept_wake", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_flock", @@ -655,6 +686,11 @@ "name": "kernel_get_pipe_ofds", "signature": "(i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_get_process_exit_signal", + "signature": "(i32) -> (i32)" + }, { "kind": "func", "name": "kernel_get_process_exit_status", @@ -1135,6 +1171,11 @@ "name": "kernel_push_argv", "signature": "(i32,i32) -> ()" }, + { + "kind": "func", + "name": "kernel_push_process_metadata_entry", + "signature": "(i32,i32,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_pwrite", diff --git a/crates/fork-instrument/src/call_graph.rs b/crates/fork-instrument/src/call_graph.rs index 3585dd001..cc58e4971 100644 --- a/crates/fork-instrument/src/call_graph.rs +++ b/crates/fork-instrument/src/call_graph.rs @@ -569,6 +569,23 @@ fn types_match(module: &Module, a: TypeId, b: TypeId) -> bool { const MAX_INDIRECT_DEPTH: u8 = 2; +/// Whether this module can resolve and invoke functions installed by Kandelo's +/// dynamic linker after static call-graph analysis has completed. +/// +/// This predicate is also used when emitting the versioned fork capability +/// marker. Keep it as the single source of truth for both the conservative +/// closure below and the artifact claim consumed by the host runtime. +pub fn has_dynamic_linker_imports(module: &Module) -> bool { + module.imports.iter().any(|import| { + import.module == "env" + && matches!(import.kind, ImportKind::Function(_)) + && matches!( + import.name.as_str(), + "__wasm_dlopen" | "__wasm_dlsym" | "__wasm_dlclose" | "__wasm_dlerror" + ) + }) +} + /// Compute the transitive closure of functions that reach `seed` via /// direct calls, plus a bounded number of table/function-pointer dispatches. /// @@ -589,6 +606,12 @@ const MAX_INDIRECT_DEPTH: u8 = 2; pub fn reaching_closure(module: &Module, seed: FunctionId) -> HashSet { let profiles = profile_functions(module); let table_targets = table_targets(module, &profiles); + // A dlsym result can be installed into the main module's table only after + // static analysis. Every call_indirect in a dlopen-capable main module is + // therefore a possible boundary above a fork-capable side-module frame. + // Keep this opt-in to the dynamic-linker imports so ordinary programs + // retain the precise table-target closure below. + let has_dynamic_linker_imports = has_dynamic_linker_imports(module); // Reverse direct-call graph: `callee -> set of callers`. let mut reverse_direct: HashMap> = HashMap::new(); @@ -652,6 +675,20 @@ pub fn reaching_closure(module: &Module, seed: FunctionId) -> HashSet Result> { // the runtime's own injected functions are not mistaken for // fork-path callers. (They can't reach the seed anyway, but the // earlier-is-simpler ordering keeps the invariant trivially.) - let fork_path = match call_graph::find_import_func(&module, &opts.entry_import) { + let entry = call_graph::find_import_func(&module, &opts.entry_import); + let fork_path = match entry { Some(seed) => call_graph::reaching_closure(&module, seed), None => Default::default(), }; + // The five wpk_fork_* exports prove only that some instrumentation runtime + // was injected. They do not prove which import seeded the transformed call + // graph or whether a dlopen-capable main used the conservative dynamic + // call_indirect boundary. Emit a separate, versioned claim for exactly the + // transformations performed in this invocation so the host can reject + // stale or generically instrumented artifacts instead of mis-resuming. + let mut fork_capabilities = 0; + if entry.is_some() && opts.entry_import == "env.fork" { + fork_capabilities |= FORK_CAP_SIDE_ENTRY; + } + if entry.is_some() + && opts.entry_import == "kernel.kernel_fork" + && call_graph::has_dynamic_linker_imports(&module) + { + fork_capabilities |= FORK_CAP_DYLINK_MAIN; + } + // Phase 4a: runtime scaffolding. Always injected so the module's // exported ABI is stable regardless of whether any caller was // actually rewritten. @@ -128,6 +154,20 @@ pub fn instrument(input: &[u8], opts: &Options) -> Result> { // No-op when `fork_path` is empty (module doesn't use fork). instrument::instrument_functions(&mut module, &runtime, &fork_path, &b1_plan); + loop { + let existing = module + .customs + .iter() + .find(|(_, section)| section.name() == FORK_CAPABILITIES_SECTION) + .map(|(id, _)| id); + let Some(existing) = existing else { break }; + module.customs.delete(existing); + } + module.customs.add(RawCustomSection { + name: FORK_CAPABILITIES_SECTION.into(), + data: vec![FORK_CAPABILITIES_VERSION, fork_capabilities], + }); + // Historical phase list (Phase 4b/4c/4d/4e/4f/5/6) was an artefact // of guard-dispatch's body-rewriting approach. Post-commit-4 those // phases are folded into `instrument::instrument_functions` itself; diff --git a/crates/fork-instrument/tests/call_graph.rs b/crates/fork-instrument/tests/call_graph.rs index 425151f33..fefcb897b 100644 --- a/crates/fork-instrument/tests/call_graph.rs +++ b/crates/fork-instrument/tests/call_graph.rs @@ -388,6 +388,35 @@ fn passive_element_with_table_init_is_followed() { ); } +#[test] +fn dynamic_linker_indirect_call_is_conservative_fork_boundary() { + // Side-module functions inserted after instrumentation are absent from + // every static element segment. A dlopen-capable main must still preserve + // the call_indirect frame and its direct callers when that side function + // later reaches fork(). + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "__wasm_dlsym" (func $dlsym (param i32 i32 i32) (result i32))) + (type $side_fn_ty (func (result i32))) + (table $t 1 funcref) + (func $dispatch_side_callback (export "dispatch_side_callback") (result i32) + i32.const 0 + call_indirect $t (type $side_fn_ty)) + (func $parent_frame (export "parent_frame") (result i32) + call $dispatch_side_callback) + (func $ordinary (export "ordinary") (result i32) + i32.const 7)) + "#; + let found = discover(wat); + assert!(found.iter().any(|n| n == "dispatch_side_callback")); + assert!(found.iter().any(|n| n == "parent_frame")); + assert!( + !found.iter().any(|n| n == "ordinary"), + "unrelated functions must stay out of the dynamic fork closure: {found:?}" + ); +} + #[test] fn constant_slot_pointing_to_safe_target_excludes_indirect_caller() { // Both functions have the same signature and inhabit the same table. diff --git a/crates/fork-instrument/tests/runtime.rs b/crates/fork-instrument/tests/runtime.rs index e34026858..6db3e8b85 100644 --- a/crates/fork-instrument/tests/runtime.rs +++ b/crates/fork-instrument/tests/runtime.rs @@ -11,9 +11,13 @@ //! - Independently validating via wasmparser that the emitted module //! is well-formed. -use fork_instrument::{Options, instrument}; use fork_instrument::runtime::names; +use fork_instrument::{ + FORK_CAP_DYLINK_MAIN, FORK_CAP_SIDE_ENTRY, FORK_CAPABILITIES_SECTION, + FORK_CAPABILITIES_VERSION, Options, instrument, +}; use walrus::{ExportItem, Module, ValType}; +use wasmparser::{Parser, Payload}; fn instrument_wat(wat_src: &str) -> Vec { let bytes = wat::parse_str(wat_src).expect("wat parse"); @@ -27,6 +31,18 @@ fn validate(bytes: &[u8]) { validator.validate_all(bytes).expect("valid wasm"); } +fn fork_capabilities(bytes: &[u8]) -> Vec> { + Parser::new(0) + .parse_all(bytes) + .filter_map(|payload| match payload.expect("parse payload") { + Payload::CustomSection(section) if section.name() == FORK_CAPABILITIES_SECTION => { + Some(section.data().to_vec()) + } + _ => None, + }) + .collect() +} + fn export_function_id(module: &Module, name: &str) -> walrus::FunctionId { let export = module .exports @@ -57,6 +73,59 @@ fn instrumented_module_validates() { validate(&bytes); } +#[test] +fn marks_dlopen_main_indirect_boundary_separately() { + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "__wasm_dlsym" (func $dlsym (param i32 i32 i32) (result i32))) + (type $callback (func (result i32))) + (table 1 funcref) + (memory 1) + (func (export "dispatch") (result i32) + i32.const 0 + call_indirect (type $callback))) + "#; + let output = instrument_wat(wat); + assert_eq!( + fork_capabilities(&output), + vec![vec![FORK_CAPABILITIES_VERSION, FORK_CAP_DYLINK_MAIN]], + ); +} + +#[test] +fn marks_env_fork_side_entry_separately() { + let input = wat::parse_str( + r#" + (module + (import "env" "fork" (func $fork (result i32))) + (memory 1) + (func (export "side_fork") (result i32) call $fork)) + "#, + ) + .expect("wat parse"); + let output = instrument( + &input, + &Options { + entry_import: "env.fork".into(), + }, + ) + .expect("instrument side"); + assert_eq!( + fork_capabilities(&output), + vec![vec![FORK_CAPABILITIES_VERSION, FORK_CAP_SIDE_ENTRY]], + ); +} + +#[test] +fn generic_runtime_exports_do_not_claim_side_or_dylink_coverage() { + let output = instrument_wat(EMPTY_MODULE_WITH_FORK); + assert_eq!( + fork_capabilities(&output), + vec![vec![FORK_CAPABILITIES_VERSION, 0]], + ); +} + #[test] fn injects_state_global_mutable_i32_init_zero() { let bytes = instrument_wat(EMPTY_MODULE_WITH_FORK); diff --git a/crates/kernel/src/audio.rs b/crates/kernel/src/audio.rs index 2b5e5b8af..86aea81e8 100644 --- a/crates/kernel/src/audio.rs +++ b/crates/kernel/src/audio.rs @@ -22,9 +22,9 @@ //! Like `/dev/fb0` and `/dev/input/mice`, `/dev/dsp` is single-open. A //! second `open` from a different pid is `EBUSY`. Re-opens by the //! current owner are accepted (matches the typical OSS exclusive-grab -//! model). Owner is released when the process closes its last `/dev/dsp` -//! fd, or exits, or `execve`s — at which point the ring is also -//! cleared so a successor open starts from silence. +//! model). A non-CLOEXEC fd retains ownership and queued samples across +//! `execve`; last close or process exit releases ownership and clears the +//! ring so a successor open starts from silence. //! //! ## Backpressure //! @@ -127,8 +127,8 @@ pub fn pending_bytes() -> usize { ring().len() } -/// Drop all buffered samples. Called on process exit / exec by the -/// owner, and by `SNDCTL_DSP_RESET`. +/// Drop all buffered samples. Called when the owner exits or closes its last +/// fd, and by `SNDCTL_DSP_RESET`. pub fn reset() { ring().clear(); } diff --git a/crates/kernel/src/descriptor_backing.rs b/crates/kernel/src/descriptor_backing.rs new file mode 100644 index 000000000..81b184382 --- /dev/null +++ b/crates/kernel/src/descriptor_backing.rs @@ -0,0 +1,467 @@ +//! Kernel-global backings for descriptor types whose state belongs to an +//! open file description rather than to a process. +//! +//! Fork and spawn clone a process's FD/OFD tables. The cloned OFDs retain a +//! stable negative handle into these tables, and each inherited OFD owns one +//! reference. This keeps state coherent across processes and prevents a +//! newly-created descriptor in a child from reusing (and aliasing) an +//! inherited process-local slot. + +extern crate alloc; + +use alloc::vec::Vec; +use core::cell::UnsafeCell; +use core::hint::spin_loop; +use core::sync::atomic::{AtomicBool, Ordering}; + +use wasm_posix_shared::Errno; + +use crate::ofd::FileType; +use crate::process::{EventFdState, Process, SignalFdState, TimerFdState}; + +#[derive(Debug)] +struct SharedBacking { + refs: u32, + value: T, + #[cfg(test)] + generation: u64, +} + +/// A stable-index table with one reference per owning OFD in each process. +pub struct SharedBackingTable { + entries: Vec>>, + #[cfg(test)] + next_generation: u64, +} + +impl SharedBackingTable { + fn new() -> Self { + Self { + entries: Vec::new(), + #[cfg(test)] + next_generation: 1, + } + } + + pub fn alloc(&mut self, value: T) -> usize { + #[cfg(test)] + let entry = { + let generation = self.next_generation; + self.next_generation = self.next_generation.wrapping_add(1).max(1); + SharedBacking { + refs: 1, + value, + generation, + } + }; + #[cfg(not(test))] + let entry = SharedBacking { refs: 1, value }; + + if let Some((idx, slot)) = self + .entries + .iter_mut() + .enumerate() + .find(|(_, slot)| slot.is_none()) + { + *slot = Some(entry); + return idx; + } + + let idx = self.entries.len(); + self.entries.push(Some(entry)); + idx + } + + pub fn get(&self, idx: usize) -> Option<&T> { + self.entries + .get(idx) + .and_then(Option::as_ref) + .map(|entry| &entry.value) + } + + pub fn get_mut(&mut self, idx: usize) -> Option<&mut T> { + self.entries + .get_mut(idx) + .and_then(Option::as_mut) + .map(|entry| &mut entry.value) + } + + pub fn add_ref(&mut self, idx: usize) -> Result<(), Errno> { + let entry = self + .entries + .get_mut(idx) + .and_then(Option::as_mut) + .ok_or(Errno::EBADF)?; + entry.refs = entry.refs.checked_add(1).ok_or(Errno::EOVERFLOW)?; + Ok(()) + } + + /// Drop one owning OFD reference. Returns true when the backing was freed. + pub fn release(&mut self, idx: usize) -> bool { + let Some(slot) = self.entries.get_mut(idx) else { + return false; + }; + let Some(entry) = slot.as_mut() else { + return false; + }; + if entry.refs > 1 { + entry.refs -= 1; + return false; + } + *slot = None; + true + } + + #[cfg(test)] + pub fn ref_count(&self, idx: usize) -> Option { + self.entries + .get(idx) + .and_then(Option::as_ref) + .map(|entry| entry.refs) + } + + #[cfg(test)] + pub fn generation(&self, idx: usize) -> Option { + self.entries + .get(idx) + .and_then(Option::as_ref) + .map(|entry| entry.generation) + } +} + +/// Shared contents and cursor for a memfd open file description. +#[derive(Debug)] +pub struct MemFdBacking { + pub data: Vec, + pub offset: i64, +} + +impl MemFdBacking { + pub fn new() -> Self { + Self { + data: Vec::new(), + offset: 0, + } + } +} + +/// Immutable procfs snapshot plus the shared open-file-description cursor. +#[derive(Debug)] +pub struct ProcfsBacking { + pub data: Vec, + pub offset: i64, +} + +impl ProcfsBacking { + pub fn new(data: Vec) -> Self { + Self { data, offset: 0 } + } +} + +struct GlobalBackingTable { + locked: AtomicBool, + table: UnsafeCell>>, +} + +struct UnlockOnDrop<'a>(&'a AtomicBool); + +impl Drop for UnlockOnDrop<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl GlobalBackingTable { + const fn new() -> Self { + Self { + locked: AtomicBool::new(false), + table: UnsafeCell::new(None), + } + } + + fn with(&'static self, f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R) -> R { + while self + .locked + .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + spin_loop(); + } + let _unlock = UnlockOnDrop(&self.locked); + // SAFETY: `locked` serializes every access and the closure's + // higher-ranked input lifetime prevents a table reference escaping. + let slot = unsafe { &mut *self.table.get() }; + f(slot.get_or_insert_with(SharedBackingTable::new)) + } +} + +// Kandelo serializes entry into one kernel instance. These tables follow the +// same UnsafeCell-backed global pattern as pipes, sockets, PTYs, and mqueues. +unsafe impl Sync for GlobalBackingTable {} + +static EVENTFDS: GlobalBackingTable = GlobalBackingTable::new(); +static TIMERFDS: GlobalBackingTable = GlobalBackingTable::new(); +static SIGNALFDS: GlobalBackingTable = GlobalBackingTable::new(); +static MEMFDS: GlobalBackingTable = GlobalBackingTable::new(); +static PROCFS_BUFS: GlobalBackingTable = GlobalBackingTable::new(); + +pub fn with_eventfds( + f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R, +) -> R { + EVENTFDS.with(f) +} + +pub fn with_timerfds( + f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R, +) -> R { + TIMERFDS.with(f) +} + +pub fn with_signalfds( + f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R, +) -> R { + SIGNALFDS.with(f) +} + +pub fn with_memfds(f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R) -> R { + MEMFDS.with(f) +} + +pub fn with_procfs_bufs( + f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R, +) -> R { + PROCFS_BUFS.with(f) +} + +fn negative_handle_idx(host_handle: i64) -> Result { + if host_handle >= 0 { + return Err(Errno::EBADF); + } + host_handle + .checked_neg() + .and_then(|value| value.checked_sub(1)) + .and_then(|value| usize::try_from(value).ok()) + .ok_or(Errno::EBADF) +} + +#[cfg_attr( + not(any(target_arch = "wasm32", target_arch = "wasm64")), + allow(dead_code) +)] +pub fn manages_ofd(file_type: FileType, host_handle: i64) -> bool { + matches!( + file_type, + FileType::EventFd | FileType::TimerFd | FileType::SignalFd | FileType::MemFd + ) || (file_type == FileType::Regular && crate::procfs::is_procfs_buf_handle(host_handle)) +} + +/// Plan ownership transfer for the legacy serialize/init exec ABI. Surviving +/// OFDs take over the old process's existing ownership reference; old OFDs +/// omitted by CLOEXEC filtering must be released exactly once. A replacement +/// may not acquire a backing it did not already own. +#[cfg_attr( + not(any(target_arch = "wasm32", target_arch = "wasm64")), + allow(dead_code) +)] +pub fn removed_backings_for_exec( + old: &Process, + replacement: &Process, +) -> Result, Errno> { + let old_backings: Vec<(FileType, i64)> = old + .ofd_table + .iter() + .filter_map(|(_, ofd)| { + manages_ofd(ofd.file_type, ofd.host_handle).then_some((ofd.file_type, ofd.host_handle)) + }) + .collect(); + let mut retained = alloc::vec![false; old_backings.len()]; + + for (_, ofd) in replacement.ofd_table.iter() { + if !manages_ofd(ofd.file_type, ofd.host_handle) { + continue; + } + let Some((idx, _)) = old_backings + .iter() + .enumerate() + .find(|(idx, key)| !retained[*idx] && **key == (ofd.file_type, ofd.host_handle)) + else { + return Err(Errno::EBADF); + }; + retained[idx] = true; + } + + Ok(old_backings + .into_iter() + .zip(retained) + .filter_map(|(key, retained)| (!retained).then_some(key)) + .collect()) +} + +#[cfg_attr( + not(any(target_arch = "wasm32", target_arch = "wasm64")), + allow(dead_code) +)] +pub fn release_backings(backings: &[(FileType, i64)]) { + for &(file_type, host_handle) in backings { + release_for_ofd(file_type, host_handle); + } +} + +/// Read the authoritative open-file-description cursor. Memfd and procfs +/// cursors live in their shared backing; all other OFDs use their local field. +pub fn current_offset( + file_type: FileType, + host_handle: i64, + local_offset: i64, +) -> Result { + match file_type { + FileType::MemFd => with_memfds(|table| { + table + .get(negative_handle_idx(host_handle)?) + .map(|backing| backing.offset) + .ok_or(Errno::EBADF) + }), + FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { + with_procfs_bufs(|table| { + table + .get(crate::procfs::procfs_buf_idx(host_handle)) + .map(|backing| backing.offset) + .ok_or(Errno::EBADF) + }) + } + _ => Ok(local_offset), + } +} + +/// Set the authoritative cursor. Returns true for shared-cursor OFDs so the +/// caller knows the local `OpenFileDesc::offset` field is only a wire-format +/// placeholder and must not become a second authority. +pub fn set_current_offset( + file_type: FileType, + host_handle: i64, + offset: i64, +) -> Result { + match file_type { + FileType::MemFd => { + with_memfds(|table| { + let backing = table + .get_mut(negative_handle_idx(host_handle)?) + .ok_or(Errno::EBADF)?; + backing.offset = offset; + Ok(()) + })?; + Ok(true) + } + FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { + with_procfs_bufs(|table| { + let backing = table + .get_mut(crate::procfs::procfs_buf_idx(host_handle)) + .ok_or(Errno::EBADF)?; + backing.offset = offset; + Ok(()) + })?; + Ok(true) + } + _ => Ok(false), + } +} + +/// Add the child's one-per-OFD ownership reference when an OFD is inherited. +/// Returns `Ok(false)` for descriptor types without a backing in this module. +pub fn add_ref_for_ofd(file_type: FileType, host_handle: i64) -> Result { + match file_type { + FileType::EventFd => { + with_eventfds(|table| table.add_ref(negative_handle_idx(host_handle)?))? + } + FileType::TimerFd => { + with_timerfds(|table| table.add_ref(negative_handle_idx(host_handle)?))? + } + FileType::SignalFd => { + with_signalfds(|table| table.add_ref(negative_handle_idx(host_handle)?))? + } + FileType::MemFd => with_memfds(|table| table.add_ref(negative_handle_idx(host_handle)?))?, + FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { + with_procfs_bufs(|table| table.add_ref(crate::procfs::procfs_buf_idx(host_handle)))? + } + _ => return Ok(false), + } + Ok(true) +} + +/// Drop one owning OFD reference. Returns true when this module owns the +/// descriptor type, including when a corrupt/stale handle had no live entry. +pub fn release_for_ofd(file_type: FileType, host_handle: i64) -> bool { + match file_type { + FileType::EventFd => { + if let Ok(idx) = negative_handle_idx(host_handle) { + with_eventfds(|table| table.release(idx)); + } + true + } + FileType::TimerFd => { + if let Ok(idx) = negative_handle_idx(host_handle) { + with_timerfds(|table| table.release(idx)); + } + true + } + FileType::SignalFd => { + if let Ok(idx) = negative_handle_idx(host_handle) { + with_signalfds(|table| table.release(idx)); + } + true + } + FileType::MemFd => { + if let Ok(idx) = negative_handle_idx(host_handle) { + with_memfds(|table| table.release(idx)); + } + true + } + FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { + with_procfs_bufs(|table| table.release(crate::procfs::procfs_buf_idx(host_handle))); + true + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::{GlobalBackingTable, SharedBackingTable}; + + #[test] + fn shared_backing_reuses_only_freed_slots() { + let mut table = SharedBackingTable::new(); + let first = table.alloc(10u32); + assert_eq!(first, 0); + table.add_ref(first).unwrap(); + assert!(!table.release(first)); + + let second = table.alloc(20u32); + assert_eq!(second, 1, "live backing must retain its stable index"); + assert!(table.release(first)); + let reused = table.alloc(30u32); + assert_eq!(reused, first); + assert_eq!(table.get(reused), Some(&30)); + } + + #[test] + fn global_backing_closure_serializes_parallel_access() { + let table: &'static GlobalBackingTable = + Box::leak(Box::new(GlobalBackingTable::new())); + let threads: Vec<_> = (0..8u64) + .map(|thread_id| { + std::thread::spawn(move || { + for iteration in 0..500u64 { + let value = (thread_id << 32) | iteration; + let idx = table.with(|entries| entries.alloc(value)); + assert_eq!(table.with(|entries| entries.get(idx).copied()), Some(value)); + assert!(table.with(|entries| entries.release(idx))); + } + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + } +} diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index 03b39af85..62b8715b0 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -17,10 +17,10 @@ extern crate alloc; -use alloc::collections::BTreeSet; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec::Vec; use wasm_posix_shared::Errno; -use wasm_posix_shared::fd_flags::FD_CLOEXEC; +use wasm_posix_shared::fd_flags::{FD_CLOEXEC, FD_CLOFORK}; use crate::fd::{FdEntry, FdTable, OpenFileDescRef}; use crate::lock::LockTable; @@ -51,6 +51,8 @@ const MAX_SOCKET_OPTIONS: usize = 4096; const MAX_SOCKET_STRING_LEN: usize = 256; const MAX_IPV4_MULTICAST_MEMBERSHIPS: usize = 4096; const MAX_IPV4_MULTICAST_SOURCES: usize = 4096; +const INITIAL_EXEC_STATE_BUFFER_LEN: usize = 64 * 1024; +const MAX_EXEC_STATE_BUFFER_LEN: usize = 4 * 1024 * 1024; // ── Writer helper ─────────────────────────────────────────────────────────── @@ -677,7 +679,15 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result = proc.fd_table.iter().collect(); + let fd_entries: Vec<(i32, &FdEntry)> = proc + .fd_table + .iter() + .filter(|(_, entry)| entry.fd_flags & FD_CLOFORK == 0) + .collect(); + let mut inherited_ofd_refs: BTreeMap = BTreeMap::new(); + for (_, entry) in &fd_entries { + *inherited_ofd_refs.entry(entry.ofd_ref.0).or_insert(0) += 1; + } w.write_u32(fd_entries.len() as u32)?; for (fd_num, entry) in &fd_entries { w.write_u32(*fd_num as u32)?; @@ -686,7 +696,11 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result = proc.ofd_table.iter().collect(); + let ofd_entries: Vec<(usize, &OpenFileDesc)> = proc + .ofd_table + .iter() + .filter(|(index, _)| inherited_ofd_refs.contains_key(index)) + .collect(); w.write_u32(ofd_entries.len() as u32)?; for (index, ofd) in &ofd_entries { w.write_u32(*index as u32)?; @@ -694,7 +708,7 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result Result Result Result = fd_entries - .iter() - .map(|(_, entry)| entry.ofd_ref.0) - .collect(); + // Recompute local OFD references from the surviving fd aliases. A + // CLOEXEC alias must not leave the replacement process with the parent's + // stale ref_count, otherwise its eventual last close cannot free the OFD. + let mut surviving_ofd_refs: BTreeMap = BTreeMap::new(); + for (_, entry) in &fd_entries { + *surviving_ofd_refs.entry(entry.ofd_ref.0).or_insert(0) += 1; + } w.write_u32(proc.fd_table.max_fds() as u32)?; w.write_u32(fd_entries.len() as u32)?; @@ -1470,7 +1482,7 @@ pub fn serialize_exec_state(proc: &Process, buf: &mut [u8]) -> Result = proc .ofd_table .iter() - .filter(|(index, _)| referenced_ofds.contains(index)) + .filter(|(index, _)| surviving_ofd_refs.contains_key(index)) .collect(); w.write_u32(ofd_entries.len() as u32)?; for (index, ofd) in &ofd_entries { @@ -1479,7 +1491,7 @@ pub fn serialize_exec_state(proc: &Process, buf: &mut [u8]) -> Result Result Result, Errno> { + let mut len = INITIAL_EXEC_STATE_BUFFER_LEN; + + loop { + let mut buf = alloc::vec![0u8; len]; + match serialize_exec_state(proc, &mut buf) { + Ok(written) => { + buf.truncate(written); + return Ok(buf); + } + Err(Errno::ENOMEM) if len < MAX_EXEC_STATE_BUFFER_LEN => { + len = len.saturating_mul(2).min(MAX_EXEC_STATE_BUFFER_LEN); + } + Err(err) => return Err(err), + } + } +} + // ── Exec Deserialize ──────────────────────────────────────────────────────── /// Deserialize process state from an exec buffer. @@ -1742,6 +1776,7 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { is_session_leader, state: ProcessState::Running, exit_status: 0, + exit_signal: 0, fd_table, ofd_table, lock_table: LockTable::new(), @@ -1768,18 +1803,13 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { next_ephemeral_port: 49152, threads: Vec::new(), // exec resets to single thread next_tid: 0, - eventfds: Vec::new(), epolls: Vec::new(), - timerfds: Vec::new(), - signalfds: Vec::new(), posix_timers: Vec::new(), alt_stack_sp: 0, alt_stack_flags: 2, // SS_DISABLE alt_stack_size: 0, alt_stack_depth: 0, fork_pipe_replay: Vec::new(), - memfds: Vec::new(), - procfs_bufs: Vec::new(), has_exec: false, // exec wipes any prior framebuffer binding — the new program // must open and mmap /dev/fb0 itself. @@ -1965,6 +1995,29 @@ mod tests { assert_eq!(restored.signals.pending, 0); } + #[test] + fn test_exec_state_grows_for_large_environment() { + let mut proc = Process::new(1); + proc.environ = (0..1200) + .map(|_| { + let mut var = b"KDE_LONG_ENV=".to_vec(); + var.extend(core::iter::repeat_n(b'x', 80)); + var + }) + .collect(); + + let mut old_limit_buf = alloc::vec![0u8; INITIAL_EXEC_STATE_BUFFER_LEN]; + assert_eq!( + serialize_exec_state(&proc, &mut old_limit_buf), + Err(Errno::ENOMEM), + ); + + let serialized = serialize_exec_state_with_growing_buffer(&proc).unwrap(); + assert!(serialized.len() > INITIAL_EXEC_STATE_BUFFER_LEN); + let restored = deserialize_exec_state(&serialized, 1).unwrap(); + assert_eq!(restored.environ, proc.environ); + } + #[test] fn test_exec_state_filters_cloexec_fds() { use wasm_posix_shared::fd_flags::FD_CLOEXEC; @@ -1989,6 +2042,35 @@ mod tests { assert!(restored.fd_table.get(0).is_ok()); } + #[test] + fn exec_recomputes_ofd_ref_count_after_filtering_cloexec_alias() { + use wasm_posix_shared::fd_flags::FD_CLOEXEC; + + let mut proc = Process::new(1); + let ofd_idx = proc.ofd_table.create( + crate::ofd::FileType::Regular, + 0, + 100, + b"/test/aliased".to_vec(), + ); + let retained_fd = proc + .fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + proc.ofd_table.inc_ref(ofd_idx); + let cloexec_fd = proc + .fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), FD_CLOEXEC) + .unwrap(); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().ref_count, 2); + + let serialized = serialize_exec_state_with_growing_buffer(&proc).unwrap(); + let restored = deserialize_exec_state(&serialized, proc.pid).unwrap(); + assert!(restored.fd_table.get(retained_fd).is_ok()); + assert!(restored.fd_table.get(cloexec_fd).is_err()); + assert_eq!(restored.ofd_table.get(ofd_idx).unwrap().ref_count, 1); + } + #[test] fn test_exec_state_resets_caught_handler_preserves_ignore() { let mut proc = Process::new(1); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index e8f0271bb..bedaf8296 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -6,6 +6,7 @@ extern crate alloc; extern crate wasm_posix_shared; pub mod audio; +pub(crate) mod descriptor_backing; pub mod devfs; pub mod dri; pub mod fd; diff --git a/crates/kernel/src/memory.rs b/crates/kernel/src/memory.rs index d63cb47ac..9eda24f37 100644 --- a/crates/kernel/src/memory.rs +++ b/crates/kernel/src/memory.rs @@ -133,9 +133,23 @@ impl MemoryManager { }); hint } else { - // Find first gap in [mmap_base, max_addr) that fits aligned_len. - // Mappings are kept sorted by address. - match self.find_gap(aligned_len) { + // A non-null address without MAP_FIXED is a placement hint. Wasm + // mappings use 64 KiB pages, so mirror mmap's page-boundary + // behavior by rounding the hint down and using it only when the + // complete range is available. An unusable hint falls back to + // the ordinary first-fit search without replacing anything. + let rounded_hint = hint & !0xFFFF; + let hinted_addr = if rounded_hint >= self.mmap_base.max(self.program_break) + && self.can_grow_at(rounded_hint, aligned_len) + { + Some(rounded_hint) + } else { + None + }; + + // Find the first gap in [mmap_base, max_addr) when the hint is + // absent or unusable. Mappings are kept sorted by address. + match hinted_addr.or_else(|| self.find_gap(aligned_len)) { Some(a) => a, None => return wasm_posix_shared::mmap::MAP_FAILED, } @@ -267,7 +281,11 @@ impl MemoryManager { if len == 0 { return false; } - let unmap_end = addr.saturating_add(len); + let aligned_len = match len.checked_add(0xFFFF) { + Some(value) => value & !0xFFFF, + None => return false, + }; + let unmap_end = addr.saturating_add(aligned_len); let mut found = false; let mut new_mappings: Vec = Vec::new(); @@ -650,6 +668,47 @@ mod tests { assert_eq!(addr2, addr + 0x10000); } + #[test] + fn test_mmap_non_fixed_prefers_free_address_hint() { + let mut mm = MemoryManager::new(); + let rw = PROT_READ | PROT_WRITE; + let anon = MAP_PRIVATE | MAP_ANONYMOUS; + let base = MemoryManager::MMAP_BASE; + + assert_eq!(mm.mmap_anonymous(base, 0x10000, rw, anon | MAP_FIXED), base); + assert_eq!( + mm.mmap_anonymous(base + 0x20000, 0x10000, rw, anon | MAP_FIXED), + base + 0x20000 + ); + + // Prefer a usable hint even though an earlier first-fit gap exists, + // and round an unaligned hint down to the Wasm page boundary. + assert_eq!( + mm.mmap_anonymous(base + 0x30042, 0x10000, rw, anon), + base + 0x30000 + ); + + // An occupied hint must not replace the existing mapping. + assert_eq!( + mm.mmap_anonymous(base + 0x20000, 0x10000, rw, anon), + base + 0x10000 + ); + assert!(mm.is_mapped(base + 0x20000)); + } + + #[test] + fn test_munmap_rounds_length_up_to_wasm_page() { + let mut mm = MemoryManager::new(); + let rw = PROT_READ | PROT_WRITE; + let anon = MAP_PRIVATE | MAP_ANONYMOUS; + let addr = mm.mmap_anonymous(0, 0x20000, rw, anon); + + assert!(mm.munmap(addr, 0x10001)); + assert!(!mm.is_mapped(addr)); + assert!(!mm.is_mapped(addr + 0x10000)); + assert_eq!(mm.mmap_anonymous(0, 0x20000, rw, anon), addr); + } + #[test] fn test_brk() { let mut mm = MemoryManager::new(); diff --git a/crates/kernel/src/mouse.rs b/crates/kernel/src/mouse.rs index 0a7e2d28d..52d26a322 100644 --- a/crates/kernel/src/mouse.rs +++ b/crates/kernel/src/mouse.rs @@ -138,8 +138,8 @@ pub fn has_data() -> bool { !queue().is_empty() } -/// Drop all queued events. Called on process exit / exec by the owner -/// so a fresh open by a successor sees an empty queue. +/// Drop all queued events when the owner exits or closes its last fd so a +/// fresh open by a successor sees an empty queue. pub fn reset() { queue().clear(); } diff --git a/crates/kernel/src/pipe.rs b/crates/kernel/src/pipe.rs index 891d80128..ec5e733f0 100644 --- a/crates/kernel/src/pipe.rs +++ b/crates/kernel/src/pipe.rs @@ -361,6 +361,16 @@ impl PipeTable { } } + /// Release both endpoints of a newly allocated buffer that was never + /// published to a socket or host bridge, then make its slot reusable. + pub fn discard_unclaimed(&mut self, idx: usize) { + if let Some(pipe) = self.get_mut(idx) { + pipe.close_read_end(); + pipe.close_write_end(); + } + self.free_if_closed(idx); + } + /// Total number of slots (including freed). pub fn len(&self) -> usize { self.pipes.len() @@ -598,4 +608,15 @@ mod tests { let idx3 = table.alloc(PipeBuffer::new(64)); assert_eq!(idx3, 0); } + + #[test] + fn test_pipe_table_discards_unclaimed_slot() { + let mut table = PipeTable::new(); + let idx = table.alloc(PipeBuffer::new(64)); + + table.discard_unclaimed(idx); + + assert_eq!(table.count_active(), 0); + assert_eq!(table.alloc(PipeBuffer::new(64)), idx); + } } diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index 181edee08..bf9b58a44 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -539,7 +539,11 @@ pub struct Process { /// POSIX uses this flag (not `sid == pid`) to gate setpgid EPERM checks. pub is_session_leader: bool, pub state: ProcessState, + /// Low 8-bit status supplied to `_exit()`/`exit_group()` for a normal + /// exit. Signal termination is recorded separately in `exit_signal` so + /// normal statuses 128..=255 remain distinguishable to waiters. pub exit_status: i32, + pub exit_signal: u32, pub fd_table: FdTable, pub ofd_table: OfdTable, pub lock_table: LockTable, @@ -576,14 +580,8 @@ pub struct Process { pub threads: Vec, /// Next thread ID to allocate. pub next_tid: u32, - /// Eventfd instances owned by this process. - pub eventfds: Vec>, /// Epoll instances owned by this process. pub epolls: Vec>, - /// Timerfd instances owned by this process. - pub timerfds: Vec>, - /// Signalfd instances owned by this process. - pub signalfds: Vec>, /// POSIX timers (timer_create / timer_settime). pub posix_timers: Vec>, /// Alternate signal stack (sigaltstack): ss_sp, ss_flags, ss_size. @@ -598,10 +596,6 @@ pub struct Process { /// from this list to return the correct FDs when the child re-runs /// code before fork(). Empty in non-fork-child processes. pub fork_pipe_replay: Vec<(i32, i32)>, - /// In-memory file buffers for memfd_create fds. - pub memfds: Vec>>, - /// Content buffers for open procfs files (snapshot at open time). - pub procfs_bufs: Vec>>, /// True if this process has called exec (for POSIX setpgid EACCES check). pub has_exec: bool, /// Live mmap of `/dev/fb0`, if any. `Some` between successful @@ -675,6 +669,9 @@ impl StdioConfig { } } +pub(crate) const PROCESS_METADATA_ARGV: u32 = 0; +pub(crate) const PROCESS_METADATA_ENVIRONMENT: u32 = 1; + impl Process { /// Create a new process with captured, pipe-backed stdio. pub fn new(pid: u32) -> Self { @@ -728,6 +725,7 @@ impl Process { is_session_leader: false, state: ProcessState::Running, exit_status: 0, + exit_signal: 0, fd_table, ofd_table, lock_table: LockTable::new(), @@ -754,18 +752,13 @@ impl Process { next_ephemeral_port: 49152, threads: Vec::new(), next_tid: 0, // will be set to pid + 1 after pid is known - eventfds: Vec::new(), epolls: Vec::new(), - timerfds: Vec::new(), - signalfds: Vec::new(), posix_timers: Vec::new(), alt_stack_sp: 0, alt_stack_flags: 2, // SS_DISABLE alt_stack_size: 0, alt_stack_depth: 0, fork_pipe_replay: Vec::new(), - memfds: Vec::new(), - procfs_bufs: Vec::new(), has_exec: false, fb_binding: None, dri_bindings: Vec::new(), @@ -998,6 +991,32 @@ impl Process { } out } + + fn metadata_vector_mut(&mut self, kind: u32) -> Result<&mut Vec>, Errno> { + match kind { + PROCESS_METADATA_ARGV => Ok(&mut self.argv), + PROCESS_METADATA_ENVIRONMENT => Ok(&mut self.environ), + _ => Err(Errno::EINVAL), + } + } + + pub(crate) fn clear_metadata(&mut self, kind: u32) -> Result<(), Errno> { + self.metadata_vector_mut(kind)?.clear(); + Ok(()) + } + + pub(crate) fn push_metadata_entry(&mut self, kind: u32, entry: &[u8]) -> Result<(), Errno> { + let mut owned = Vec::new(); + owned + .try_reserve_exact(entry.len()) + .map_err(|_| Errno::ENOMEM)?; + owned.extend_from_slice(entry); + + let entries = self.metadata_vector_mut(kind)?; + entries.try_reserve(1).map_err(|_| Errno::ENOMEM)?; + entries.push(owned); + Ok(()) + } } /// A `HostIO` impl that returns sensible defaults for the methods our @@ -1227,6 +1246,30 @@ mod tests { assert_eq!(proc.fork_count(), 0); } + #[test] + fn metadata_entry_transport_preserves_empty_values_and_empty_environment() { + let mut proc = Process::new(77); + proc.argv = vec![b"old".to_vec()]; + proc.environ = vec![b"OLD=value".to_vec()]; + + proc.clear_metadata(PROCESS_METADATA_ARGV).unwrap(); + proc.push_metadata_entry(PROCESS_METADATA_ARGV, b"new") + .unwrap(); + proc.push_metadata_entry(PROCESS_METADATA_ARGV, b"") + .unwrap(); + proc.clear_metadata(PROCESS_METADATA_ENVIRONMENT).unwrap(); + + assert_eq!(proc.argv, vec![b"new".to_vec(), Vec::new()]); + assert!(proc.environ.is_empty()); + } + + #[test] + fn metadata_entry_transport_rejects_unknown_vector_kind() { + let mut proc = Process::new(78); + assert_eq!(proc.clear_metadata(99), Err(Errno::EINVAL)); + assert_eq!(proc.push_metadata_entry(99, b"value"), Err(Errno::EINVAL)); + } + #[test] fn new_creates_captured_stdio_as_pipes() { let proc = Process::new(1); diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index cb0c75718..3cb4801f8 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -108,7 +108,28 @@ struct SpawnInheritFromParent { /// The function operates only on global tables and the child's own state, /// so it does not need access to `ProcessTable`. `parent_pid` identifies the /// exact source owner when copying machine-wide INET binding ownership. -fn bump_inherited_resource_refcounts(parent_pid: u32, child: &Process) { +pub(crate) fn bump_inherited_resource_refcounts( + parent_pid: u32, + child: &Process, +) -> Result<(), Errno> { + // Backings for eventfd/timerfd/signalfd/memfd/procfs are indexed by the + // inherited OFD's stable negative handle. Add these fallible references + // first, rolling them back if a stale handle is encountered, before + // touching the older infallible global-resource refcounts below. + let mut shared_backings_bumped: Vec<(FileType, i64)> = Vec::new(); + for (_idx, ofd) in child.ofd_table.iter() { + match crate::descriptor_backing::add_ref_for_ofd(ofd.file_type, ofd.host_handle) { + Ok(true) => shared_backings_bumped.push((ofd.file_type, ofd.host_handle)), + Ok(false) => {} + Err(err) => { + for (file_type, host_handle) in shared_backings_bumped.into_iter().rev() { + crate::descriptor_backing::release_for_ofd(file_type, host_handle); + } + return Err(err); + } + } + } + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; // Pipe-OFDs (host_handle is the negative-encoded global pipe index). @@ -201,6 +222,8 @@ fn bump_inherited_resource_refcounts(parent_pid: u32, child: &Process) { } } } + + Ok(()) } /// Build the fork-only `fork_pipe_replay` table: a list of (read_fd, @@ -210,18 +233,16 @@ fn bump_inherited_resource_refcounts(parent_pid: u32, child: &Process) { fn build_fork_pipe_replay(child: &Process) -> Vec<(i32, i32)> { use alloc::collections::BTreeMap; let mut pipe_fd_pairs: BTreeMap = BTreeMap::new(); - for fd in 0..1024i32 { - if let Ok(entry) = child.fd_table.get(fd) { - if let Some(ofd) = child.ofd_table.get(entry.ofd_ref.0) { - if ofd.file_type == FileType::Pipe && ofd.host_handle < 0 { - let pipe_idx = (-(ofd.host_handle + 1)) as usize; - let access_mode = ofd.status_flags & O_ACCMODE; - let pair = pipe_fd_pairs.entry(pipe_idx).or_insert((-1, -1)); - if access_mode == wasm_posix_shared::flags::O_RDONLY { - pair.0 = fd; - } else { - pair.1 = fd; - } + for (fd, entry) in child.fd_table.iter() { + if let Some(ofd) = child.ofd_table.get(entry.ofd_ref.0) { + if ofd.file_type == FileType::Pipe && ofd.host_handle < 0 { + let pipe_idx = (-(ofd.host_handle + 1)) as usize; + let access_mode = ofd.status_flags & O_ACCMODE; + let pair = pipe_fd_pairs.entry(pipe_idx).or_insert((-1, -1)); + if access_mode == wasm_posix_shared::flags::O_RDONLY { + pair.0 = fd; + } else { + pair.1 = fd; } } } @@ -366,6 +387,13 @@ impl ProcessTable { } } + // Drop kernel-global eventfd/timerfd/signalfd/memfd/procfs backing + // references for every OFD the process still owns. Normal exit closes + // fds first; this also covers crash removal and spawn rollback. + for (_ofd_idx, ofd) in proc.ofd_table.iter() { + crate::descriptor_backing::release_for_ofd(ofd.file_type, ofd.host_handle); + } + // Clean up socket OFDs. Active TCP streams use the same orderly FIN // and orphaned receive state as close(2); other socket kinds close // their pipe endpoints directly. @@ -510,6 +538,7 @@ impl ProcessTable { limbo.is_session_leader = proc.is_session_leader; limbo.state = ProcessState::Limbo; limbo.exit_status = proc.exit_status; + limbo.exit_signal = proc.exit_signal; limbo.cwd = proc.cwd.clone(); limbo.environ = proc.environ.clone(); limbo.argv = proc.argv.clone(); @@ -586,6 +615,9 @@ impl ProcessTable { } let serialized_parent = { let parent = self.processes.get(&parent_pid).ok_or(Errno::ESRCH)?; + if parent.state != crate::process::ProcessState::Running { + return Err(Errno::ESRCH); + } serialize_fork_state_with_growing_buffer(parent)? }; @@ -595,7 +627,7 @@ impl ProcessTable { // Bump cross-process refcounts on inherited fd state (host handles, // global pipes, PTYs, socket-pipes). Identical to spawn's needs — // factored out into a free helper. - bump_inherited_resource_refcounts(parent_pid, &child); + bump_inherited_resource_refcounts(parent_pid, &child)?; // Build fork-only `fork_pipe_replay` (fork replay needs it to // return the same fds as the parent did when re-running @@ -614,6 +646,76 @@ impl ProcessTable { Ok(()) } + /// Insert a process produced by the retained legacy fork-state ABI. + /// Unlike a raw map insert, this refuses to replace an existing pid and + /// either establishes same-instance inherited refs or preserves fresh- + /// kernel sole ownership before moving the process into the table. + #[cfg_attr( + not(any(target_arch = "wasm32", target_arch = "wasm64")), + allow(dead_code) + )] + pub(crate) fn insert_legacy_fork_process(&mut self, child: Process) -> Result<(), Errno> { + if self.processes.contains_key(&child.pid) { + return Err(Errno::EEXIST); + } + + if self.processes.contains_key(&child.ppid) { + // Same-instance legacy install: the parent still owns every + // inherited resource, so establish the child's additional refs. + bump_inherited_resource_refcounts(child.ppid, &child)?; + } else { + // The retained ABI also initializes a fresh kernel instance where + // the parent Process is intentionally absent. Ordinary host-backed + // handles are sole-owned by that child and must not receive a + // phantom parent ref. Kernel-global descriptor backings are not + // serialized, however, so accepting one here could alias a reused + // slot; fail truthfully instead. + if child.ofd_table.iter().any(|(_, ofd)| { + crate::descriptor_backing::manages_ofd(ofd.file_type, ofd.host_handle) + }) { + return Err(Errno::EBADF); + } + } + self.processes.insert(child.pid, child); + Ok(()) + } + + /// Replace an existing process through the retained legacy exec-state + /// ABI, transferring one ownership reference for surviving descriptor + /// backings and releasing old CLOEXEC-only/orphaned OFDs exactly once. + #[cfg_attr( + not(any(target_arch = "wasm32", target_arch = "wasm64")), + allow(dead_code) + )] + pub(crate) fn replace_legacy_exec_process( + &mut self, + pid: u32, + replacement: Process, + ) -> Result<(), Errno> { + if replacement.pid != pid { + return Err(Errno::EINVAL); + } + if let Some(old) = self.processes.get(&pid) { + let removed = crate::descriptor_backing::removed_backings_for_exec(old, &replacement)?; + let old = self.processes.insert(pid, replacement).unwrap(); + crate::descriptor_backing::release_backings(&removed); + drop(old); + } else { + // A fresh kernel instance has no old Process from which to + // transfer global backing ownership, and the retained wire format + // does not serialize those backing values. Reject them instead of + // letting a stale stable index alias this instance's current or + // future allocation at the same slot. + if replacement.ofd_table.iter().any(|(_, ofd)| { + crate::descriptor_backing::manages_ofd(ofd.file_type, ofd.host_handle) + }) { + return Err(Errno::EBADF); + } + self.processes.insert(pid, replacement); + } + Ok(()) + } + /// Non-forking spawn: build a child process for `posix_spawn` without /// going through fork continuation at all. The child is constructed from a /// fresh `Process::new(child_pid)` and selectively inherits only what @@ -640,6 +742,9 @@ impl ProcessTable { // Snapshot inheritable parent state under an immutable borrow. let inherit = { let parent = self.processes.get(&parent_pid).ok_or(Errno::ESRCH)?; + if parent.state != crate::process::ProcessState::Running { + return Err(Errno::ESRCH); + } // Compute the SIG_IGN-disposition bitmask for signals 1..=64. let mut ignored_signals: u64 = 0; for sig in 1u32..=64 { @@ -748,12 +853,11 @@ impl ProcessTable { } } - self.processes.insert(child_pid, child); - // Bump cross-process refcounts on the inherited fd state. The same // helper fork uses — this is the genuinely-shared concern. - let child_ref = self.processes.get(&child_pid).unwrap(); - bump_inherited_resource_refcounts(parent_pid, child_ref); + bump_inherited_resource_refcounts(parent_pid, &child)?; + + self.processes.insert(child_pid, child); // Apply file actions in forward order against the child. Any failure // rolls back the partial child via remove_process — which runs the @@ -909,7 +1013,8 @@ impl ProcessTable { pub fn mark_process_signaled(&mut self, pid: u32, signum: u32) -> Result<(), Errno> { let proc = self.processes.get_mut(&pid).ok_or(Errno::ESRCH)?; proc.state = ProcessState::Exited; - proc.exit_status = 128 + signum as i32; + proc.exit_status = 0; + proc.exit_signal = signum & 0x7f; Ok(()) } @@ -939,7 +1044,7 @@ impl ProcessTable { if child.state == ProcessState::Exited { return Ok(Some(( child_pid, - Self::wait_status_from_exit_status(child.exit_status), + Self::wait_status_from_process(child), ))); } } @@ -980,11 +1085,11 @@ impl ProcessTable { child.pgid == target_pgid } - fn wait_status_from_exit_status(exit_status: i32) -> i32 { - if exit_status >= 128 { - (exit_status - 128) & 0x7f + fn wait_status_from_process(proc: &Process) -> i32 { + if proc.exit_signal != 0 { + (proc.exit_signal as i32) & 0x7f } else { - (exit_status & 0xff) << 8 + (proc.exit_status & 0xff) << 8 } } } @@ -1087,6 +1192,87 @@ pub fn current_pid() -> u32 { mod tests { use super::*; + #[test] + fn legacy_state_install_rejects_collisions_but_allows_fresh_kernel_tables() { + let mut table = ProcessTable::new(); + table.create_process(100).unwrap(); + table.get_mut(100).unwrap().argv = alloc::vec![b"original".to_vec()]; + + let mut colliding_fork = Process::new(100); + colliding_fork.ppid = 100; + assert_eq!( + table.insert_legacy_fork_process(colliding_fork), + Err(Errno::EEXIST) + ); + assert_eq!(table.get(100).unwrap().argv[0], b"original"); + + let mut child_without_local_parent = Process::new(101); + child_without_local_parent.ppid = 999; + table + .insert_legacy_fork_process(child_without_local_parent) + .unwrap(); + assert_eq!(table.get(101).unwrap().ppid, 999); + + table + .replace_legacy_exec_process(777, Process::new(777)) + .unwrap(); + assert!(table.get(777).is_some()); + assert_eq!( + table.replace_legacy_exec_process(100, Process::new(102)), + Err(Errno::EINVAL) + ); + assert_eq!(table.get(100).unwrap().argv[0], b"original"); + } + + #[test] + fn fork_pipe_replay_includes_fds_above_default_nofile_limit() { + use crate::fd::OpenFileDescRef; + use wasm_posix_shared::flags::{O_RDONLY, O_WRONLY}; + + let mut child = Process::new(100); + child.fd_table.set_max_fds(4096); + let read_ofd = child + .ofd_table + .create(FileType::Pipe, O_RDONLY, -1, b"pipe-read".to_vec()); + let write_ofd = child + .ofd_table + .create(FileType::Pipe, O_WRONLY, -1, b"pipe-write".to_vec()); + let read_fd = child + .fd_table + .alloc_at_min(OpenFileDescRef(read_ofd), 0, 2048) + .unwrap(); + let write_fd = child + .fd_table + .alloc_at_min(OpenFileDescRef(write_ofd), 0, 2049) + .unwrap(); + + assert_eq!(build_fork_pipe_replay(&child), vec![(read_fd, write_fd)]); + } + + #[test] + fn exited_parent_cannot_fork_or_spawn() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + + let mut table = ProcessTable::new(); + table.create_process(100).unwrap(); + table.get_mut(100).unwrap().state = crate::process::ProcessState::Exited; + + assert_eq!(table.fork_process(100, 101), Err(Errno::ESRCH)); + let mut host = NoopHost; + assert_eq!( + table.spawn_child( + 100, + &[b"/bin/child".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ), + Err(Errno::ESRCH), + ); + } + #[test] fn process_exit_closes_tcp_pipes_orderly() { use crate::pipe::{global_pipe_table, PipeBuffer, DEFAULT_PIPE_CAPACITY}; @@ -1170,7 +1356,7 @@ mod tests { for _ in 0..LARGE_FD_COUNT { let path = alloc::vec![b'x'; LARGE_PATH_LEN]; - let ofd_ref = parent.ofd_table.create(FileType::MemFd, 0, -1, path); + let ofd_ref = parent.ofd_table.create(FileType::Regular, 0, -10, path); last_fd = parent .fd_table .alloc(crate::fd::OpenFileDescRef(ofd_ref), 0) @@ -1199,7 +1385,7 @@ mod tests { let child_ofd = child.ofd_table.get(child_fd.ofd_ref.0).unwrap(); assert_eq!(child.ppid, 100); - assert_eq!(child_ofd.file_type, FileType::MemFd); + assert_eq!(child_ofd.file_type, FileType::Regular); assert_eq!(child_ofd.path.len(), LARGE_PATH_LEN); } @@ -1303,6 +1489,23 @@ mod tests { assert_eq!(table.poll_waitable_child(10, 11).unwrap(), Some((11, 15))); } + #[test] + fn poll_waitable_child_preserves_high_normal_exit_status() { + let mut table = ProcessTable::new(); + table.create_process(10).unwrap(); + table.create_process(11).unwrap(); + let child = table.processes.get_mut(&11).unwrap(); + child.ppid = 10; + child.state = ProcessState::Exited; + child.exit_status = 255; + child.exit_signal = 0; + + assert_eq!( + table.poll_waitable_child(10, -1).unwrap(), + Some((11, 255 << 8)) + ); + } + #[test] fn poll_waitable_child_distinguishes_running_from_no_child() { let mut table = ProcessTable::new(); diff --git a/crates/kernel/src/procfs.rs b/crates/kernel/src/procfs.rs index 14da1f697..94b0cbcea 100644 --- a/crates/kernel/src/procfs.rs +++ b/crates/kernel/src/procfs.rs @@ -424,9 +424,12 @@ pub fn generate_fdinfo(proc: &Process, fd: i32) -> Option> { let entry = proc.fd_table.get(fd).ok()?; let ofd = proc.ofd_table.get(entry.ofd_ref.0)?; + let offset = + crate::descriptor_backing::current_offset(ofd.file_type, ofd.host_handle, ofd.offset) + .ok()?; let content = format!( "pos:\t{}\nflags:\t{:o}\nmnt_id:\t0\n", - ofd.offset, ofd.status_flags, + offset, ofd.status_flags, ); Some(content.into_bytes()) } @@ -544,7 +547,7 @@ fn entry_ids(entry: &ProcfsEntry) -> (u32, u8) { /// Open a procfs entry. Returns the fd number on success. /// -/// - Regular files: generates content snapshot → stores in proc.procfs_bufs +/// - Regular files: generates a refcounted content snapshot /// - Directories: creates OFD with PROCFS_DIR_HANDLE /// - Symlinks: returns ELOOP (caller should follow the link) pub fn procfs_open( @@ -601,20 +604,33 @@ pub fn procfs_open( if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_host_handle = PROCFS_DIR_HANDLE; } - let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; - return Ok(fd); + return match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { + Ok(fd) => Ok(fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_idx); + Err(err) + } + }; } - // Regular file: generate content and store in procfs_bufs + // Regular file: generate one snapshot backing per open file description. let content = generate_content(proc, entry)?; - let buf_idx = alloc_procfs_buf(proc, content); + let buf_idx = crate::descriptor_backing::with_procfs_bufs(|table| { + table.alloc(crate::descriptor_backing::ProcfsBacking::new(content)) + }); let host_handle = procfs_buf_handle(buf_idx); let ofd_idx = proc.ofd_table .create(FileType::Regular, status_flags, host_handle, resolved_path); - let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; - Ok(fd) + match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { + Ok(fd) => Ok(fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_idx); + crate::descriptor_backing::with_procfs_bufs(|table| table.release(buf_idx)); + Err(err) + } + } } /// Generate content for a procfs regular file entry. @@ -706,19 +722,6 @@ pub fn validate_entry(proc: &Process, entry: &ProcfsEntry) -> Result<(), Errno> Ok(()) } -/// Allocate a procfs buffer slot, reusing freed slots. -fn alloc_procfs_buf(proc: &mut Process, data: Vec) -> usize { - for (i, slot) in proc.procfs_bufs.iter().enumerate() { - if slot.is_none() { - proc.procfs_bufs[i] = Some(data); - return i; - } - } - let idx = proc.procfs_bufs.len(); - proc.procfs_bufs.push(Some(data)); - idx -} - // ── Readlink handler ──────────────────────────────────────────────────────── /// Handle readlink for procfs symlinks. @@ -1223,14 +1226,14 @@ mod tests { let fd = procfs_open(&mut proc, &entry, b"/proc/1/stat".to_vec(), 0).unwrap(); assert!(fd >= 0); - // Verify buffer was stored - assert!(!proc.procfs_bufs.is_empty()); - assert!(proc.procfs_bufs[0].is_some()); - // Verify OFD has procfs buf handle let fe = proc.fd_table.get(fd).unwrap(); let ofd = proc.ofd_table.get(fe.ofd_ref.0).unwrap(); assert!(is_procfs_buf_handle(ofd.host_handle)); + assert!(crate::descriptor_backing::with_procfs_bufs(|table| table + .get(procfs_buf_idx(ofd.host_handle)) + .is_some_and(|backing| !backing.data.is_empty()))); + crate::descriptor_backing::release_for_ofd(ofd.file_type, ofd.host_handle); } #[test] diff --git a/crates/kernel/src/signal.rs b/crates/kernel/src/signal.rs index 52924020e..ac7696052 100644 --- a/crates/kernel/src/signal.rs +++ b/crates/kernel/src/signal.rs @@ -299,6 +299,50 @@ impl SignalState { Ok(old) } + /// Apply exec disposition rules without rebuilding pending-signal state. + /// + /// Caught dispositions reset to default and ignored dispositions remain + /// ignored. All per-action flags and masks belong to the old image and are + /// cleared, including metadata attached to SIG_DFL and SIG_IGN entries. + /// The process blocked mask, pending bitset, RT queue multiplicity, and + /// queued siginfo metadata deliberately remain untouched. + pub fn reset_dispositions_for_exec(&mut self) { + for action in self.actions.iter_mut().skip(1) { + let handler = if matches!(action.handler, SignalHandler::Ignore) { + SignalHandler::Ignore + } else { + SignalHandler::Default + }; + *action = SignalAction { + handler, + flags: 0, + mask: 0, + }; + } + } + + /// Promote the calling pthread's signal mask and directed pending queue + /// when exec collapses a multithreaded process to that one thread. + pub fn promote_exec_thread(&mut self, thread: PerThreadSignalState) { + self.blocked = thread.blocked; + self.pending |= thread.pending; + for entry in thread.rt_queue { + if entry.signum >= SIGRTMIN { + self.rt_queue.push_back(entry); + } else if let Some(existing) = self + .rt_queue + .iter_mut() + .find(|queued| queued.signum == entry.signum) + { + // Standard signals coalesce. Prefer the calling thread's + // siginfo because it was specifically directed there. + *existing = entry; + } else { + self.rt_queue.push_back(entry); + } + } + } + /// Mark a signal as pending (via kill/raise — SI_USER). /// Standard signals (1-31) are coalesced. RT signals (32-63) are queued. /// Bit position = signum - 1 (musl convention: signal N uses bit N-1). @@ -690,6 +734,53 @@ mod tests { assert_eq!(current.mask, 0x04); } + #[test] + fn test_exec_resets_action_metadata_and_preserves_only_ignore() { + let mut state = SignalState::new(); + state + .set_action( + SIGINT, + SignalAction { + handler: SignalHandler::Handler(42), + flags: wasm_posix_shared::signal::SA_RESTART, + mask: 0x04, + }, + ) + .unwrap(); + state + .set_action( + SIGTERM, + SignalAction { + handler: SignalHandler::Ignore, + flags: wasm_posix_shared::signal::SA_RESTART, + mask: 0x08, + }, + ) + .unwrap(); + state.actions[wasm_posix_shared::signal::SIGCHLD as usize] = SignalAction { + handler: SignalHandler::Default, + flags: wasm_posix_shared::signal::SA_NOCLDWAIT, + mask: 0x10, + }; + + state.reset_dispositions_for_exec(); + + let caught = state.get_action(SIGINT); + assert!(matches!(caught.handler, SignalHandler::Default)); + assert_eq!(caught.flags, 0); + assert_eq!(caught.mask, 0); + + let ignored = state.get_action(SIGTERM); + assert!(matches!(ignored.handler, SignalHandler::Ignore)); + assert_eq!(ignored.flags, 0); + assert_eq!(ignored.mask, 0); + + let defaulted = state.get_action(wasm_posix_shared::signal::SIGCHLD); + assert!(matches!(defaulted.handler, SignalHandler::Default)); + assert_eq!(defaulted.flags, 0); + assert_eq!(defaulted.mask, 0); + } + #[test] fn test_set_action_cannot_change_sigkill() { let mut state = SignalState::new(); diff --git a/crates/kernel/src/socket.rs b/crates/kernel/src/socket.rs index bc4b25729..f882d6640 100644 --- a/crates/kernel/src/socket.rs +++ b/crates/kernel/src/socket.rs @@ -868,15 +868,12 @@ impl SocketTable { // accept queue across parent and children — any process can accept a // pending connection. Our SocketInfo lives in per-process tables, so a // naive fork+accept model would give each process its own backlog. To -// match POSIX semantics for AF_INET/AF_INET6 listeners (the typical fork-server -// pattern: nginx master + workers), we keep the actual pending queue +// match POSIX semantics for AF_INET, AF_INET6, and AF_UNIX listeners (the +// typical pre-fork server pattern), we keep the actual pending queue // in this global table and reference it by index from each forked // SocketInfo copy. -// -// AF_UNIX same-process listeners still use the inline `listen_backlog` -// field (sys_connect pre-allocates the accepted SocketInfo there). -/// A pending TCP connection waiting in a shared accept queue. +/// A pending stream connection waiting in a shared accept queue. pub struct PendingConnection { pub peer_addr: [u8; 4], pub peer_addr6: [u8; 16], @@ -885,6 +882,11 @@ pub struct PendingConnection { /// is converted to an IPv4-mapped address at accept time. pub peer_is_ipv6: bool, pub peer_port: u16, + /// Process-local peer identity, used only when the accepting process is + /// also the AF_UNIX client owner. Pipe identity is revalidated before it + /// is installed so a recycled socket slot cannot receive stale OOB data. + pub peer_pid: u32, + pub peer_sock_idx: Option, /// Recv pipe index (in the global pipe table). Host writes incoming /// TCP data here; the accepting process reads from it. pub recv_pipe_idx: usize, @@ -945,20 +947,35 @@ impl SharedBacklogTable { } } - /// Decrement the reference count. If it reaches zero, free the slot - /// (queue is dropped — pending connections are lost, matching what - /// happens when the last process holding a listener fd closes it). + /// Decrement the reference count. If it reaches zero, free the slot and + /// close the server endpoints owned by queued, not-yet-accepted + /// connections. pub fn dec_ref(&mut self, idx: usize) { + let mut abandoned = Vec::new(); if let Some(entry) = self.entries.get_mut(idx) { if !entry.in_use { return; } entry.ref_count = entry.ref_count.saturating_sub(1); if entry.ref_count == 0 { - entry.queue.clear(); + abandoned = core::mem::take(&mut entry.queue); entry.in_use = false; } } + if abandoned.is_empty() { + return; + } + let pipes = unsafe { crate::pipe::global_pipe_table() }; + for pending in abandoned { + if let Some(pipe) = pipes.get_mut(pending.recv_pipe_idx) { + pipe.close_read_end(); + } + pipes.free_if_closed(pending.recv_pipe_idx); + if let Some(pipe) = pipes.get_mut(pending.send_pipe_idx) { + pipe.close_write_end(); + } + pipes.free_if_closed(pending.send_pipe_idx); + } } /// Push a pending connection. Returns true on success. diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 66bd2017b..b1c9d67fc 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -251,21 +251,24 @@ pub(crate) fn maybe_release_fb0(pid: u32) { ); } +/// True iff `proc` still has an open fd referencing `device`. +/// +/// Iterate the authoritative fd table rather than assuming the default +/// 1024-descriptor limit: `RLIMIT_NOFILE` and `F_DUPFD` can place a surviving +/// alias at a much higher number. +fn proc_has_virtual_device_fd(proc: &Process, device: VirtualDevice) -> bool { + use crate::ofd::FileType; + proc.fd_table.iter().any(|(_, entry)| { + proc.ofd_table.get(entry.ofd_ref.0).is_some_and(|ofd| { + ofd.file_type == FileType::CharDevice + && VirtualDevice::from_host_handle(ofd.host_handle) == Some(device) + }) + }) +} + /// True iff `proc` still has an open fd referencing `/dev/fb0`. fn proc_has_fb0_fd(proc: &Process) -> bool { - use crate::ofd::FileType; - for fd_i in 0..1024i32 { - if let Ok(entry) = proc.fd_table.get(fd_i) { - if let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) { - if ofd.file_type == FileType::CharDevice - && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Fb0) - { - return true; - } - } - } - } - false + proc_has_virtual_device_fd(proc, VirtualDevice::Fb0) } /// Try to claim `/dev/input/mice` for the calling process. @@ -304,19 +307,7 @@ pub(crate) fn maybe_release_mice(pid: u32) { /// True iff `proc` still has an open fd referencing `/dev/input/mice`. fn proc_has_mice_fd(proc: &Process) -> bool { - use crate::ofd::FileType; - for fd_i in 0..1024i32 { - if let Ok(entry) = proc.fd_table.get(fd_i) { - if let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) { - if ofd.file_type == FileType::CharDevice - && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Mice) - { - return true; - } - } - } - } - false + proc_has_virtual_device_fd(proc, VirtualDevice::Mice) } /// Try to claim `/dev/dsp` for the calling process. @@ -355,19 +346,7 @@ pub(crate) fn maybe_release_dsp(pid: u32) { /// True iff `proc` still has an open fd referencing `/dev/dsp`. fn proc_has_dsp_fd(proc: &Process) -> bool { - use crate::ofd::FileType; - for fd_i in 0..1024i32 { - if let Ok(entry) = proc.fd_table.get(fd_i) { - if let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) { - if ofd.file_type == FileType::CharDevice - && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Dsp) - { - return true; - } - } - } - } - false + proc_has_virtual_device_fd(proc, VirtualDevice::Dsp) } /// Handle ioctl on `/dev/dsp`. @@ -680,25 +659,90 @@ fn release_process_dri_mappings(proc: &mut Process, host: &mut dyn HostIO) { } pub(crate) fn release_exec_image_state(proc: &mut Process, host: &mut dyn HostIO) { - // /dev/fb0 cleanup: exec wipes the binding. Tell the host before - // its memory snapshot of the process is invalidated, then drop the - // global ownership claim so the new image can re-acquire if it - // needs the device. + // /dev/fb0 cleanup: exec wipes the address-space binding. Tell the host + // before its memory snapshot is invalidated, but retain device ownership + // while a non-CLOEXEC fb fd remains open in this same process. if proc.fb_binding.is_some() { host.unbind_framebuffer(proc.pid as i32); proc.fb_binding = None; - maybe_release_fb0(proc.pid); + if !proc_has_fb0_fd(proc) { + maybe_release_fb0(proc.pid); + } } - // /dev/input/mice cleanup: exec also drops mouse ownership. The - // post-exec image starts with a clean queue — no stale packets from - // the parent program survive across exec. - maybe_release_mice(proc.pid); - // /dev/dsp cleanup: same — drop ownership and flush any queued PCM - // so a post-exec program doesn't hear the tail of its predecessor. - maybe_release_dsp(proc.pid); + // Mouse and DSP state belongs to their surviving open descriptions, not + // the discarded Wasm image. A CLOEXEC close (or the eventual last close) + // releases ownership and drains the corresponding queue. release_process_dri_mappings(proc, host); } +/// Commit the exec-defined state transition without cloning descriptor-backed +/// kernel objects through a wire format. +pub(crate) fn commit_exec_state( + proc: &mut Process, + host: &mut dyn HostIO, + caller_tid: u32, +) -> Result<(), Errno> { + if proc.state != crate::process::ProcessState::Running { + return Err(Errno::ESRCH); + } + if caller_tid != 0 && caller_tid != proc.pid { + let thread_index = proc + .threads + .iter() + .position(|thread| thread.tid == caller_tid) + .ok_or(Errno::ESRCH)?; + let caller = proc.threads.remove(thread_index); + proc.signals.promote_exec_thread(caller.signals); + } + + let cloexec_fds: Vec = proc + .fd_table + .iter() + .filter(|(_, entry)| entry.fd_flags & FD_CLOEXEC != 0) + .map(|(fd, _)| fd) + .collect(); + for fd in cloexec_fds { + let _ = sys_close(proc, host, fd); + } + for stream in proc.dir_streams.iter_mut().filter_map(Option::take) { + let _ = host.host_closedir(stream.host_handle); + } + + release_exec_image_state(proc, host); + proc.signals.reset_dispositions_for_exec(); + + // Kernel-backed process-shared primitives outlive the process address + // space, but this image's participation in them does not. Drop stale + // mutex ownership and cond/barrier waiter entries before the memory and + // sibling-thread state that could complete those operations disappears. + unsafe { crate::pshared::global_pshared_table() }.cleanup_process(proc.pid); + + // Reset state owned by the discarded address space or its threads. Open + // descriptions and their backing tables, terminal queues, CWD, IDs, + // rlimits, locks, and alarm/ITIMER_REAL state remain in place. + proc.memory = crate::memory::MemoryManager::new(); + proc.state = crate::process::ProcessState::Running; + proc.exit_status = 0; + proc.exit_signal = 0; + proc.thread_name = [0; 16]; + proc.threads.clear(); + proc.next_tid = 0; + proc.sigsuspend_saved_mask = None; + proc.alt_stack_sp = 0; + proc.alt_stack_flags = 2; // SS_DISABLE + proc.alt_stack_size = 0; + proc.alt_stack_depth = 0; + proc.posix_timers.clear(); + proc.fork_child = false; + proc.fork_exec_path = None; + proc.fork_exec_argv = None; + proc.fork_fd_actions.clear(); + proc.fork_pipe_replay.clear(); + proc.fork_count = 0; + proc.has_exec = true; + Ok(()) +} + /// Release a per-fd handle (DESTROY_DUMB / GEM_CLOSE): drops the /// handle from the fd's namespace, decrefs the bo, and if the /// refcount hits zero asks the host to free the backing. @@ -1873,18 +1917,17 @@ pub fn sys_open( // /dev/tty — open controlling terminal (alias for current session's PTY or stdin) if resolved == b"/dev/tty" { // Check if any open fd refers to a PTY slave — use that - for fd_i in 0..1024i32 { - if let Ok(entry) = proc.fd_table.get(fd_i as i32) { - if let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) { - if ofd.file_type == FileType::PtySlave { - // Dup this fd - proc.ofd_table.inc_ref(entry.ofd_ref.0); - let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(entry.ofd_ref, fd_flags)?; - return Ok(fd); - } - } - } + let pty_ofd = proc.fd_table.iter().find_map(|(_, entry)| { + proc.ofd_table + .get(entry.ofd_ref.0) + .is_some_and(|ofd| ofd.file_type == FileType::PtySlave) + .then_some(entry.ofd_ref) + }); + if let Some(ofd_ref) = pty_ofd { + proc.ofd_table.inc_ref(ofd_ref.0); + let fd_flags = oflags_to_fd_flags(oflags); + let fd = proc.fd_table.alloc(ofd_ref, fd_flags)?; + return Ok(fd); } // Fallback: dup stdin (fd 0) as the controlling terminal if let Ok(entry) = proc.fd_table.get(0) { @@ -2144,11 +2187,7 @@ pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( } } FileType::EventFd => { - // Free the eventfd state - let efd_idx = (-(host_handle + 1)) as usize; - if let Some(slot) = proc.eventfds.get_mut(efd_idx) { - *slot = None; - } + crate::descriptor_backing::release_for_ofd(file_type, host_handle); } FileType::Epoll => { let ep_idx = (-(host_handle + 1)) as usize; @@ -2157,22 +2196,13 @@ pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( } } FileType::TimerFd => { - let tfd_idx = (-(host_handle + 1)) as usize; - if let Some(slot) = proc.timerfds.get_mut(tfd_idx) { - *slot = None; - } + crate::descriptor_backing::release_for_ofd(file_type, host_handle); } FileType::SignalFd => { - let sfd_idx = (-(host_handle + 1)) as usize; - if let Some(slot) = proc.signalfds.get_mut(sfd_idx) { - *slot = None; - } + crate::descriptor_backing::release_for_ofd(file_type, host_handle); } FileType::MemFd => { - let memfd_idx = (-(host_handle + 1)) as usize; - if let Some(slot) = proc.memfds.get_mut(memfd_idx) { - *slot = None; - } + crate::descriptor_backing::release_for_ofd(file_type, host_handle); } FileType::PtyMaster => { let pty_idx = host_handle as usize; @@ -2202,11 +2232,10 @@ pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( let _ = host.host_closedir(dir_host_handle); } // Procfs buffers: free the content buffer - if crate::procfs::is_procfs_buf_handle(host_handle) { - let buf_idx = crate::procfs::procfs_buf_idx(host_handle); - if let Some(slot) = proc.procfs_bufs.get_mut(buf_idx) { - *slot = None; - } + if file_type == FileType::Regular + && crate::procfs::is_procfs_buf_handle(host_handle) + { + crate::descriptor_backing::release_for_ofd(file_type, host_handle); } else if host_handle == crate::procfs::PROCFS_DIR_HANDLE || host_handle == crate::devfs::DEVFS_DIR_HANDLE { @@ -2364,24 +2393,16 @@ pub fn sys_read( let tfd_idx = (-(host_handle + 1)) as usize; // Compute expirations lazily let (now_sec, now_nsec) = host.host_clock_gettime(0)?; - if let Some(Some(tfd)) = proc.timerfds.get_mut(tfd_idx) { + let count = crate::descriptor_backing::with_timerfds(|table| { + let tfd = table.get_mut(tfd_idx).ok_or(Errno::EBADF)?; timerfd_compute_expirations(tfd, now_sec, now_nsec); - } - let tfd = proc - .timerfds - .get_mut(tfd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - if tfd.expirations == 0 { - return Err(Errno::EAGAIN); - } - let tfd = proc - .timerfds - .get_mut(tfd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - let count = tfd.expirations; - tfd.expirations = 0; + if tfd.expirations == 0 { + return Err(Errno::EAGAIN); + } + let count = tfd.expirations; + tfd.expirations = 0; + Ok(count) + })?; buf[..8].copy_from_slice(&count.to_le_bytes()); Ok(8) } @@ -2392,30 +2413,15 @@ pub fn sys_read( return Err(Errno::EINVAL); } let sfd_idx = (-(host_handle + 1)) as usize; - let mask = proc - .signalfds - .get(sfd_idx) - .and_then(|s| s.as_ref()) - .ok_or(Errno::EBADF)? - .mask; + let mask = crate::descriptor_backing::with_signalfds(|table| { + table.get(sfd_idx).map(|sfd| sfd.mask).ok_or(Errno::EBADF) + })?; // Find a pending signal matching the mask let pending = proc.signals.pending_mask(); let matching = pending & mask; if matching == 0 { return Err(Errno::EAGAIN); } - // Re-read mask and find signal - let mask = proc - .signalfds - .get(sfd_idx) - .and_then(|s| s.as_ref()) - .ok_or(Errno::EBADF)? - .mask; - let pending = proc.signals.pending_mask(); - let matching = pending & mask; - if matching == 0 { - return Err(Errno::EAGAIN); - } // Find lowest matching signal (bit N = signal N+1, musl 0-based convention) let signo = matching.trailing_zeros() + 1; // Consume the signal from pending @@ -2433,27 +2439,20 @@ pub fn sys_read( return Err(Errno::EINVAL); } let efd_idx = (-(host_handle + 1)) as usize; - let efd = proc - .eventfds - .get_mut(efd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - if efd.counter == 0 { - return Err(Errno::EAGAIN); - } - let efd = proc - .eventfds - .get_mut(efd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - let value = if efd.semaphore { - efd.counter -= 1; - 1u64 - } else { - let v = efd.counter; - efd.counter = 0; - v - }; + let value = crate::descriptor_backing::with_eventfds(|table| { + let efd = table.get_mut(efd_idx).ok_or(Errno::EBADF)?; + if efd.counter == 0 { + return Err(Errno::EAGAIN); + } + if efd.semaphore { + efd.counter -= 1; + Ok(1u64) + } else { + let value = efd.counter; + efd.counter = 0; + Ok(value) + } + })?; buf[..8].copy_from_slice(&value.to_le_bytes()); Ok(8) } @@ -2576,45 +2575,35 @@ pub fn sys_read( } } // procfs: read from snapshot buffer - if crate::procfs::is_procfs_buf_handle(host_handle) { + if file_type == FileType::Regular && crate::procfs::is_procfs_buf_handle(host_handle) { let buf_idx = crate::procfs::procfs_buf_idx(host_handle); - let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - let offset = ofd.offset as usize; - let data = proc - .procfs_bufs - .get(buf_idx) - .and_then(|s| s.as_ref()) - .ok_or(Errno::EBADF)?; - if offset >= data.len() { - return Ok(0); // EOF - } - let remaining = &data[offset..]; - let n = buf.len().min(remaining.len()); - buf[..n].copy_from_slice(&remaining[..n]); - let ofd = proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?; - ofd.offset += n as i64; - return Ok(n); + return crate::descriptor_backing::with_procfs_bufs(|table| { + let backing = table.get_mut(buf_idx).ok_or(Errno::EBADF)?; + let offset = usize::try_from(backing.offset).map_err(|_| Errno::EOVERFLOW)?; + if offset >= backing.data.len() { + return Ok(0); + } + let n = buf.len().min(backing.data.len() - offset); + buf[..n].copy_from_slice(&backing.data[offset..offset + n]); + backing.offset += n as i64; + Ok(n) + }); } // memfd: read from in-memory buffer if file_type == FileType::MemFd { let memfd_idx = (-(host_handle + 1)) as usize; - let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - let offset = ofd.offset as usize; - let data = proc - .memfds - .get(memfd_idx) - .and_then(|s| s.as_ref()) - .ok_or(Errno::EBADF)?; - if offset >= data.len() { - return Ok(0); // EOF - } - let remaining = &data[offset..]; - let n = buf.len().min(remaining.len()); - buf[..n].copy_from_slice(&remaining[..n]); - let ofd = proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?; - ofd.offset += n as i64; - return Ok(n); + return crate::descriptor_backing::with_memfds(|table| { + let backing = table.get_mut(memfd_idx).ok_or(Errno::EBADF)?; + let offset = usize::try_from(backing.offset).map_err(|_| Errno::EOVERFLOW)?; + if offset >= backing.data.len() { + return Ok(0); + } + let n = buf.len().min(backing.data.len() - offset); + buf[..n].copy_from_slice(&backing.data[offset..offset + n]); + backing.offset += n as i64; + Ok(n) + }); } if host_handle == SYNTHETIC_FILE_HANDLE { @@ -2773,21 +2762,15 @@ pub fn sys_write( return Err(Errno::EINVAL); } let efd_idx = (-(host_handle + 1)) as usize; - let efd = proc - .eventfds - .get_mut(efd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - let max_val = u64::MAX - 1; - if efd.counter > max_val - value { - return Err(Errno::EAGAIN); - } - let efd = proc - .eventfds - .get_mut(efd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - efd.counter += value; + crate::descriptor_backing::with_eventfds(|table| { + let efd = table.get_mut(efd_idx).ok_or(Errno::EBADF)?; + let max_val = u64::MAX - 1; + if efd.counter > max_val - value { + return Err(Errno::EAGAIN); + } + efd.counter += value; + Ok(()) + })?; Ok(8) } FileType::PtyMaster => { @@ -2818,21 +2801,17 @@ pub fn sys_write( // memfd: write to in-memory buffer if file_type == FileType::MemFd { let memfd_idx = (-(host_handle + 1)) as usize; - let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - let offset = ofd.offset as usize; - let data = proc - .memfds - .get_mut(memfd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - let end = offset + buf.len(); - if end > data.len() { - data.resize(end, 0); - } - data[offset..end].copy_from_slice(buf); - let ofd = proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?; - ofd.offset += buf.len() as i64; - return Ok(buf.len()); + return crate::descriptor_backing::with_memfds(|table| { + let backing = table.get_mut(memfd_idx).ok_or(Errno::EBADF)?; + let offset = usize::try_from(backing.offset).map_err(|_| Errno::EOVERFLOW)?; + let end = offset.checked_add(buf.len()).ok_or(Errno::EFBIG)?; + if end > backing.data.len() { + backing.data.resize(end, 0); + } + backing.data[offset..end].copy_from_slice(buf); + backing.offset += buf.len() as i64; + Ok(buf.len()) + }); } // Virtual character devices — handle in-kernel @@ -3036,23 +3015,26 @@ pub fn sys_lseek( } // Procfs file buffers: compute offset against snapshot length - if crate::procfs::is_procfs_buf_handle(ofd.host_handle) { + if ofd.file_type == FileType::Regular && crate::procfs::is_procfs_buf_handle(ofd.host_handle) { let buf_idx = crate::procfs::procfs_buf_idx(ofd.host_handle); - let size = proc - .procfs_bufs - .get(buf_idx) - .and_then(|s| s.as_ref()) - .map_or(0, |d| d.len() as i64); + let size = crate::descriptor_backing::with_procfs_bufs(|table| { + table + .get(buf_idx) + .map(|backing| backing.data.len() as i64) + .ok_or(Errno::EBADF) + })?; + let current = + crate::descriptor_backing::current_offset(ofd.file_type, ofd.host_handle, ofd.offset)?; let new_pos = match whence { SEEK_SET => offset, - SEEK_CUR => ofd.offset + offset, - SEEK_END => size + offset, + SEEK_CUR => current.checked_add(offset).ok_or(Errno::EOVERFLOW)?, + SEEK_END => size.checked_add(offset).ok_or(Errno::EOVERFLOW)?, _ => return Err(Errno::EINVAL), }; if new_pos < 0 { return Err(Errno::EINVAL); } - ofd.offset = new_pos; + crate::descriptor_backing::set_current_offset(ofd.file_type, ofd.host_handle, new_pos)?; return Ok(new_pos); } @@ -3061,7 +3043,7 @@ pub fn sys_lseek( let new_pos = match whence { SEEK_SET => offset, SEEK_CUR => ofd.offset + offset, - SEEK_END => size + offset, + SEEK_END => size.checked_add(offset).ok_or(Errno::EOVERFLOW)?, _ => return Err(Errno::EINVAL), }; if new_pos < 0 { @@ -3074,21 +3056,24 @@ pub fn sys_lseek( // MemFd: compute offset against in-memory buffer if ofd.file_type == FileType::MemFd { let memfd_idx = (-(ofd.host_handle + 1)) as usize; - let size = proc - .memfds - .get(memfd_idx) - .and_then(|s| s.as_ref()) - .map_or(0, |d| d.len() as i64); + let size = crate::descriptor_backing::with_memfds(|table| { + table + .get(memfd_idx) + .map(|backing| backing.data.len() as i64) + .ok_or(Errno::EBADF) + })?; + let current = + crate::descriptor_backing::current_offset(ofd.file_type, ofd.host_handle, ofd.offset)?; let new_pos = match whence { SEEK_SET => offset, - SEEK_CUR => ofd.offset + offset, + SEEK_CUR => current.checked_add(offset).ok_or(Errno::EOVERFLOW)?, SEEK_END => size + offset, _ => return Err(Errno::EINVAL), }; if new_pos < 0 { return Err(Errno::EINVAL); } - ofd.offset = new_pos; + crate::descriptor_backing::set_current_offset(ofd.file_type, ofd.host_handle, new_pos)?; return Ok(new_pos); } @@ -3154,7 +3139,7 @@ pub fn sys_pread( if host_handle == SYNTHETIC_FILE_HANDLE { let data = synthetic_file_content(&ofd.path).ok_or(Errno::EBADF)?; - let start = offset as usize; + let start = usize::try_from(offset).map_err(|_| Errno::EOVERFLOW)?; if start >= data.len() { return Ok(0); } @@ -3163,6 +3148,35 @@ pub fn sys_pread( return Ok(n); } + if ofd.file_type == FileType::Regular && crate::procfs::is_procfs_buf_handle(host_handle) { + let start = usize::try_from(offset).map_err(|_| Errno::EOVERFLOW)?; + return crate::descriptor_backing::with_procfs_bufs(|table| { + let backing = table + .get(crate::procfs::procfs_buf_idx(host_handle)) + .ok_or(Errno::EBADF)?; + if start >= backing.data.len() { + return Ok(0); + } + let n = buf.len().min(backing.data.len() - start); + buf[..n].copy_from_slice(&backing.data[start..start + n]); + Ok(n) + }); + } + + if ofd.file_type == FileType::MemFd { + let memfd_idx = (-(host_handle + 1)) as usize; + let start = usize::try_from(offset).map_err(|_| Errno::EOVERFLOW)?; + return crate::descriptor_backing::with_memfds(|table| { + let backing = table.get(memfd_idx).ok_or(Errno::EBADF)?; + if start >= backing.data.len() { + return Ok(0); + } + let n = buf.len().min(backing.data.len() - start); + buf[..n].copy_from_slice(&backing.data[start..start + n]); + Ok(n) + }); + } + // Seek to the requested offset, read, then restore. // Single-threaded, so save/seek/read/restore is safe. host.host_seek(host_handle, offset, SEEK_SET)?; @@ -3209,6 +3223,20 @@ pub fn sys_pwrite( let host_handle = ofd.host_handle; let saved_offset = ofd.offset; + if ofd.file_type == FileType::MemFd { + let memfd_idx = (-(host_handle + 1)) as usize; + let start = usize::try_from(offset).map_err(|_| Errno::EOVERFLOW)?; + let end = start.checked_add(buf.len()).ok_or(Errno::EFBIG)?; + return crate::descriptor_backing::with_memfds(|table| { + let backing = table.get_mut(memfd_idx).ok_or(Errno::EBADF)?; + if end > backing.data.len() { + backing.data.resize(end, 0); + } + backing.data[start..end].copy_from_slice(buf); + Ok(buf.len()) + }); + } + host.host_seek(host_handle, offset, SEEK_SET)?; let n = host.host_write(host_handle, buf)?; host.host_seek(host_handle, saved_offset, SEEK_SET)?; @@ -3683,11 +3711,12 @@ pub fn sys_fstat(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result Result flock.l_start, // SEEK_SET - 1 => offset + flock.l_start, // SEEK_CUR + 0 => flock.l_start, // SEEK_SET + 1 => offset.checked_add(flock.l_start).ok_or(Errno::EOVERFLOW)?, // SEEK_CUR 2 => { // SEEK_END: resolve relative to file size if host_handle >= 0 { @@ -5487,9 +5521,10 @@ pub fn sys_sigprocmask(proc: &mut Process, how: u32, set: u64) -> Result = proc.fd_table.iter().map(|(fd, _)| fd).collect(); + for fd in open_fds { let _ = sys_close(proc, host, fd); } @@ -5507,7 +5542,8 @@ pub fn sys_exit(proc: &mut Process, host: &mut dyn HostIO, status: i32) { fallback_lock_table(proc).remove_all_for_pid(pid); proc.state = ProcessState::Exited; - proc.exit_status = status; + proc.exit_status = status & 0xff; + proc.exit_signal = 0; } /// Get the current time from the specified clock. @@ -5795,6 +5831,21 @@ pub fn sys_unsetenv(proc: &mut Process, name: &[u8]) -> Result<(), Errno> { Ok(()) } +/// The host's generic MAP_SHARED tracker must be restricted to descriptors +/// whose `pwrite` path reaches persistent host storage. Device mappings and +/// in-kernel regular-looking files own their state elsewhere. +pub(crate) fn fd_supports_mmap_writeback(proc: &Process, fd: i32) -> bool { + let Ok(entry) = proc.fd_table.get(fd) else { + return false; + }; + let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) else { + return false; + }; + ofd.file_type == FileType::Regular + && ofd.host_handle >= 0 + && (ofd.status_flags & O_ACCMODE) == O_RDWR +} + /// mmap -- supports anonymous, file-backed MAP_PRIVATE and MAP_SHARED mappings. /// File-backed mappings are allocated as anonymous regions; the host populates /// them from the file and (for MAP_SHARED) writes back on msync/munmap. @@ -5993,12 +6044,13 @@ pub fn sys_munmap( if addr & 0xFFFF != 0 { return Err(Errno::EINVAL); } + let aligned_len = len.checked_add(0xFFFF).ok_or(Errno::EINVAL)? & !0xFFFF; // POSIX: addresses in [addr, addr+len) must be within the valid address space. // Reject if the range overflows or extends beyond Wasm linear memory limits. - if addr.checked_add(len).is_none() { + if addr.checked_add(aligned_len).is_none() { return Err(Errno::EINVAL); } - if proc.memory.overlaps_host_reserved_region(addr, len) { + if proc.memory.overlaps_host_reserved_region(addr, aligned_len) { return Err(Errno::EINVAL); } @@ -6007,7 +6059,7 @@ pub fn sys_munmap( // munmap so the host stops reading the region before its address // space is freed. let fb_release = if let Some(b) = proc.fb_binding { - let end = addr.saturating_add(len); + let end = addr.saturating_add(aligned_len); let b_end = b.addr.saturating_add(b.len); addr <= b.addr && end >= b_end } else { @@ -6025,7 +6077,7 @@ pub fn sys_munmap( // GL cmdbuf cleanup: any munmap overlap invalidates the host's // single cmdbuf view for this pid. The GL session itself may remain // initialized, but it must mmap the cmdbuf again before submitting. - unbind_gl_cmdbufs_in_range(proc, host, addr, len); + unbind_gl_cmdbufs_in_range(proc, host, addr, aligned_len); // DRI bo cleanup: drop every binding overlapped by [addr, addr+len) // and tell the host so it stops mirroring the region before the @@ -6036,7 +6088,7 @@ pub fn sys_munmap( let pid = proc.pid as i32; let mut released: alloc::vec::Vec = alloc::vec::Vec::new(); proc.dri_bindings.retain(|b| { - if ranges_overlap(addr, len, b.addr, b.len) { + if ranges_overlap(addr, aligned_len, b.addr, b.len) { released.push(*b); false } else { @@ -6049,7 +6101,7 @@ pub fn sys_munmap( // Linux munmap succeeds (returns 0) even if no mappings overlap the range, // as long as the address is valid and page-aligned. - proc.memory.munmap(addr, len); + proc.memory.munmap(addr, aligned_len); Ok(()) } @@ -6070,6 +6122,33 @@ pub fn sys_mprotect(_proc: &Process, _addr: usize, _len: usize, _prot: u32) -> R Ok(()) } +pub(crate) fn listener_accept_wake_for_entry( + proc: &Process, + entry: &crate::fd::FdEntry, +) -> Option { + use crate::socket::SocketState; + + let ofd = proc.ofd_table.get(entry.ofd_ref.0)?; + if ofd.file_type != FileType::Socket || ofd.host_handle >= 0 { + return None; + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sock_idx)?; + if sock.state != SocketState::Listening { + return None; + } + sock.accept_wake_idx +} + +pub(crate) fn find_listener_fd_by_accept_wake( + proc: &Process, + wake_idx: u32, +) -> Option { + proc.fd_table.iter().find_map(|(fd, entry)| { + (listener_accept_wake_for_entry(proc, entry) == Some(wake_idx)).then_some(fd) + }) +} + /// Create a socket, returning the new fd. pub fn sys_socket( proc: &mut Process, @@ -8394,16 +8473,11 @@ pub fn sys_listen( sock.accept_wake_idx = Some(crate::wakeup::alloc_accept_wake_idx()); } - // For AF_INET/AF_INET6 listeners, allocate a shared accept queue that fork - // children will inherit. This way every process sharing this listener - // pulls from the same queue (POSIX semantics) — see socket.rs. + // Every stream listener uses a shared accept queue that fork children + // inherit. This lets pre-fork AF_INET, AF_INET6, and AF_UNIX servers race + // on one kernel-owned queue rather than receiving per-process copies. let domain = sock.domain; - if matches!( - domain, - SocketDomain::Inet | SocketDomain::Inet6 - ) - && sock.shared_backlog_idx.is_none() - { + if sock.shared_backlog_idx.is_none() { let backlog_idx = unsafe { crate::socket::shared_listener_backlog_table().alloc() }; sock.shared_backlog_idx = Some(backlog_idx); } @@ -8432,6 +8506,35 @@ pub fn sys_listen( Ok(()) } +fn discard_accepted_socket_without_fd(proc: &mut Process, sock_idx: usize) { + let Some(sock) = proc.sockets.get(sock_idx) else { + return; + }; + let (recv_idx, send_idx, peer_idx) = + (sock.recv_buf_idx, sock.send_buf_idx, sock.peer_idx); + if let Some(peer_idx) = peer_idx { + if let Some(peer) = proc.sockets.get_mut(peer_idx) { + if peer.peer_idx == Some(sock_idx) { + peer.peer_idx = None; + } + } + } + let pipes = unsafe { crate::pipe::global_pipe_table() }; + if let Some(recv_idx) = recv_idx { + if let Some(pipe) = pipes.get_mut(recv_idx) { + pipe.close_read_end(); + } + pipes.free_if_closed(recv_idx); + } + if let Some(send_idx) = send_idx { + if let Some(pipe) = pipes.get_mut(send_idx) { + pipe.close_write_end(); + } + pipes.free_if_closed(send_idx); + } + proc.sockets.free(sock_idx); +} + /// Accept a connection on a listening socket. /// /// Pops a pending connection from the listener's backlog, creates an OFD + FD @@ -8457,13 +8560,13 @@ pub fn sys_accept(proc: &mut Process, _host: &mut dyn HostIO, fd: i32) -> Result return Err(Errno::EINVAL); } - // AF_INET/AF_INET6 listeners use a shared cross-process accept queue. Try - // popping from there first; the accepted SocketInfo is created - // lazily here in the accepting process. See socket.rs. + // All listeners use a shared cross-process accept queue. The accepted + // SocketInfo is created lazily in whichever process wins accept(). if let Some(shared_idx) = sock.shared_backlog_idx { let domain = sock.domain; let bind_addr = sock.bind_addr; let bind_addr6 = sock.bind_addr6; + let bind_path = sock.bind_path.clone(); let bind_port = sock.bind_port; let pending = unsafe { crate::socket::shared_listener_backlog_table().pop(shared_idx) }; if let Some(pc) = pending { @@ -8485,11 +8588,28 @@ pub fn sys_accept(proc: &mut Process, _host: &mut dyn HostIO, fd: i32) -> Result ipv4_mapped_addr6(pc.peer_addr) }; } - SocketDomain::Unix => unreachable!("AF_UNIX does not use this queue yet"), + SocketDomain::Unix => { + accepted.bind_path = bind_path; + } } accepted.peer_port = pc.peer_port; accepted.global_pipes = true; let accepted_sock_idx = proc.sockets.alloc(accepted); + if domain == SocketDomain::Unix && pc.peer_pid == proc.pid { + if let Some(peer_idx) = pc.peer_sock_idx { + let peer_matches = proc.sockets.get(peer_idx).is_some_and(|peer| { + peer.domain == SocketDomain::Unix + && peer.sock_type == SocketType::Stream + && peer.state == SocketState::Connected + && peer.send_buf_idx == Some(pc.recv_pipe_idx) + && peer.recv_buf_idx == Some(pc.send_pipe_idx) + }); + if peer_matches { + proc.sockets.get_mut(accepted_sock_idx).unwrap().peer_idx = Some(peer_idx); + proc.sockets.get_mut(peer_idx).unwrap().peer_idx = Some(accepted_sock_idx); + } + } + } let host_handle = -((accepted_sock_idx as i64) + 1); let ofd_idx = proc.ofd_table.create( FileType::Socket, @@ -8497,12 +8617,17 @@ pub fn sys_accept(proc: &mut Process, _host: &mut dyn HostIO, fd: i32) -> Result host_handle, b"/dev/socket".to_vec(), ); - let new_fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0)?; - return Ok(new_fd); + return match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0) { + Ok(new_fd) => Ok(new_fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_idx); + discard_accepted_socket_without_fd(proc, accepted_sock_idx); + Err(err) + } + }; } - // Shared queue empty — fall through to per-process backlog - // for AF_UNIX-style entries (none for INET listeners). We return - // EAGAIN below if both queues are empty. + // Shared queue empty — retain the inline fallback for legacy/manual + // listener state, then return EAGAIN if both queues are empty. let _ = SocketType::Stream; // silence unused-import warning if path unused let _ = SocketDomain::Inet; } @@ -8523,8 +8648,14 @@ pub fn sys_accept(proc: &mut Process, _host: &mut dyn HostIO, fd: i32) -> Result ); // Allocate fd - let new_fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0)?; - Ok(new_fd) + match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0) { + Ok(new_fd) => Ok(new_fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_idx); + discard_accepted_socket_without_fd(proc, accepted_sock_idx); + Err(err) + } + } } /// Connect a socket to an address. @@ -9005,39 +9136,63 @@ pub fn sys_connect( if listener.state != SocketState::Listening { return Err(Errno::ECONNREFUSED); } + let shared_idx = listener.shared_backlog_idx; + let accept_wake_idx = listener.accept_wake_idx; // Create pipe pair for bidirectional communication (in global table for fork safety) let pipe_table = unsafe { crate::pipe::global_pipe_table() }; let pipe_a_idx = pipe_table.alloc(PipeBuffer::new(65536)); let pipe_b_idx = pipe_table.alloc(PipeBuffer::new(65536)); - // Create accepted socket (server side) - let mut accepted_sock = SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0); - accepted_sock.state = SocketState::Connected; - accepted_sock.recv_buf_idx = Some(pipe_a_idx); // reads client's writes - accepted_sock.send_buf_idx = Some(pipe_b_idx); // writes to client's reads - accepted_sock.global_pipes = true; - let accepted_idx = proc.sockets.alloc(accepted_sock); - - // Push to listener's backlog - let listener = proc - .sockets - .get_mut(listener_sock_idx) - .ok_or(Errno::EBADF)?; - listener.listen_backlog.push(accepted_idx); - let accept_wake_idx = listener.accept_wake_idx; + if let Some(shared_idx) = shared_idx { + let pending = crate::socket::PendingConnection { + peer_addr: [0; 4], + peer_addr6: [0; 16], + peer_is_ipv6: false, + peer_port: 0, + peer_pid: proc.pid, + peer_sock_idx: Some(sock_idx), + recv_pipe_idx: pipe_a_idx, + send_pipe_idx: pipe_b_idx, + }; + if !unsafe { + crate::socket::shared_listener_backlog_table().push(shared_idx, pending) + } { + pipe_table.discard_unclaimed(pipe_a_idx); + pipe_table.discard_unclaimed(pipe_b_idx); + return Err(Errno::ECONNREFUSED); + } - // Set up client socket - let client = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; - client.send_buf_idx = Some(pipe_a_idx); // writes to pipe_a (server's reads) - client.recv_buf_idx = Some(pipe_b_idx); // reads from pipe_b (server's writes) - client.state = SocketState::Connected; - client.peer_idx = Some(accepted_idx); - client.global_pipes = true; + let client = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + client.send_buf_idx = Some(pipe_a_idx); + client.recv_buf_idx = Some(pipe_b_idx); + client.state = SocketState::Connected; + client.peer_idx = None; + client.global_pipes = true; + } else { + // Defensive compatibility for manually restored listener state + // without a shared queue. + let mut accepted_sock = + SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0); + accepted_sock.state = SocketState::Connected; + accepted_sock.recv_buf_idx = Some(pipe_a_idx); + accepted_sock.send_buf_idx = Some(pipe_b_idx); + accepted_sock.global_pipes = true; + let accepted_idx = proc.sockets.alloc(accepted_sock); - // Set peer_idx on accepted socket - let accepted = proc.sockets.get_mut(accepted_idx).ok_or(Errno::EBADF)?; - accepted.peer_idx = Some(sock_idx); + proc.sockets + .get_mut(listener_sock_idx) + .ok_or(Errno::EBADF)? + .listen_backlog + .push(accepted_idx); + let client = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + client.send_buf_idx = Some(pipe_a_idx); + client.recv_buf_idx = Some(pipe_b_idx); + client.state = SocketState::Connected; + client.peer_idx = Some(accepted_idx); + client.global_pipes = true; + proc.sockets.get_mut(accepted_idx).unwrap().peer_idx = Some(sock_idx); + } if let Some(idx) = accept_wake_idx { crate::wakeup::push_accept(idx); @@ -9290,11 +9445,17 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) match ofd.file_type { FileType::EventFd => { let efd_idx = (-(ofd.host_handle + 1)) as usize; - if let Some(Some(efd)) = proc.eventfds.get(efd_idx) { - if pollfd.events & POLLIN != 0 && efd.counter > 0 { + if let Some((counter, semaphore_room)) = + crate::descriptor_backing::with_eventfds(|table| { + table + .get(efd_idx) + .map(|efd| (efd.counter, efd.counter < u64::MAX - 1)) + }) + { + if pollfd.events & POLLIN != 0 && counter > 0 { revents |= POLLIN; } - if pollfd.events & POLLOUT != 0 && efd.counter < u64::MAX - 1 { + if pollfd.events & POLLOUT != 0 && semaphore_room { revents |= POLLOUT; } } @@ -9304,17 +9465,21 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) } FileType::TimerFd => { let tfd_idx = (-(ofd.host_handle + 1)) as usize; - if let Some(Some(tfd)) = proc.timerfds.get(tfd_idx) { + if let Some(expirations) = crate::descriptor_backing::with_timerfds(|table| { + table.get(tfd_idx).map(|tfd| tfd.expirations) + }) { // Check if timer has expired (lazy: just check expirations counter) - if pollfd.events & POLLIN != 0 && tfd.expirations > 0 { + if pollfd.events & POLLIN != 0 && expirations > 0 { revents |= POLLIN; } } } FileType::SignalFd => { let sfd_idx = (-(ofd.host_handle + 1)) as usize; - if let Some(Some(sfd)) = proc.signalfds.get(sfd_idx) { - let matching = proc.signals.pending_mask() & sfd.mask; + if let Some(mask) = crate::descriptor_backing::with_signalfds(|table| { + table.get(sfd_idx).map(|sfd| sfd.mask) + }) { + let matching = proc.signals.pending_mask() & mask; if pollfd.events & POLLIN != 0 && matching != 0 { revents |= POLLIN; } @@ -9707,17 +9872,17 @@ pub fn sys_openat( // /dev/tty — open controlling terminal if resolved == b"/dev/tty" { - for fd_i in 0..1024i32 { - if let Ok(entry) = proc.fd_table.get(fd_i as i32) { - if let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) { - if ofd.file_type == FileType::PtySlave { - proc.ofd_table.inc_ref(entry.ofd_ref.0); - let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(entry.ofd_ref, fd_flags)?; - return Ok(fd); - } - } - } + let pty_ofd = proc.fd_table.iter().find_map(|(_, entry)| { + proc.ofd_table + .get(entry.ofd_ref.0) + .is_some_and(|ofd| ofd.file_type == FileType::PtySlave) + .then_some(entry.ofd_ref) + }); + if let Some(ofd_ref) = pty_ofd { + proc.ofd_table.inc_ref(ofd_ref.0); + let fd_flags = oflags_to_fd_flags(oflags); + let fd = proc.fd_table.alloc(ofd_ref, fd_flags)?; + return Ok(fd); } if let Ok(entry) = proc.fd_table.get(0) { let ofd_ref = entry.ofd_ref; @@ -10792,27 +10957,7 @@ pub fn sys_eventfd2(proc: &mut Process, initval: u32, flags: u32) -> Result { - proc.eventfds[i] = Some(state); - i - } - None => { - let i = proc.eventfds.len(); - proc.eventfds.push(Some(state)); - i - } - } - }; + let efd_idx = crate::descriptor_backing::with_eventfds(|table| table.alloc(state)); // Eventfd handle is negative: -(efd_idx + 1) let efd_handle = -((efd_idx as i64) + 1); @@ -10835,7 +10980,7 @@ pub fn sys_eventfd2(proc: &mut Process, initval: u32, flags: u32) -> Result Ok(fd), Err(e) => { proc.ofd_table.dec_ref(ofd_idx); - proc.eventfds[efd_idx] = None; + crate::descriptor_backing::with_eventfds(|table| table.release(efd_idx)); Err(e) } } @@ -11115,26 +11260,7 @@ pub fn sys_timerfd_create(proc: &mut Process, clock_id: u32, flags: u32) -> Resu expirations: 0, }; - let tfd_idx = { - let mut found = None; - for (i, slot) in proc.timerfds.iter().enumerate() { - if slot.is_none() { - found = Some(i); - break; - } - } - match found { - Some(i) => { - proc.timerfds[i] = Some(state); - i - } - None => { - let i = proc.timerfds.len(); - proc.timerfds.push(Some(state)); - i - } - } - }; + let tfd_idx = crate::descriptor_backing::with_timerfds(|table| table.alloc(state)); let handle = -((tfd_idx as i64) + 1); let mut status_flags = O_RDWR; @@ -11152,7 +11278,7 @@ pub fn sys_timerfd_create(proc: &mut Process, clock_id: u32, flags: u32) -> Resu Ok(fd) => Ok(fd), Err(e) => { proc.ofd_table.dec_ref(ofd_idx); - proc.timerfds[tfd_idx] = None; + crate::descriptor_backing::with_timerfds(|table| table.release(tfd_idx)); Err(e) } } @@ -11178,51 +11304,67 @@ pub fn sys_timerfd_settime( return Err(Errno::EINVAL); } let tfd_idx = (-(ofd.host_handle + 1)) as usize; - let tfd = proc - .timerfds - .get_mut(tfd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - - let old = ( - tfd.interval_sec, - tfd.interval_nsec, - tfd.value_sec, - tfd.value_nsec, - ); const TFD_TIMER_ABSTIME: u32 = 1; - if value_sec == 0 && value_nsec == 0 { - // Disarm the timer - tfd.interval_sec = 0; - tfd.interval_nsec = 0; - tfd.value_sec = 0; - tfd.value_nsec = 0; - tfd.expirations = 0; + // The clock import is an external callback boundary. Snapshot the timer's + // immutable clock id under the backing lock, release it for the host call, + // then reacquire only for the final linearizable state update. Holding the + // non-reentrant backing spinlock across HostIO would deadlock if a host + // implementation ever called back into timerfd handling. + let relative_now = if value_sec != 0 || value_nsec != 0 { + if flags & TFD_TIMER_ABSTIME == 0 { + let clock_id = crate::descriptor_backing::with_timerfds(|table| { + table + .get(tfd_idx) + .map(|tfd| tfd.clock_id) + .ok_or(Errno::EBADF) + })?; + Some(host.host_clock_gettime(clock_id)?) + } else { + None + } } else { - tfd.interval_sec = interval_sec; - tfd.interval_nsec = interval_nsec; - if flags & TFD_TIMER_ABSTIME != 0 { - tfd.value_sec = value_sec; - tfd.value_nsec = value_nsec; + None + }; + + crate::descriptor_backing::with_timerfds(|table| { + let tfd = table.get_mut(tfd_idx).ok_or(Errno::EBADF)?; + let old = ( + tfd.interval_sec, + tfd.interval_nsec, + tfd.value_sec, + tfd.value_nsec, + ); + + if value_sec == 0 && value_nsec == 0 { + tfd.interval_sec = 0; + tfd.interval_nsec = 0; + tfd.value_sec = 0; + tfd.value_nsec = 0; + tfd.expirations = 0; } else { - // Relative: add current time - let clock_id = tfd.clock_id; - let (now_sec, now_nsec) = host.host_clock_gettime(clock_id)?; - let mut total_nsec = now_nsec + value_nsec; - let mut total_sec = now_sec + value_sec; - if total_nsec >= 1_000_000_000 { - total_sec += total_nsec / 1_000_000_000; - total_nsec %= 1_000_000_000; + tfd.interval_sec = interval_sec; + tfd.interval_nsec = interval_nsec; + if flags & TFD_TIMER_ABSTIME != 0 { + tfd.value_sec = value_sec; + tfd.value_nsec = value_nsec; + } else { + let (now_sec, now_nsec) = relative_now.ok_or(Errno::EIO)?; + let mut total_nsec = now_nsec + value_nsec; + let mut total_sec = now_sec + value_sec; + if total_nsec >= 1_000_000_000 { + total_sec += total_nsec / 1_000_000_000; + total_nsec %= 1_000_000_000; + } + tfd.value_sec = total_sec; + tfd.value_nsec = total_nsec; } - tfd.value_sec = total_sec; - tfd.value_nsec = total_nsec; + tfd.expirations = 0; } - tfd.expirations = 0; - } - Ok(old) + Ok(old) + }) } /// timerfd_gettime — get remaining time until next expiration. @@ -11237,19 +11379,27 @@ pub fn sys_timerfd_gettime( return Err(Errno::EINVAL); } let tfd_idx = (-(ofd.host_handle + 1)) as usize; - let tfd = proc - .timerfds - .get(tfd_idx) - .and_then(|s| s.as_ref()) - .ok_or(Errno::EBADF)?; + let snapshot = crate::descriptor_backing::with_timerfds(|table| { + table.get(tfd_idx).map(|tfd| { + ( + tfd.clock_id, + tfd.interval_sec, + tfd.interval_nsec, + tfd.value_sec, + tfd.value_nsec, + ) + }) + }) + .ok_or(Errno::EBADF)?; + let (clock_id, interval_sec, interval_nsec, timer_sec, timer_nsec) = snapshot; - if tfd.value_sec == 0 && tfd.value_nsec == 0 { + if timer_sec == 0 && timer_nsec == 0 { return Ok((0, 0, 0, 0)); } - let (now_sec, now_nsec) = host.host_clock_gettime(tfd.clock_id)?; - let mut remain_sec = tfd.value_sec - now_sec; - let mut remain_nsec = tfd.value_nsec - now_nsec; + let (now_sec, now_nsec) = host.host_clock_gettime(clock_id)?; + let mut remain_sec = timer_sec - now_sec; + let mut remain_nsec = timer_nsec - now_nsec; if remain_nsec < 0 { remain_sec -= 1; remain_nsec += 1_000_000_000; @@ -11258,7 +11408,7 @@ pub fn sys_timerfd_gettime( remain_sec = 0; remain_nsec = 0; } - Ok((tfd.interval_sec, tfd.interval_nsec, remain_sec, remain_nsec)) + Ok((interval_sec, interval_nsec, remain_sec, remain_nsec)) } /// Helper: compute timerfd expirations lazily. @@ -11322,38 +11472,18 @@ pub fn sys_signalfd4(proc: &mut Process, fd: i32, mask: u64, flags: u32) -> Resu return Err(Errno::EINVAL); } let sfd_idx = (-(ofd.host_handle + 1)) as usize; - let sfd = proc - .signalfds - .get_mut(sfd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - sfd.mask = mask; + crate::descriptor_backing::with_signalfds(|table| { + let sfd = table.get_mut(sfd_idx).ok_or(Errno::EBADF)?; + sfd.mask = mask; + Ok(()) + })?; return Ok(fd); } // Create new signalfd let state = SignalFdState { mask }; - let sfd_idx = { - let mut found = None; - for (i, slot) in proc.signalfds.iter().enumerate() { - if slot.is_none() { - found = Some(i); - break; - } - } - match found { - Some(i) => { - proc.signalfds[i] = Some(state); - i - } - None => { - let i = proc.signalfds.len(); - proc.signalfds.push(Some(state)); - i - } - } - }; + let sfd_idx = crate::descriptor_backing::with_signalfds(|table| table.alloc(state)); let handle = -((sfd_idx as i64) + 1); let mut status_flags = O_RDONLY; @@ -11371,7 +11501,7 @@ pub fn sys_signalfd4(proc: &mut Process, fd: i32, mask: u64, flags: u32) -> Resu Ok(fd) => Ok(fd), Err(e) => { proc.ofd_table.dec_ref(ofd_idx); - proc.signalfds[sfd_idx] = None; + crate::descriptor_backing::with_signalfds(|table| table.release(sfd_idx)); Err(e) } } @@ -11416,7 +11546,7 @@ pub fn sys_uname(buf: &mut [u8]) -> Result<(), Errno> { /// sysconf — get configurable system variables pub fn sys_sysconf(name: i32) -> Result { match name { - 0 => Ok(4096), // _SC_ARG_MAX + 0 => Ok(4 * 1024 * 1024), // _SC_ARG_MAX: host exec argv+env aggregate cap 1 => Ok(0), // _SC_CHILD_MAX (unspecified) 2 => Ok(100), // _SC_CLK_TCK 4 => Ok(1024), // _SC_OPEN_MAX @@ -11485,12 +11615,12 @@ pub fn sys_ftruncate( // MemFd: truncate in-memory buffer if ofd.file_type == FileType::MemFd { let memfd_idx = (-(ofd.host_handle + 1)) as usize; - let data = proc - .memfds - .get_mut(memfd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - data.resize(length as usize, 0); + let new_len = usize::try_from(length).map_err(|_| Errno::EFBIG)?; + crate::descriptor_backing::with_memfds(|table| { + let backing = table.get_mut(memfd_idx).ok_or(Errno::EBADF)?; + backing.data.resize(new_len, 0); + Ok(()) + })?; return Ok(()); } @@ -12514,9 +12644,9 @@ pub fn sys_memfd_create(proc: &mut Process, name: &[u8], flags: u32) -> Result Result Ok(fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_idx); + crate::descriptor_backing::with_memfds(|table| table.release(memfd_idx)); + Err(err) + } + } } #[cfg(test)] @@ -12663,6 +12800,7 @@ mod tests { sigsuspend_signal: u32, sigsuspend_error: bool, clock_time: (i64, i64), + clock_error: Option, /// Per-path owner overrides for host_stat / host_lstat. Mirrors how a real /// host-side VFS owns ownership; tests use `set_file_with_owner` to seed. file_owners: std::collections::HashMap, (u32, u32)>, @@ -12691,6 +12829,8 @@ mod tests { net_send_result: Result, net_connect_calls: Vec<(i32, Vec, u16)>, net_listen_calls: Vec<(i32, u16, [u8; 4])>, + closed_handles: Vec, + closed_dir_handles: Vec, } impl MockHostIO { @@ -12703,6 +12843,7 @@ mod tests { sigsuspend_signal: 0, sigsuspend_error: false, clock_time: (1234567890, 123456789), + clock_error: None, file_owners: std::collections::HashMap::new(), file_modes: std::collections::HashMap::new(), handle_owners: std::collections::HashMap::new(), @@ -12719,6 +12860,8 @@ mod tests { net_send_result: Err(Errno::ENOTCONN), net_connect_calls: Vec::new(), net_listen_calls: Vec::new(), + closed_handles: Vec::new(), + closed_dir_handles: Vec::new(), } } @@ -12777,7 +12920,8 @@ mod tests { Ok(handle) } - fn host_close(&mut self, _handle: i64) -> Result<(), Errno> { + fn host_close(&mut self, handle: i64) -> Result<(), Errno> { + self.closed_handles.push(handle); Ok(()) } @@ -12980,11 +13124,15 @@ mod tests { } } - fn host_closedir(&mut self, _handle: i64) -> Result<(), Errno> { + fn host_closedir(&mut self, handle: i64) -> Result<(), Errno> { + self.closed_dir_handles.push(handle); Ok(()) } fn host_clock_gettime(&mut self, _clock_id: u32) -> Result<(i64, i64), Errno> { + if let Some(err) = self.clock_error { + return Err(err); + } Ok(self.clock_time) } @@ -14012,6 +14160,7 @@ mod tests { assert_eq!(proc.state, ProcessState::Exited); assert_eq!(proc.exit_status, 42); + assert_eq!(proc.exit_signal, 0); // All fds should be closed - trying to read from fd should fail let mut buf = [0u8; 10]; @@ -14019,6 +14168,22 @@ mod tests { assert_eq!(result, Err(Errno::EBADF)); } + #[test] + fn test_exit_closes_fd_above_default_nofile_limit() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + sys_setrlimit(&mut proc, 7, 4096, 4096).unwrap(); + let low_fd = + sys_open(&mut proc, &mut host, b"/tmp/high-fd", O_RDWR | O_CREAT, 0o644).unwrap(); + let high_fd = sys_fcntl(&mut proc, low_fd, F_DUPFD, 2048).unwrap(); + sys_close(&mut proc, &mut host, low_fd).unwrap(); + + sys_exit(&mut proc, &mut host, 0); + + assert_eq!(high_fd, 2048); + assert_eq!(proc.fd_table.get(high_fd), Err(Errno::EBADF)); + } + #[test] fn test_exit_with_zero_status() { let mut proc = Process::new(1); @@ -14028,6 +14193,18 @@ mod tests { assert_eq!(proc.exit_status, 0); } + #[test] + fn test_exit_keeps_only_the_low_status_byte() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.exit_signal = 15; + + sys_exit(&mut proc, &mut host, 0x1ff); + + assert_eq!(proc.exit_status, 255); + assert_eq!(proc.exit_signal, 0); + } + #[test] fn test_kill_marks_signal_pending() { let mut proc = Process::new(1); @@ -14348,123 +14525,1053 @@ mod tests { ofd.host_handle } - #[test] - fn test_fcntl_setlk_fallback_conflict_reports_would_block() { - let _guard = enter_fallback_lock_test(); - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - let host_handle = fd_host_handle(&proc, write_fd); + fn descriptor_backing_idx(proc: &Process, fd: i32) -> usize { + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + if ofd.file_type == FileType::Regular + && crate::procfs::is_procfs_buf_handle(ofd.host_handle) + { + crate::procfs::procfs_buf_idx(ofd.host_handle) + } else { + (-(ofd.host_handle + 1)) as usize + } + } - unsafe { crate::lock::global_fallback_lock_table() }.set_lock( - host_handle, - FileLock { - pid: 2, - lock_type: F_WRLCK, - start: 0, - len: 100, - }, - ); + fn descriptor_backing_ref_count(file_type: FileType, idx: usize) -> Option { + match file_type { + FileType::EventFd => { + crate::descriptor_backing::with_eventfds(|table| table.ref_count(idx)) + } + FileType::TimerFd => { + crate::descriptor_backing::with_timerfds(|table| table.ref_count(idx)) + } + FileType::SignalFd => { + crate::descriptor_backing::with_signalfds(|table| table.ref_count(idx)) + } + FileType::MemFd => crate::descriptor_backing::with_memfds(|table| table.ref_count(idx)), + FileType::Regular => { + crate::descriptor_backing::with_procfs_bufs(|table| table.ref_count(idx)) + } + _ => None, + } + } - let mut flock = WasmFlock { - l_type: F_WRLCK as i16, - l_whence: 0, - _pad1: 0, - l_start: 0, - l_len: 100, - l_pid: 0, - _pad2: 0, - }; - let result = sys_fcntl_lock(&mut proc, write_fd, F_SETLK, &mut flock, &mut host); - assert_eq!(result, Err(Errno::EAGAIN)); + fn descriptor_backing_generation(file_type: FileType, idx: usize) -> Option { + match file_type { + FileType::EventFd => { + crate::descriptor_backing::with_eventfds(|table| table.generation(idx)) + } + FileType::TimerFd => { + crate::descriptor_backing::with_timerfds(|table| table.generation(idx)) + } + FileType::SignalFd => { + crate::descriptor_backing::with_signalfds(|table| table.generation(idx)) + } + FileType::MemFd => { + crate::descriptor_backing::with_memfds(|table| table.generation(idx)) + } + FileType::Regular => { + crate::descriptor_backing::with_procfs_bufs(|table| table.generation(idx)) + } + _ => None, + } + } + + fn assert_descriptor_backing_released(file_type: FileType, idx: usize, generation: u64) { + assert_ne!( + descriptor_backing_generation(file_type, idx), + Some(generation), + "the original backing identity must no longer be live" + ); } #[test] - fn test_fcntl_setlkw_fallback_conflict_uses_cooperative_retry() { - let _guard = enter_fallback_lock_test(); - let mut proc = Process::new(1); + fn shared_eventfd_backing_survives_fork_and_spawn_without_aliasing() { + use crate::process_table::ProcessTable; + use crate::spawn::SpawnAttrs; + + const PARENT: u32 = 970_100; + const FORK_CHILD: u32 = 970_101; + let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - let host_handle = fd_host_handle(&proc, write_fd); + table.create_process(PARENT).unwrap(); - unsafe { crate::lock::global_fallback_lock_table() }.set_lock( - host_handle, - FileLock { - pid: 2, - lock_type: F_WRLCK, - start: 0, - len: 100, - }, + let inherited_fd = sys_eventfd2(table.get_mut(PARENT).unwrap(), 0, O_NONBLOCK).unwrap(); + let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); + let backing_generation = + descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); + table.fork_process(PARENT, FORK_CHILD).unwrap(); + let spawn_child = table + .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .unwrap(); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(3) ); - let mut flock = WasmFlock { - l_type: F_WRLCK as i16, - l_whence: 0, - _pad1: 0, - l_start: 0, - l_len: 100, - l_pid: 0, - _pad2: 0, - }; - let result = sys_fcntl_lock(&mut proc, write_fd, F_SETLKW, &mut flock, &mut host); - assert_eq!(result, Err(Errno::EAGAIN)); - } - - struct FallbackLockTestGuard { - _guard: std::sync::MutexGuard<'static, ()>, - } - - impl Drop for FallbackLockTestGuard { - fn drop(&mut self) { - unsafe { crate::lock::global_fallback_lock_table().clear() }; - } - } + let fresh_fd = sys_eventfd2(table.get_mut(FORK_CHILD).unwrap(), 33, O_NONBLOCK).unwrap(); + assert_ne!( + fd_host_handle(table.get(FORK_CHILD).unwrap(), inherited_fd), + fd_host_handle(table.get(FORK_CHILD).unwrap(), fresh_fd), + "a child-created eventfd must not reuse an inherited live backing" + ); - fn enter_fallback_lock_test() -> FallbackLockTestGuard { - let guard = crate::lock::FALLBACK_LOCK_TEST_LOCK.lock().unwrap(); - unsafe { crate::lock::global_fallback_lock_table().clear() }; - FallbackLockTestGuard { _guard: guard } - } + sys_write( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + &9u64.to_le_bytes(), + ) + .unwrap(); + let mut value = [0u8; 8]; + sys_read( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &mut value, + ) + .unwrap(); + assert_eq!(u64::from_le_bytes(value), 9); + assert_eq!( + sys_read( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut value, + ), + Err(Errno::EAGAIN), + "consuming through one descendant must drain the shared counter" + ); - fn add_fallback_pipe_fd(proc: &mut Process, host_handle: i64, status_flags: u32) -> i32 { - let ofd_idx = proc.ofd_table.create( - FileType::Pipe, - status_flags, - host_handle, - b"/dev/pipe".to_vec(), + sys_close(table.get_mut(PARENT).unwrap(), &mut host, inherited_fd).unwrap(); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(2) + ); + table.remove_process(FORK_CHILD).unwrap(); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(1) ); - proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0).unwrap() - } - fn write_lock(start: i64, len: i64) -> WasmFlock { - WasmFlock { - l_type: F_WRLCK as i16, - l_whence: 0, - _pad1: 0, - l_start: start, - l_len: len, - l_pid: 0, - _pad2: 0, - } + sys_write( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &4u64.to_le_bytes(), + ) + .unwrap(); + sys_read( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &mut value, + ) + .unwrap(); + assert_eq!(u64::from_le_bytes(value), 4); + sys_close(table.get_mut(spawn_child).unwrap(), &mut host, inherited_fd).unwrap(); + assert_descriptor_backing_released(FileType::EventFd, backing_idx, backing_generation); + table.remove_process(spawn_child).unwrap(); + table.remove_process(PARENT).unwrap(); } #[test] - fn test_fcntl_getlk_fallback_conflict_reports_blocking_owner() { - let _guard = enter_fallback_lock_test(); - let mut proc = Process::new(1); + fn shared_timerfd_backing_survives_fork_and_spawn_without_aliasing() { + use crate::process_table::ProcessTable; + use crate::spawn::SpawnAttrs; + + const PARENT: u32 = 970_200; + const FORK_CHILD: u32 = 970_201; + let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - let host_handle = fd_host_handle(&proc, write_fd); + host.clock_time = (100, 0); + table.create_process(PARENT).unwrap(); + let inherited_fd = + sys_timerfd_create(table.get_mut(PARENT).unwrap(), 0, O_NONBLOCK).unwrap(); + let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); + let backing_generation = + descriptor_backing_generation(FileType::TimerFd, backing_idx).unwrap(); + table.fork_process(PARENT, FORK_CHILD).unwrap(); + let spawn_child = table + .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .unwrap(); - unsafe { crate::lock::global_fallback_lock_table() }.set_lock( - host_handle, - FileLock { - pid: 7, - lock_type: F_WRLCK, - start: 10, - len: 25, - }, - ); + sys_timerfd_settime( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + 0, + 0, + 0, + 5, + 0, + ) + .unwrap(); + assert_eq!( + sys_timerfd_gettime(table.get_mut(spawn_child).unwrap(), &mut host, inherited_fd,) + .unwrap(), + (0, 0, 5, 0) + ); + let fresh_fd = + sys_timerfd_create(table.get_mut(spawn_child).unwrap(), 0, O_NONBLOCK).unwrap(); + assert_ne!( + fd_host_handle(table.get(spawn_child).unwrap(), inherited_fd), + fd_host_handle(table.get(spawn_child).unwrap(), fresh_fd) + ); + + host.clock_time = (106, 0); + let mut count = [0u8; 8]; + sys_read( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut count, + ) + .unwrap(); + assert_eq!(u64::from_le_bytes(count), 1); + assert_eq!( + sys_read( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + &mut count, + ), + Err(Errno::EAGAIN) + ); + + table.remove_process(PARENT).unwrap(); + table.remove_process(FORK_CHILD).unwrap(); + table.remove_process(spawn_child).unwrap(); + assert_descriptor_backing_released(FileType::TimerFd, backing_idx, backing_generation); + } + + #[test] + fn shared_signalfd_mask_keeps_pending_queues_process_local() { + use crate::process_table::ProcessTable; + use crate::spawn::SpawnAttrs; + use wasm_posix_shared::signal::{SIGINT, SIGTERM, SIGUSR1}; + + const PARENT: u32 = 970_300; + const FORK_CHILD: u32 = 970_301; + let mut table = ProcessTable::new(); + let mut host = MockHostIO::new(); + table.create_process(PARENT).unwrap(); + let inherited_fd = sys_signalfd4( + table.get_mut(PARENT).unwrap(), + -1, + 1u64 << (SIGINT - 1), + O_NONBLOCK, + ) + .unwrap(); + let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); + let backing_generation = + descriptor_backing_generation(FileType::SignalFd, backing_idx).unwrap(); + table.fork_process(PARENT, FORK_CHILD).unwrap(); + let spawn_child = table + .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .unwrap(); + + let usr1_mask = 1u64 << (SIGUSR1 - 1); + sys_signalfd4( + table.get_mut(FORK_CHILD).unwrap(), + inherited_fd, + usr1_mask, + 0, + ) + .unwrap(); + table.get_mut(PARENT).unwrap().signals.raise(SIGUSR1); + table.get_mut(spawn_child).unwrap().signals.raise(SIGUSR1); + + let mut info = [0u8; 128]; + assert_eq!( + sys_read( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + &mut info, + ), + Err(Errno::EAGAIN), + "the shared mask must not merge per-process pending queues" + ); + sys_read( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &mut info, + ) + .unwrap(); + assert_eq!(u32::from_le_bytes(info[..4].try_into().unwrap()), SIGUSR1); + sys_read( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut info, + ) + .unwrap(); + assert_eq!(u32::from_le_bytes(info[..4].try_into().unwrap()), SIGUSR1); + + let fresh_fd = sys_signalfd4( + table.get_mut(FORK_CHILD).unwrap(), + -1, + 1u64 << (SIGTERM - 1), + O_NONBLOCK, + ) + .unwrap(); + assert_ne!( + fd_host_handle(table.get(FORK_CHILD).unwrap(), inherited_fd), + fd_host_handle(table.get(FORK_CHILD).unwrap(), fresh_fd) + ); + + table.remove_process(PARENT).unwrap(); + table.remove_process(FORK_CHILD).unwrap(); + table.remove_process(spawn_child).unwrap(); + assert_descriptor_backing_released(FileType::SignalFd, backing_idx, backing_generation); + } + + #[test] + fn shared_memfd_data_and_cursor_survive_fork_and_spawn() { + use crate::process_table::ProcessTable; + use crate::spawn::SpawnAttrs; + + const PARENT: u32 = 970_400; + const FORK_CHILD: u32 = 970_401; + let mut table = ProcessTable::new(); + let mut host = MockHostIO::new(); + table.create_process(PARENT).unwrap(); + let inherited_fd = sys_memfd_create(table.get_mut(PARENT).unwrap(), b"shared", 0).unwrap(); + let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); + let backing_generation = + descriptor_backing_generation(FileType::MemFd, backing_idx).unwrap(); + sys_write( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + b"abcdef", + ) + .unwrap(); + sys_lseek( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + 0, + SEEK_SET, + ) + .unwrap(); + table.fork_process(PARENT, FORK_CHILD).unwrap(); + let spawn_child = table + .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .unwrap(); + + let mut pair = [0u8; 2]; + sys_read( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut pair, + ) + .unwrap(); + assert_eq!(&pair, b"ab"); + sys_read( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + &mut pair, + ) + .unwrap(); + assert_eq!(&pair, b"cd"); + sys_read( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &mut pair, + ) + .unwrap(); + assert_eq!(&pair, b"ef"); + + sys_pwrite( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + b"ZZ", + 1, + ) + .unwrap(); + let mut all = [0u8; 6]; + sys_pread( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &mut all, + 0, + ) + .unwrap(); + assert_eq!(&all, b"aZZdef"); + assert_eq!( + sys_lseek( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + 0, + SEEK_CUR, + ) + .unwrap(), + 6, + "positioned I/O must not move the shared cursor" + ); + + sys_ftruncate( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + 4, + ) + .unwrap(); + assert_eq!( + sys_fstat(table.get_mut(PARENT).unwrap(), &mut host, inherited_fd) + .unwrap() + .st_size, + 4 + ); + + let fresh_fd = sys_memfd_create(table.get_mut(FORK_CHILD).unwrap(), b"fresh", 0).unwrap(); + assert_ne!( + fd_host_handle(table.get(FORK_CHILD).unwrap(), inherited_fd), + fd_host_handle(table.get(FORK_CHILD).unwrap(), fresh_fd) + ); + sys_write( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + fresh_fd, + b"independent", + ) + .unwrap(); + let mut truncated = [0u8; 4]; + sys_pread( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut truncated, + 0, + ) + .unwrap(); + assert_eq!(&truncated, b"aZZd"); + + table.remove_process(PARENT).unwrap(); + table.remove_process(FORK_CHILD).unwrap(); + table.remove_process(spawn_child).unwrap(); + assert_descriptor_backing_released(FileType::MemFd, backing_idx, backing_generation); + } + + #[test] + fn inherited_memfd_seek_cur_lock_and_fdinfo_use_peer_advanced_cursor() { + use crate::process_table::ProcessTable; + + let _locks = enter_fallback_lock_test(); + const PARENT: u32 = 970_450; + const CHILD: u32 = 970_451; + let mut table = ProcessTable::new(); + let mut host = MockHostIO::new(); + table.create_process(PARENT).unwrap(); + let fd = sys_memfd_create(table.get_mut(PARENT).unwrap(), b"cursor-lock", 0).unwrap(); + sys_write(table.get_mut(PARENT).unwrap(), &mut host, fd, b"abcdefgh").unwrap(); + sys_lseek(table.get_mut(PARENT).unwrap(), &mut host, fd, 0, SEEK_SET).unwrap(); + table.fork_process(PARENT, CHILD).unwrap(); + + let mut prefix = [0u8; 3]; + sys_read(table.get_mut(CHILD).unwrap(), &mut host, fd, &mut prefix).unwrap(); + assert_eq!(&prefix, b"abc"); + let fdinfo = crate::procfs::generate_fdinfo(table.get(PARENT).unwrap(), fd).unwrap(); + assert!( + core::str::from_utf8(&fdinfo).unwrap().contains("pos:\t3\n"), + "fdinfo must report the peer-advanced shared cursor" + ); + + let mut parent_lock = WasmFlock { + l_type: F_WRLCK as i16, + l_whence: SEEK_CUR as i16, + _pad1: 0, + l_start: 2, + l_len: 1, + l_pid: 0, + _pad2: 0, + }; + sys_fcntl_lock( + table.get_mut(PARENT).unwrap(), + fd, + F_SETLK, + &mut parent_lock, + &mut host, + ) + .unwrap(); + + let mut query = WasmFlock { + l_type: F_WRLCK as i16, + l_whence: SEEK_SET as i16, + _pad1: 0, + l_start: 5, + l_len: 1, + l_pid: 0, + _pad2: 0, + }; + sys_fcntl_lock( + table.get_mut(CHILD).unwrap(), + fd, + F_GETLK, + &mut query, + &mut host, + ) + .unwrap(); + assert_eq!(query.l_type as u32, F_WRLCK); + assert_eq!(query.l_start, 5); + assert_eq!(query.l_pid, PARENT); + + table.remove_process(CHILD).unwrap(); + table.remove_process(PARENT).unwrap(); + } + + #[test] + fn shared_procfs_snapshot_and_cursor_survive_fork_and_spawn() { + use crate::process_table::ProcessTable; + use crate::spawn::SpawnAttrs; + + const PARENT: u32 = 970_500; + const FORK_CHILD: u32 = 970_501; + let mut table = ProcessTable::new(); + let mut host = MockHostIO::new(); + table.create_process(PARENT).unwrap(); + table.get_mut(PARENT).unwrap().argv = vec![b"parent-program".to_vec()]; + let expected = crate::procfs::generate_stat(table.get(PARENT).unwrap()); + let inherited_fd = crate::procfs::procfs_open( + table.get_mut(PARENT).unwrap(), + &crate::procfs::ProcfsEntry::Stat(PARENT), + b"/proc/self/stat".to_vec(), + 0, + ) + .unwrap(); + let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); + let backing_generation = + descriptor_backing_generation(FileType::Regular, backing_idx).unwrap(); + + let mut first = [0u8; 7]; + sys_read( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut first, + ) + .unwrap(); + assert_eq!(&first, &expected[..7]); + table.fork_process(PARENT, FORK_CHILD).unwrap(); + let spawn_child = table + .spawn_child( + PARENT, + &[b"spawn-program"], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap(); + + let mut second = [0u8; 9]; + sys_read( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + &mut second, + ) + .unwrap(); + assert_eq!(&second, &expected[7..16]); + let fdinfo = + crate::procfs::generate_fdinfo(table.get(PARENT).unwrap(), inherited_fd).unwrap(); + assert!( + core::str::from_utf8(&fdinfo) + .unwrap() + .contains("pos:\t16\n") + ); + let mut third = [0u8; 11]; + sys_read( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &mut third, + ) + .unwrap(); + assert_eq!(&third, &expected[16..27]); + + let fresh_expected = crate::procfs::generate_stat(table.get(spawn_child).unwrap()); + let fresh_fd = crate::procfs::procfs_open( + table.get_mut(spawn_child).unwrap(), + &crate::procfs::ProcfsEntry::Stat(spawn_child), + b"/proc/self/stat".to_vec(), + 0, + ) + .unwrap(); + assert_ne!( + fd_host_handle(table.get(spawn_child).unwrap(), inherited_fd), + fd_host_handle(table.get(spawn_child).unwrap(), fresh_fd) + ); + let mut fresh_prefix = [0u8; 12]; + sys_read( + table.get_mut(spawn_child).unwrap(), + &mut host, + fresh_fd, + &mut fresh_prefix, + ) + .unwrap(); + assert_eq!(&fresh_prefix, &fresh_expected[..12]); + + let mut fourth = [0u8; 5]; + sys_read( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut fourth, + ) + .unwrap(); + assert_eq!(&fourth, &expected[27..32]); + + table.remove_process(PARENT).unwrap(); + table.remove_process(FORK_CHILD).unwrap(); + table.remove_process(spawn_child).unwrap(); + assert_descriptor_backing_released(FileType::Regular, backing_idx, backing_generation); + } + + #[test] + fn fork_clofork_filter_recomputes_ofd_refs_and_backing_lifetime() { + use crate::process_table::ProcessTable; + + const PARENT: u32 = 970_600; + const CHILD: u32 = 970_601; + let mut table = ProcessTable::new(); + let mut host = MockHostIO::new(); + table.create_process(PARENT).unwrap(); + let clo_fork_fd = sys_eventfd2(table.get_mut(PARENT).unwrap(), 1, 0).unwrap(); + let inherited_alias = sys_dup(table.get_mut(PARENT).unwrap(), clo_fork_fd).unwrap(); + let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), clo_fork_fd); + let backing_generation = + descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); + table + .get_mut(PARENT) + .unwrap() + .fd_table + .get_mut(clo_fork_fd) + .unwrap() + .fd_flags |= FD_CLOFORK; + + table.fork_process(PARENT, CHILD).unwrap(); + let child = table.get(CHILD).unwrap(); + assert!(child.fd_table.get(clo_fork_fd).is_err()); + let child_entry = child.fd_table.get(inherited_alias).unwrap(); + assert_eq!( + child + .ofd_table + .get(child_entry.ofd_ref.0) + .unwrap() + .ref_count, + 1, + "fork must recompute local OFD refs after filtering CLOFORK aliases" + ); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(2) + ); + + sys_close(table.get_mut(CHILD).unwrap(), &mut host, inherited_alias).unwrap(); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(1) + ); + sys_close(table.get_mut(PARENT).unwrap(), &mut host, clo_fork_fd).unwrap(); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(1), + "closing one local alias must not drop the process's OFD reference" + ); + sys_close(table.get_mut(PARENT).unwrap(), &mut host, inherited_alias).unwrap(); + assert_descriptor_backing_released(FileType::EventFd, backing_idx, backing_generation); + table.remove_process(CHILD).unwrap(); + table.remove_process(PARENT).unwrap(); + } + + #[test] + fn spawn_cloexec_and_action_rollback_balance_all_shared_backings() { + use crate::process_table::ProcessTable; + use crate::spawn::{FileAction, SpawnAttrs}; + use wasm_posix_shared::signal::SIGINT; + + const PARENT: u32 = 970_700; + let mut table = ProcessTable::new(); + let mut host = MockHostIO::new(); + table.create_process(PARENT).unwrap(); + + let eventfd = sys_eventfd2(table.get_mut(PARENT).unwrap(), 0, O_CLOEXEC).unwrap(); + let timerfd = sys_timerfd_create(table.get_mut(PARENT).unwrap(), 0, O_CLOEXEC).unwrap(); + let signalfd = sys_signalfd4( + table.get_mut(PARENT).unwrap(), + -1, + 1u64 << (SIGINT - 1), + O_CLOEXEC, + ) + .unwrap(); + let memfd = sys_memfd_create(table.get_mut(PARENT).unwrap(), b"cloexec", 1).unwrap(); + let procfd = crate::procfs::procfs_open( + table.get_mut(PARENT).unwrap(), + &crate::procfs::ProcfsEntry::Stat(PARENT), + b"/proc/self/stat".to_vec(), + O_CLOEXEC, + ) + .unwrap(); + let tracked = [ + (eventfd, FileType::EventFd), + (timerfd, FileType::TimerFd), + (signalfd, FileType::SignalFd), + (memfd, FileType::MemFd), + (procfd, FileType::Regular), + ] + .map(|(fd, file_type)| { + let idx = descriptor_backing_idx(table.get(PARENT).unwrap(), fd); + ( + fd, + file_type, + idx, + descriptor_backing_generation(file_type, idx).unwrap(), + ) + }); + + let child = table + .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .unwrap(); + for (fd, file_type, idx, _) in tracked { + assert!(table.get(child).unwrap().fd_table.get(fd).is_err()); + assert_eq!(descriptor_backing_ref_count(file_type, idx), Some(1)); + } + + let err = table + .spawn_child( + PARENT, + &[], + &[], + &[FileAction::Dup2 { srcfd: 999, fd: 1 }], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap_err(); + assert_eq!(err, Errno::EBADF); + for (_, file_type, idx, _) in tracked { + assert_eq!( + descriptor_backing_ref_count(file_type, idx), + Some(1), + "failed spawn must roll back every inherited backing ref" + ); + } + + table.remove_process(child).unwrap(); + for (fd, _, _, _) in tracked { + sys_close(table.get_mut(PARENT).unwrap(), &mut host, fd).unwrap(); + } + for (_, file_type, idx, generation) in tracked { + assert_descriptor_backing_released(file_type, idx, generation); + } + table.remove_process(PARENT).unwrap(); + } + + #[test] + fn legacy_exec_transfers_survivor_and_releases_cloexec_only_backing() { + use crate::process_table::ProcessTable; + + const PID: u32 = 970_800; + let mut old = Process::new(PID); + let mut host = MockHostIO::new(); + let removed_fd = sys_eventfd2(&mut old, 11, O_CLOEXEC).unwrap(); + let retained_fd = sys_eventfd2(&mut old, 22, 0).unwrap(); + let filtered_alias = sys_dup(&mut old, retained_fd).unwrap(); + old.fd_table.get_mut(filtered_alias).unwrap().fd_flags |= FD_CLOEXEC; + let removed_idx = descriptor_backing_idx(&old, removed_fd); + let removed_generation = + descriptor_backing_generation(FileType::EventFd, removed_idx).unwrap(); + let retained_idx = descriptor_backing_idx(&old, retained_fd); + + let serialized = crate::fork::serialize_exec_state_with_growing_buffer(&old).unwrap(); + let replacement = crate::fork::deserialize_exec_state(&serialized, PID).unwrap(); + assert!(replacement.fd_table.get(removed_fd).is_err()); + assert!(replacement.fd_table.get(filtered_alias).is_err()); + let retained_ofd_idx = replacement.fd_table.get(retained_fd).unwrap().ofd_ref.0; + assert_eq!( + replacement + .ofd_table + .get(retained_ofd_idx) + .unwrap() + .ref_count, + 1 + ); + + let mut table = ProcessTable::new(); + table.processes.insert(PID, old); + table.replace_legacy_exec_process(PID, replacement).unwrap(); + assert_descriptor_backing_released(FileType::EventFd, removed_idx, removed_generation); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, retained_idx), + Some(1), + "the replacement must transfer, not duplicate, the survivor's ownership ref" + ); + + let mut value = [0u8; 8]; + sys_read( + table.get_mut(PID).unwrap(), + &mut host, + retained_fd, + &mut value, + ) + .unwrap(); + assert_eq!(u64::from_le_bytes(value), 22); + sys_close(table.get_mut(PID).unwrap(), &mut host, retained_fd).unwrap(); + table.remove_process(PID).unwrap(); + } + + #[test] + fn legacy_fork_into_fresh_table_keeps_host_handle_single_owned() { + use crate::process_table::ProcessTable; + + const PARENT: u32 = 970_810; + const CHILD: u32 = 970_811; + const HOST_HANDLE: i64 = 9_708_110; + + let mut source = Process::new(PARENT); + let ofd_idx = source.ofd_table.create( + FileType::Regular, + O_RDWR, + HOST_HANDLE, + b"/fresh-kernel-handle".to_vec(), + ); + let fd = source + .fd_table + .alloc(OpenFileDescRef(ofd_idx), 0) + .unwrap(); + let mut serialized = vec![0u8; 64 * 1024]; + let written = crate::fork::serialize_fork_state(&source, &mut serialized).unwrap(); + let child = crate::fork::deserialize_fork_state(&serialized[..written], CHILD).unwrap(); + assert_eq!(child.ppid, PARENT); + + let mut table = ProcessTable::new(); + table.insert_legacy_fork_process(child).unwrap(); + let mut host = MockHostIO::new(); + sys_close(table.get_mut(CHILD).unwrap(), &mut host, fd).unwrap(); + assert_eq!(host.closed_handles, vec![HOST_HANDLE]); + } + + #[test] + fn legacy_fork_into_fresh_table_rejects_reused_special_backing() { + use crate::process_table::ProcessTable; + + const OWNER: u32 = 970_820; + const CHILD: u32 = 970_821; + let mut owner = Process::new(OWNER); + let owner_fd = sys_eventfd2(&mut owner, 37, 0).unwrap(); + let backing_idx = descriptor_backing_idx(&owner, owner_fd); + let generation = + descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); + + // Model a stale serialized child whose stable index now names another + // process's live object. A fresh-table legacy install has no parent + // ownership to transfer and must reject rather than add a reference. + let mut child = Process::new(CHILD); + child.ppid = 999_999; + let stale_handle = -((backing_idx as i64) + 1); + let stale_ofd = child.ofd_table.create( + FileType::EventFd, + O_RDWR, + stale_handle, + b"/dev/eventfd".to_vec(), + ); + child + .fd_table + .alloc(OpenFileDescRef(stale_ofd), 0) + .unwrap(); + + let mut table = ProcessTable::new(); + assert_eq!(table.insert_legacy_fork_process(child), Err(Errno::EBADF)); + assert!(table.get(CHILD).is_none()); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(1) + ); + assert_eq!( + descriptor_backing_generation(FileType::EventFd, backing_idx), + Some(generation) + ); + + let mut host = MockHostIO::new(); + let mut value = [0u8; 8]; + sys_read(&mut owner, &mut host, owner_fd, &mut value).unwrap(); + assert_eq!(u64::from_le_bytes(value), 37); + sys_close(&mut owner, &mut host, owner_fd).unwrap(); + } + + #[test] + fn legacy_exec_into_fresh_table_rejects_reused_special_backing() { + use crate::process_table::ProcessTable; + + const OWNER: u32 = 970_830; + const EXEC_PID: u32 = 970_831; + let mut owner = Process::new(OWNER); + let owner_fd = sys_eventfd2(&mut owner, 41, 0).unwrap(); + let backing_idx = descriptor_backing_idx(&owner, owner_fd); + let generation = + descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); + + let mut replacement = Process::new(EXEC_PID); + let stale_handle = -((backing_idx as i64) + 1); + let stale_ofd = replacement.ofd_table.create( + FileType::EventFd, + O_RDWR, + stale_handle, + b"/dev/eventfd".to_vec(), + ); + replacement + .fd_table + .alloc(OpenFileDescRef(stale_ofd), 0) + .unwrap(); + + let mut table = ProcessTable::new(); + assert_eq!( + table.replace_legacy_exec_process(EXEC_PID, replacement), + Err(Errno::EBADF) + ); + assert!(table.get(EXEC_PID).is_none()); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(1) + ); + assert_eq!( + descriptor_backing_generation(FileType::EventFd, backing_idx), + Some(generation) + ); + + let mut host = MockHostIO::new(); + let mut value = [0u8; 8]; + sys_read(&mut owner, &mut host, owner_fd, &mut value).unwrap(); + assert_eq!(u64::from_le_bytes(value), 41); + sys_close(&mut owner, &mut host, owner_fd).unwrap(); + } + + #[test] + fn test_fcntl_setlk_fallback_conflict_reports_would_block() { + let _guard = enter_fallback_lock_test(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let host_handle = fd_host_handle(&proc, write_fd); + + unsafe { crate::lock::global_fallback_lock_table() }.set_lock( + host_handle, + FileLock { + pid: 2, + lock_type: F_WRLCK, + start: 0, + len: 100, + }, + ); + + let mut flock = WasmFlock { + l_type: F_WRLCK as i16, + l_whence: 0, + _pad1: 0, + l_start: 0, + l_len: 100, + l_pid: 0, + _pad2: 0, + }; + let result = sys_fcntl_lock(&mut proc, write_fd, F_SETLK, &mut flock, &mut host); + assert_eq!(result, Err(Errno::EAGAIN)); + } + + #[test] + fn test_fcntl_setlkw_fallback_conflict_uses_cooperative_retry() { + let _guard = enter_fallback_lock_test(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let host_handle = fd_host_handle(&proc, write_fd); + + unsafe { crate::lock::global_fallback_lock_table() }.set_lock( + host_handle, + FileLock { + pid: 2, + lock_type: F_WRLCK, + start: 0, + len: 100, + }, + ); + + let mut flock = WasmFlock { + l_type: F_WRLCK as i16, + l_whence: 0, + _pad1: 0, + l_start: 0, + l_len: 100, + l_pid: 0, + _pad2: 0, + }; + let result = sys_fcntl_lock(&mut proc, write_fd, F_SETLKW, &mut flock, &mut host); + assert_eq!(result, Err(Errno::EAGAIN)); + } + + struct FallbackLockTestGuard { + _guard: std::sync::MutexGuard<'static, ()>, + } + + impl Drop for FallbackLockTestGuard { + fn drop(&mut self) { + unsafe { crate::lock::global_fallback_lock_table().clear() }; + } + } + + fn enter_fallback_lock_test() -> FallbackLockTestGuard { + let guard = crate::lock::FALLBACK_LOCK_TEST_LOCK.lock().unwrap(); + unsafe { crate::lock::global_fallback_lock_table().clear() }; + FallbackLockTestGuard { _guard: guard } + } + + fn add_fallback_pipe_fd(proc: &mut Process, host_handle: i64, status_flags: u32) -> i32 { + let ofd_idx = proc.ofd_table.create( + FileType::Pipe, + status_flags, + host_handle, + b"/dev/pipe".to_vec(), + ); + proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0).unwrap() + } + + fn write_lock(start: i64, len: i64) -> WasmFlock { + WasmFlock { + l_type: F_WRLCK as i16, + l_whence: 0, + _pad1: 0, + l_start: start, + l_len: len, + l_pid: 0, + _pad2: 0, + } + } + + #[test] + fn test_fcntl_getlk_fallback_conflict_reports_blocking_owner() { + let _guard = enter_fallback_lock_test(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let host_handle = fd_host_handle(&proc, write_fd); + + unsafe { crate::lock::global_fallback_lock_table() }.set_lock( + host_handle, + FileLock { + pid: 7, + lock_type: F_WRLCK, + start: 10, + len: 25, + }, + ); let mut query = WasmFlock { l_type: F_RDLCK as i16, @@ -14872,6 +15979,44 @@ mod tests { assert_ne!(addr, 0xFFFFFFFF); } + #[test] + fn mmap_writeback_capability_requires_a_writable_host_regular_file() { + fn install_fd( + proc: &mut Process, + file_type: FileType, + flags: u32, + host_handle: i64, + ) -> i32 { + let ofd = proc.ofd_table.create( + file_type, + flags, + host_handle, + b"/mapped".to_vec(), + ); + proc.fd_table.alloc(OpenFileDescRef(ofd), 0).unwrap() + } + + let mut proc = Process::new(41); + let host_rdwr = install_fd(&mut proc, FileType::Regular, O_RDWR, 50); + let host_wronly = install_fd(&mut proc, FileType::Regular, O_WRONLY, 51); + let host_rdonly = install_fd(&mut proc, FileType::Regular, O_RDONLY, 52); + let invalid_access = install_fd(&mut proc, FileType::Regular, O_ACCMODE, 54); + let synthetic = install_fd(&mut proc, FileType::Regular, O_RDWR, -100); + let memfd = install_fd(&mut proc, FileType::MemFd, O_RDWR, -1); + let device = install_fd(&mut proc, FileType::CharDevice, O_RDWR, -5); + let directory = install_fd(&mut proc, FileType::Directory, O_RDWR, 53); + + assert!(fd_supports_mmap_writeback(&proc, host_rdwr)); + assert!(!fd_supports_mmap_writeback(&proc, host_wronly)); + assert!(!fd_supports_mmap_writeback(&proc, host_rdonly)); + assert!(!fd_supports_mmap_writeback(&proc, invalid_access)); + assert!(!fd_supports_mmap_writeback(&proc, synthetic)); + assert!(!fd_supports_mmap_writeback(&proc, memfd)); + assert!(!fd_supports_mmap_writeback(&proc, device)); + assert!(!fd_supports_mmap_writeback(&proc, directory)); + assert!(!fd_supports_mmap_writeback(&proc, 999)); + } + #[test] fn test_munmap() { let mut proc = Process::new(1); @@ -14880,6 +16025,18 @@ mod tests { sys_munmap(&mut proc, &mut host, addr, 0x10000).unwrap(); } + #[test] + fn test_munmap_rounds_length_before_releasing_pages() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let addr = sys_mmap(&mut proc, &mut host, 0, 0x20000, 3, 0x22, -1, 0).unwrap(); + + sys_munmap(&mut proc, &mut host, addr, 0x10001).unwrap(); + + assert!(!proc.memory.is_mapped(addr)); + assert!(!proc.memory.is_mapped(addr + 0x10000)); + } + #[test] fn test_munmap_invalid_address_minus_one() { // munmap((void*)-1, 1) — address 0xFFFFFFFF is not page-aligned, should return EINVAL @@ -14904,114 +16061,542 @@ mod tests { } #[test] - fn test_munmap_unaligned_address() { - // munmap with non-page-aligned address should return EINVAL + fn test_munmap_unaligned_address() { + // munmap with non-page-aligned address should return EINVAL + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + assert_eq!( + sys_munmap(&mut proc, &mut host, 0x1000, 0x10000), + Err(Errno::EINVAL) + ); + } + + #[test] + fn test_munmap_zero_length() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + assert_eq!( + sys_munmap(&mut proc, &mut host, 0x10000, 0), + Err(Errno::EINVAL) + ); + } + + #[test] + fn test_munmap_rejects_host_reserved_region() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let addr = proc.memory.reserve_host_region(0x10000); + assert_ne!(addr, MAP_FAILED); + + assert_eq!( + sys_munmap(&mut proc, &mut host, addr, 0x10000), + Err(Errno::EINVAL) + ); + } + + #[test] + fn test_brk_query() { + let mut proc = Process::new(1); + let brk = sys_brk(&mut proc, 0); + assert!(brk > 0); + } + + #[test] + fn test_brk_set() { + let mut proc = Process::new(1); + let initial = sys_brk(&mut proc, 0); + let new_brk = sys_brk(&mut proc, initial + 4096); + assert_eq!(new_brk, initial + 4096); + } + + #[test] + fn test_mprotect_succeeds_noop() { + let proc = Process::new(1); + assert_eq!(sys_mprotect(&proc, 0, 4096, 3), Ok(())); + } + + #[test] + fn test_socket_creation() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + assert!(fd >= 3); + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + assert_eq!(ofd.file_type, FileType::Socket); + } + + #[test] + fn listener_wake_resolver_finds_fd_above_default_nofile_limit() { + use crate::socket::SocketState; + use wasm_posix_shared::socket::{AF_INET, SOCK_STREAM}; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + sys_setrlimit(&mut proc, 7, 4096, 4096).unwrap(); + let low_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let sock_idx = test_socket_idx(&proc, low_fd); + let listener = proc.sockets.get_mut(sock_idx).unwrap(); + listener.state = SocketState::Listening; + listener.accept_wake_idx = Some(77); + + let high_fd = sys_fcntl(&mut proc, low_fd, F_DUPFD, 2048).unwrap(); + assert_eq!(high_fd, 2048); + sys_close(&mut proc, &mut host, low_fd).unwrap(); + + assert_eq!(find_listener_fd_by_accept_wake(&proc, 77), Some(high_fd)); + assert_eq!(find_listener_fd_by_accept_wake(&proc, 78), None); + sys_close(&mut proc, &mut host, high_fd).unwrap(); + } + + #[test] + fn test_socket_unsupported_domain() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_socket(&mut proc, &mut host, 999, 1, 0); + assert_eq!(result, Err(Errno::EAFNOSUPPORT)); + } + + #[test] + fn test_socket_unsupported_type() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::AF_UNIX; + let result = sys_socket(&mut proc, &mut host, AF_UNIX, 999, 0); + assert_eq!(result, Err(Errno::EPROTOTYPE)); + } + + #[test] + fn test_socketpair_unix_stream() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + assert!(fd0 >= 3); + assert!(fd1 >= 3); + assert_ne!(fd0, fd1); + + // Write through fd0, read from fd1 + let n = sys_write(&mut proc, &mut host, fd0, b"hello").unwrap(); + assert_eq!(n, 5); + let mut buf = [0u8; 5]; + let n = sys_read(&mut proc, &mut host, fd1, &mut buf).unwrap(); + assert_eq!(n, 5); + assert_eq!(&buf, b"hello"); + + // Write through fd1, read from fd0 (bidirectional) + let n = sys_write(&mut proc, &mut host, fd1, b"world").unwrap(); + assert_eq!(n, 5); + let mut buf = [0u8; 5]; + let n = sys_read(&mut proc, &mut host, fd0, &mut buf).unwrap(); + assert_eq!(n, 5); + assert_eq!(&buf, b"world"); + } + + #[test] + fn exec_transfer_keeps_connected_socket_and_queued_bytes() { + use wasm_posix_shared::socket::*; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (writer, reader) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + sys_write(&mut proc, &mut host, writer, b"queued before exec").unwrap(); + + let pid = proc.pid; + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + let mut buf = [0u8; 18]; + let n = sys_read(&mut proc, &mut host, reader, &mut buf).unwrap(); + assert_eq!(n, buf.len()); + assert_eq!(&buf, b"queued before exec"); + sys_close(&mut proc, &mut host, writer).unwrap(); + sys_close(&mut proc, &mut host, reader).unwrap(); + } + + #[test] + fn exec_cannot_resurrect_an_exited_process() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); + proc.state = crate::process::ProcessState::Exited; + proc.exit_status = 23; + let pid = proc.pid; + assert_eq!( - sys_munmap(&mut proc, &mut host, 0x1000, 0x10000), - Err(Errno::EINVAL) + commit_exec_state(&mut proc, &mut host, pid), + Err(Errno::ESRCH), ); + assert_eq!(proc.state, crate::process::ProcessState::Exited); + assert_eq!(proc.exit_status, 23); + assert!(!proc.has_exec); } #[test] - fn test_munmap_zero_length() { - let mut proc = Process::new(1); + fn exec_keeps_queued_unix_accept_state_through_surviving_listener_dup() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + use wasm_posix_shared::socket::{AF_UNIX, SOCK_CLOEXEC, SOCK_STREAM}; + + const PID: u32 = 0x6eec_0010; + let path = b"/tmp/exec-listener-survives.sock"; + let resolved = crate::path::resolve_path(path, b"/"); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + + let mut proc = Process::new(PID); let mut host = MockHostIO::new(); + let cloexec_listener = + sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0) + .unwrap(); + let addr = test_unix_addr(path); + sys_bind(&mut proc, &mut host, cloexec_listener, &addr).unwrap(); + sys_listen(&mut proc, &mut host, cloexec_listener, 4).unwrap(); + + // dup() clears FD_CLOEXEC while retaining the same open file + // description and listener identity. + let listener = sys_dup(&mut proc, cloexec_listener).unwrap(); + assert_eq!(proc.fd_table.get(listener).unwrap().fd_flags, 0); + + let client = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + sys_connect(&mut proc, &mut host, client, &addr).unwrap(); + let listener_sock_idx = test_socket_idx(&proc, listener); + let shared_idx = proc + .sockets + .get(listener_sock_idx) + .unwrap() + .shared_backlog_idx + .unwrap(); assert_eq!( - sys_munmap(&mut proc, &mut host, 0x10000, 0), - Err(Errno::EINVAL) + unsafe { crate::socket::shared_listener_backlog_table() }.len(shared_idx), + 1 + ); + + commit_exec_state(&mut proc, &mut host, PID).unwrap(); + + assert!(proc.fd_table.get(cloexec_listener).is_err()); + assert_eq!(test_socket_idx(&proc, listener), listener_sock_idx); + let backlog = &unsafe { crate::socket::shared_listener_backlog_table() }.entries + [shared_idx]; + assert!(backlog.in_use); + assert_eq!(backlog.ref_count, 1); + assert_eq!(backlog.queue.len(), 1); + assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(&resolved) + .is_some()); + + let accepted = sys_accept(&mut proc, &mut host, listener).unwrap(); + assert_eq!(sys_send(&mut proc, &mut host, client, b"after exec", 0), Ok(10)); + let mut buf = [0u8; 10]; + assert_eq!( + sys_recv(&mut proc, &mut host, accepted, &mut buf, 0), + Ok(buf.len()) ); + assert_eq!(&buf, b"after exec"); + + sys_close(&mut proc, &mut host, accepted).unwrap(); + sys_close(&mut proc, &mut host, client).unwrap(); + sys_close(&mut proc, &mut host, listener).unwrap(); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); } #[test] - fn test_munmap_rejects_host_reserved_region() { - let mut proc = Process::new(1); + fn exec_cloexec_last_unix_listener_abandons_queued_connection() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + use wasm_posix_shared::signal::SIGPIPE; + use wasm_posix_shared::socket::{AF_UNIX, SOCK_CLOEXEC, SOCK_STREAM}; + + const PID: u32 = 0x6eec_0011; + let path = b"/tmp/exec-listener-closes.sock"; + let resolved = crate::path::resolve_path(path, b"/"); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + + let mut proc = Process::new(PID); let mut host = MockHostIO::new(); - let addr = proc.memory.reserve_host_region(0x10000); - assert_ne!(addr, MAP_FAILED); + let listener = + sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0) + .unwrap(); + let addr = test_unix_addr(path); + sys_bind(&mut proc, &mut host, listener, &addr).unwrap(); + sys_listen(&mut proc, &mut host, listener, 4).unwrap(); + + let client = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + sys_connect(&mut proc, &mut host, client, &addr).unwrap(); + let listener_sock_idx = test_socket_idx(&proc, listener); + let shared_idx = proc + .sockets + .get(listener_sock_idx) + .unwrap() + .shared_backlog_idx + .unwrap(); + let (recv_pipe_idx, send_pipe_idx) = { + let backlog = &unsafe { crate::socket::shared_listener_backlog_table() }.entries + [shared_idx]; + assert!(backlog.in_use); + assert_eq!(backlog.ref_count, 1); + assert_eq!(backlog.queue.len(), 1); + ( + backlog.queue[0].recv_pipe_idx, + backlog.queue[0].send_pipe_idx, + ) + }; + commit_exec_state(&mut proc, &mut host, PID).unwrap(); + + assert!(proc.fd_table.get(listener).is_err()); + assert!(proc.sockets.get(listener_sock_idx).is_none()); + assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(&resolved) + .is_none()); + let backlog = &unsafe { crate::socket::shared_listener_backlog_table() }.entries + [shared_idx]; + assert!(!backlog.in_use); + assert_eq!(backlog.ref_count, 0); + assert!(backlog.queue.is_empty()); + + let pipes = unsafe { crate::pipe::global_pipe_table() }; + assert!(!pipes.get(recv_pipe_idx).unwrap().has_readers()); + assert!(!pipes.get(send_pipe_idx).unwrap().is_write_end_open()); assert_eq!( - sys_munmap(&mut proc, &mut host, addr, 0x10000), - Err(Errno::EINVAL) + sys_send(&mut proc, &mut host, client, b"orphaned", 0), + Err(Errno::EPIPE) ); + assert!(proc.signals.is_pending(SIGPIPE)); + let mut buf = [0u8; 1]; + assert_eq!(sys_recv(&mut proc, &mut host, client, &mut buf, 0), Ok(0)); + + sys_close(&mut proc, &mut host, client).unwrap(); + let pipes = unsafe { crate::pipe::global_pipe_table() }; + assert!(pipes.get(recv_pipe_idx).is_none()); + assert!(pipes.get(send_pipe_idx).is_none()); } #[test] - fn test_brk_query() { + fn exec_keeps_eventfd_epoll_timerfd_signalfd_and_memfd_state() { + use wasm_posix_shared::signal::SIGINT; + let mut proc = Process::new(1); - let brk = sys_brk(&mut proc, 0); - assert!(brk > 0); + let mut host = MockHostIO::new(); + + let eventfd = sys_eventfd2(&mut proc, 5, O_NONBLOCK).unwrap(); + let epollfd = sys_epoll_create1(&mut proc, 0).unwrap(); + sys_epoll_ctl(&mut proc, epollfd, 1, eventfd, POLLIN as u32, 0xfeed).unwrap(); + + host.clock_time = (100, 0); + let timerfd = sys_timerfd_create(&mut proc, 0, O_NONBLOCK).unwrap(); + sys_timerfd_settime(&mut proc, &mut host, timerfd, 0, 0, 0, 5, 0).unwrap(); + + let signal_mask = crate::signal::sig_bit(SIGINT); + let signalfd = sys_signalfd4(&mut proc, -1, signal_mask, O_NONBLOCK).unwrap(); + proc.signals.raise(SIGINT); + + let memfd = sys_memfd_create(&mut proc, b"exec-state", 0).unwrap(); + sys_write(&mut proc, &mut host, memfd, b"before exec").unwrap(); + sys_lseek(&mut proc, &mut host, memfd, 7, SEEK_SET).unwrap(); + + let pid = proc.pid; + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + let (count, events) = + sys_epoll_pwait(&mut proc, &mut host, epollfd, 1, 0, None).unwrap(); + assert_eq!(count, 1); + assert_eq!(events[0].1, 0xfeed); + + let mut counter = [0u8; 8]; + sys_read(&mut proc, &mut host, eventfd, &mut counter).unwrap(); + assert_eq!(u64::from_le_bytes(counter), 5); + + host.clock_time = (106, 0); + let mut expirations = [0u8; 8]; + sys_read(&mut proc, &mut host, timerfd, &mut expirations).unwrap(); + assert_eq!(u64::from_le_bytes(expirations), 1); + + let mut signal_info = [0u8; 128]; + sys_read(&mut proc, &mut host, signalfd, &mut signal_info).unwrap(); + assert_eq!(u32::from_le_bytes(signal_info[0..4].try_into().unwrap()), SIGINT); + + let mut suffix = [0u8; 4]; + sys_read(&mut proc, &mut host, memfd, &mut suffix).unwrap(); + assert_eq!(&suffix, b"exec"); + + for fd in [epollfd, eventfd, timerfd, signalfd, memfd] { + sys_close(&mut proc, &mut host, fd).unwrap(); + } } #[test] - fn test_brk_set() { + fn exec_closes_only_cloexec_alias_and_keeps_backing_object() { let mut proc = Process::new(1); - let initial = sys_brk(&mut proc, 0); - let new_brk = sys_brk(&mut proc, initial + 4096); - assert_eq!(new_brk, initial + 4096); - } + let mut host = MockHostIO::new(); + let cloexec_fd = sys_eventfd2(&mut proc, 19, O_CLOEXEC).unwrap(); + let retained_fd = sys_dup(&mut proc, cloexec_fd).unwrap(); + let backing_idx = { + let entry = proc.fd_table.get(retained_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (-(ofd.host_handle + 1)) as usize + }; + let backing_generation = + descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); + assert_eq!(proc.fd_table.get(retained_fd).unwrap().fd_flags, 0); - #[test] - fn test_mprotect_succeeds_noop() { - let proc = Process::new(1); - assert_eq!(sys_mprotect(&proc, 0, 4096, 3), Ok(())); + let pid = proc.pid; + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + assert!(proc.fd_table.get(cloexec_fd).is_err()); + let mut value = [0u8; 8]; + sys_read(&mut proc, &mut host, retained_fd, &mut value).unwrap(); + assert_eq!(u64::from_le_bytes(value), 19); + sys_close(&mut proc, &mut host, retained_fd).unwrap(); + assert_descriptor_backing_released(FileType::EventFd, backing_idx, backing_generation); } #[test] - fn test_socket_creation() { + fn exec_cloexec_pipe_writer_leaves_buffer_then_eof() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - assert!(fd >= 3); - let entry = proc.fd_table.get(fd).unwrap(); - let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - assert_eq!(ofd.file_type, FileType::Socket); + let (reader, writer) = sys_pipe(&mut proc).unwrap(); + sys_write(&mut proc, &mut host, writer, b"queued").unwrap(); + proc.fd_table.get_mut(writer).unwrap().fd_flags |= FD_CLOEXEC; + + let pid = proc.pid; + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + assert!(proc.fd_table.get(writer).is_err()); + let mut buf = [0u8; 6]; + assert_eq!(sys_read(&mut proc, &mut host, reader, &mut buf), Ok(6)); + assert_eq!(&buf, b"queued"); + assert_eq!(sys_read(&mut proc, &mut host, reader, &mut buf), Ok(0)); + sys_close(&mut proc, &mut host, reader).unwrap(); } #[test] - fn test_socket_unsupported_domain() { - let mut proc = Process::new(1); + fn exec_promotes_calling_thread_signals_and_resets_image_state() { + use crate::process::{PosixTimerState, ThreadInfo}; + use crate::signal::SignalHandler; + use wasm_posix_shared::signal::{SIGINT, SIGTERM}; + + let mut proc = Process::new(10); let mut host = MockHostIO::new(); - let result = sys_socket(&mut proc, &mut host, 999, 1, 0); - assert_eq!(result, Err(Errno::EAFNOSUPPORT)); + let mut caller = ThreadInfo::new(11, 0, 0, 0); + caller.signals.blocked = crate::signal::sig_bit(SIGTERM); + caller.signals.raise_with_value(32, 101); + caller.signals.raise_with_value(32, 202); + proc.add_thread(caller); + proc.add_thread(ThreadInfo::new(12, 0, 0, 0)); + proc.signals + .set_handler(SIGINT, SignalHandler::Handler(0x1234)) + .unwrap(); + proc.alarm_deadline_ns = 8_000_000_000; + proc.alarm_interval_ns = 1_000_000_000; + proc.posix_timers.push(Some(PosixTimerState { + clock_id: 0, + sigev_signo: SIGINT, + sigev_value: 0, + interval_sec: 1, + interval_nsec: 0, + value_sec: 1, + value_nsec: 0, + overrun: 0, + })); + + commit_exec_state(&mut proc, &mut host, 11).unwrap(); + + assert!(proc.threads.is_empty()); + assert_eq!(proc.signals.blocked, crate::signal::sig_bit(SIGTERM)); + assert_eq!(proc.signals.get_handler(SIGINT), SignalHandler::Default); + assert_eq!(proc.signals.consume_one(32), (101, -1)); + assert_eq!(proc.signals.consume_one(32), (202, -1)); + assert_eq!(proc.alarm_deadline_ns, 8_000_000_000); + assert_eq!(proc.alarm_interval_ns, 1_000_000_000); + assert!(proc.posix_timers.is_empty()); + assert!(proc.has_exec); } #[test] - fn test_socket_unsupported_type() { - let mut proc = Process::new(1); + fn exec_removes_discarded_pshared_participation() { + use crate::pshared::{MUTEX_TYPE_NORMAL, PTHREAD_BARRIER_SERIAL_THREAD}; + + const EXEC_PID: u32 = 0x6eec_0001; + const PEER_A: u32 = 0x6eec_0002; + const PEER_B: u32 = 0x6eec_0003; + + let (owned_mutex, cond_mutex, cond, barrier) = { + let table = unsafe { crate::pshared::global_pshared_table() }; + let owned_mutex = table.mutex_init(MUTEX_TYPE_NORMAL); + let cond_mutex = table.mutex_init(MUTEX_TYPE_NORMAL); + let cond = table.cond_init(); + let barrier = table.barrier_init(2).unwrap(); + + table.mutex_lock(owned_mutex, EXEC_PID).unwrap(); + table.mutex_lock(cond_mutex, EXEC_PID).unwrap(); + table + .cond_wait_begin(cond, cond_mutex, EXEC_PID) + .unwrap(); + assert_eq!(table.barrier_wait(barrier, EXEC_PID), Err(Errno::EAGAIN)); + (owned_mutex, cond_mutex, cond, barrier) + }; + + let mut proc = Process::new(EXEC_PID); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::AF_UNIX; - let result = sys_socket(&mut proc, &mut host, AF_UNIX, 999, 0); - assert_eq!(result, Err(Errno::EPROTOTYPE)); + commit_exec_state(&mut proc, &mut host, EXEC_PID).unwrap(); + + let table = unsafe { crate::pshared::global_pshared_table() }; + assert_eq!(table.mutex_lock(owned_mutex, PEER_A), Ok(())); + assert_eq!(table.cond_signal(cond), Ok(0)); + assert_eq!(table.barrier_wait(barrier, PEER_A), Err(Errno::EAGAIN)); + assert_eq!( + table.barrier_wait(barrier, PEER_B), + Ok(PTHREAD_BARRIER_SERIAL_THREAD) + ); + assert_eq!(table.barrier_wait(barrier, PEER_A), Ok(0)); + + table.mutex_unlock(owned_mutex, PEER_A).unwrap(); + table.mutex_destroy(owned_mutex).unwrap(); + table.mutex_destroy(cond_mutex).unwrap(); + table.cond_destroy(cond).unwrap(); + table.barrier_destroy(barrier).unwrap(); } #[test] - fn test_socketpair_unix_stream() { + fn exec_closes_dir_stream_but_keeps_raw_directory_fd_position() { + use crate::process::DirStream; + let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - assert!(fd0 >= 3); - assert!(fd1 >= 3); - assert_ne!(fd0, fd1); - - // Write through fd0, read from fd1 - let n = sys_write(&mut proc, &mut host, fd0, b"hello").unwrap(); - assert_eq!(n, 5); - let mut buf = [0u8; 5]; - let n = sys_read(&mut proc, &mut host, fd1, &mut buf).unwrap(); - assert_eq!(n, 5); - assert_eq!(&buf, b"hello"); + let ofd_idx = proc.ofd_table.create( + FileType::Directory, + O_RDONLY, + 55, + b"/tmp".to_vec(), + ); + { + let ofd = proc.ofd_table.get_mut(ofd_idx).unwrap(); + ofd.dir_host_handle = 77; + ofd.dir_synth_state = 2; + ofd.dir_entry_offset = 9; + } + let dir_fd = proc + .fd_table + .alloc(OpenFileDescRef(ofd_idx), 0) + .unwrap(); + proc.dir_streams.push(Some(DirStream { + host_handle: 88, + path: b"/tmp".to_vec(), + position: 4, + synth_dot_state: 2, + })); + + let pid = proc.pid; + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + assert!(proc.dir_streams.iter().all(Option::is_none)); + assert_eq!(host.closed_dir_handles, vec![88]); + let entry = proc.fd_table.get(dir_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + assert_eq!(ofd.dir_host_handle, 77); + assert_eq!(ofd.dir_synth_state, 2); + assert_eq!(ofd.dir_entry_offset, 9); - // Write through fd1, read from fd0 (bidirectional) - let n = sys_write(&mut proc, &mut host, fd1, b"world").unwrap(); - assert_eq!(n, 5); - let mut buf = [0u8; 5]; - let n = sys_read(&mut proc, &mut host, fd0, &mut buf).unwrap(); - assert_eq!(n, 5); - assert_eq!(&buf, b"world"); + sys_close(&mut proc, &mut host, dir_fd).unwrap(); + assert_eq!(host.closed_dir_handles, vec![88, 77]); } #[test] @@ -16669,6 +18254,11 @@ mod tests { assert_eq!(sys_sysconf(4), Ok(1024)); // _SC_OPEN_MAX } + #[test] + fn test_sysconf_arg_max_matches_exec_metadata_boundary() { + assert_eq!(sys_sysconf(0), Ok(4 * 1024 * 1024)); // _SC_ARG_MAX + } + #[test] fn test_sysconf_nprocessors() { assert_eq!(sys_sysconf(6), Ok(1)); // _SC_NPROCESSORS_ONLN @@ -18505,10 +20095,106 @@ mod tests { let accepted_fd = sys_accept(&mut proc, &mut host, server_fd).unwrap(); assert!(accepted_fd >= 0); + // Lazily materializing the accepted socket must restore process-local + // peer identity when the client and acceptor are still the same + // process, so MSG_OOB retains its existing semantics. + sys_send( + &mut proc, + &mut host, + client_fd, + b"X", + wasm_posix_shared::socket::MSG_OOB, + ) + .unwrap(); + let mut oob = [0u8; 1]; + assert_eq!( + sys_recv( + &mut proc, + &mut host, + accepted_fd, + &mut oob, + wasm_posix_shared::socket::MSG_OOB, + ) + .unwrap(), + 1, + ); + assert_eq!(oob, [b'X']); + // Clean up unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); } + #[test] + fn test_fork_child_accepts_parent_unix_listener_queue() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + use crate::process_table::ProcessTable; + + const PARENT: u32 = 9031; + const CHILD: u32 = 9032; + let path = b"/tmp/fork_accept_9031.sock"; + let resolved = crate::path::resolve_path(path, b"/"); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + + let mut host = MockHostIO::new(); + let mut parent = Process::new(PARENT); + let server_fd = sys_socket(&mut parent, &mut host, 1, 1, 0).unwrap(); + let mut addr = [0u8; 110]; + addr[0] = 1; + addr[2..2 + path.len()].copy_from_slice(path); + let addrlen = 2 + path.len() + 1; + sys_bind(&mut parent, &mut host, server_fd, &addr[..addrlen]).unwrap(); + sys_listen(&mut parent, &mut host, server_fd, 5).unwrap(); + + let mut table = ProcessTable::new(); + table.processes.insert(PARENT, parent); + table.fork_process(PARENT, CHILD).unwrap(); + + let client_fd = { + let parent = table.get_mut(PARENT).unwrap(); + let fd = sys_socket(parent, &mut host, 1, 1, 0).unwrap(); + sys_connect(parent, &mut host, fd, &addr[..addrlen]).unwrap(); + fd + }; + let accepted_fd = { + let child = table.get_mut(CHILD).unwrap(); + sys_accept(child, &mut host, server_fd).unwrap() + }; + + assert_eq!( + sys_send( + table.get_mut(PARENT).unwrap(), + &mut host, + client_fd, + b"shared queue", + 0, + ) + .unwrap(), + 12, + ); + let mut buf = [0u8; 16]; + let received = sys_recv( + table.get_mut(CHILD).unwrap(), + &mut host, + accepted_fd, + &mut buf, + 0, + ) + .unwrap(); + assert_eq!(&buf[..received], b"shared queue"); + + { + let child = table.get_mut(CHILD).unwrap(); + sys_close(child, &mut host, accepted_fd).unwrap(); + sys_close(child, &mut host, server_fd).unwrap(); + } + { + let parent = table.get_mut(PARENT).unwrap(); + sys_close(parent, &mut host, client_fd).unwrap(); + sys_close(parent, &mut host, server_fd).unwrap(); + } + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + } + #[test] fn test_unix_stream_connect_pushes_accept_wakeup() { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); @@ -21968,12 +23654,18 @@ mod tests { let mut proc = Process::new(1); let mut host = MockHostIO::new(); let fd = sys_eventfd2(&mut proc, 42, 0).unwrap(); + let backing_idx = { + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (-(ofd.host_handle + 1)) as usize + }; + let backing_generation = + descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); // Close the eventfd sys_close(&mut proc, &mut host, fd).unwrap(); - // eventfd slot should be freed - assert!(proc.eventfds[0].is_none()); + assert_descriptor_backing_released(FileType::EventFd, backing_idx, backing_generation); } #[test] @@ -22108,6 +23800,46 @@ mod tests { assert_eq!(vnsec, 0); } + #[test] + fn test_timerfd_settime_host_error_leaves_backing_unchanged() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_timerfd_create(&mut proc, 0, 0).unwrap(); + let backing_idx = descriptor_backing_idx(&proc, fd); + + sys_timerfd_settime(&mut proc, &mut host, fd, 1, 2, 3, 110, 4).unwrap(); + let before = crate::descriptor_backing::with_timerfds(|table| { + let timer = table.get(backing_idx).unwrap(); + ( + timer.interval_sec, + timer.interval_nsec, + timer.value_sec, + timer.value_nsec, + timer.expirations, + ) + }); + + host.clock_error = Some(Errno::EIO); + assert_eq!( + sys_timerfd_settime(&mut proc, &mut host, fd, 0, 9, 8, 7, 6), + Err(Errno::EIO) + ); + let after = crate::descriptor_backing::with_timerfds(|table| { + let timer = table.get(backing_idx).unwrap(); + ( + timer.interval_sec, + timer.interval_nsec, + timer.value_sec, + timer.value_nsec, + timer.expirations, + ) + }); + assert_eq!(after, before); + + host.clock_error = None; + sys_close(&mut proc, &mut host, fd).unwrap(); + } + #[test] fn test_timerfd_settime_absolute() { let mut proc = Process::new(1); @@ -24275,7 +26007,7 @@ mod tests { } #[test] - fn execve_releases_fb_binding_and_unbinds() { + fn execve_unbinds_fb_mapping_but_keeps_open_fd_owner() { use core::sync::atomic::Ordering; use wasm_posix_shared::flags::O_RDWR; use wasm_posix_shared::mmap::{MAP_SHARED, PROT_READ, PROT_WRITE}; @@ -24300,6 +26032,11 @@ mod tests { sys_execve(&mut proc, &mut host, b"/bin/sh").unwrap(); assert!(proc.fb_binding.is_none()); assert_eq!(host.unbind_framebuffer_calls, alloc::vec![proc.pid as i32]); + assert_eq!( + crate::process_table::FB0_OWNER.load(Ordering::SeqCst), + proc.pid as i32 + ); + sys_close(&mut proc, &mut host, fd).unwrap(); assert_eq!(crate::process_table::FB0_OWNER.load(Ordering::SeqCst), -1); } @@ -24462,7 +26199,7 @@ mod tests { } #[test] - fn exec_clears_mice_owner() { + fn exec_keeps_mice_owner_and_queue_for_open_fd() { use core::sync::atomic::Ordering; let _g = MICE_OWNER_LOCK.lock().unwrap(); reset_mice_state(); @@ -24477,11 +26214,53 @@ mod tests { crate::mouse::inject_event(2, 2, 0); sys_execve(&mut proc, &mut host, b"/bin/sh").unwrap(); - assert_eq!(crate::mouse::MICE_OWNER.load(Ordering::SeqCst), -1); + assert_eq!( + crate::mouse::MICE_OWNER.load(Ordering::SeqCst), + proc.pid as i32 + ); assert!( - !crate::mouse::has_data(), - "exec should reset the queue when releasing ownership" + crate::mouse::has_data(), + "exec should retain packets behind a surviving open fd" + ); + sys_close(&mut proc, &mut host, _fd).unwrap(); + } + + #[test] + fn exec_keeps_device_state_through_surviving_high_fd_alias() { + use core::sync::atomic::Ordering; + let _g = MICE_OWNER_LOCK.lock().unwrap(); + reset_mice_state(); + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + sys_setrlimit(&mut proc, 7, 4096, 4096).unwrap(); + let cloexec_fd = + sys_open(&mut proc, &mut host, b"/dev/input/mice", O_RDONLY, 0).unwrap(); + proc.fd_table.get_mut(cloexec_fd).unwrap().fd_flags = FD_CLOEXEC; + let retained_fd = sys_fcntl(&mut proc, cloexec_fd, F_DUPFD, 2048).unwrap(); + assert_eq!(retained_fd, 2048); + + crate::mouse::inject_event(7, -3, 1); + let pid = proc.pid; + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + assert!(proc.fd_table.get(cloexec_fd).is_err()); + assert!(proc.fd_table.get(retained_fd).is_ok()); + assert_eq!( + crate::mouse::MICE_OWNER.load(Ordering::SeqCst), + proc.pid as i32 + ); + let mut packet = [0u8; 3]; + assert_eq!( + sys_read(&mut proc, &mut host, retained_fd, &mut packet), + Ok(3) ); + assert_eq!(packet[1] as i8, 7); + assert_eq!(packet[2] as i8, -3); + + sys_close(&mut proc, &mut host, retained_fd).unwrap(); + assert_eq!(crate::mouse::MICE_OWNER.load(Ordering::SeqCst), -1); + assert!(!crate::mouse::has_data()); } // ----------------------------------------------------------------- @@ -24684,7 +26463,7 @@ mod tests { } #[test] - fn exec_clears_dsp_owner() { + fn exec_keeps_dsp_owner_and_ring_for_open_fd() { use core::sync::atomic::Ordering; let _g = DSP_OWNER_LOCK.lock().unwrap(); reset_dsp_state(); @@ -24699,12 +26478,16 @@ mod tests { sys_write(&mut proc, &mut host, _fd, &[5, 5, 5, 5]).unwrap(); sys_execve(&mut proc, &mut host, b"/bin/sh").unwrap(); - assert_eq!(crate::audio::DSP_OWNER.load(Ordering::SeqCst), -1); + assert_eq!( + crate::audio::DSP_OWNER.load(Ordering::SeqCst), + proc.pid as i32 + ); assert_eq!( crate::audio::pending_bytes(), - 0, - "exec should drain the ring when releasing ownership" + 4, + "exec should retain samples behind a surviving open fd" ); + sys_close(&mut proc, &mut host, _fd).unwrap(); } // ----------------------------------------------------------------- diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 74310931b..16583c446 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -17,7 +17,6 @@ extern crate alloc; use alloc::vec::Vec; use core::slice; -use wasm_posix_shared::fd_flags::FD_CLOEXEC; use wasm_posix_shared::{Errno, WasmDirent, WasmStat, WasmStatfs, WasmTimespec}; use crate::ofd::FileType; @@ -1188,6 +1187,15 @@ fn ensure_memory_covers(_end_addr: usize) { // No-op on non-Wasm targets (tests) } +fn terminate_process_by_signal(proc: &mut Process, host: &mut WasmHostIO, signum: u32) { + proc.sigsuspend_saved_mask = None; + for thread in &mut proc.threads { + thread.signals.sigsuspend_saved_mask = None; + } + syscalls::sys_exit(proc, host, 0); + proc.exit_signal = signum & 0x7f; +} + // 3c. Signal delivery at syscall boundaries // --------------------------------------------------------------------------- @@ -1195,7 +1203,6 @@ fn ensure_memory_covers(_end_addr: usize) { fn deliver_pending_signals(proc: &mut Process, host: &mut WasmHostIO) { use crate::signal::{DefaultAction, SignalHandler, default_action}; let tid = crate::process_table::current_tid(); - let _ = host; loop { // Caught signals are delivered by the glue code via // kernel_dequeue_signal; default and ignored signals are consumed here. @@ -1214,8 +1221,7 @@ fn deliver_pending_signals(proc: &mut Process, host: &mut WasmHostIO) { let _ = dequeue_signal_for(proc, tid, signum); match default_action(signum) { DefaultAction::Terminate | DefaultAction::CoreDump => { - proc.state = crate::process::ProcessState::Exited; - proc.exit_status = 128 + signum as i32; + terminate_process_by_signal(proc, host, signum); } _ => {} } @@ -1489,6 +1495,46 @@ pub extern "C" fn kernel_set_process_argv(pid: u32, data_ptr: *const u8, data_le } } +/// Clear one process string vector before bounded, entry-at-a-time replacement. +/// +/// `kind == 0` selects argv and `kind == 1` selects the environment. The host +/// uses this together with `kernel_push_process_metadata_entry` instead of +/// copying an arbitrarily large NUL-joined payload into its fixed-size scratch +/// allocation. Clearing without any subsequent pushes deliberately represents +/// an empty vector, which is required when exec installs an empty environment. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_clear_process_metadata(pid: u32, kind: u32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let Some(proc) = table.get_mut(pid) else { + return -(Errno::ESRCH as i32); + }; + match proc.clear_metadata(kind) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +/// Append one argv or environment entry from the host's bounded scratch area. +/// Empty entries are preserved; entry boundaries are supplied by the call +/// itself rather than inferred from NUL bytes. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_push_process_metadata_entry( + pid: u32, + kind: u32, + data_ptr: *const u8, + data_len: u32, +) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let Some(proc) = table.get_mut(pid) else { + return -(Errno::ESRCH as i32); + }; + let data = unsafe { core::slice::from_raw_parts(data_ptr, data_len as usize) }; + match proc.push_metadata_entry(kind, data) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + fn finish_removed_process(pid: u32, result: crate::process_table::RemoveProcessResult) { use core::sync::atomic::Ordering; @@ -1660,13 +1706,35 @@ pub extern "C" fn kernel_clear_fork_child(pid: u32) -> i32 { } } -/// Get process exit status. -/// Returns exit_status if process is exited, -1 if still alive, -ESRCH if not found. +/// Get the shell-style process exit status used by the host lifecycle scan. +/// Returns a normal exit code, 128+signal for signal termination, -1 while +/// alive, or -ESRCH when the process does not exist. #[unsafe(no_mangle)] pub extern "C" fn kernel_get_process_exit_status(pid: u32) -> i32 { let table = unsafe { &*PROCESS_TABLE.0.get() }; match table.get(pid) { - Some(proc) if proc.state == crate::process::ProcessState::Exited => proc.exit_status, + Some(proc) if proc.state == crate::process::ProcessState::Exited => { + if proc.exit_signal != 0 { + 128 + proc.exit_signal as i32 + } else { + proc.exit_status + } + } + Some(_) => -1, + None => -(Errno::ESRCH as i32), + } +} + +/// Return the signal that terminated an exited process, or zero for a normal +/// exit. Returns -1 while the process is alive and -ESRCH when it is absent. +/// Hosts use this explicit cause instead of guessing from high exit codes. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_get_process_exit_signal(pid: u32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + match table.get(pid) { + Some(proc) if proc.state == crate::process::ProcessState::Exited => { + proc.exit_signal as i32 + } Some(_) => -1, None => -(Errno::ESRCH as i32), } @@ -1689,9 +1757,13 @@ pub extern "C" fn kernel_get_parent_pid(pid: u32) -> i32 { #[unsafe(no_mangle)] pub extern "C" fn kernel_mark_process_signaled(pid: u32, signum: u32) -> i32 { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - match table.mark_process_signaled(pid, signum) { - Ok(()) => 0, - Err(e) => -(e as i32), + match table.get_mut(pid) { + Some(proc) => { + let mut host = WasmHostIO; + terminate_process_by_signal(proc, &mut host, signum); + 0 + } + None => -(Errno::ESRCH as i32), } } @@ -1900,6 +1972,28 @@ pub extern "C" fn kernel_get_fd_path(pid: u32, fd: i32, buf_ptr: *mut u8, buf_le } } +/// Return 1 when `fd` names a live descriptor in `pid`, 0 when it does not, +/// and a negative errno when the process itself is absent. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_fd_is_open(pid: u32, fd: i32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + match table.get(pid) { + Some(proc) => i32::from(proc.fd_table.get(fd).is_ok()), + None => -(Errno::ESRCH as i32), + } +} + +/// Return 1 when `fd` can back host-persisted MAP_SHARED writeback, 0 for an +/// unsupported or absent descriptor, and `-ESRCH` when `pid` is absent. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_fd_supports_mmap_writeback(pid: u32, fd: i32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + match table.get(pid) { + Some(proc) => i32::from(syscalls::fd_supports_mmap_writeback(proc, fd)), + None => -(Errno::ESRCH as i32), + } +} + /// Snapshot the process table for the host (Kandelo Inspector → Procs tab, /// and any host that wants a `ps`-equivalent view without spawning a user /// process). Walks every active pid and writes a compact, length-prefixed @@ -2133,13 +2227,8 @@ pub extern "C" fn kernel_dequeue_signal(pid: u32, out_ptr: *mut u8) -> i32 { let _ = dequeue_signal_for(proc, tid, signum); match default_action(signum) { DefaultAction::Terminate | DefaultAction::CoreDump => { - // Process is dying; clear sigsuspend state - proc.sigsuspend_saved_mask = None; - for t in proc.threads.iter_mut() { - t.signals.sigsuspend_saved_mask = None; - } - proc.state = crate::process::ProcessState::Exited; - proc.exit_status = 128 + signum as i32; + let mut host = WasmHostIO; + terminate_process_by_signal(proc, &mut host, signum); return 0; } _ => continue, @@ -2197,115 +2286,107 @@ fn dequeue_signal_for( } /// Handle exec semantics on a process in the process table. -/// Serializes the process as exec state (closes CLOEXEC, resets handlers), then -/// re-creates the process from that sanitized state. +/// Closes CLOEXEC descriptors and resets image-specific state in place so +/// surviving kernel objects retain their exact identity and queues. /// Returns 0 on success, negative errno on error. #[unsafe(no_mangle)] pub extern "C" fn kernel_exec_setup(pid: u32) -> i32 { + kernel_exec_setup_inner(pid, pid) +} + +/// Thread-aware exec setup. When a pthread invokes exec, its signal mask and +/// directed pending signals become the surviving process thread's state. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_exec_setup_for_thread(pid: u32, caller_tid: u32) -> i32 { + kernel_exec_setup_inner(pid, caller_tid) +} + +/// Validate the exec caller and apply any deferred posix_spawn file actions. +/// +/// The host calls this before it starts the irreversible address-space +/// transition. Keeping these fallible operations separate means a bad caller +/// tid or failed file action cannot strand a process after its old image has +/// already been discarded. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_exec_prepare(pid: u32, caller_tid: u32) -> i32 { + match prepare_exec_state(pid, caller_tid) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +fn prepare_exec_state(pid: u32, caller_tid: u32) -> Result<(), Errno> { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let proc = table.get_mut(pid).ok_or(Errno::ESRCH)?; + + if proc.state != crate::process::ProcessState::Running { + return Err(Errno::ESRCH); + } + + if caller_tid != 0 + && caller_tid != pid + && !proc.threads.iter().any(|thread| thread.tid == caller_tid) { - let proc = match table.get_mut(pid) { - Some(p) => p, - None => return -(Errno::ESRCH as i32), - }; + return Err(Errno::ESRCH); + } - // Apply pending fork fd actions (from posix_spawn) before exec. - // These are dup2/close ops that rearrange fds (e.g., pipe write end → fd 1) - // and must take effect before CLOEXEC removal during exec serialization. - if !proc.fork_fd_actions.is_empty() { - let actions: alloc::vec::Vec<_> = proc.fork_fd_actions.drain(..).collect(); - let mut host = WasmHostIO; - for action in actions { - use crate::process::FdAction; - match action { - FdAction::Dup2 { old_fd, new_fd } => { - if let Err(e) = syscalls::sys_dup2(proc, &mut host, old_fd, new_fd) { - return -(e as i32); - } - } - FdAction::Close { fd } => { - if let Err(e) = syscalls::sys_close(proc, &mut host, fd) { - return -(e as i32); - } - } - FdAction::Open { - fd, - ref path, - flags, - mode, - } => { - match syscalls::sys_open(proc, &mut host, path, flags as u32, mode as u32) { - Ok(opened_fd) => { - if opened_fd != fd { - if let Err(e) = - syscalls::sys_dup2(proc, &mut host, opened_fd, fd) - { - return -(e as i32); - } - let _ = syscalls::sys_close(proc, &mut host, opened_fd); - } - } - Err(e) => return -(e as i32), - } - } + // Apply pending fork fd actions (from posix_spawn) before exec. + // These are dup2/close/open operations that rearrange descriptors (for + // example, a pipe write end onto fd 1) and must precede CLOEXEC removal. + let actions: alloc::vec::Vec<_> = proc.fork_fd_actions.drain(..).collect(); + let mut host = WasmHostIO; + for action in actions { + use crate::process::FdAction; + match action { + FdAction::Dup2 { old_fd, new_fd } => { + syscalls::sys_dup2(proc, &mut host, old_fd, new_fd)?; + } + FdAction::Close { fd } => { + syscalls::sys_close(proc, &mut host, fd)?; + } + FdAction::Open { + fd, + ref path, + flags, + mode, + } => { + let opened_fd = + syscalls::sys_open(proc, &mut host, path, flags as u32, mode as u32)?; + if opened_fd != fd { + syscalls::sys_dup2(proc, &mut host, opened_fd, fd)?; + let _ = syscalls::sys_close(proc, &mut host, opened_fd); } } } } + Ok(()) +} - // Close CLOEXEC fds BEFORE serialization so pipe/host-handle refcounts - // are properly decremented. Without this, the exec serialization silently - // drops CLOEXEC fds from the FD table without adjusting global refcounts, - // leaving pipes with phantom writers and preventing EOF on reads. - { - let proc = match table.get_mut(pid) { - Some(p) => p, +fn kernel_exec_setup_inner(pid: u32, caller_tid: u32) -> i32 { + // Compatibility fallback for hosts that have not adopted the explicit + // prepare step yet. New hosts call kernel_exec_prepare first, leaving no + // actions here; validating twice is deliberate and harmless. + let has_pending_actions = { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + match table.get(pid) { + Some(proc) => !proc.fork_fd_actions.is_empty(), None => return -(Errno::ESRCH as i32), - }; - let cloexec_fds: alloc::vec::Vec = proc - .fd_table - .iter() - .filter(|(_, entry)| entry.fd_flags & FD_CLOEXEC != 0) - .map(|(fd, _)| fd) - .collect(); - let mut host = WasmHostIO; - for fd in cloexec_fds { - let _ = syscalls::sys_close(proc, &mut host, fd); + } + }; + if has_pending_actions { + if let Err(e) = prepare_exec_state(pid, caller_tid) { + return -(e as i32); } } - { - let proc = match table.get_mut(pid) { - Some(p) => p, - None => return -(Errno::ESRCH as i32), - }; - let mut host = WasmHostIO; - syscalls::release_exec_image_state(proc, &mut host); - } - - // Re-borrow after fd action scope ends - let proc = match table.get(pid) { - Some(p) => p, + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let proc = match table.get_mut(pid) { + Some(proc) => proc, None => return -(Errno::ESRCH as i32), }; - - // Serialize as exec state (signal handler reset, etc.) - // CLOEXEC fds were already closed above, so serialization just preserves what's left. - let mut buf = alloc::vec![0u8; 64 * 1024]; - let written = match crate::fork::serialize_exec_state(proc, &mut buf) { - Ok(n) => n, - Err(e) => return -(e as i32), - }; - - // Deserialize back to replace the process with exec-sanitized version - match crate::fork::deserialize_exec_state(&buf[..written], pid) { - Ok(new_proc) => { - table.get_mut(pid).map(|p| { - *p = new_proc; - p.has_exec = true; - }); - 0 - } + let mut host = WasmHostIO; + match syscalls::commit_exec_state(proc, &mut host, caller_tid) { + Ok(()) => 0, Err(e) => -(e as i32), } } @@ -4494,11 +4575,16 @@ pub extern "C" fn kernel_get_fork_state(buf_ptr: *mut u8, buf_len: u32) -> i32 { /// Initialize kernel from serialized fork state (child side). #[unsafe(no_mangle)] pub extern "C" fn kernel_init_from_fork(buf_ptr: *const u8, buf_len: u32, child_pid: u32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + if table.get(child_pid).is_some() { + return -(Errno::EEXIST as i32); + } let buf = unsafe { core::slice::from_raw_parts(buf_ptr, buf_len as usize) }; match crate::fork::deserialize_fork_state(buf, child_pid) { Ok(proc) => { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - table.processes.insert(child_pid, proc); + if let Err(e) = table.insert_legacy_fork_process(proc) { + return -(e as i32); + } table.set_current_pid(child_pid); 0 } @@ -4523,11 +4609,13 @@ pub extern "C" fn kernel_get_exec_state(buf_ptr: *mut u8, buf_len: u32) -> i32 { /// Returns 0 on success, negative errno on error. #[unsafe(no_mangle)] pub extern "C" fn kernel_init_from_exec(buf_ptr: *const u8, buf_len: u32, pid: u32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; let buf = unsafe { core::slice::from_raw_parts(buf_ptr, buf_len as usize) }; match crate::fork::deserialize_exec_state(buf, pid) { Ok(proc) => { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - table.processes.insert(pid, proc); + if let Err(e) = table.replace_legacy_exec_process(pid, proc) { + return -(e as i32); + } table.set_current_pid(pid); 0 } @@ -6960,7 +7048,8 @@ pub extern "C" fn kernel_exit(status: i32) -> ! { if unsafe { host_is_thread_worker() } != 0 { // Thread exit: don't destroy shared process state (FDs, pipes, etc.). // Just set exit status and return — the glue will trap via unreachable. - proc.exit_status = status; + proc.exit_status = status & 0xff; + proc.exit_signal = 0; // Drop GKL guard before trapping } else { let mut host = WasmHostIO; @@ -7340,6 +7429,8 @@ fn cross_process_loopback_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> R peer_addr6: [0; 16], peer_is_ipv6: false, peer_port: client_port, + peer_pid: 0, + peer_sock_idx: None, recv_pipe_idx: pipe_a_idx, // server reads client's writes send_pipe_idx: pipe_b_idx, // server writes to client's reads }; @@ -7461,6 +7552,8 @@ fn cross_process_loopback_connect6( peer_addr6: client_addr6, peer_is_ipv6: true, peer_port: client_port, + peer_pid: 0, + peer_sock_idx: None, recv_pipe_idx: pipe_a_idx, send_pipe_idx: pipe_b_idx, }; @@ -7480,7 +7573,7 @@ fn cross_process_loopback_connect6( /// (possibly in a different process). fn cross_process_unix_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> Result<(), Errno> { use crate::pipe::PipeBuffer; - use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; + use crate::socket::{SocketDomain, SocketState, SocketType}; // Parse path from sockaddr_un if addr.len() < 3 { @@ -7548,6 +7641,10 @@ fn cross_process_unix_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> Resul { return Err(Errno::ECONNREFUSED); } + let shared_idx = listener + .shared_backlog_idx + .ok_or(Errno::ECONNREFUSED)?; + let accept_wake_idx = listener.accept_wake_idx; // Allocate pipes only after both endpoints have been validated, so a // stale or wrong-type registry entry cannot leak global pipe slots. @@ -7555,21 +7652,21 @@ fn cross_process_unix_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> Resul let pipe_a_idx = pipe_table.alloc(PipeBuffer::new(65536)); let pipe_b_idx = pipe_table.alloc(PipeBuffer::new(65536)); - // Create accepted socket in the listener's process - let mut accepted_sock = SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0); - accepted_sock.state = SocketState::Connected; - accepted_sock.recv_buf_idx = Some(pipe_a_idx); - accepted_sock.send_buf_idx = Some(pipe_b_idx); - accepted_sock.global_pipes = true; - let accepted_idx = listener_proc.sockets.alloc(accepted_sock); - - // Push to listener's backlog - let listener = listener_proc - .sockets - .get_mut(listener_sock_idx) - .ok_or(Errno::EBADF)?; - listener.listen_backlog.push(accepted_idx); - let accept_wake_idx = listener.accept_wake_idx; + let pending = crate::socket::PendingConnection { + peer_addr: [0; 4], + peer_addr6: [0; 16], + peer_is_ipv6: false, + peer_port: 0, + peer_pid: my_pid, + peer_sock_idx: Some(sock_idx), + recv_pipe_idx: pipe_a_idx, + send_pipe_idx: pipe_b_idx, + }; + if !unsafe { crate::socket::shared_listener_backlog_table().push(shared_idx, pending) } { + pipe_table.discard_unclaimed(pipe_a_idx); + pipe_table.discard_unclaimed(pipe_b_idx); + return Err(Errno::ECONNREFUSED); + } // Set up client socket (in current process) let client_proc = table.get_mut(my_pid).ok_or(Errno::ESRCH)?; @@ -7577,6 +7674,7 @@ fn cross_process_unix_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> Resul client.send_buf_idx = Some(pipe_a_idx); client.recv_buf_idx = Some(pipe_b_idx); client.state = SocketState::Connected; + client.peer_idx = None; client.global_pipes = true; if let Some(idx) = accept_wake_idx { @@ -10124,14 +10222,16 @@ pub extern "C" fn kernel_inject_connection( peer_addr6: [0; 16], peer_is_ipv6: false, peer_port: peer_port as u16, + peer_pid: 0, + peer_sock_idx: None, recv_pipe_idx, send_pipe_idx, }; let pushed = unsafe { crate::socket::shared_listener_backlog_table().push(shared_idx, pc) }; if !pushed { // Slot was freed (last listener closed concurrently) — release pipes - pipe_table.free_if_closed(recv_pipe_idx); - pipe_table.free_if_closed(send_pipe_idx); + pipe_table.discard_unclaimed(recv_pipe_idx); + pipe_table.discard_unclaimed(send_pipe_idx); return -(Errno::EBADF as i32); } @@ -10385,34 +10485,31 @@ pub extern "C" fn kernel_get_fd_pipe_idx(pid: u32, fd: i32) -> i32 { /// Returns -1 if the fd is not a listening socket with a wake token. #[unsafe(no_mangle)] pub extern "C" fn kernel_get_fd_accept_wake_idx(pid: u32, fd: i32) -> i32 { - use crate::ofd::FileType; - use crate::socket::SocketState; - let table = unsafe { &*PROCESS_TABLE.0.get() }; - let proc = match table.get(pid) { - Some(p) => p, - None => return -1, - }; - let entry = match proc.fd_table.get(fd) { - Ok(e) => e, - Err(_) => return -1, - }; - let ofd = match proc.ofd_table.get(entry.ofd_ref.0) { - Some(o) => o, - None => return -1, - }; - if ofd.file_type != FileType::Socket { + let Some(proc) = table.get(pid) else { return -1; - } - let sock_idx = (-(ofd.host_handle + 1)) as usize; - let sock = match proc.sockets.get(sock_idx) { - Some(s) => s, - None => return -1, }; - if sock.state != SocketState::Listening { + let Ok(entry) = proc.fd_table.get(fd) else { return -1; - } - sock.accept_wake_idx.map(|idx| idx as i32).unwrap_or(-1) + }; + syscalls::listener_accept_wake_for_entry(proc, entry) + .map(|idx| idx as i32) + .unwrap_or(-1) +} + +/// Find the lowest live listener fd carrying `wake_idx` in `pid`. +/// +/// The wake token identifies the shared listener/open-description state across +/// descriptor aliases. Iterating the kernel fd table lets the host remap a +/// listener mirror after exec closes a CLOEXEC alias without guessing the +/// process's current `RLIMIT_NOFILE` ceiling. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_find_listener_fd_by_accept_wake(pid: u32, wake_idx: u32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + table + .get(pid) + .and_then(|proc| syscalls::find_listener_fd_by_accept_wake(proc, wake_idx)) + .unwrap_or(-1) } /// Check if a file descriptor has O_NONBLOCK set. diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 5b016a715..06f21ee51 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -1147,6 +1147,7 @@ pub mod abi { ]; pub const HOST_ADAPTER_OPTIONAL_KERNEL_EXPORTS: &[&str] = &[ + "kernel_get_process_exit_signal", "kernel_reserve_host_region", "kernel_reserve_host_region_at", "kernel_set_cwd", diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 48c5b6c80..7e6757bc0 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -39,11 +39,28 @@ kernel. Specifically, any of the following requires an `ABI_VERSION` bump: fork-using user program. The kernel does not read these exports directly, but the host runtime in `host/src/worker-main.ts` does — a rename here silently breaks fork for every already-built binary. +- Changing the name, version, encoding, or role semantics of the + `kandelo.wpk_fork.capabilities` custom section. The host uses these claims to + decide whether a main/side-module pair can safely coordinate fork replay. - Renaming the ABI custom section or the process-expected globals. - Changing the meaning of a syscall argument, errno, or blocking behavior without changing its signature. **This is not caught structurally — reviewers must flag it and bump anyway.** +The fork-capability section has an explicit ABI transition rule. ABI 16 accepts +an absent section through the pre-existing five-export fallback, while treating +a present marker as authoritative. ABI 17 and later require the role marker. +Do not make absence an error on ABI 16: mandatory enforcement must land in the +same commit as the ABI 16-to-17 bump and regenerated snapshot. + +The current constituent branch remains ABI 16. Its runtime contains the +ABI-17 enforcement path, but that path is intentionally inactive until the +aggregate integration change performs the single ABI 16-to-17 reconciliation, +bumps `ABI_VERSION`, and regenerates the snapshot in that same commit. +For the same reason, `kernel_get_process_exit_signal` is an optional runtime +probe on ABI 16 rather than a required host-adapter export. Requiring it belongs +in that ABI-17 reconciliation, not under the existing ABI number. + Pure internal refactors (renaming a kernel-side function, reorganizing a source file, tightening a bound in a non-ABI type) are *not* ABI changes and do not require a bump. diff --git a/docs/architecture.md b/docs/architecture.md index b60ff145f..858ad1b16 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -212,7 +212,9 @@ Fork uses the in-tree `wasm-fork-instrument` tool to snapshot the Wasm call stac 2. Host's `kernel_fork` override calls `wpk_fork_unwind_begin(buf)`. The tool-injected export sets state to UNWINDING, initializes `current_pos = frames_start_offset` at `*(buf+0)`, and snapshots every mutable scalar global (including `__tls_base` and `__stack_pointer`) into the buffer's `saved_globals[]` area. 3. The return-to-caller chain unwinds; each instrumented function's postamble writes its frame to the buffer and bumps `current_pos`. 4. Once `_start` returns (top-of-stack), the host sends SYS_FORK through the channel. -5. Kernel's `kernel_fork_process` copies fd table, signals, env, CWD, etc. +5. Kernel's `kernel_fork_process` copies process metadata and the fd/OFD tables, + while inherited stateful descriptors retain references to their existing + kernel-global backings. 6. Host copies the parent's linear memory to a new `WebAssembly.Memory` and spawns a child worker. 7. Child worker calls `wpk_fork_rewind_begin(buf)` — the tool's export restores all saved globals. The host then calls `setupChannelBase(...)` (which reads the now-correct `__tls_base`) and invokes `_start`. 8. Each instrumented function's preamble sees state=REWINDING, reloads its frame, and re-enters the call site where the parent was interrupted. Eventually reaches the `kernel_fork` call site in the leaf function, which returns 0. @@ -220,13 +222,53 @@ Fork uses the in-tree `wasm-fork-instrument` tool to snapshot the Wasm call stac The instrumentation handles LLVM's new-EH `try_table` output correctly, including fork from inside C++ catch handlers. See [fork-instrumentation.md](fork-instrumentation.md) for the current guarantees and documented unanticipated Wasm-level carve-outs. +A fork reached directly inside an instrumented dlopened side module uses two +ordered state machines and two save buffers: side then main during unwind, main +then side during rewind. Versioned fork-instrument capability metadata lets +marker-present artifacts prove their role. The current ABI 16 source retains +its legacy five-export fallback; mandatory role claims and rejection of stale +call-graph artifacts are deferred to the aggregate ABI 16-to-17 bump and its +regenerated snapshot. Dlopen replay records both the parent's memory base and +exact table base, including null gaps left by failed loads. The supported +direct-main-to-side boundary and the remaining opaque cross-side callback +limitation are specified in +[fork-instrumentation.md](fork-instrumentation.md#fork-from-a-dlopened-side-module). + +Fork and non-forking spawn still copy each process's fd and OFD metadata. The +objects whose mutable state must remain identical across those copies use +refcounted kernel-global backings: eventfd counters, timerfd timers, signalfd +masks, memfd contents and cursors, and procfs snapshots and cursors. Pipes, +sockets, PTYs, terminal devices, and listener queues likewise retain their +existing global object identity. Ordinary regular-file OFD metadata, including +the seek position and status flags, is still copied rather than shared; that +remaining POSIX gap is tracked in [posix-status.md](posix-status.md) and +[future-improvements.md](future-improvements.md). + ### exec() 1. User calls `execve(path, argv, envp)` → kernel returns exec request to host 2. Host resolves `path` to a Wasm binary (via filesystem or program map) -3. `kernel_exec_setup` closes CLOEXEC fds, resets signals, and **resets the program break** (POSIX/Linux behavior — the prior program's brk does not carry over) -4. Host terminates the old worker -5. Host creates fresh `WebAssembly.Memory` and re-registers the PID +3. The host compiles the replacement module, checks its ABI marker, and preallocates its fresh `WebAssembly.Memory` before the irreversible transition. It also validates a 4 MiB combined argv/environment representation (UTF-8 strings, NUL terminators, and caller-width pointer entries, with each string limited to one 64 KiB scratch transfer); oversized metadata returns `E2BIG` to the old image. After commit, argv and environment entries cross into the kernel one at a time, so the fixed host scratch allocation is never overrun and an empty environment explicitly clears the prior one. +4. The host validates the exec caller and deferred file actions, then publishes + and flushes writable tracked mappings while the old image is still live. + Tracked shared file mappings hold a lifetime-stable host handle independent + of the guest fd, so closing the original fd does not by itself prevent + writeback. A failed flush leaves the old mapping trackers and SysV + attachments in place. + `kernel_exec_setup` then closes CLOEXEC fds and directory streams and resets + image-specific state **in place**, including the program break (POSIX/Linux + behavior — the prior program's brk does not carry over). Exact kernel objects + behind surviving descriptors are never fork-cloned or reconstructed: socket + queues, eventfd/epoll/timerfd/signalfd state, memfd contents, procfs + snapshots, terminal input, and OFD identity therefore survive without + refcount churn. After that commit the host forgets the old mapping trackers + and detaches SysV segments. The calling pthread's signal mask and directed + queue become the process state; sibling workers terminate. + `alarm()`/`ITIMER_REAL` survives, while `timer_create()` timers are deleted. + The conformance gaps in [posix-status.md](posix-status.md) still apply, + notably numeric-fd epoll tracking and main-thread-directed signal + attribution. +5. Host terminates the old process and sibling-thread workers, then re-registers the PID with the preallocated memory 6. Host parses the new binary's `__heap_base` export and calls `kernel_set_brk_base(pid, __heap_base)` so `brk(0)` returns a value above the new program's data + stack region 7. Host spawns a new worker with the new program binary 8. New program starts from `_start` with the given argv/envp @@ -341,7 +383,43 @@ The Rust ABI declaration in `crates/shared/src/lib.rs` is the source of truth fo Processes may export `__wasm_posix_thread_slots` to declare their maximum concurrent pthread count. A value of `-1` uses the host default, `0` allows no pthreads, and a positive value sets the exact per-process limit. The kernel worker creation options expose `defaultThreadSlots` for the `-1`/missing-export case. The built-in default is 1024: an intentionally arbitrary high limit meant to avoid pthread availability problems for most programs now that slots are reserved on demand. Hosts can lower or raise it with `defaultThreadSlots` when they need a different resource policy. This limit is a resource-control guard, not a static memory reservation. -`mmap` remains coherent because the kernel has one per-process address-space model for brk, mmap, and host-reserved dynamic control ranges. Automatic `mmap` starts at the process's `mmap_base`, not at the legacy fixed 64MB floor. `brk` growth succeeds only when the adjacent range is free; if an mmap region or host-reserved pthread slot occupies the next pages, `brk` fails by returning the old break. `MAP_FIXED`, `munmap`, and `mremap` growth are rejected when they would overlap the reserved prefix, legacy host-control range, or a host-reserved pthread slot. The host grows the process `WebAssembly.Memory` after successful brk/mmap/mremap syscalls and after dynamic pthread-slot reservations so returned guest addresses are backed before user code touches them. +`mmap` remains coherent because the kernel has one per-process address-space model for brk, mmap, and host-reserved dynamic control ranges. Automatic `mmap` starts at the process's `mmap_base`, not at the legacy fixed 64MB floor. A usable non-fixed address hint is preferred after rounding it down to the 64KB Wasm page boundary; an occupied or invalid hint falls back to the ordinary first-fit search. `brk` growth succeeds only when the adjacent range is free; if an mmap region or host-reserved pthread slot occupies the next pages, `brk` fails by returning the old break. `MAP_FIXED`, `munmap`, and `mremap` growth are rejected when they would overlap the reserved prefix, legacy host-control range, or a host-reserved pthread slot. `munmap` rounds its length up to a Wasm page before updating both kernel mappings and host-owned bindings. The host grows the process `WebAssembly.Memory` after successful brk/mmap/mremap syscalls and after dynamic pthread-slot reservations so returned guest addresses are backed before user code touches them. + +### Shared mapping coherence + +Different processes have different WebAssembly memories, so a pointer store in +one process cannot immediately mutate another process's linear memory. Kandelo +coordinates anonymous `MAP_SHARED`, SysV SHM attachments, and regular-file +`MAP_SHARED` mappings at guest-to-kernel syscall boundaries. For each mapping, +the host compares process memory with the snapshot that process last observed, +merges only changed byte runs into one authoritative backing, and then imports +peer updates into every stale alias in the calling process. Fork force-publishes +the parent before the child inherits the same backing; `exec`, exit, crash, +`munmap`, `mremap`, and `MAP_FIXED` update backing ownership explicitly. + +Regular-file mappings add a backend-qualified stable identity and a +lifetime-stable host handle. Node uses native device/inode identity; VFS +backends scope device/inode identity to the backend object, so hard links and +the same backend mounted at more than one path alias correctly without +colliding with a different backend. Dirty mapped data is published before +direct file reads or writes and before a private mapping takes its snapshot; +successful direct writes, truncation, allocation, splice, and copy operations +invalidate or refresh mapped cache pages. `msync`, replacement, unmap, exec, +and process teardown persist dirty pages through the stable handle, including +after the original guest fd is closed or the pathname is later unlinked or +renamed. + +This is syscall-boundary coherence, not shared physical memory. A process that +only performs direct loads/stores does not publish or import peer changes until +it crosses into the kernel. Futex waits and wakes also target the caller's own +process `SharedArrayBuffer`, so process-shared pthread mutexes/futexes remain +unsupported across PIDs. Shared mappings of in-kernel memfds return `ENOTSUP`, +as do file mappings on a backend that cannot provide stable identity (currently +OPFS reports zero inode identity); `MAP_PRIVATE` is unaffected. File bytes past +the current EOF are zero-filled or discarded on refresh/writeback rather than +raising Linux's `SIGBUS`, and writes made outside Kandelo's file syscall paths +are not detected. The boundary scans are on the syscall hot path; no performance +claim is made without before/after Node and browser benchmarks. Every spawn or exec computes a fresh layout from the target binary's memory import and `__heap_base`; the layout is per-process and is discarded when the process is unregistered. Fork children copy the parent's current memory length, not the configured maximum, and pthread workers share the owning process memory plus that process's thread allocator. WebAssembly memory cannot shrink, so a fork child may inherit the parent's current byte length, but it does not inherit dead parent pthread slot reservations. Correctness must not depend on page reloads, context resets, periodic kernel resets, or browser garbage collection reclaiming old shared memories. @@ -632,7 +710,7 @@ The kernel exposes an OSS-style `/dev/dsp` character device so unmodified Linux The kernel does **not** mix or synthesize audio. The user program (DOOM's mixer in `i_kernel_sound.c` plus the OPL2 software synth in `i_oplmusic.c` + `opl/opl3.c` for music) does that work and writes interleaved S16_LE frames; the kernel ring is just transport. fbDOOM's mixer produces 1280 stereo frames per ~28 ms game tic — slightly more than the 1260 frames the AudioContext consumes per tic — so the ring stays full enough to hide drain jitter, and the drop-oldest-on-overflow policy keeps memory bounded. -Single-open semantics match the typical OSS exclusive-grab model. Owner ownership is released on `close` of the last `/dev/dsp` fd, on `execve`, and on process exit; the ring is flushed at the same time so a successor open hears silence rather than the tail of the previous program. ABI version bumped 7 → 8 to register the new `kernel_drain_audio(i64, i32) -> i32` export plus the three readouts `kernel_audio_sample_rate / channels / pending`. The OSS ioctl encodings live in `crates/shared/src/lib.rs::oss`. +Single-open semantics match the typical OSS exclusive-grab model. Ownership is released on `close` of the last `/dev/dsp` fd or on process exit; a surviving non-CLOEXEC fd retains both ownership and queued samples across exec. The ring is flushed when ownership is released so a successor open hears silence rather than the previous owner's tail. ABI version bumped 7 → 8 to register the new `kernel_drain_audio(i64, i32) -> i32` export plus the three readouts `kernel_audio_sample_rate / channels / pending`. The OSS ioctl encodings live in `crates/shared/src/lib.rs::oss`. ## Signal Subsystem @@ -646,6 +724,13 @@ Signals are delivered at syscall boundaries. When a process has a pending signal Features: RT signal queuing with `si_value`, cross-process `kill`/`killpg`, `sigaltstack` with shadow stack swap, `sigsuspend`, `sigtimedwait`, `setitimer`/`alarm` via host timers. +Normal exit status and signal termination are stored separately. `_exit()` and +`exit_group()` retain the low eight status bits, including values 128 through +255; a default terminating signal records its signal number independently. +`waitpid()` therefore emits the POSIX wait encoding without guessing that a +high normal exit code was a signal, while host lifecycle callbacks may still +present the conventional shell-style `128 + signal` value. + ## Browser-Specific Architecture In the browser, an additional layer wraps the kernel: diff --git a/docs/browser-support.md b/docs/browser-support.md index 765f6933a..5b39918db 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -81,12 +81,17 @@ Injected TCP pipes live in the kernel's global pipe table (`pid == 0` for connection in any nginx worker. The standalone nginx image runs with `master_process on` and `worker_processes 2`. +AF_UNIX stream listeners use the same shared-queue ownership model. This is the +path used by pre-fork PHP-FPM workers: a connection is queued once and whichever +worker wins `accept()` materializes its own connected socket around the global +pipe pair. + ## Capabilities ### Multi-Process - `fork()` via `wasm-fork-instrument` snapshot/restore — child runs in new sub-worker with copied memory - `exec()` reads program binary from the shared filesystem, replaces process -- `posix_spawn()` — fork+exec with file actions (addchdir, addfchdir, addclose, adddup2) +- `posix_spawn()` — non-forking child creation with file actions (addchdir, addfchdir, addclose, adddup2) - Process groups, wait/waitpid, cross-process signals, pipes ### Threads @@ -105,8 +110,9 @@ connection in any nginx worker. The standalone nginx image runs with ### Filesystem - `MemoryFileSystem` — SharedArrayBuffer-based VFS shared between main thread and kernel worker -- `OpfsFileSystem` — Origin Private File System for browser persistence +- `OpfsFileSystem` — Origin Private File System for browser persistence. Its current stat metadata has no stable inode identity, so regular-file `MAP_SHARED` returns `ENOTSUP` instead of using unsafe pathname identity; `MAP_PRIVATE` is unaffected. - `DeviceFileSystem` — `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/ptmx` +- Stable-identity regular files can be shared across process memories through the host mapping cache, but updates become visible at syscall boundaries rather than immediately on direct loads/stores. Cross-process futex waits/wakes remain unsupported; see [architecture.md](architecture.md#shared-mapping-coherence). ### Terminal - PTY support with full line discipline @@ -130,7 +136,7 @@ connection in any nginx worker. The standalone nginx image runs with ### Audio output (`/dev/dsp`) - The kernel exposes an OSS-style `/dev/dsp` character device. User programs `open(O_WRONLY)`, configure rate / channels / format via `SNDCTL_DSP_*` ioctls, and `write()` interleaved 16-bit-LE PCM. The kernel buffers samples in a 256 KiB ring (~1.5 s of stereo S16 @ 44.1 kHz). On overflow the *oldest* whole frame drops — same trade-off real OSS hardware makes under hardware overrun. - Demo pages drive a `setInterval` loop (~50 ms cadence) that calls `BrowserKernel.drainAudio(maxBytes)`. The kernel-worker drains the ring via the `kernel_drain_audio` wasm export (which respects whole-frame boundaries so stereo L/R never tear) and posts the bytes back. Main thread converts S16 → Float32, builds an `AudioBuffer`, and schedules an `AudioBufferSourceNode` on the `AudioContext` clock with a small lookahead so brief drain hiccups don't underrun. -- Single-owner device. Owner is released on close-of-last-fd / `execve` / `exit`; the ring is flushed at the same time so a successor open starts from silence. Format must be `AFMT_S16_LE`; other formats are `EINVAL`. +- Single-owner device. A non-CLOEXEC fd retains ownership and queued samples across `execve`; last close or process exit releases the owner and flushes the ring so a successor starts from silence. Format must be `AFMT_S16_LE`; other formats are `EINVAL`. - **AudioContext gesture requirement.** `new AudioContext()` starts suspended in modern browsers and only resumes after a user gesture. The DOOM demo creates the context immediately after the user's "Start" click (which is itself a gesture), so `audioCtx.resume()` succeeds without a separate prompt. ## Browser Demos diff --git a/docs/fork-instrumentation.md b/docs/fork-instrumentation.md index 5ac9e6315..98c5350de 100644 --- a/docs/fork-instrumentation.md +++ b/docs/fork-instrumentation.md @@ -106,6 +106,35 @@ wpk_fork_state() -> i32 Returns current state. Exported for host-side assertions. ``` +The five exports identify the state-machine ABI, but they do not prove which +import seeded call-graph discovery. The tool therefore also emits the custom +section `kandelo.wpk_fork.capabilities`. Its two-byte payload is +`[version, flags]`; version 1 defines: + +- bit 0 (`0x01`): the module was instrumented with `--entry env.fork`, so an + `env.fork`-importing side module has complete side-entry coverage; +- bit 1 (`0x02`): a default-entry main module imported Kandelo's dynamic-linker + functions and conservatively instrumented every `call_indirect` boundary + plus its direct callers. + +Capability enforcement follows the compiled kernel ABI. ABI 16 predates this +section, so an artifact with no section retains the legacy five-export fallback +and can still coordinate a main/side-module fork. If an ABI-16 artifact does +carry the section, its marker is authoritative and malformed, unknown, or +role-inconsistent claims fail loudly. Starting with ABI 17, the +role-appropriate bit is mandatory: generic five-export artifacts and binaries +produced by the older call-graph pass fail with a rebuild diagnostic. This +threshold ensures that mandatory enforcement and the incompatible artifact +contract activate in the same ABI-bump commit. Changing the meaning or encoding +of these capability claims changes fork replay assumptions and must follow the +ABI-versioning policy. + +The current constituent source still declares ABI 16, so its compatibility +fallback is active. The mandatory ABI-17 branch is staged in the runtime but is +not a claim that ABI 17 has landed: activation is deferred to the aggregate +ABI 16-to-17 reconciliation commit, which must bump `ABI_VERSION` and regenerate +`abi/snapshot.json` together. + `ptr` is `i32` on wasm32 user programs and `i64` on wasm64 user programs. The tool picks the pointer width from the module's primary memory — a memory64 memory yields `i64`, anything else yields `i32`. @@ -155,6 +184,42 @@ This path is covered by `host/test/fork-instrument-coverage.test.ts` P-06 (`pthread_create` worker calls `fork`) and K-03 (`pthread_cleanup_push` handler calls `fork`). +## Fork from a dlopened side module + +The supported dynamic-linking shape is a direct main-module `call_indirect` +into one side-module instance whose call stack reaches `env.fork`: + +1. Instrument the main program normally. If it imports Kandelo's dlopen host + functions, the tool marks and preserves all possible dynamic indirect-call + boundaries. +2. Instrument the fork-capable side module with `--entry env.fork`. It receives + its own fork save buffer and versioned side-entry capability. +3. The process worker unwinds the side module, then the main module. Fork replay + restores dlopen instances at their exact memory and table bases, rewinds the + main module, then rewinds the active side module. + +The main fork trampoline is captured before side exports enter the symbol +table, so a later extension cannot interpose the coordinator's `fork` target. +Failed dlopen attempts may leave non-shrinkable null table gaps; each successful +archive entry records its exact parent table base, and child replay pads to and +validates that base. + +The loader preserves ordinary independent multi-extension loading. When a +fork-capable extension participates, it rejects statically visible +side-to-side function/GOT linkage and side-originated `dlopen`/`dlsym`, because +an intervening side-module frame would need a third ordered unwind. Opaque +function pointers passed through main-module memory or the shared table cannot +currently be attributed to their originating module; using such a pointer to +create a side A -> side B -> fork path is unsupported and is not yet guaranteed +to fail before control-flow corruption. A future module-activation protocol is +required to close that residual. Fork from a pthread into a dlopened side +module is also unsupported; pthread workers do not install the side-module +coordinator. + +Every participating module still uses the fixed 16 KiB save-buffer limit +described below. A dynamically allocated side buffer avoids overlap with the +main control slab but does not make deep/unbounded frame use safe. + ## Save buffer format All offsets are byte-exact, all values little-endian. `P` is pointer width diff --git a/docs/future-improvements.md b/docs/future-improvements.md index 2587342f2..fdba1bd81 100644 --- a/docs/future-improvements.md +++ b/docs/future-improvements.md @@ -1,15 +1,29 @@ # Future Improvements -Technical debt and improvement opportunities. None are bugs — all are deferred enhancements. +Technical debt, deferred enhancements, and explicitly documented conformance +gaps. Listing an item here does not imply that the current behavior is fully +supported. ## Kernel -### Per-process OFD storage breaks POSIX fork OFD-sharing -Open File Descriptions live inside `Process` (`crates/kernel/src/ofd.rs`'s `OfdTable`), not in a kernel-global table. POSIX.1-2017 §2.4.1 requires that "each of the child's file descriptors shall refer to the same open file description as the corresponding file descriptor of the parent" — meaning the seek pointer, status flags, and pending I/O state are SHARED across fork siblings. Our model deep-clones the parent's `OfdTable` into the child, so each process has independent copies — independent seek pointers, independent status flags after fork. - -A program that does `fork()` then both processes append to the same fd expecting interleaved output (a common idiom for cooperative log writers, parallel `make` job-server pipes, or any pattern that relies on shared-position semantics) will silently produce garbled output instead. No regression test exercises this today; it's structurally there. - -The cleanest redesign: move OFDs to a kernel-global `OfdTable` and have `Process` hold `FdTable` where `OfdRef` is a stable index. Fork's "fd inheritance" becomes the trivial pointer/refcount operation it should be (no deep clone, no per-resource cross-process refcount machinery). The non-forking `posix_spawn` work added a lot of refcount bookkeeping (host file handles, global pipes, PTYs, listener backlogs, host_net_handle) to compensate for the per-process model — that machinery would mostly disappear with kernel-global OFDs. +### Per-process ordinary OFD metadata still breaks POSIX fork sharing +Open File Descriptions live inside `Process` (`crates/kernel/src/ofd.rs`'s +`OfdTable`), not in a kernel-global table. POSIX requires a child descriptor to +refer to the same open file description as its parent counterpart. Kandelo now +retains exact refcounted backings for stateful objects that cannot be safely +reconstructed: pipes, sockets, PTYs, eventfd, timerfd, signalfd, memfd, and +procfs snapshots/cursors. Those fixes preserve the underlying object state but +do not make the ordinary OFD record itself global. + +Regular-file seek positions, status flags, and owners are therefore still +deep-copied at fork/spawn. A program that forks and coordinates writes through +one inherited regular fd can observe divergent positions or flag changes. + +The cleanest redesign is still to move OFDs to a kernel-global `OfdTable` and +have `Process` hold `FdTable`, where `OfdRef` is a stable index. Fork's +fd inheritance then becomes the pointer/refcount operation POSIX describes, +and much of the per-resource inheritance bookkeeping can collapse into the +global OFD lifetime. Cost of the redesign: locking / borrow-checker complexity around the global table, plus a careful migration that doesn't regress the syscall hot path. Worth scheduling on the next big initiative — the savings compound across fork, spawn, exec, and dup. @@ -30,6 +44,50 @@ When `host_call_signal_handler` fails (invalid function table index, handler thr **Files:** `crates/kernel/src/wasm_api.rs` — `deliver_pending_signals` +### Make cross-process shared memory immediate and futex-addressable + +Anonymous `MAP_SHARED` inherits one host-owned backing across fork; SysV SHM +and stable-identity regular-file mappings share backings across separately +attached or mapped processes. Because each PID still owns a different +WebAssembly `Memory`, coherence happens only when a process crosses a syscall +boundary: the host merges bytes changed relative to that process's snapshot and +then imports peer changes. A direct store does not immediately change another +PID's memory, and futex WAIT/WAKE cannot target the peer's separate +`SharedArrayBuffer`. + +Closing this gap requires a memory architecture or host protocol that supports +both immediate observation and wakeups, not just periodic byte merging. Any +design must preserve independent process address spaces, fork continuation, +Node/browser parity, and signal/cancellation behavior. It also needs explicit +performance evidence: the current boundary coordinator runs in the syscall hot +path, and its cost has not yet been established by before/after micro and full +application benchmarks on both hosts. + +**Files:** `host/src/kernel-worker.ts`, `host/src/worker-main.ts`, +`host/src/browser-kernel-worker-entry.ts`, +`host/src/node-kernel-worker-entry.ts` + +### Close the remaining regular-file `MAP_SHARED` gaps + +The mapping cache deliberately rejects objects it cannot identify or keep +alive safely. Current OPFS stats use inode zero, so OPFS `MAP_SHARED` returns +`ENOTSUP`; in-kernel memfds also return `ENOTSUP` because they do not expose the +host handle used by the file page cache. An initial mapping also needs to reopen +the descriptor's current pathname, so mapping an already renamed/unlinked fd +can fail even though an established mapping survives later close, rename, or +unlink through its stable handle. + +Further gaps are observable VM semantics rather than cache bookkeeping. Stores +beyond the current file size are zero-filled or discarded on refresh/writeback +instead of raising Linux `SIGBUS`, and writers outside Kandelo's direct file +syscall paths do not invalidate cached pages. Complete support needs stable +OPFS identity, a kernel-owned memfd mapping bridge, external invalidation (or a +documented ownership boundary), and a Wasm mechanism or instrumentation for +faulting beyond EOF. + +**Files:** `host/src/kernel-worker.ts`, `host/src/vfs/opfs.ts`, +`host/src/vfs/vfs.ts`, `crates/kernel/src/descriptor_backing.rs` + ## Browser ### PTY terminal integration with xterm.js diff --git a/docs/posix-status.md b/docs/posix-status.md index fa83ff6e0..63fc4e13f 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -20,7 +20,10 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve **Key properties:** - **Single kernel instance** with a `ProcessTable` mapping PIDs to `Process` structs - **Process workers** communicate with the kernel via channel IPC — each process/thread has a channel region in shared memory, and the kernel services syscalls one at a time from the JS event loop -- **Cross-process shared state** (open file descriptions, pipes, locks, IPC) is managed directly by the kernel — no extra SharedArrayBuffer structures needed per feature +- **Cross-process shared state** uses kernel-global or host-coordinated + backings where implemented. Pipes, locks, IPC objects, sockets, and selected + stateful descriptors retain one backing across fork; ordinary regular-file + OFD seek positions and status flags are still copied per process. - **Serialized syscall execution** — the kernel handles one syscall at a time, which provides natural atomicity for operations like O_APPEND writes and PIPE_BUF-sized pipe writes - **Signal delivery** across processes is direct — the kernel can write to any process's pending signal mask @@ -47,7 +50,7 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `dup()` | Full | Lowest available fd. FD_CLOEXEC cleared. Shares OFD with original. | | `dup2()` | Full | Atomic close-and-dup. Same-fd no-op. FD_CLOEXEC cleared. | | `dup3()` | Full | Like dup2 but returns EINVAL if oldfd==newfd. Supports O_CLOEXEC flag. | -| `pipe()` | Partial | Kernel-space ring buffer (64KB). PIPE_BUF=4096 atomicity guaranteed by serialized syscalls in the kernel. O_NONBLOCK enforced (EAGAIN). Cross-process pipes work naturally via shared OFD table after fork. | +| `pipe()` | Partial | Kernel-space ring buffer (64KB). PIPE_BUF=4096 atomicity is guaranteed by serialized kernel syscalls. O_NONBLOCK returns EAGAIN. Forked descriptors retain the same global pipe backing even though their per-process OFD metadata is copied. | | `pipe2()` | Full | Like pipe with O_NONBLOCK and O_CLOEXEC flag support. | | `readv()` | Full | Scatter read. Iterates over iovec array calling sys_read for each buffer. Stops on short read or EOF. | | `writev()` | Full | Gather write. Iterates over iovec array calling sys_write for each buffer. Stops on short write. | @@ -99,10 +102,10 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | Function | Status | Notes | |----------|--------|-------| -| `fork()` | Full | The kernel serializes full process state (FD/OFD tables, signals, environment, CWD, rlimits, brk, terminal), and the host spawns a child Worker with copied Memory. Child resumes execution at the `fork()` call site with return value 0 via the `wpk_fork_*` instrumentation injected by `wasm-fork-instrument` (Phase 7; see [fork-instrumentation.md](fork-instrumentation.md)) — the call stack, local variables, and `__tls_base`/`__stack_pointer` are preserved across the boundary. Fork from pthread workers is supported by routing the child through the saved pthread entry function and the calling thread's fork buffer. Cross-process pipes, signals, and waitpid all functional. | -| `exec()` | Full | Kernel-initiated via SYS_EXECVE (syscall 211). Host `handleExec` reads path/argv/envp from process memory, calls `onExec` callback. Replaces process image. Preserves PID, open fds (closes CLOEXEC), environment, CWD, signal mask. **Resets** the program break (POSIX-correct); host then re-installs the new program's `__heap_base` via `kernel_set_brk_base`. | +| `fork()` | Partial | The kernel copies process state and the host starts a child Worker with copied Memory. `wasm-fork-instrument` resumes the child at the call site with preserved stack locals and mutable globals. Main-thread and pthread fork are supported, as is the documented direct main-to-one-side-module path; nested/opaque cross-side callbacks and fork from a pthread inside a side module remain unsupported. Pipes, sockets, PTYs, eventfd/timerfd/signalfd, memfd, procfs snapshots, and shared mappings retain their existing backings; signal and wait lifecycle state is copied/coordinated by the kernel. Ordinary regular-file OFD seek positions/status flags are still copied rather than shared. See [fork-instrumentation.md](fork-instrumentation.md) and the known OFD gap below. | +| `exec()` | Partial | Kernel-initiated via SYS_EXECVE (syscall 211). The host preflights the module, ABI, replacement memory, caller, deferred file actions, and a 4 MiB combined argv/environment representation (strings, terminators, and pointer entries) before replacing the image in place; individual strings are limited to 64 KiB and oversize returns `E2BIG` without truncation. Preserves PID, non-CLOEXEC fds and their exact kernel-backed object state, new argv/envp (including an explicitly empty environment), CWD, the calling pthread's signal mask and directed queue, terminal queues, and `alarm()`/`ITIMER_REAL`; closes directory streams, deletes `timer_create()` timers, publishes and detaches old mappings, terminates sibling threads, and resets the program break before installing the new `__heap_base`. File mappings retain a stable writeback handle even after their original fd closes. Remaining gaps: POSIX message-queue descriptors are not process-owned and therefore cannot yet be closed on exec; epoll registrations track numeric fds rather than OFD identity, so close/dup and same-number replacement cases are incomplete; and main-thread-directed signals share the process-pending queue and therefore cannot be distinguished from process-directed signals when a worker pthread execs. | | `waitpid()` | Full | Kernel-internal: blocks parent until child exits (WNOHANG supported). Reaps zombie processes. Supports pid>0 (specific child), pid=-1 (any child), pid=0 (same pgid), pid<-1 (specific pgid). Returns normal-exit status with WIFEXITED/WEXITSTATUS and signal-death status with WIFSIGNALED/WTERMSIG. | -| `exit()` / `_exit()` | Full | Closes all fds and dir streams, releases all fcntl locks, sets ProcessState::Exited. SIGCHLD delivered to parent. Zombie state maintained until reaped by waitpid. | +| `exit()` / `_exit()` | Full | Closes all fds and dir streams, releases locks and mapping/backing ownership, and retains the low eight status bits. Normal codes 128–255 remain distinct from signal termination, which is stored separately. SIGCHLD is delivered to the parent and zombie state remains until `waitpid()` reaps it. | | `getpid()` | Full | Returns pid from Process struct. | | `getppid()` | Full | Returns ppid (0 for init process). | | `getuid()` / `geteuid()` | Full | Simulated; defaults to uid=0 (root). Configurable via setuid/seteuid. | @@ -118,11 +121,11 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `gettid()` | Partial | Returns pid for the main thread and the host-bound worker TID for pthread workers. Remaining limitation: this is Linux-compatible rather than POSIX-standard, and not all signal/thread APIs consume TID-specific state yet. | | `set_tid_address()` | Partial | Returns the calling TID and stores the clear-TID pointer for thread exit notification. Host thread cleanup writes 0 and futex-wakes the address for normal pthread exit and forced cleanup paths. Robust-list handling remains deferred. | | `set_robust_list()` | Stub | No-op. Robust futex list tracking deferred until threading is fully tested. | -| `futex()` | Partial | FUTEX_WAIT, FUTEX_WAKE, FUTEX_REQUEUE, FUTEX_CMP_REQUEUE, FUTEX_WAKE_OP implemented. Main-process WAIT returns EAGAIN so the host retries via Atomics.waitAsync. Thread workers use direct Atomics.wait. | -| `execve()` | Full | Delegates to kernel_execve. Replaces process image. | -| `execveat()` | Full | SYS_EXECVEAT (386). Resolves fd path via `kernel_get_fd_path`. Supports AT_EMPTY_PATH for `fexecve()`. Relative paths resolved against process CWD. | -| `fork()` (syscall) | Full | Glue traps to the kernel via channel IPC. Kernel serializes state, host callback spawns child Worker. Returns child pid to parent, 0 to child. Continuation across the fork boundary uses `wasm-fork-instrument`'s `wpk_fork_*` exports. | -| `vfork()` | Full | Alias for fork(). | +| `futex()` | Partial | FUTEX_WAIT, FUTEX_WAKE, FUTEX_REQUEUE, FUTEX_CMP_REQUEUE, and FUTEX_WAKE_OP operate on one process's shared memory. Main-process WAIT uses host `Atomics.waitAsync`; pthread workers use direct `Atomics.wait`. Separate processes have separate `SharedArrayBuffer` objects, so these operations do not wake or synchronize a peer PID even when the futex word lies in a host-coordinated MAP_SHARED mapping. | +| `execve()` | Partial | Delegates to the in-place `exec()` path and has the same remaining descriptor/signal/mapping limitations described above. | +| `execveat()` | Partial | SYS_EXECVEAT (386). Resolves fd path via `kernel_get_fd_path`, supports AT_EMPTY_PATH for `fexecve()`, and resolves relative paths against process CWD; otherwise has the same remaining `exec()` limitations. | +| `fork()` (syscall) | Partial | Glue traps through channel IPC; the kernel copies process state, the host starts a child Worker, and `wasm-fork-instrument` replays the supported call stack so parent/child receive the POSIX return values. The side-module and ordinary-OFD limitations in the main `fork()` row still apply. | +| `vfork()` | Partial | Alias for `fork()` and therefore has the same continuation/OFD limitations; it does not provide distinct vfork address-space semantics. | | `posix_spawn()` | Full | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). Host parses the blob, calls `kernel_spawn_process` to allocate a child pid + build the child Process descriptor, then invokes `onSpawn` to launch a fresh Worker. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | | `posix_spawnp()` | Full | PATH search lives in libc (`libc/musl-overlay/src/process/wasm32posix/posix_spawnp.c`); resolves the absolute path then delegates to `posix_spawn()`. Empty PATH entries treated as `.`; defers EACCES per `__execvpe` policy. | | `clone()` | Partial | Thread-style clone (CLONE_VM\|CLONE_THREAD) supported. The kernel allocates the TID, and the host spawns a thread Worker sharing the parent's Memory. Normal pthread return, pthread_exit, and cancellation cleanup remain per-thread and wake join/clear-TID waiters; uncaught fatal Wasm traps in a pthread worker terminate the whole process with signal-style wait status. | @@ -159,25 +162,26 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `sigsuspend()` | Full | Atomically replaces signal mask and blocks until deliverable signal arrives. Uses SharedArrayBuffer + Atomics.wait/notify for cross-thread wake. Always returns EINTR. | | `pause()` | Full | Suspends until a signal is delivered. Delegates to sigsuspend with current mask. Always returns EINTR. | | `raise()` | Full | Equivalent to kill(getpid(), sig). | -| `alarm()` | Full | Sets SIGALRM timer via host setTimeout. Returns previous remaining seconds. alarm(0) cancels. Not inherited by fork, canceled by exec. | +| `alarm()` | Full | Sets SIGALRM timer via host setTimeout. Returns previous remaining seconds. alarm(0) cancels. Not inherited by fork; preserved across exec. | | `setitimer()` | Full | ITIMER_REAL: sets alarm deadline + interval via host_set_alarm. ITIMER_VIRTUAL/ITIMER_PROF: no-op (no CPU time tracking). Fixes musl's alarm() which internally calls setitimer. | | `getitimer()` | Full | ITIMER_REAL: returns stored interval + remaining time from deadline. ITIMER_VIRTUAL/ITIMER_PROF: returns zero. | | `sigtimedwait()` | Full | Checks pending signals in mask, dequeues lowest. Returns si_signo, si_code (SI_USER/SI_QUEUE), and si_value in siginfo_t. Polls with 1ms sleep on timeout. Returns EAGAIN on timeout. | | `sigqueue()` / `rt_sigqueueinfo()` | Full | Sends signal with si_value. RT signals (32-63) are queued with FIFO ordering; standard signals (1-31) coalesced. si_code set to SI_QUEUE (-1). | | `rt_sigreturn()` | Stub | Returns 0. Signal trampoline handled by host. | -| `signalfd()` / `signalfd4()` | Full | Creates a file descriptor for accepting signals. Reads return `signalfd_siginfo` structs (128 bytes) for pending signals matching the mask. Supports poll() for readiness. | +| `signalfd()` / `signalfd4()` | Full | Creates a descriptor whose mask is held in a refcounted kernel-global backing, shared across inherited descriptors and retained by non-CLOEXEC exec. Reads return 128-byte `signalfd_siginfo` records for matching pending signals; poll readiness is supported. | ## Memory Management | Function | Status | Notes | |----------|--------|-------| -| `mmap()` | Partial | Anonymous, file-backed MAP_PRIVATE, and file-backed MAP_SHARED. Page-aligned (64KB Wasm pages). MAP_FIXED supported. Host populates file-backed regions via pread; MAP_SHARED regions are flushed on msync via pwrite. | -| `msync()` | Full | Flushes MAP_SHARED regions back to the file via pwrite. No-op for MAP_PRIVATE (correct per POSIX). | -| `shm_open()` / `shm_unlink()` | Full | musl maps to `/dev/shm/` paths; host rewrites to tmpdir on macOS. Works with MAP_SHARED mmap. | -| `munmap()` | Full | Removes tracked region. Page-aligned address required. Partial munmap supported: front trim, back trim, and middle split. | +| `mmap()` | Partial | Anonymous, regular-file `MAP_PRIVATE`, and regular-file `MAP_SHARED` mappings use 64 KiB Wasm pages. `MAP_FIXED` replaces the complete rounded page range; a usable non-fixed hint is rounded down and preferred without replacing occupied mappings. Anonymous and regular-file shared mappings inherited by fork converge at syscall boundaries through a host-owned backing, not immediately on direct loads/stores. Regular-file sharing requires stable backend device/inode identity and a reopenable path at initial map time. In-kernel memfd and identity-less backends (currently OPFS) return `ENOTSUP` for `MAP_SHARED`; `MAP_PRIVATE` still works. Bytes beyond EOF are zero-filled/dropped instead of delivering Linux `SIGBUS`, and external host writers are not detected. | +| `msync()` | Partial | Publishes the calling process's changed bytes and writes dirty regular-file pages through the stable mapping handle; `MAP_PRIVATE` remains private. Writeback is clipped to the current file size and reports `EIO` on a coherence/writeback failure. `MS_SYNC` versus `MS_ASYNC` scheduling is not distinguished, and visibility between processes is not immediate between syscalls. | +| `shm_open()` / `shm_unlink()` | Partial | musl maps names to `/dev/shm/` files (with the Node/macOS host rewrite). The resulting regular-file `MAP_SHARED` mappings work across fork and independent fds at syscall boundaries, subject to the file-mapping, futex, and EOF limitations in this section. | +| `munmap()` | Full | Removes tracked regions. The address must be 64KB-page-aligned; the length is rounded up to the next Wasm page. Partial munmap supports front trim, back trim, and middle split, including matching host-side MAP_SHARED tracking. | +| `mremap()` | Partial | Supports page-rounded shrink, in-place growth, and `MREMAP_MAYMOVE`; other flags are rejected. The host moves/resizes matching anonymous and file-shared tracking and preloads a file expansion before the destructive kernel step. Wasm cannot revoke the old bytes after a move, just as `munmap()` cannot make later direct access fault. | | `brk()` / `sbrk()` | Partial | Kernel-managed program break. Initial break installed by host from the program's `__heap_base` export via `kernel_set_brk_base` (16MB hardcoded fallback for binaries without `__heap_base`). Growing and shrinking supported. Inherited on `fork`; **reset** on `exec` and re-installed from the new program's `__heap_base` (POSIX-correct). | -| `mprotect()` | Partial | Returns success (no-op). Wasm linear memory has no page-level protection, so protection changes are silently accepted. | -| `memfd_create()` | Full | In-kernel anonymous file backed by Vec. MFD_CLOEXEC and MFD_ALLOW_SEALING flags. Supports read, write, lseek, ftruncate, fstat, mmap. | +| `mprotect()` | Partial | Wasm cannot enforce page protection on direct memory access. A successful write upgrade validates that an overlapping file-shared mapping has a lifetime-stable writable handle and marks the whole tracked interval writeback-eligible; that eligibility remains monotonic after a later downgrade. Other protection effects are not enforced. | +| `memfd_create()` | Full | In-kernel anonymous file backed by a refcounted global object whose contents and cursor survive fork/spawn and non-CLOEXEC exec. MFD_CLOEXEC and MFD_ALLOW_SEALING flags are supported. `MAP_PRIVATE` population works; `MAP_SHARED` deliberately returns `ENOTSUP` until memfd has a coherent mapping bridge. | ## Directory Operations @@ -231,8 +235,8 @@ shortcuts. | `socket()` | Partial | AF_UNIX, AF_INET, and AF_INET6 support SOCK_STREAM and SOCK_DGRAM. SOCK_NONBLOCK and SOCK_CLOEXEC flags are handled. AF_INET6 is limited to local `::`/`::1` routes; external and virtual-network IPv6 transports are not implemented. AF_INET SOCK_DGRAM uses kernel queues for loopback and a HostIO backend for routed virtual IPv4; external raw UDP is not exposed directly to userspace. | | `socketpair()` | Full | AF_UNIX SOCK_STREAM. Bidirectional ring buffers (64KB each). Returns pre-connected pair. | | `bind()` | Partial | AF_UNIX pathname and Linux abstract-namespace addresses, AF_INET TCP host-backed bind/listen, and AF_INET UDP in-kernel bind for INADDR_ANY, loopback, and broadcast addresses. AF_UNIX pathname bind creates a VFS inode with mode `0777 & ~umask`; `stat`/`lstat`/`fstatat`, `chmod`, and `chown` share that inode metadata, and the socket metadata remains until unlink after the final close. Abstract addresses create no VFS inode and become reusable after their final inherited owner closes. Registry state does not yet follow pathname hard links or renames. AF_INET6 accepts `::` and `::1`; stream and datagram binds use machine-wide conflict tables, and a non-`IPV6_V6ONLY` wildcard stream bind also reserves the IPv4 wildcard port. The browser local virtual-network backend supports AF_INET TCP/UDP binds between attached Kandelo machines, not IPv6. | -| `listen()` | Partial | AF_INET TCP delegates to the active HostIO networking backend, including Node `net` and the browser local virtual-network backend. AF_UNIX stream listen is implemented. AF_INET6 `::`/`::1` loopback listeners support same- and cross-process connections; external and virtual IPv6 listeners do not. Datagram listen rejects as unsupported. | -| `accept()` / `accept4()` | Partial | AF_INET TCP delegates to the active HostIO networking backend; AF_UNIX and AF_INET6 loopback streams return connected sockets from kernel queues. A dual-stack IPv6 listener reports IPv4 peers as IPv4-mapped `sockaddr_in6`. Linux-style accept does not inherit O_NONBLOCK; accept4 applies SOCK_NONBLOCK and SOCK_CLOEXEC explicitly and rejects other flags before consuming a pending connection. Datagram accept rejects as unsupported. | +| `listen()` | Partial | AF_INET TCP delegates to the active HostIO networking backend, including Node `net` and the browser local virtual-network backend. AF_UNIX stream listen is implemented. AF_INET, AF_INET6, and AF_UNIX listeners inherited by fork share one accept queue, so any surviving pre-fork worker can accept each connection once. AF_INET6 `::`/`::1` loopback listeners support same- and cross-process connections; external and virtual IPv6 listeners do not. Datagram listen rejects as unsupported. | +| `accept()` / `accept4()` | Partial | AF_INET TCP delegates to the active HostIO networking backend; AF_UNIX and AF_INET6 loopback streams return connected sockets from the shared kernel queue. A dual-stack IPv6 listener reports IPv4 peers as IPv4-mapped `sockaddr_in6`. Linux-style accept does not inherit O_NONBLOCK; accept4 applies SOCK_NONBLOCK and SOCK_CLOEXEC explicitly and rejects other flags before consuming a pending connection. Datagram accept rejects as unsupported. | | `connect()` | Partial | AF_UNIX streams support same- and cross-process pathname or abstract-namespace listeners. AF_UNIX datagrams deliver to a registered peer only within the same process; a missing, wrong-type, or cross-process peer returns ECONNREFUSED until machine-wide datagram routing exists. AF_INET TCP is host-backed and works over Node external TCP or the browser local virtual-network backend. AF_INET UDP connect stores the peer, auto-binds an ephemeral local port when needed, filters receives to the connected peer, and supports AF_UNSPEC unconnect. AF_INET6 streams support same- and cross-process `::1`; AF_INET6 datagrams are process-local and report `IPV6_V6ONLY=1` because dual-stack datagram routing is not implemented. Non-loopback IPv6 fails with EADDRNOTAVAIL for streams and ENETUNREACH for datagrams. External raw UDP also returns ENETUNREACH without another HostIO transport. | | `send()` / `recv()` | Partial | Unix domain streams and datagrams, AF_INET/AF_INET6 TCP streams, and connected AF_INET/AF_INET6 UDP preserve their socket-family addressing and datagram boundaries. TCP send/recv works over Node external TCP and the local virtual-network backend. Datagram MSG_PEEK and MSG_DONTWAIT are handled through recvfrom. Normal TCP close drains queued bytes before FIN and EOF; no transport invents a fixed post-FIN write count. A send rejected by a closed/reset stream returns EPIPE and raises SIGPIPE, while direct host/virtual handles may preserve ECONNRESET; accepted pipe-bridged resets currently surface as EOF/EPIPE. MSG_NOSIGNAL suppresses SIGPIPE without changing the errno. | | `sendto()` / `recvfrom()` | Partial | AF_INET, AF_INET6, and AF_UNIX datagrams support connected and unconnected send, receive queues, and connected-peer filtering. IPv4/IPv6 return sender addresses; AF_UNIX currently returns only the family. IPv4 limited-broadcast sends to `255.255.255.255` require `SO_BROADCAST` and fail with `EACCES` without it; enabling the option passes that permission gate, after which the send reaches the active routing/backend boundary. Kandelo does not itself model broadcast delivery. On AF_INET, AF_INET6, and AF_UNIX datagrams, Linux's input `MSG_TRUNC` extension returns the full datagram length while copying at most the caller's buffer; ordinary consume/`MSG_PEEK` behavior is unchanged. IPv4/IPv6 UDP receive queues hold 128 datagrams and drop a new arrival once full, preserving the accepted queue's order; `SO_RCVBUF` requests do not size that fixed queue, and `getsockopt` reports the fixed default capacity. AF_UNIX uses the same bound but preserves reliable delivery: a full queue blocks a blocking send through host retry and returns EAGAIN for `O_NONBLOCK`/`MSG_DONTWAIT`; capacity, association, shutdown, close, and pathname changes wake blocked sends and writable readiness waits to observe capacity or the new immediate error. In-kernel IPv4/IPv6 loopback, AF_UNIX datagram, and IPv4 multicast delivery currently reaches sockets in the sender's process only; machine-wide cross-process datagram routing remains unimplemented. Fork preserves kernel-local bind reservations and lookup ownership, but it does not yet share or transfer a host-backed UDP registration. The `10.88.*` LocalVirtualNetwork path can route IPv4 datagrams between attached Kandelo machines through HostIO for the process that registered the endpoint. IPv4 multicast supports interface selection, loop suppression, membership, and source filtering only; IPv6 multicast and external raw UDP are not implemented. | @@ -283,9 +287,9 @@ shortcuts. | Function | Status | Notes | |----------|--------|-------| -| `eventfd()` / `eventfd2()` | Full | Per-process u64 counter. read returns 8-byte counter value (blocks/EAGAIN if zero). write adds to counter. EFD_SEMAPHORE: read returns 1, decrements by 1. EFD_NONBLOCK, EFD_CLOEXEC supported. poll reports POLLIN when counter > 0, POLLOUT when writable. | -| `timerfd_create()` | Full | Creates timer fd with CLOCK_REALTIME or CLOCK_MONOTONIC. TFD_NONBLOCK and TFD_CLOEXEC flags. | -| `timerfd_settime()` / `timerfd_gettime()` | Full | Arms/disarms timer with interval and initial expiration. TFD_TIMER_ABSTIME for absolute time. read returns 8-byte expiration count. poll reports POLLIN when expired. | +| `eventfd()` / `eventfd2()` | Full | A refcounted kernel-global u64 counter is shared by inherited descriptors across fork/spawn and survives exec unless CLOEXEC. read returns the counter (or 1 for EFD_SEMAPHORE); write adds to it. EFD_NONBLOCK/EFD_CLOEXEC and poll readiness are supported. | +| `timerfd_create()` | Full | Creates a refcounted kernel-global timerfd backing with CLOCK_REALTIME or CLOCK_MONOTONIC. Inherited descriptors observe the same timer and expiration count; non-CLOEXEC state survives exec. TFD_NONBLOCK and TFD_CLOEXEC are supported. | +| `timerfd_settime()` / `timerfd_gettime()` | Full | Arms/disarms the shared timerfd backing with interval and initial expiration. TFD_TIMER_ABSTIME is supported; read returns the shared expiration count and poll reports POLLIN when expired. | | `inotify_init()` / `inotify_init1()` | Stub | Returns ENOSYS. | | `inotify_add_watch()` / `inotify_rm_watch()` | Stub | Returns EBADF. | | `fanotify_init()` / `fanotify_mark()` | Stub | Returns ENOSYS. | @@ -300,7 +304,7 @@ shortcuts. |----------|--------|-------| | `msgget()` / `msgsnd()` / `msgrcv()` / `msgctl()` | Full | Host-side SysV message queues via SharedIpcTable. Key-based creation, blocking send/recv with message types, IPC_STAT/IPC_SET/IPC_RMID control. | | `semget()` / `semop()` / `semctl()` / `semtimedop()` | Full | Host-side SysV semaphore sets. Atomic multi-semaphore operations, SEM_UNDO support, IPC_STAT/SETVAL/GETVAL/SETALL/GETALL. | -| `shmget()` / `shmat()` / `shmdt()` / `shmctl()` | Full | Host-side SysV shared memory segments. Attach/detach via kernel mmap, IPC_STAT/IPC_RMID control. Cross-process sharing via SharedIpcTable. | +| `shmget()` / `shmat()` / `shmdt()` / `shmctl()` | Partial | Host-side SysV shared-memory segments support IPC_STAT/IPC_RMID, fork inheritance, and exact attach/detach accounting. Separate process memories merge changed attachment bytes and import peer changes at syscall boundaries. Direct stores are not immediately visible and cross-process futex synchronization over an attachment is unsupported. | | `ftok()` | Full | Standard ftok algorithm using stat inode + proj_id. | | `mq_open()` / `mq_close()` / `mq_unlink()` | Full | Host-side POSIX message queues via PosixMqueueTable. O_CREAT/O_EXCL/O_RDONLY/O_WRONLY/O_RDWR/O_NONBLOCK. Descriptor range 0x40000000+. | | `mq_timedsend()` / `mq_timedreceive()` | Full | Priority-ordered message delivery. Blocking with timeout support. O_NONBLOCK returns EAGAIN. | @@ -344,10 +348,10 @@ shortcuts. | `/dev/tty` | Full | Controlling terminal. Opens the session's controlling PTY slave (ENXIO if none). | | `/dev/ptmx` | Full | PTY master multiplexer. `open()` allocates a new PTY pair, returns master fd. | | `/dev/pts/*` | Full | PTY slave devices. `posix_openpt()` + `grantpt()` + `unlockpt()` + `ptsname()`. Full line discipline, canonical/raw mode, OPOST/ONLCR, 16 terminal ioctls. | -| `/dev/fb0` | Full | Linux fbdev framebuffer. Single-open (`EBUSY` for second opener). 640×400 BGRA32 packed-pixel. ioctls: `FBIOGET_VSCREENINFO`, `FBIOGET_FSCREENINFO`, `FBIOPAN_DISPLAY` (no-op success), `FBIOPUT_VSCREENINFO` (validates geometry). `mmap` returns a region in process memory and notifies the host (`bind_framebuffer` callback) so the browser canvas can mirror pixels. `munmap`/`close`/`exit`/`exec` clean up. Linux-VT keyboard ioctls (`KDGKBTYPE`/`KDGKBMODE`/`KDSKBMODE`) accepted with sensible defaults so fbDOOM-style software works unmodified. | -| `/dev/input/mice` | Full | Linux `mousedev` PS/2 mouse stream. Single-open (`EBUSY` for second pid). 3-byte packets: byte0 button bits + sign/overflow flags, bytes 1..2 signed dx/dy with positive-up dy. Host pushes events via `kernel_inject_mouse_event(dx, dy, buttons)`; the kernel buffers up to 4096 packets (whole-packet drop on overflow). `read()` drains queued bytes; returns `EAGAIN` when empty. `poll()` reports `POLLIN` only when bytes are queued. Ownership released on `close`/`exec`/`exit`. No IMPS/2 wheel protocol, no `evdev`/`/dev/input/eventN`. | -| `/dev/dsp` | Full (write-only) | OSS-style PCM audio sink. Single-open (`EBUSY` for second pid). `write()` accepts interleaved 16-bit-LE PCM and buffers it in a 256 KiB ring; the host drains via the `kernel_drain_audio` wasm export and feeds a Web Audio `AudioContext`. ioctls: `SNDCTL_DSP_RESET`, `SNDCTL_DSP_SYNC`, `SNDCTL_DSP_SPEED` (clamp 4000–192000 Hz), `SNDCTL_DSP_STEREO` / `SNDCTL_DSP_CHANNELS` (1 or 2), `SNDCTL_DSP_SETFMT` (only `AFMT_S16_LE`), `SNDCTL_DSP_GETFMTS`, `SNDCTL_DSP_SETFRAGMENT` (accept-and-acknowledge). On overflow drops the *oldest whole frame* — never tears L/R alignment. Ownership released on close-of-last-fd / `execve` / `exit`; the ring is flushed at the same time. `read()` returns 0 (EOF-like). `poll()` reports `POLLOUT` always, never `POLLIN`. No record path, no `mmap`-based zero-copy; DOOM's mixer is in user space. | -| `/dev/shm/*` | Not yet | POSIX shared memory — requires cross-process SharedArrayBuffer. | +| `/dev/fb0` | Full | Linux fbdev framebuffer. Single-open (`EBUSY` for second opener). 640×400 BGRA32 packed-pixel. ioctls: `FBIOGET_VSCREENINFO`, `FBIOGET_FSCREENINFO`, `FBIOPAN_DISPLAY` (no-op success), `FBIOPUT_VSCREENINFO` (validates geometry). `mmap` returns a region in process memory and notifies the host (`bind_framebuffer` callback) so the browser canvas can mirror pixels. `munmap`/`exit`/`exec` discard the image mapping; a surviving fd retains device ownership across exec. Ownership is released after both the final fd and any live mapping are gone, since a mapping remains valid after `close()`. Linux-VT keyboard ioctls (`KDGKBTYPE`/`KDGKBMODE`/`KDSKBMODE`) accepted with sensible defaults so fbDOOM-style software works unmodified. | +| `/dev/input/mice` | Full | Linux `mousedev` PS/2 mouse stream. Single-open (`EBUSY` for second pid). 3-byte packets: byte0 button bits + sign/overflow flags, bytes 1..2 signed dx/dy with positive-up dy. Host pushes events via `kernel_inject_mouse_event(dx, dy, buttons)`; the kernel buffers up to 4096 packets (whole-packet drop on overflow). `read()` drains queued bytes; returns `EAGAIN` when empty. `poll()` reports `POLLIN` only when bytes are queued. Ownership and queued packets survive exec with a non-CLOEXEC fd; last close or exit releases and clears them. No IMPS/2 wheel protocol, no `evdev`/`/dev/input/eventN`. | +| `/dev/dsp` | Full (write-only) | OSS-style PCM audio sink. Single-open (`EBUSY` for second pid). `write()` accepts interleaved 16-bit-LE PCM and buffers it in a 256 KiB ring; the host drains via the `kernel_drain_audio` wasm export and feeds a Web Audio `AudioContext`. ioctls: `SNDCTL_DSP_RESET`, `SNDCTL_DSP_SYNC`, `SNDCTL_DSP_SPEED` (clamp 4000–192000 Hz), `SNDCTL_DSP_STEREO` / `SNDCTL_DSP_CHANNELS` (1 or 2), `SNDCTL_DSP_SETFMT` (only `AFMT_S16_LE`), `SNDCTL_DSP_GETFMTS`, `SNDCTL_DSP_SETFRAGMENT` (accept-and-acknowledge). On overflow drops the *oldest whole frame* — never tears L/R alignment. Ownership and queued samples survive exec with a non-CLOEXEC fd; last close or exit releases and flushes them. `read()` returns 0 (EOF-like). `poll()` reports `POLLOUT` always, never `POLLIN`. No record path, no `mmap`-based zero-copy; DOOM's mixer is in user space. | +| `/dev/shm/*` | Partial | POSIX shm objects are regular files used by `shm_open()`. Stable-identity backends support host-coordinated `MAP_SHARED` across processes at syscall boundaries; this is not immediate shared linear memory and does not make process-shared futexes work. | All virtual devices return synthetic `stat()` with `S_IFCHR | 0666`, deterministic inode numbers, and `st_dev=5`. Path interception in kernel before host delegation — no host filesystem changes needed. `access()` returns OK for all virtual devices. @@ -384,7 +388,7 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego | Gap | Subsystem | Description | |-----|-----------|-------------| -| **fork siblings have independent OFD seek positions** | fork / fd | POSIX.1-2017 §2.4.1 requires that fork children's fds refer to the **same** open file description as the parent — meaning seek pointer, status flags, and pending I/O state are SHARED. Our `OfdTable` lives inside `Process`, so fork deep-clones it; each process has independent OFD copies thereafter. A program that forks then both processes append to the same fd expecting interleaved output (cooperative log writers, parallel `make` job-server pipes, etc.) will silently produce garbled output. Tracked as a redesign item in `docs/future-improvements.md` ("Per-process OFD storage breaks POSIX fork OFD-sharing") — fix is to move OFDs to a kernel-global table with `Process` holding `FdTable`. | +| **fork siblings have independent ordinary-file OFD metadata** | fork / fd | POSIX requires the parent and child descriptors to refer to one open file description. Kandelo now retains exact global backings for pipes, sockets, PTYs, eventfd/timerfd/signalfd, memfd, and procfs snapshots, but it still deep-copies ordinary regular-file seek positions, status flags, and owners with the per-process `OfdTable`. Workloads that coordinate through one inherited regular fd can therefore observe divergent offsets or flags. The global-OFD redesign remains tracked in [future-improvements.md](future-improvements.md). | ### High — Missing features that affect common programs @@ -420,7 +424,7 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego | Gap | Subsystem | Reason | |-----|-----------|--------| | **mprotect() is a no-op** | memory | Returns success but does not enforce. Wasm linear memory has no page-level protection. | -| **No cross-process MAP_SHARED** | memory | MAP_SHARED works within one process address space (file-backed, with msync writeback). Cross-process shared memory would require SharedArrayBuffer coordination. The PHP opcache package therefore rejects its normal SHM mode and supports only explicitly configured `opcache.file_cache_only=1`; otherwise FPM workers would observe divergent cache and lock state. | +| **No immediate cross-process shared-memory or futex semantics** | memory | Anonymous, SysV, and stable-identity regular-file shared mappings now merge and refresh across processes at syscall boundaries. They are not one physical linear memory: direct stores remain private until a syscall, a peer spinning only on loads sees no update, and futex WAIT/WAKE targets only the caller's process `SharedArrayBuffer`. Process-shared pthread locks and PHP opcache's normal shared-memory locking model therefore remain unsupported. memfd `MAP_SHARED`, current OPFS file mappings, Linux `SIGBUS` on access beyond EOF, and detection of external host writes also remain gaps. | | **External raw UDP routes** | socket | AF_INET SOCK_DGRAM has POSIX-style in-kernel loopback/virtual semantics, but browsers cannot expose raw UDP and Node raw UDP is not yet wired behind HostIO. Non-loopback UDP routes currently return ENETUNREACH unless a future host backend/proxy handles them. | | **Setuid/setgid enforcement** | process | Single-user Wasm environment; privilege checks simulated only. | | **Permission checks** | filesystem | Delegated to host. Kernel does not independently verify file permissions. | @@ -433,19 +437,19 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego - `clone()` — CLONE_VM|CLONE_THREAD: kernel allocates TID, host spawns thread Worker sharing parent's Memory. TLS initialization via `__wasm_thread_init` export. - `gettid()` — returns actual TID for threads, pid for main thread - `set_tid_address()` — stores tidptr; kernel writes 0 + futex-wakes on thread exit (CLONE_CHILD_CLEARTID) -- `futex()` — WAIT/WAKE/REQUEUE/CMP_REQUEUE/WAKE_OP implemented; main-process WAIT returns EAGAIN (host retries via Atomics.waitAsync), thread workers use direct Atomics.wait +- `futex()` — WAIT/WAKE/REQUEUE/CMP_REQUEUE/WAKE_OP are implemented within one process; cross-process waits/wakes remain unsupported even over a coordinated shared mapping - `pthread_create` — works via clone(). Basic pthreads tested (mutex, join). Normal thread return, `pthread_exit`, and cancellation cleanup are per-thread; uncaught fatal Wasm traps in a pthread worker are process-fatal and visible to parent `waitpid()` as signal termination. Cancellation remains limited; see the Wasm-inherent gaps below. **Hard / Architectural:** -- Cross-process MAP_SHARED mmap (would need SharedArrayBuffer coordination between workers) +- Immediate cross-process MAP_SHARED visibility and process-shared futexes (would require one addressable shared backing or an equivalent wake protocol across process workers) - True async poll/select (replace polling loop with host-based event notification) -- SA_NOCLDWAIT / SA_NOCLDSTOP (stored but not acted upon; waitpid is host-delegated) +- SA_NOCLDSTOP behavior (the flag is stored but stop notifications/job control are not implemented) - Full VMIN/VTIME raw mode semantics (timer-based timeout) **Shared-kernel advantages (already free):** - O_APPEND atomicity (serialized syscalls) - PIPE_BUF atomicity (serialized syscalls) -- Cross-process eventfd/pipe/epoll sharing via shared OFD table +- Cross-process pipe/socket/PTY and eventfd/timerfd/signalfd/memfd/procfs backing identity across inherited descriptors - Signal delivery across processes is direct --- @@ -464,7 +468,7 @@ These features require SharedArrayBuffer (and cross-origin isolation headers in | `fcntl()` locking | Kernel-coordinated via atomic ops | postMessage round-trip, higher latency | | `pipe()` blocking read | Blocks worker until data available | Not supported without a worker/blocking bridge | | `nanosleep()` | `Atomics.wait()` with timeout | Not supported without a worker/blocking bridge | -| Multi-process shared memory | Direct shared linear memory | Not supported; would need serialization | +| Multi-process shared memory | Host-coordinated merge/import at syscall boundaries; not direct shared pages or cross-process futexes | Not supported without the worker/channel SAB runtime | ### Browser vs Node.js @@ -514,11 +518,11 @@ These features require SharedArrayBuffer (and cross-origin isolation headers in - host_kill Wasm import with cross-process routing in sys_kill - DeliverSignalMessage protocol and ProcessManager.deliverSignal() - KillRequestMessage: worker → host → target worker signal routing -13e. **Phase 13e (Complete):** Exec -- Exec state serialization: CLOEXEC fd filtering, signal handler reset, pending preservation -- kernel_get_exec_state / kernel_init_from_exec Wasm exports +13e. **Phase 13e (historical milestone complete; current conformance remains Partial):** Exec +- In-place centralized exec: CLOEXEC filtering, signal disposition reset, pending-queue preservation +- Legacy kernel_get_exec_state / kernel_init_from_exec Wasm exports (scheduled for ABI cleanup) - host_exec Wasm import and sys_execve syscall -- Worker re-initialization: new kernel instance with exec state in same worker +- Worker re-initialization against the continuing centralized kernel Process - ProcessManager.exec() for host-initiated exec 14. **POSIX Compliance Batch 4 (Complete):** ~20 syscalls — tkill, sigpending, getpgid, setreuid/setregid, sysinfo, times, lchown, waitid, plus glue-only stubs 15. **POSIX Compliance Batch 5 (Complete):** ~100+ syscalls diff --git a/docs/wasm-limitations.md b/docs/wasm-limitations.md index 1a87903fa..a448ac819 100644 --- a/docs/wasm-limitations.md +++ b/docs/wasm-limitations.md @@ -52,7 +52,32 @@ Kernel-side `sys_munmap` correctly updates the MemoryManager range tracking (so Until one of those ships, this remains a fundamental wasm limitation. The kernel's optional syscall-path EFAULT validation (defensive hardening, described in [compromising-xfails.md §2](compromising-xfails.md)) does not flip these XFAIL entries. -## 7. Summary: What Cannot Be Implemented in Wasm +## 7. Separate Process Memories Are Not Immediate Shared Memory + +Each Kandelo process owns a different WebAssembly `Memory`. Fork copies the +parent's bytes into a new shared memory for the child; it does not create one +linear memory that both PIDs can address. Kandelo therefore coordinates +anonymous `MAP_SHARED`, SysV SHM, and stable-identity regular-file mappings at +syscall boundaries: changed bytes are merged into a host-owned backing and peer +updates are imported when a process next enters the kernel. + +That closes file/data handoff cases but is not equivalent to shared physical +pages. Direct stores are not visible to another PID until a syscall boundary, +and a peer that only spins on loads does not import the update. Futex WAIT/WAKE +also operates on the caller's own `SharedArrayBuffer`, so process-shared +pthread mutexes and similar lock protocols remain unsupported. Threads created +with `CLONE_VM` do share one memory and are not subject to this cross-process +boundary. + +Regular-file `MAP_SHARED` has further explicit limits. The backend must provide +stable device/inode identity; current OPFS metadata reports no stable inode and +is rejected with `ENOTSUP`. In-kernel memfds also return `ENOTSUP` for shared +mappings until they have a mapping bridge. Bytes beyond EOF are zero-filled or +dropped rather than raising Linux's `SIGBUS`, and changes made by an external +host writer do not invalidate Kandelo's page cache. `MAP_PRIVATE` is unaffected +by the identity and memfd restrictions. + +## 8. Summary: What Cannot Be Implemented in Wasm | Feature | Why | |---------|-----| @@ -60,7 +85,7 @@ Until one of those ships, this remains a fundamental wasm limitation. The kernel | `munmap()` SIGSEGV-on-deref | Wasm linear memory cannot revoke page access — see §6 | | FP exceptions / alternate rounding | Wasm FP is non-trapping IEEE 754 with fixed round-to-nearest mode | | `getrusage()` with real data | No CPU/memory tracking available in Wasm runtime | -| `mremap()` | Wasm memory can only grow, not remap regions | +| Immediate cross-process `MAP_SHARED` + futex | Distinct process memories cannot directly address or wake on one another's bytes; Kandelo provides syscall-boundary data coherence instead | | Raw server sockets (browser) | Web sandbox prevents listening on ports | | Guest-initiated `pthread_create` | Wasm threads require host orchestration via Web Workers | | `pthread_cancel` | No architecture-specific `__syscall_cp_asm` for Wasm | @@ -69,17 +94,19 @@ Until one of those ships, this remains a fundamental wasm limitation. The kernel | Feature | Status | |---------|--------| -| `fork()` | `wasm-fork-instrument`-based, fully working | +| `fork()` | `wasm-fork-instrument` resumes supported main-thread, pthread, and direct main-to-one-side-module stacks; nested/opaque cross-side and pthread-inside-side-module paths remain unsupported | | `sigaltstack` | Shadow stack swap via inline asm (PR #174) | | `dlopen()` / `dlsym()` | Dynamic Wasm module linker (host/src/dylink.ts) | -| `exec()` / `posix_spawn()` | Full host-side exec with CWD resolution (PR #167, #178) | +| `exec()` | In-place host-side replacement with CWD resolution and stable mapping writeback handles; remaining descriptor and signal-attribution gaps are tracked in [posix-status.md](posix-status.md) | +| `mremap()` | Kernel range bookkeeping and in-memory move/grow/shrink are implemented; old bytes cannot be revoked after a move, for the same reason as `munmap()` | +| `posix_spawn()` | Non-forking host-side spawn with CWD resolution and file actions | | SysV IPC | Host-side handlers (PR #146) | | POSIX mqueues | Host-side handlers (PR #147) | | POSIX timers | setitimer/getitimer (PR #148) | | `sem_open` | Implemented | | PTY / terminal | Full pseudoterminal with line discipline (PR #181) | | Threads via `clone()` | Host-managed Web Workers, MariaDB runs 5 threads (PR #88) | -| OPFS filesystem | `host/src/vfs/opfs.ts` (browser persistence) | +| OPFS filesystem | `host/src/vfs/opfs.ts` provides browser persistence; current zero-inode metadata means regular-file `MAP_SHARED` returns `ENOTSUP` there | ## Current libc-test Results (2026-04-05) diff --git a/examples/mmap_shared_anonymous_fork.c b/examples/mmap_shared_anonymous_fork.c new file mode 100644 index 000000000..b876abfa2 --- /dev/null +++ b/examples/mmap_shared_anonymous_fork.c @@ -0,0 +1,84 @@ +#include +#include +#include +#include +#include + +static int wait_ok(pid_t pid) { + int status = 0; + if (waitpid(pid, &status, 0) < 0) { + perror("waitpid"); + return 0; + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fprintf(stderr, "child failed: status=%d\n", status); + return 0; + } + return 1; +} + +int main(void) { + const long page_size = sysconf(_SC_PAGESIZE); + if (page_size <= 0) { + perror("sysconf"); + return 1; + } + + char *shared = mmap( + NULL, + (size_t)page_size, + PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_ANONYMOUS, + -1, + 0 + ); + if (shared == MAP_FAILED) { + perror("mmap"); + return 1; + } + + shared[0] = 'A'; + pid_t pid = fork(); + if (pid < 0) { + perror("fork"); + return 1; + } + if (pid == 0) { + if (shared[0] != 'A') { + fprintf(stderr, "child did not see parent write: %c\n", shared[0]); + _exit(2); + } + shared[0] = 'B'; + _exit(0); + } + if (!wait_ok(pid)) return 1; + if (shared[0] != 'B') { + fprintf(stderr, "parent did not see child write: %c\n", shared[0]); + return 1; + } + printf("inherited anonymous mapping coherent\n"); + + shared[1] = 'C'; + pid = fork(); + if (pid < 0) { + perror("fork second"); + return 1; + } + if (pid == 0) { + shared[1] = 'D'; + _exit(0); + } + if (!wait_ok(pid)) return 1; + if (shared[1] != 'D') { + fprintf(stderr, "parent did not see second child write: %c\n", shared[1]); + return 1; + } + printf("reused anonymous backing coherent\n"); + + if (munmap(shared, (size_t)page_size) < 0) { + perror("munmap"); + return 1; + } + printf("PASS\n"); + return 0; +} diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 70b73b5cd..155f19489 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -60,7 +60,12 @@ if (typeof globalThis.setImmediate === "undefined") { }; } -import { CAPTURED_STDIO, CentralizedKernelWorker, TERMINAL_STDIO } from "./kernel-worker"; +import { + CAPTURED_STDIO, + CentralizedKernelWorker, + isCurrentProcessGeneration, + TERMINAL_STDIO, +} from "./kernel-worker"; import type { ForkFromThreadContext, ResolvedSpawnProgram, @@ -79,7 +84,7 @@ import { import type { MountConfig } from "./vfs/types"; import { TlsNetworkBackend } from "./networking/tls-network-backend"; import { patchWasmForThread } from "./worker-main"; -import { detectPtrWidth, extractHeapBase, isWasmModuleBytes } from "./constants"; +import { detectPtrWidth, extractAbiVersion, extractHeapBase, isWasmModuleBytes } from "./constants"; import { ThreadExitCoordinator } from "./thread-exit-coordinator"; import { classifiedSignalOrFallback, @@ -87,7 +92,10 @@ import { signalExitStatus, SIGSEGV, } from "./trap-signals"; -import { threadWorkerFailureDisposition } from "./thread-worker-disposition"; +import { + removeThreadWorkerRegistryEntry, + threadWorkerFailureDisposition, +} from "./thread-worker-disposition"; import type { CentralizedWorkerInitMessage, CentralizedThreadInitMessage, @@ -142,7 +150,7 @@ interface ProcessInfo { threadAllocator: ThreadPageAllocator; } const processes = new Map(); -const processTeardowns = new Map>(); +const processTeardowns = new Map>(); // Includes standalone thread-worker teardown promises that may outlive the // process map entry they came from. const workerTeardowns = new Set>(); @@ -286,6 +294,9 @@ async function terminateThreadWorkers(pid: number): Promise { const threads = threadWorkers.get(pid); if (!threads) return; threadWorkers.delete(pid); + for (const thread of threads) { + intentionallyTerminated.add(thread.worker as object); + } for (const t of threads) { await ( t.termination ?? @@ -727,12 +738,17 @@ async function handleInit(msg: Extract) { post({ type: "proc_event", kind: "spawn", pid: childPid, ppid: parentPid }); return handleFork(parentPid, childPid, parentMemory, threadFork); }, - onExec: async (pid, path, argv, envp) => { - const result = await handleExec(pid, path, argv, envp); + onExec: async (pid, path, argv, envp, callerTid) => { + const previousWorker = processes.get(pid)?.worker; + const result = await handleExec(pid, path, argv, envp, callerTid); // Fire after handleExec updates the kernel Process.argv. If this is // sent before registerProcess(..., { argv }), Kandelo's Procs tab // refreshes against stale cmdline data and only corrects on remount. - if (result === 0) post({ type: "proc_event", kind: "exec", pid }); + // Fatal post-commit handoffs return 0 too, but install no new worker. + const installedWorker = processes.get(pid)?.worker; + if (result === 0 && installedWorker && installedWorker !== previousWorker) { + post({ type: "proc_event", kind: "exec", pid }); + } return result; }, onResolveSpawn: handlePosixSpawnResolve, @@ -972,8 +988,9 @@ function installProcessWorkerListeners( let exited = false; const finalize = (status: number, source: string, crashSignum?: number) => { if (exited) return; - exited = true; + if (intentionallyTerminated.has(worker as object)) return; if (processes.get(pid)?.worker !== worker) return; // already replaced (e.g. by exec) + exited = true; const message = `[kernel-worker] pid=${pid} ${source} -> forcing exit ${status}`; if (status === 0 && source === "worker-main exit message") { console.debug(message); @@ -981,7 +998,7 @@ function installProcessWorkerListeners( console.warn(message); } reportNonzeroProcessExitDiagnostic(pid, status, source); - handleExit(pid, status, crashSignum); + handleExit(pid, status, crashSignum, worker); }; // Status conventions match the Node host: @@ -1011,6 +1028,8 @@ function installProcessWorkerListeners( finalize(signalExitStatus(SIGSEGV), "worker exit event", SIGSEGV); }); worker.on("message", (msg: unknown) => { + if (intentionallyTerminated.has(worker as object)) return; + if (processes.get(pid)?.worker !== worker) return; const m = msg as { type?: string; message?: string; pid?: number; status?: number }; if (m.type === "error") { console.error(`[kernel-worker] Process error pid=${pid}:`, m.message); @@ -1040,15 +1059,20 @@ async function handleFork( parentMemory: WebAssembly.Memory, threadFork?: ForkFromThreadContext, ): Promise { - await waitForProcessTeardowns(); - const parentInfo = processes.get(parentPid); - if (!parentInfo) throw new Error(`Unknown parent pid ${parentPid}`); + if (!parentInfo || parentInfo.memory !== parentMemory) { + throw new Error(`Unknown parent generation for pid ${parentPid}`); + } + + // Capture the exact program/layout generation associated with the kernel's + // already-created child before yielding to unrelated worker teardowns. + await waitForProcessTeardowns(); // Pre-compile module for TurboFan-optimized code (smaller stack frames). if (!parentInfo.programModule) { parentInfo.programModule = await WebAssembly.compile(parentInfo.programBytes); } + if (!kernelWorker.shouldLaunchPendingChild(childPid)) return []; const parentBuf = new Uint8Array(parentMemory.buffer); const parentPages = Math.ceil(parentBuf.byteLength / PAGE_SIZE); @@ -1070,6 +1094,7 @@ async function handleFork( maxAddr: childLayout.maxAddr, mmapBase: childLayout.mmapBase, }); + kernelWorker.inheritProcessSharedMappings(parentPid, childPid); const forkBufAddr = threadFork ? threadFork.forkBufAddr @@ -1114,99 +1139,172 @@ async function handleExec( path: string, argv: string[], envp: string[], + callerTid: number, ): Promise { + const initiatingInfo = processes.get(pid); + if (!initiatingInfo) return -3; // ESRCH + if (!kernelWorker.supportsExecMetadataReplacement()) return -38; // ENOSYS + const resolved = await resolveExecutableForLaunch(path, argv); if (!resolved) return -2; // ENOENT if ("errno" in resolved) return -resolved.errno; const { programBytes: bytes, argv: launchArgv } = resolved; - // Program found — run kernel exec setup - const setupResult = kernelWorker.kernelExecSetup(pid); - if (setupResult < 0) return setupResult; - - kernelWorker.prepareProcessForExec(pid); - - // Terminate old worker. Mark it as intentionally terminated *before* - // calling terminate(): the synthesized "exit" event from - // BrowserWorkerHandle would otherwise fire installProcessWorkerListeners' - // crash detector and tear down the kernel's view of the still-alive - // (post-exec) process. - const oldInfo = processes.get(pid); - if (oldInfo?.worker) { - intentionallyTerminated.add(oldInfo.worker as object); - await oldInfo.worker.terminate().catch(() => {}); + try { + await WebAssembly.compile(bytes); + } catch { + return -8; // ENOEXEC: reject malformed modules before changing old state } - - // DIAGNOSTIC: track pid → exec path so the sysprof dump can name - // each pid (otherwise the table is just opaque numbers). - { - const g = globalThis as { __pidMap?: Map }; - if (!g.__pidMap) g.__pidMap = new Map(); - g.__pidMap.set(pid, path); + const declaredAbi = extractAbiVersion(bytes); + if (declaredAbi !== null && declaredAbi !== kernelWorker.getKernelAbiVersion()) { + return -8; } - // Create fresh memory sized for the new binary's arch (exec across - // wasm32↔wasm64 replaces the process image — memory type must match). + // Preallocate the replacement address space before the irreversible commit. const ptrWidth = detectPtrWidth(bytes); - const { - memory: newMemory, - layout: newLayout, - threadAllocator: newThreadAllocator, - } = createFreshProcessMemory(pid, bytes, ptrWidth, maxPages, { - operation: "exec", - path, - argv: launchArgv, - }); - const newChannelOffset = newLayout.channelOffset; + const metadataResult = kernelWorker.validateExecMetadata( + launchArgv, + envp, + initiatingInfo.ptrWidth, + ); + if (metadataResult < 0) return metadataResult; + let prepared: ReturnType; + try { + prepared = createFreshProcessMemory(pid, bytes, ptrWidth, maxPages, { + operation: "exec", + path, + argv: launchArgv, + }); + } catch { + return -12; // ENOMEM + } - kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { - skipKernelCreate: true, - ptrWidth, - brkBase: newLayout.brkBase, - mmapBase: newLayout.mmapBase, - maxAddr: newLayout.maxAddr, - // Refresh the kernel's Process.argv so /proc//cmdline and - // host-side enumeration (Kandelo Inspector → Procs) show the new - // program's argv after exec, not the parent's pre-exec argv. - argv: launchArgv, - }); + // Resolution/compilation yielded to the event loop. The numeric pid may + // now name a replacement generation; a stale continuation must not commit + // exec state against it. + if (processes.get(pid) !== initiatingInfo + || kernelWorker.isExecHandoffActive(pid) + || !kernelWorker.isProcessExecutionActive(pid)) return -3; // ESRCH + const prepareResult = kernelWorker.kernelExecPrepare(pid, callerTid); + if (prepareResult < 0) return prepareResult; + const addressSpaceResult = kernelWorker.prepareAddressSpaceForExec(pid); + if (addressSpaceResult < 0) return addressSpaceResult; + let replacementWorker: ReturnType | undefined; + try { + const setupResult = kernelWorker.kernelExecSetup(pid, callerTid); + if (setupResult < 0) return setupResult; + + // Invalidate the discarded image synchronously at the commit point. This + // keeps stale clone/listener continuations out even if later detach or + // replacement-worker setup fails. + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + } + threadedProcessPids.delete(pid); + kernelWorker.prepareProcessForExec(pid); - const execInitData: CentralizedWorkerInitMessage = { - type: "centralized_init", - pid, - ppid: 0, - programBytes: bytes, - memory: newMemory, - channelOffset: newChannelOffset, - argv: launchArgv, - env: envp, - ptrWidth, - kernelAbiVersion: kernelWorker.getKernelAbiVersion(), - }; + const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(pid); + if (finalizeResult < 0) { + throw new Error("failed to detach the discarded address space"); + } - const newWorker = workerAdapter.createWorker(execInitData); + await terminateThreadWorkers(pid); + if (initiatingInfo.worker) { + await initiatingInfo.worker.terminate().catch(() => {}); + } + if (kernelWorker.finalizeExecHandoffTermination(pid) > 0) return 0; - // Clear cached thread module — the new program binary is different - threadModuleCache.delete(pid); + // DIAGNOSTIC: track pid → exec path so the sysprof dump can name + // each pid (otherwise the table is just opaque numbers). + { + const g = globalThis as { __pidMap?: Map }; + if (!g.__pidMap) g.__pidMap = new Map(); + g.__pidMap.set(pid, path); + } + const { + memory: newMemory, + layout: newLayout, + threadAllocator: newThreadAllocator, + } = prepared; + const newChannelOffset = newLayout.channelOffset; - processes.set(pid, { - memory: newMemory, - programBytes: bytes, - worker: newWorker, - argv: launchArgv, - channelOffset: newChannelOffset, - ptrWidth, - layout: newLayout, - threadAllocator: newThreadAllocator, - }); + const execInitData: CentralizedWorkerInitMessage = { + type: "centralized_init", + pid, + ppid: 0, + programBytes: bytes, + memory: newMemory, + channelOffset: newChannelOffset, + argv: launchArgv, + env: envp, + ptrWidth, + kernelAbiVersion: kernelWorker.getKernelAbiVersion(), + }; - // Wire post-exec error/exit handling. The handleFork listener (on the - // pre-exec worker) is gone with the terminated worker; without re-arming - // here, a wasm trap in the exec'd binary (php-fpm child handling a slow - // request, sed inside wp-config-init, etc.) leaves the kernel believing - // the process is alive and the parent's waitpid blocks forever. - installProcessWorkerListeners(newWorker, pid); + kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { + skipKernelCreate: true, + ptrWidth, + metadataPtrWidth: initiatingInfo.ptrWidth, + brkBase: newLayout.brkBase, + mmapBase: newLayout.mmapBase, + maxAddr: newLayout.maxAddr, + // Refresh kernel-owned argv/environment for procfs and kernel APIs. + argv: launchArgv, + env: envp, + }); + replacementWorker = workerAdapter.createWorker(execInitData); - return 0; + // Clear cached thread module — the new program binary is different + threadModuleCache.delete(pid); + + processes.set(pid, { + memory: newMemory, + programBytes: bytes, + worker: replacementWorker, + argv: launchArgv, + channelOffset: newChannelOffset, + ptrWidth, + layout: newLayout, + threadAllocator: newThreadAllocator, + }); + + // Wire post-exec error/exit handling. The handleFork listener (on the + // pre-exec worker) is gone with the terminated worker; without re-arming + // here, a wasm trap in the exec'd binary leaves waitpid blocked forever. + installProcessWorkerListeners(replacementWorker, pid); + kernelWorker.finishProcessExecHandoff(pid); + return 0; + } catch (err) { + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + } + threadedProcessPids.delete(pid); + try { + kernelWorker.prepareProcessForExec(pid); + } catch { + // Continue with best-effort process death below. + } + if (replacementWorker && processes.get(pid)?.worker !== replacementWorker) { + await terminateTrackedWorker(replacementWorker); + } + + const message = err instanceof Error ? err.message : String(err); + try { + post({ + type: "stderr", + pid, + data: new TextEncoder().encode(`[exec] post-commit transition failed: ${message}\n`), + }); + } catch { + // A closed host port must not prevent kernel-side reap. + } + try { kernelWorker.notifyHostProcessCrashed(pid, SIGSEGV); } catch { /* best-effort */ } + try { + handleExit(pid, signalExitStatus(SIGSEGV), SIGSEGV); + } catch { + try { kernelWorker.deactivateProcess(pid); } catch { /* best-effort */ } + } + return 0; + } } /** @@ -1254,6 +1352,11 @@ async function handlePosixSpawn( ): Promise { await waitForProcessTeardowns(); + // The child can receive a group-directed signal while the browser waits for + // an unrelated teardown. Keep the successful spawn and kernel-owned zombie, + // but never install a Worker for a process that has already terminated. + if (!kernelWorker.shouldLaunchPendingChild(childPid)) return 0; + post({ type: "proc_event", kind: "spawn", pid: childPid }); const ptrWidth = detectPtrWidth(programBytes); @@ -1328,12 +1431,27 @@ async function handleClone( // We keep this separate from processInfo.programModule (which is the unpatched // module used for fork children) to avoid conflating the two. let threadModule = threadModuleCache.get(pid); + let cacheCompiledModule = false; if (!threadModule) { const patched = patchWasmForThread(processInfo.programBytes); threadModule = await WebAssembly.compile(patched); - threadModuleCache.set(pid, threadModule); + cacheCompiledModule = true; } + // Compilation yields. A sibling pthread may have committed exec while this + // clone continuation was suspended; never attach the old program/Memory to + // the replacement process that now owns the same numeric pid. + if (!isCurrentProcessGeneration( + processes, + pid, + processInfo, + memory, + kernelWorker.isExecHandoffActive(pid), + ) || !kernelWorker.isProcessExecutionActive(pid)) { + throw new Error(`Process ${pid} changed generation during clone`); + } + if (cacheCompiledModule) threadModuleCache.set(pid, threadModule); + let alloc: ReturnType; try { alloc = processInfo.threadAllocator.allocate(memory); @@ -1350,7 +1468,12 @@ async function handleClone( // Register fnPtr/argPtr so handleFork can route a fork() from this // thread back through its entry point. Mirrors handleClone in // host/src/node-kernel-worker-entry.ts. - kernelWorker.addChannel(pid, alloc.channelOffset, tid, fnPtr, argPtr); + try { + kernelWorker.addChannel(pid, alloc.channelOffset, tid, fnPtr, argPtr, memory); + } catch (err) { + processInfo.threadAllocator.free(alloc.basePage); + throw err; + } const threadInitData: CentralizedThreadInitMessage = { type: "centralized_thread_init", @@ -1381,17 +1504,23 @@ async function handleClone( }; threadWorkers.get(pid)!.push(threadEntry); + const belongsToCurrentProcessImage = () => + isCurrentProcessGeneration( + processes, + pid, + processInfo, + memory, + kernelWorker.isExecHandoffActive(pid), + ); let reclaimed = false; const reclaimThread = () => { if (reclaimed) return; reclaimed = true; processInfo.threadAllocator.free(alloc.basePage); - threadExits.release(pid, alloc.channelOffset); - const threads = threadWorkers.get(pid); - if (threads) { - const idx = threads.indexOf(threadEntry); - if (idx >= 0) threads.splice(idx, 1); + if (belongsToCurrentProcessImage()) { + threadExits.release(pid, alloc.channelOffset); } + removeThreadWorkerRegistryEntry(threadWorkers, pid, threadEntry); }; const terminateThreadEntry = (): Promise => { if (!threadEntry.termination) { @@ -1404,7 +1533,14 @@ async function handleClone( }; threadExits.register(pid, alloc.channelOffset, terminateThreadEntry); + const isCurrentThreadGeneration = () => + !intentionallyTerminated.has(threadWorker as object) + && belongsToCurrentProcessImage(); const failThread = (reason: string) => { + if (!isCurrentThreadGeneration()) { + void terminateThreadEntry(); + return; + } const text = `[kernel-worker] pid=${pid} tid=${tid}: ${reason}\n`; post({ type: "stderr", pid, data: new TextEncoder().encode(text) }); const disposition = threadWorkerFailureDisposition(reason); @@ -1418,6 +1554,10 @@ async function handleClone( threadWorker.on("message", (msg: unknown) => { const m = msg as WorkerToHostMessage; if (m.type === "thread_exit") { + if (!isCurrentThreadGeneration()) { + void terminateThreadEntry(); + return; + } void terminateThreadEntry(); } else if ((m as { type?: string }).type === "error") { // worker-main posted {type:"error"} — instantiation failure, top-level @@ -1441,18 +1581,26 @@ function signalFromExitStatus(exitStatus: number): number | null { return exitStatus >= 128 ? (exitStatus - 128) & 0x7f : null; } -function handleExit(pid: number, exitStatus: number, crashSignum?: number): void { - void finishProcessExit(pid, exitStatus, crashSignum); +function handleExit( + pid: number, + exitStatus: number, + crashSignum?: number, + expectedWorker = processes.get(pid)?.worker, +): void { + void finishProcessExit(pid, exitStatus, crashSignum, expectedWorker); } async function finishProcessExit( pid: number, exitStatus: number, crashSignum: number = signalFromExitStatus(exitStatus) ?? SIGSEGV, + expectedWorker = processes.get(pid)?.worker, ): Promise { - if (processTeardowns.has(pid)) return; - + if (!expectedWorker) return; const info = processes.get(pid); + if (!info || info.worker !== expectedWorker) return; + if (processTeardowns.has(expectedWorker)) return; + reportNonzeroProcessExitDiagnostic(pid, exitStatus, "kernel process exit"); const threadedSettleMs = threadedProcessPids.has(pid) ? THREADED_WORKER_TERMINATION_SETTLE_MS @@ -1481,9 +1629,11 @@ async function finishProcessExit( // completions, otherwise the worker can park in Atomics.wait with no // registered listener left to wake it. await terminateThreadWorkers(pid); - if (info?.worker) { - await terminateTrackedWorker(info.worker, settleMs); - } + await terminateTrackedWorker(expectedWorker, settleMs); + + // Exec may have installed a replacement while old worker termination was + // settling. Never apply pid-wide cleanup to a different generation. + if (processes.get(pid)?.worker !== expectedWorker) return; // Check if this is a "top-level" process or a fork child. For now, // always deactivate after worker termination; the main thread tracks @@ -1495,14 +1645,14 @@ async function finishProcessExit( threadModuleCache.delete(pid); ptyByPid.delete(pid); })(); - processTeardowns.set(pid, teardown); + processTeardowns.set(expectedWorker, teardown); post({ type: "exit", pid, status: exitStatus }); try { await teardown; } finally { - processTeardowns.delete(pid); + processTeardowns.delete(expectedWorker); } } diff --git a/host/src/dylink.ts b/host/src/dylink.ts index f159d2b8e..1bd43deee 100644 --- a/host/src/dylink.ts +++ b/host/src/dylink.ts @@ -6,6 +6,9 @@ * https://github.com/WebAssembly/tool-conventions/blob/main/DynamicLinking.md */ +import { ABI_VERSION } from "./generated/abi"; +import { FORK_SAVE_BUFFER_SIZE } from "./process-memory"; + // dylink.0 sub-section types const WASM_DYLINK_MEM_INFO = 1; const WASM_DYLINK_NEEDED = 2; @@ -16,6 +19,77 @@ const WASM_DYLINK_IMPORT_INFO = 4; const WASM_DYLINK_FLAG_TLS = 0x01; const WASM_DYLINK_FLAG_WEAK = 0x02; +export const SIDE_MODULE_FORK_EXPORTS = [ + "wpk_fork_unwind_begin", + "wpk_fork_unwind_end", + "wpk_fork_rewind_begin", + "wpk_fork_rewind_end", + "wpk_fork_state", +] as const; + +export const FORK_CAPABILITIES_SECTION = "kandelo.wpk_fork.capabilities"; +export const FORK_CAPABILITIES_VERSION = 1; +export const FORK_CAP_SIDE_ENTRY = 1 << 0; +export const FORK_CAP_DYLINK_MAIN = 1 << 1; +const FORK_CAP_KNOWN_MASK = FORK_CAP_SIDE_ENTRY | FORK_CAP_DYLINK_MAIN; +export const FORK_CAPABILITIES_REQUIRED_ABI = 17; + +const WPK_FORK_NORMAL = 0; +const WPK_FORK_UNWINDING = 1; +const WPK_FORK_REWINDING = 2; + +export interface ForkInstrumentCapabilityClaim { + /** False for an ABI-16 artifact built before role markers were introduced. */ + present: boolean; + flags: number; +} + +/** Read and validate the explicit call-graph claims emitted by the tool. */ +export function readForkInstrumentCapabilityClaim( + module: WebAssembly.Module, +): ForkInstrumentCapabilityClaim { + const sections = WebAssembly.Module.customSections(module, FORK_CAPABILITIES_SECTION); + if (sections.length === 0) return { present: false, flags: 0 }; + if (sections.length !== 1) { + throw new Error(`duplicate ${FORK_CAPABILITIES_SECTION} custom sections`); + } + const data = new Uint8Array(sections[0]!); + if (data.length !== 2) { + throw new Error(`malformed ${FORK_CAPABILITIES_SECTION} custom section`); + } + if (data[0] !== FORK_CAPABILITIES_VERSION) { + throw new Error( + `unsupported fork-instrument capability version ${data[0]}; ` + + `expected ${FORK_CAPABILITIES_VERSION}`, + ); + } + if ((data[1]! & ~FORK_CAP_KNOWN_MASK) !== 0) { + throw new Error(`unknown fork-instrument capability flags 0x${data[1]!.toString(16)}`); + } + return { present: true, flags: data[1]! }; +} + +/** Return just the validated flags for callers that do not need presence. */ +export function readForkInstrumentCapabilities(module: WebAssembly.Module): number { + return readForkInstrumentCapabilityClaim(module).flags; +} + +/** + * Decide whether an artifact may serve one fork-instrument role. + * + * ABI 16 predates role markers, so an absent section falls back to the legacy + * five-export contract. ABI 17 makes the role claim mandatory. A marker that + * is present is always authoritative, including during ABI 16 migration. + */ +export function forkInstrumentRoleAvailable( + claim: ForkInstrumentCapabilityClaim, + roleFlag: number, + abiVersion: number = ABI_VERSION, +): boolean { + if (claim.present) return (claim.flags & roleFlag) !== 0; + return abiVersion < FORK_CAPABILITIES_REQUIRED_ABI; +} + export interface DylinkMetadata { /** Bytes of linear memory this module needs */ memorySize: number; @@ -170,18 +244,47 @@ export interface LoadedSharedLibrary { metadata: DylinkMetadata; /** Path/name of the library */ name: string; + /** Fork save buffer for an instrumented side module importing env.fork. */ + forkBufAddr?: number; + /** Whether this module can originate a coordinated env.fork unwind. */ + forkCapable: boolean; + /** Function/GOT.func imports used for conservative cross-side isolation. */ + functionImports: ReadonlySet; + /** Function exports visible to later side modules. */ + functionExports: ReadonlySet; + /** Dynamic lookup from a side module defeats static cross-side isolation. */ + importsDynamicLookup: boolean; +} + +export interface SideModuleForkState { + name: string; + instance: WebAssembly.Instance; + forkBufAddr: number; +} + +/** + * Process-worker coordination for the one supported side-module fork shape: + * a main-module call_indirect directly invokes one instrumented side module. + * The loader rejects statically visible side-to-side linkage and side-owned + * dlopen/dlsym around a fork-capable module. Opaque callbacks passed through + * main memory or the shared table cannot yet be attributed to a module at + * runtime and remain an explicit unsupported residual. + */ +export interface SideModuleForkSupport { + setActiveFork: (state: SideModuleForkState) => void; + clearActiveFork: (state: SideModuleForkState) => void; + /** Invoke the immutable main-module fork trampoline and verify its state. */ + invokeMainFork: (expectedStateAfter: 0 | 1) => number; } /** * Options used when re-instantiating a side module in a fork child. * * Preconditions: - * - Replay must run in the same order as the parent's original dlopens, - * against the same initial table/memory state. `__table_base` is - * captured from `options.table.length` at replay time and must equal - * the parent's value — anything that grows the child's table before - * replay (a future GOT-prealloc pass, an interleaved dlsym, etc.) - * diverges the layout and corrupts call_indirect targets. + * - Replay must run in the same order as the parent's original dlopens. + * Each entry supplies the parent's exact `__table_base`; replay may pad + * null gaps up to that base but rejects a child table that already grew + * past it (an interleaved dlsym, future GOT preallocation, etc.). * - `options.loadedLibraries` must NOT already contain `name`. Replay * does not refresh existing entries; a duplicate would be silently * deduped and return a handle whose memoryBase may not match. @@ -193,6 +296,10 @@ export interface DylinkReplayOptions { * the memcpy'd data section encode (memoryBase + offset); using any * other base corrupts pointers. */ memoryBase: number; + /** Exact table base observed in the parent, including failed-load gaps. */ + tableBase: number; + /** Exact side-module save buffer copied from the fork parent. */ + forkBufAddr?: number; } /** @@ -209,18 +316,90 @@ export interface LoadSharedLibraryOptions { heapPointer?: { value: number }; /** Allocate side-module linear-memory data in the process address space */ allocateMemory?: (size: number, align: number) => number; + /** Release a successful allocateMemory result when loading rolls back. */ + deallocateMemory?: (addr: number, size: number) => void; /** Global symbol table: name → function or WebAssembly.Global */ globalSymbols: Map; /** GOT entries: symbol name → mutable i32 WebAssembly.Global */ got: Map; /** Already-loaded libraries for dedup and dependency resolution */ loadedLibraries: Map; + /** Immutable symbol names exported by the main module. */ + mainModuleSymbols?: ReadonlySet; + /** Present only in a process worker that can drive side-module unwind. */ + sideModuleFork?: SideModuleForkSupport; + /** Precise rebuild/boundary diagnostic when sideModuleFork is unavailable. */ + sideModuleForkUnavailableReason?: string; /** Callback to locate and read a library file by name (async version) */ resolveLibrary?: (name: string) => Promise; /** Callback to locate and read a library file by name (sync version) */ resolveLibrarySync?: (name: string) => Uint8Array | null; } +const SIDE_DYNAMIC_LOOKUP_IMPORTS = new Set([ + "__wasm_dlopen", + "__wasm_dlsym", + "dlopen", + "dlsym", +]); + +function intersectSideSymbols( + imports: ReadonlySet, + exports: ReadonlySet, + mainSymbols: ReadonlySet, +): string[] { + return Array.from(imports) + .filter((name) => !mainSymbols.has(name) && exports.has(name)) + .sort(); +} + +/** + * The current two-module unwind protocol supports main -> one side module. + * It cannot serialize an intervening side-module frame. Preserve ordinary + * independent multi-extension loading, but reject statically visible + * side-to-side linkage and side-originated dynamic lookup whenever either + * participant can fork. Function pointers passed opaquely through main memory + * remain a documented residual until the runtime has module activation hooks. + */ +function enforceDirectMainSideForkBoundary( + name: string, + forkCapable: boolean, + functionImports: ReadonlySet, + functionExports: ReadonlySet, + importsDynamicLookup: boolean, + options: LoadSharedLibraryOptions, +): void { + const mainSymbols = options.mainModuleSymbols ?? new Set(); + for (const loaded of options.loadedLibraries.values()) { + if (!forkCapable && !loaded.forkCapable) continue; + + if (importsDynamicLookup || loaded.importsDynamicLookup) { + throw new Error( + `${name}: fork-capable side modules cannot coexist with side-originated ` + + `dlopen/dlsym; only a direct main-module-to-side fork path is supported`, + ); + } + + const newToLoaded = intersectSideSymbols( + functionImports, + loaded.functionExports, + mainSymbols, + ); + const loadedToNew = intersectSideSymbols( + loaded.functionImports, + functionExports, + mainSymbols, + ); + const crossSymbols = [...newToLoaded, ...loadedToNew]; + if (crossSymbols.length > 0) { + throw new Error( + `${name}: fork-capable side-module nesting through ${loaded.name} is unsupported ` + + `(cross-side symbols: ${Array.from(new Set(crossSymbols)).join(", ")})`, + ); + } + } +} + /** * Core shared library loading logic — instantiates a pre-parsed Wasm * side module into the process address space. Used by both async and sync @@ -233,225 +412,436 @@ function instantiateSharedLibrary( options: LoadSharedLibraryOptions, replay?: DylinkReplayOptions, ): LoadedSharedLibrary { - // Allocate memory region - const memAlign = 1 << metadata.memoryAlign; - let memoryBase = 0; - if (metadata.memorySize > 0) { + const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); + const moduleImports = WebAssembly.Module.imports(module); + const moduleExports = WebAssembly.Module.exports(module); + const importsFork = moduleImports.some((imp) => + imp.module === "env" && imp.name === "fork" && imp.kind === "function" + ); + const presentForkExports = SIDE_MODULE_FORK_EXPORTS.filter((exportName) => + moduleExports.some((exp) => exp.kind === "function" && exp.name === exportName) + ); + const hasCompleteForkInstrumentation = + presentForkExports.length === SIDE_MODULE_FORK_EXPORTS.length; + const forkCapabilityClaim = readForkInstrumentCapabilityClaim(module); + const claimsSideEntry = + forkCapabilityClaim.present + && (forkCapabilityClaim.flags & FORK_CAP_SIDE_ENTRY) !== 0; + const sideEntryAvailable = forkInstrumentRoleAvailable( + forkCapabilityClaim, + FORK_CAP_SIDE_ENTRY, + ); + const functionImports = new Set( + moduleImports + .filter((imp) => + (imp.module === "env" && imp.kind === "function") + || imp.module === "GOT.func" + ) + .map((imp) => imp.name), + ); + const functionExports = new Set( + moduleExports.filter((exp) => exp.kind === "function").map((exp) => exp.name), + ); + const importsDynamicLookup = moduleImports.some((imp) => + imp.module === "env" + && imp.kind === "function" + && SIDE_DYNAMIC_LOOKUP_IMPORTS.has(imp.name) + ); + + if (presentForkExports.length > 0 && !hasCompleteForkInstrumentation) { + const missing = SIDE_MODULE_FORK_EXPORTS.filter((exportName) => + !moduleExports.some((exp) => exp.kind === "function" && exp.name === exportName) + ); + throw new Error( + `${name}: incomplete wasm-fork-instrument exports; missing ${missing.join(", ")}`, + ); + } + if (importsFork && !hasCompleteForkInstrumentation) { + throw new Error( + `${name}: env.fork requires complete side-module instrumentation; ` + + "rebuild with wasm-fork-instrument --entry env.fork", + ); + } + if (importsFork && !sideEntryAvailable) { + throw new Error( + `${name}: env.fork requires the versioned side-entry capability; ` + + "rebuild with the current wasm-fork-instrument --entry env.fork", + ); + } + if (claimsSideEntry && !importsFork) { + throw new Error(`${name}: side-entry capability is present without an env.fork import`); + } + if (importsFork && !options.sideModuleFork) { + throw new Error( + `${name}: env.fork cannot be coordinated: ` + + (options.sideModuleForkUnavailableReason + ?? "side-module fork requires a process-worker unwind coordinator"), + ); + } + enforceDirectMainSideForkBoundary( + name, + importsFork, + functionImports, + functionExports, + importsDynamicLookup, + options, + ); + + const tableRollbackBase = options.table.length; + const heapRollbackValue = options.heapPointer?.value; + const symbolRollback = new Map(options.globalSymbols); + const gotRollback = new Map( + Array.from(options.got, ([symbol, global]) => [ + symbol, + { global, value: global.value }, + ] as const), + ); + const allocations: Array<{ addr: number; size: number }> = []; + const allocate = (size: number, align: number): number => { + if (!options.allocateMemory) { + throw new Error(`${name}: no side-module memory allocator configured`); + } + const addr = options.allocateMemory(size, align); + allocations.push({ addr, size }); + return addr; + }; + + try { + // Allocate memory region + const memAlign = 1 << metadata.memoryAlign; + let memoryBase = 0; + if (metadata.memorySize > 0) { + if (replay) { + // Reuse parent's memoryBase: data-reloc'd pointers baked into the + // memcpy'd data section already encode (parentMemoryBase + offset). + memoryBase = replay.memoryBase; + } else if (options.allocateMemory) { + memoryBase = allocate(metadata.memorySize, memAlign); + const end = memoryBase + metadata.memorySize; + if (end > options.memory.buffer.byteLength) { + throw new Error( + `${name}: allocator returned 0x${memoryBase.toString(16)} but memory only covers 0x${options.memory.buffer.byteLength.toString(16)}`, + ); + } + } else { + if (!options.heapPointer) { + throw new Error(`${name}: no side-module memory allocator configured`); + } + memoryBase = alignUp(options.heapPointer.value, memAlign); + options.heapPointer.value = memoryBase + metadata.memorySize; + + // Ensure the memory is large enough for standalone linker tests and + // non-POSIX embedders. Process workers pass allocateMemory so side-module + // data is tracked by the guest allocator instead of a host-only pointer. + const neededPages = Math.ceil(options.heapPointer.value / 65536); + const currentPages = options.memory.buffer.byteLength / 65536; + if (neededPages > currentPages) { + options.memory.grow(neededPages - currentPages); + } + } + + if (!replay) { + // Skip zero-init in replay: child memory already holds parent's + // post-startup data via fork memcpy. + new Uint8Array(options.memory.buffer, memoryBase, metadata.memorySize).fill(0); + } + } + + // Reproduce the parent's exact table base, including null gaps left by a + // failed dlopen. WebAssembly.Table cannot shrink, so successful archive + // entries carry the next library's exact base and replay pads up to it. + let tableBase = options.table.length; if (replay) { - // Reuse parent's memoryBase: data-reloc'd pointers baked into the - // memcpy'd data section already encode (parentMemoryBase + offset). - memoryBase = replay.memoryBase; - } else if (options.allocateMemory) { - memoryBase = options.allocateMemory(metadata.memorySize, memAlign); - const end = memoryBase + metadata.memorySize; - if (end > options.memory.buffer.byteLength) { + if (!Number.isSafeInteger(replay.tableBase) || replay.tableBase < 0) { + throw new Error(`${name}: invalid replay table base ${replay.tableBase}`); + } + if (tableBase > replay.tableBase) { throw new Error( - `${name}: allocator returned 0x${memoryBase.toString(16)} but memory only covers 0x${options.memory.buffer.byteLength.toString(16)}`, + `${name}: replay table already at ${tableBase}, past parent base ${replay.tableBase}`, ); } - } else { - if (!options.heapPointer) { - throw new Error(`${name}: no side-module memory allocator configured`); - } - memoryBase = alignUp(options.heapPointer.value, memAlign); - options.heapPointer.value = memoryBase + metadata.memorySize; - - // Ensure the memory is large enough for standalone linker tests and - // non-POSIX embedders. Process workers pass allocateMemory so side-module - // data is tracked by the guest allocator instead of a host-only pointer. - const neededPages = Math.ceil(options.heapPointer.value / 65536); - const currentPages = options.memory.buffer.byteLength / 65536; - if (neededPages > currentPages) { - options.memory.grow(neededPages - currentPages); + if (tableBase < replay.tableBase) { + options.table.grow(replay.tableBase - tableBase); } + tableBase = replay.tableBase; } + if (metadata.tableSize > 0) options.table.grow(metadata.tableSize); - if (!replay) { - // Skip zero-init in replay: child memory already holds parent's - // post-startup data via fork memcpy. - new Uint8Array(options.memory.buffer, memoryBase, metadata.memorySize).fill(0); + let sideForkBufAddr = 0; + if (importsFork) { + if (replay) { + sideForkBufAddr = replay.forkBufAddr ?? 0; + } else if (options.allocateMemory) { + sideForkBufAddr = allocate(FORK_SAVE_BUFFER_SIZE, 16); + } else if (options.heapPointer) { + sideForkBufAddr = alignUp(options.heapPointer.value, 16); + options.heapPointer.value = sideForkBufAddr + FORK_SAVE_BUFFER_SIZE; + const neededPages = Math.ceil(options.heapPointer.value / 65536); + const currentPages = options.memory.buffer.byteLength / 65536; + if (neededPages > currentPages) options.memory.grow(neededPages - currentPages); + } + if ( + sideForkBufAddr <= 0 + || sideForkBufAddr + FORK_SAVE_BUFFER_SIZE > options.memory.buffer.byteLength + ) { + throw new Error(`${name}: invalid side-module fork save buffer`); + } } - } - // Allocate table slots - let tableBase = 0; - if (metadata.tableSize > 0) { - tableBase = options.table.length; - options.table.grow(metadata.tableSize); - } + // Create immutable globals for memory_base and table_base + const memoryBaseGlobal = new WebAssembly.Global( + { value: "i32", mutable: false }, + memoryBase, + ); + const tableBaseGlobal = new WebAssembly.Global( + { value: "i32", mutable: false }, + tableBase, + ); - // Create immutable globals for memory_base and table_base - const memoryBaseGlobal = new WebAssembly.Global( - { value: "i32", mutable: false }, - memoryBase, - ); - const tableBaseGlobal = new WebAssembly.Global( - { value: "i32", mutable: false }, - tableBase, - ); + // Build GOT proxy for imports. + // + // GOT.mem entries hold the *address in linear memory* of a data symbol the + // side module imports from the main process. If the main module exports + // that symbol as a WebAssembly.Global (typical for `--export-all`), its + // value is the address. Without this seeding, side modules read 0 for + // any imported global — silent NULL deref (e.g. opcache.so reads + // `sapi_module.name` as NULL, accel_find_sapi fails at startup). + // + // GOT.func entries hold a *table index* — the address-of-function value + // a C function pointer stores. Side-module data sections capture function + // pointers (e.g. opcache.so's ini_entries[].on_modify == &OnUpdateString + // exported from main). For those references to dispatch to the real + // function at runtime, the function must live in the shared + // indirect_function_table and the GOT entry must hold its index. + const tableIndexFor = (fn: Function): number => { + const tbl = options.table; + for (let i = 0; i < tbl.length; i++) { + if (tbl.get(i) === fn) return i; + } + const idx = tbl.length; + tbl.grow(1); + tbl.set(idx, fn); + return idx; + }; - // Build GOT proxy for imports. - // - // GOT.mem entries hold the *address in linear memory* of a data symbol the - // side module imports from the main process. If the main module exports - // that symbol as a WebAssembly.Global (typical for `--export-all`), its - // value is the address. Without this seeding, side modules read 0 for - // any imported global — silent NULL deref (e.g. opcache.so reads - // `sapi_module.name` as NULL, accel_find_sapi fails at startup). - // - // GOT.func entries hold a *table index* — the address-of-function value - // a C function pointer stores. Side-module data sections capture function - // pointers (e.g. opcache.so's ini_entries[].on_modify == &OnUpdateString - // exported from main). For those references to dispatch to the real - // function at runtime, the function must live in the shared - // indirect_function_table and the GOT entry must hold its index. - const tableIndexFor = (fn: Function): number => { - const tbl = options.table; - for (let i = 0; i < tbl.length; i++) { - if (tbl.get(i) === fn) return i; - } - const idx = tbl.length; - tbl.grow(1); - tbl.set(idx, fn); - return idx; - }; + const getOrCreateGOTEntry = ( + symName: string, + kind: "mem" | "func", + ): WebAssembly.Global => { + let entry = options.got.get(symName); + if (!entry) { + let initial = 0; + const sym = options.globalSymbols.get(symName); + if (kind === "mem" && sym instanceof WebAssembly.Global) { + initial = sym.value as number; + } else if (kind === "func" && typeof sym === "function") { + initial = tableIndexFor(sym); + } + entry = new WebAssembly.Global({ value: "i32", mutable: true }, initial); + options.got.set(symName, entry); + } + return entry; + }; + + // Tag imported by side modules compiled with clang's wasm SjLj lowering + // (`-mllvm -wasm-enable-sjlj`). The host doesn't actually catch these — the + // main process either has its own __c_longjmp tag (LLVM 22) or doesn't use + // SjLj (LLVM 21). A stub Tag lets the side module's import type-check and + // instantiate; behavior at throw time is undefined but the side module + // typically never throws this tag itself. + const longjmpTag = (typeof (WebAssembly as any).Tag === "function") + ? new (WebAssembly as any).Tag({ parameters: ["i32"] }) + : undefined; - const getOrCreateGOTEntry = ( - symName: string, - kind: "mem" | "func", - ): WebAssembly.Global => { - let entry = options.got.get(symName); - if (!entry) { - let initial = 0; - const sym = options.globalSymbols.get(symName); - if (kind === "mem" && sym instanceof WebAssembly.Global) { - initial = sym.value as number; - } else if (kind === "func" && typeof sym === "function") { - initial = tableIndexFor(sym); + let instance: WebAssembly.Instance | null = null; + let sideForkState: SideModuleForkState | null = null; + const forkState = (): number => { + if (!instance) throw new Error(`${name}: side-module fork before instantiation`); + return Number((instance.exports.wpk_fork_state as () => number)()); + }; + + const sideModuleForkImport = (): number => { + if (!instance || !options.sideModuleFork || sideForkBufAddr === 0) { + throw new Error(`${name}: side-module fork coordinator is unavailable`); + } + const state = forkState(); + if (state === WPK_FORK_NORMAL) { + (instance.exports.wpk_fork_unwind_begin as (addr: number) => void)(sideForkBufAddr); + if (forkState() !== WPK_FORK_UNWINDING) { + throw new Error(`${name}: side-module fork failed to enter UNWINDING`); + } + sideForkState = { name, instance, forkBufAddr: sideForkBufAddr }; + options.sideModuleFork.setActiveFork(sideForkState); + return options.sideModuleFork.invokeMainFork(WPK_FORK_UNWINDING); } - entry = new WebAssembly.Global({ value: "i32", mutable: true }, initial); - options.got.set(symName, entry); - } - return entry; - }; - // Tag imported by side modules compiled with clang's wasm SjLj lowering - // (`-mllvm -wasm-enable-sjlj`). The host doesn't actually catch these — the - // main process either has its own __c_longjmp tag (LLVM 22) or doesn't use - // SjLj (LLVM 21). A stub Tag lets the side module's import type-check and - // instantiate; behavior at throw time is undefined but the side module - // typically never throws this tag itself. - const longjmpTag = (typeof (WebAssembly as any).Tag === "function") - ? new (WebAssembly as any).Tag({ parameters: ["i32"] }) - : undefined; - - // Construct imports - const imports: WebAssembly.Imports = { - env: new Proxy({} as Record, { - get(_target, prop: string) { - switch (prop) { - case "memory": return options.memory; - case "__indirect_function_table": return options.table; - case "__memory_base": return memoryBaseGlobal; - case "__table_base": return tableBaseGlobal; - case "__stack_pointer": return options.stackPointer; - case "__c_longjmp": return longjmpTag; + if (state === WPK_FORK_REWINDING) { + (instance.exports.wpk_fork_rewind_end as () => void)(); + if (forkState() !== WPK_FORK_NORMAL) { + throw new Error(`${name}: side-module fork failed to finish REWINDING`); } - const sym = options.globalSymbols.get(prop); - if (sym !== undefined) return sym; - return undefined; - }, - has(_target, prop: string) { - if (["memory", "__indirect_function_table", "__memory_base", - "__table_base", "__stack_pointer", "__c_longjmp"].includes(prop)) return true; - return options.globalSymbols.has(prop); - }, - }), - "GOT.mem": new Proxy({} as Record, { - get(_target, prop: string) { - return getOrCreateGOTEntry(prop, "mem"); - }, - }), - "GOT.func": new Proxy({} as Record, { - get(_target, prop: string) { - return getOrCreateGOTEntry(prop, "func"); - }, - }), - }; + // A fork child re-instantiates this module, so its closure cannot retain + // the parent's SideModuleForkState object. The worker reconstructs the + // active identity from the copied archive/buffer metadata; rebuild the + // same structural identity here before clearing it. + const completedState = sideForkState ?? { + name, + instance, + forkBufAddr: sideForkBufAddr, + }; + const result = options.sideModuleFork.invokeMainFork(WPK_FORK_NORMAL); + options.sideModuleFork.clearActiveFork(completedState); + sideForkState = null; + return result; + } - // Compile and instantiate synchronously - const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); - const instance = new WebAssembly.Instance(module, imports); - - // Relocate exports: data address globals need memoryBase added - const relocatedExports: Record = {}; - for (const [exportName, exportValue] of Object.entries(instance.exports)) { - if (exportValue instanceof WebAssembly.Global) { - try { - (exportValue as any).value = (exportValue as any).value; + throw new Error(`${name}: env.fork reached in unexpected state ${state}`); + }; + + // Construct imports + const imports: WebAssembly.Imports = { + env: new Proxy({} as Record, { + get(_target, prop: string) { + switch (prop) { + case "memory": return options.memory; + case "__indirect_function_table": return options.table; + case "__memory_base": return memoryBaseGlobal; + case "__table_base": return tableBaseGlobal; + case "__stack_pointer": return options.stackPointer; + case "__c_longjmp": return longjmpTag; + case "fork": + if (importsFork) return sideModuleForkImport; + break; + } + const sym = options.globalSymbols.get(prop); + if (sym !== undefined) return sym; + return undefined; + }, + has(_target, prop: string) { + if (["memory", "__indirect_function_table", "__memory_base", + "__table_base", "__stack_pointer", "__c_longjmp"].includes(prop)) return true; + if (prop === "fork" && importsFork) return true; + return options.globalSymbols.has(prop); + }, + }), + "GOT.mem": new Proxy({} as Record, { + get(_target, prop: string) { + return getOrCreateGOTEntry(prop, "mem"); + }, + }), + "GOT.func": new Proxy({} as Record, { + get(_target, prop: string) { + return getOrCreateGOTEntry(prop, "func"); + }, + }), + }; + + // Instantiate synchronously after validating the side-module fork contract. + instance = new WebAssembly.Instance(module, imports); + + // Relocate exports: data address globals need memoryBase added + const relocatedExports: Record = {}; + for (const [exportName, exportValue] of Object.entries(instance.exports)) { + if (exportValue instanceof WebAssembly.Global) { + try { + (exportValue as any).value = (exportValue as any).value; + relocatedExports[exportName] = exportValue; + } catch { + relocatedExports[exportName] = new WebAssembly.Global( + { value: "i32", mutable: false }, + (exportValue as WebAssembly.Global).value + memoryBase, + ); + } + } else { relocatedExports[exportName] = exportValue; - } catch { - relocatedExports[exportName] = new WebAssembly.Global( - { value: "i32", mutable: false }, - (exportValue as WebAssembly.Global).value + memoryBase, - ); } - } else { - relocatedExports[exportName] = exportValue; } - } - // Update GOT with this library's exports - for (const [exportName, exportValue] of Object.entries(relocatedExports)) { - if (exportName.startsWith("__")) continue; + // Update GOT with this library's exports + for (const [exportName, exportValue] of Object.entries(relocatedExports)) { + if (exportName.startsWith("__")) continue; + const alreadyDefined = options.globalSymbols.has(exportName); - if (typeof exportValue === "function") { - const tableIdx = options.table.length; - options.table.grow(1); - options.table.set(tableIdx, exportValue as unknown as Function); + if (typeof exportValue === "function") { + const tableIdx = options.table.length; + options.table.grow(1); + options.table.set(tableIdx, exportValue as unknown as Function); - const gotEntry = options.got.get(exportName); - if (gotEntry) { - gotEntry.value = tableIdx; - } - options.globalSymbols.set(exportName, exportValue as Function); - } else if (exportValue instanceof WebAssembly.Global) { - const addr = (exportValue as WebAssembly.Global).value; - const gotEntry = options.got.get(exportName); - if (gotEntry) { - gotEntry.value = addr as number; + const gotEntry = options.got.get(exportName); + if (gotEntry && !alreadyDefined) { + gotEntry.value = tableIdx; + } + if (!alreadyDefined) { + options.globalSymbols.set(exportName, exportValue as Function); + } + } else if (exportValue instanceof WebAssembly.Global) { + const addr = (exportValue as WebAssembly.Global).value; + const gotEntry = options.got.get(exportName); + if (gotEntry && !alreadyDefined) { + gotEntry.value = addr as number; + } + if (!alreadyDefined) { + options.globalSymbols.set(exportName, exportValue); + } } - options.globalSymbols.set(exportName, exportValue); } - } - // Run data relocations - const applyRelocs = instance.exports.__wasm_apply_data_relocs as Function | undefined; - if (applyRelocs) { - applyRelocs(); - } + // Run data relocations + const applyRelocs = instance.exports.__wasm_apply_data_relocs as Function | undefined; + if (applyRelocs) { + applyRelocs(); + } - if (!replay) { - // Skip ctors in replay: parent already ran them and post-startup state - // (e.g. opcache accel_globals, registered INI entries) is in the - // memcpy'd data; re-running would clobber it. - const ctors = instance.exports.__wasm_call_ctors as Function | undefined; - if (ctors) { - ctors(); + if (!replay) { + // Skip ctors in replay: parent already ran them and post-startup state + // (e.g. opcache accel_globals, registered INI entries) is in the + // memcpy'd data; re-running would clobber it. + const ctors = instance.exports.__wasm_call_ctors as Function | undefined; + if (ctors) { + ctors(); + } } - } - const loaded: LoadedSharedLibrary = { - instance, - memoryBase, - tableBase, - exports: relocatedExports, - metadata, - name, - }; + const loaded: LoadedSharedLibrary = { + instance, + memoryBase, + tableBase, + exports: relocatedExports, + metadata, + name, + forkBufAddr: sideForkBufAddr || undefined, + forkCapable: importsFork, + functionImports, + functionExports, + importsDynamicLookup, + }; - options.loadedLibraries.set(name, loaded); - return loaded; + options.loadedLibraries.set(name, loaded); + return loaded; + } catch (error) { + // Restore every mutable host-side linker structure we can. Table length and + // Wasm memory cannot shrink, so clear newly-addressable table slots and let + // the next successful archive entry record the resulting exact table base. + for (let i = tableRollbackBase; i < options.table.length; i++) { + try { options.table.set(i, null); } catch { /* best-effort for nullable funcref */ } + } + options.globalSymbols.clear(); + for (const [symbol, value] of symbolRollback) options.globalSymbols.set(symbol, value); + options.got.clear(); + for (const [symbol, snapshot] of gotRollback) { + try { snapshot.global.value = snapshot.value; } catch { /* immutable should not occur */ } + options.got.set(symbol, snapshot.global); + } + if (options.heapPointer && heapRollbackValue !== undefined) { + options.heapPointer.value = heapRollbackValue; + } + if (options.deallocateMemory) { + for (const allocation of allocations.reverse()) { + try { options.deallocateMemory(allocation.addr, allocation.size); } catch { /* preserve cause */ } + } + } + throw error; + } } /** diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index a30190e7f..c3e7990c4 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -34,6 +34,7 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ ] as const; export const HOST_ADAPTER_OPTIONAL_KERNEL_EXPORTS = [ + "kernel_get_process_exit_signal", "kernel_reserve_host_region", "kernel_reserve_host_region_at", "kernel_set_cwd", diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 9e6cf9ad3..6144bd213 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -21,7 +21,7 @@ * 72 64KB data transfer buffer */ -import { WasmPosixKernel, type KernelPointer } from "./kernel"; +import { negErrno, WasmPosixKernel, type KernelPointer } from "./kernel"; import { SharedLockTable } from "./shared-lock-table"; import { buildRawHttpRequest, @@ -81,6 +81,19 @@ function concatChunksLocal(chunks: Uint8Array[]): Uint8Array { return out; } +/** @internal Exact process-generation guard for async worker-entry continuations. */ +export function isCurrentProcessGeneration( + registry: ReadonlyMap, + pid: number, + expected: T, + memory: WebAssembly.Memory, + execHandoffActive: boolean = false, +): boolean { + return !execHandoffActive + && registry.get(pid) === expected + && expected.memory === memory; +} + /** Channel status values */ const CH_IDLE = CHANNEL_STATUS_IDLE; const CH_PENDING = CHANNEL_STATUS_PENDING; @@ -101,10 +114,34 @@ export function shouldDeliverPosixTimerSignal(signo: number): boolean { const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; /** Errno values */ +const E2BIG = 7; const EAGAIN = 11; +const EACCES = 13; +const EBADF = 9; +const EEXIST = 17; +const EFAULT = 14; +const EIO = 5; +const EINVAL = 22; +const ENOMEM = 12; +const ENAMETOOLONG = 36; +const ENOENT = 2; +const ENOSYS = 38; +const ENOTSUP = 95; const ETIMEDOUT = 110; const EINTR_ERRNO = 4; +/** + * Maximum combined exec argv + environment representation: UTF-8 strings, + * their terminating NUL bytes, and one source-width pointer per entry plus + * each list's terminating null pointer. This matches the advertised 4 MiB + * _SC_ARG_MAX boundary without imposing a separate argument-count ceiling. + * Individual entries must also fit one bounded host scratch transfer. + */ +const EXEC_METADATA_MAX_BYTES = 4 * 1024 * 1024; +const EXEC_PATH_MAX_BYTES = 4096; +const PROCESS_METADATA_ARGV = 0; +const PROCESS_METADATA_ENVIRONMENT = 1; + /** Syscall numbers for sleep/delay */ const SYS_NANOSLEEP = ABI_SYSCALLS.Nanosleep; const SYS_USLEEP = ABI_SYSCALLS.Usleep; @@ -177,6 +214,7 @@ const SYS_IOCTL = ABI_SYSCALLS.Ioctl; /** Syscall numbers for memory management */ const SYS_MMAP = ABI_SYSCALLS.Mmap; const SYS_MUNMAP = ABI_SYSCALLS.Munmap; +const SYS_MPROTECT = ABI_SYSCALLS.Mprotect; const SYS_BRK = ABI_SYSCALLS.Brk; const SYS_MREMAP = ABI_SYSCALLS.Mremap; const SYS_MSYNC = ABI_SYSCALLS.Msync; @@ -184,6 +222,19 @@ const SYS_WRITE = ABI_SYSCALLS.Write; const SYS_READ = ABI_SYSCALLS.Read; const SYS_PREAD = ABI_SYSCALLS.Pread; const SYS_PWRITE = ABI_SYSCALLS.Pwrite; +const SYS_FSYNC = ABI_SYSCALLS.Fsync; +const SYS_FDATASYNC = ABI_SYSCALLS.Fdatasync; +const SYS_FTRUNCATE = ABI_SYSCALLS.Ftruncate; +const SYS_TRUNCATE = ABI_SYSCALLS.Truncate; +const SYS_FALLOCATE = ABI_SYSCALLS.Fallocate; +const SYS_SENDFILE = ABI_SYSCALLS.Sendfile; +// Implemented by the kernel dispatcher but not yet classified in generated +// host marshalling. NULL-offset calls still traverse the ordinary channel. +const SYS_COPY_FILE_RANGE = 290; +const SYS_SPLICE = 291; +const SYS_DUP = ABI_SYSCALLS.Dup; +const SYS_DUP2 = ABI_SYSCALLS.Dup2; +const SYS_DUP3 = ABI_SYSCALLS.Dup3; const SYS_SEND = ABI_SYSCALLS.Send; const SYS_RECV = ABI_SYSCALLS.Recv; const SYS_SENDTO = ABI_SYSCALLS.Sendto; @@ -198,7 +249,26 @@ const MSG_DONTWAIT = 0x0040; /** mmap flags */ const MAP_SHARED = 0x01; +const PROT_READ = 0x01; +const PROT_WRITE = 0x02; +const MAP_FIXED = 0x10; const MAP_ANONYMOUS = 0x20; +const O_RDONLY = 0; +const O_WRONLY = 1; +const O_RDWR = 2; +const O_ACCMODE = 3; +const O_TRUNC = 0o1000; +const FILE_PAGE_SIZE = 4096; +const AT_FDCWD = -100; + +const F_DUPFD = 0; +const F_GETFL = 3; +const F_DUPFD_CLOFORK = 1028; +const F_DUPFD_CLOEXEC = 1030; + +function alignWasmPageLength(len: number): number { + return Math.ceil(len / WASM_PAGE_SIZE) * WASM_PAGE_SIZE; +} /** Syscall numbers for scatter/gather I/O */ const SYS_WRITEV = ABI_SYSCALLS.Writev; @@ -218,10 +288,13 @@ const SYS_SHMDT = ABI_SYSCALLS.Shmdt; const SYS_MQ_TIMEDSEND = ABI_SYSCALLS.MqTimedsend; const SYS_MQ_TIMEDRECEIVE = ABI_SYSCALLS.MqTimedreceive; +const SYS_OPEN = ABI_SYSCALLS.Open; +const SYS_OPENAT = ABI_SYSCALLS.Openat; const SYS_CLOSE = ABI_SYSCALLS.Close; /** IPC constants (must match musl) */ const IPC_64 = 0x100; +const SHM_RDONLY = 0o10000; const F_GETLK = 5; const F_SETLK = 6; @@ -467,10 +540,95 @@ interface ProcessRegistration { explicitMaxAddr: boolean; } +/** + * Host metadata for a MAP_SHARED interval. File mappings retain the existing + * fd-backed writeback path. Anonymous mappings additionally point at a + * host-owned backing and keep a snapshot of the bytes this process last saw. + */ +interface SharedMmapMapping { + fd: number; + fileOffset: number; + len: number; + writable: boolean; + /** Whether the original fd permits a later PROT_WRITE upgrade. */ + writeAllowed?: boolean; + backingKind?: "anonymous" | "file"; + backingKey?: string; + snapshot?: Uint8Array; + seenVersion?: number; +} + +interface SharedMmapFdStat { + dev: bigint; + ino: bigint; + size: number; + mode: number; +} + +interface SharedMmapBacking { + key: string; + path: string; + handle: number; + writable: boolean; + /** Authoritative size from the stable handle's fstat. */ + size: number; + sizeValid: boolean; + pages: Map; + dirtyPages: Set; + refCount: number; + version: number; +} + +interface OpenedSharedMmapBacking { + handle: number; + size: number; +} + +type SharedMmapHostResult = + | { kind: "ok"; value: T } + | { kind: "error"; errno: number }; + +type FileSharedMmapResult = + | { kind: "mapped" } + | { kind: "unsupported" } + | { kind: "error"; errno: number }; + +interface PreparedFileSharedMmap { + fd: number; + fileOffset: number; + len: number; + writable: boolean; + writeAllowed: boolean; + backing: SharedMmapBacking; +} + +type FileSharedMmapPreparationResult = + | { kind: "prepared"; context: PreparedFileSharedMmap } + | { kind: "unsupported" } + | { kind: "error"; errno: number }; + +interface AnonymousSharedMmapBacking { + key: string; + bytes: Uint8Array; + refCount: number; + version: number; +} + +interface SysvShmMapping { + segId: number; + size: number; + readOnly: boolean; + snapshot: Uint8Array; + seenVersion: number; +} + interface RegisterProcessOptions { skipKernelCreate?: boolean; argv?: string[]; + env?: string[]; ptrWidth?: 4 | 8; + /** Width of the exec caller's argv/envp pointer arrays for ARG_MAX accounting. */ + metadataPtrWidth?: 4 | 8; /** Required for new kernel Process creation; ignored when skipKernelCreate is true. */ stdio?: RegisterProcessStdio; /** Initial program break after any host-owned low control pages. */ @@ -595,7 +753,13 @@ export interface CentralizedKernelCallbacks { * new binary, and call registerProcess with skipKernelCreate. * Returns 0 on success, negative errno on error. */ - onExec?: (pid: number, path: string, argv: string[], envp: string[]) => Promise; + onExec?: ( + pid: number, + path: string, + argv: string[], + envp: string[], + callerTid: number, + ) => Promise; /** * Pre-flight resolution step for SYS_SPAWN. Returns the program bytes @@ -657,6 +821,20 @@ export interface CentralizedKernelCallbacks { onExitGroup?: (pid: number) => void; } +interface TcpListenerBridge { + server: import("net").Server; + pid: number; + port: number; + connections: Set; +} + +interface TcpListenerTarget { + pid: number; + fd: number; + /** Stable kernel accept-queue identity retained even if this fd is closed. */ + acceptWakeIdx?: number; +} + export class CentralizedKernelWorker { private kernel: WasmPosixKernel; private kernelInstance: WebAssembly.Instance | null = null; @@ -665,6 +843,8 @@ export class CentralizedKernelWorker { private kernelAbiVersion: number = 0; private processes = new Map(); private activeChannels: ChannelInfo[] = []; + /** Pids whose old image committed exec but whose replacement has no channel yet. */ + private execHandoffPids = new Set(); private scratchOffset = 0; private initialized = false; private nextChildPid = 100; @@ -725,12 +905,17 @@ export class CentralizedKernelWorker { ((tid: number) => void) | undefined; if (setTid) setTid(tid); } + + private guestTidForChannel(channel: ChannelInfo): number { + return this.channelTids.get(`${channel.pid}:${channel.channelOffset}`) + ?? channel.pid; + } /** Alarm timers per process: pid → NodeJS.Timeout */ private alarmTimers = new Map>(); /** POSIX timers: "pid:timerId" → {timeout, interval?, signo} */ private posixTimers = new Map; interval?: ReturnType; signo: number }>(); - /** Pending sleep timers per process: pid → {timer, channel, syscallNr, origArgs, retVal, errVal} */ - private pendingSleeps = new Map; channel: ChannelInfo; syscallNr: number; @@ -741,16 +926,11 @@ export class CentralizedKernelWorker { /** Maps "pid:tid" to ctidPtr for CLONE_CHILD_CLEARTID on thread exit */ private threadCtidPtrs = new Map(); /** TCP listeners: "pid:fd" → { server, pid, port, connections } */ - private tcpListeners = new Map; - }>(); - /** TCP listener targets: port → list of {pid, fd} for round-robin dispatch. + private tcpListeners = new Map(); + /** TCP listener targets: port → listener aliases for round-robin dispatch. * When multiple processes share a listening socket (e.g., nginx master forks * workers), incoming connections are distributed among them. */ - private tcpListenerTargets = new Map>(); + private tcpListenerTargets = new Map(); private tcpListenerRRIndex = new Map(); /** Virtual-network listener registration key for each shared TCP port. */ private tcpVirtualListenerKeys = new Map(); @@ -772,8 +952,8 @@ export class CentralizedKernelWorker { /** Cached kernel memory typed array view (invalidated on memory.grow) */ private cachedKernelMem: Uint8Array | null = null; private cachedKernelBuffer: ArrayBuffer | null = null; - /** Pending poll/ppoll retries — keyed by channelOffset for per-thread tracking */ - private pendingPollRetries = new Map(); - /** Pending pselect6/select retries — keyed by channelOffset for per-thread tracking */ - private pendingSelectRetries = new Map>(); - /** Pending futex waits: channelOffset → { futexAddr, futexIndex }. + /** Pending futex waits keyed by exact channel generation. * Tracked so SYS_THREAD_CANCEL can force-wake a futex-blocked thread * by firing Atomics.notify on the address it is waiting on. The waitAsync * Promise in handleFutex then resolves and writes the channel result. */ - private pendingFutexWaits = new Map(); - /** Channel offsets with a cancellation request pending. Set by + private pendingFutexWaits = new Map(); + /** Exact channel generations with a cancellation request pending. Set by * SYS_THREAD_CANCEL. Checked at the entry of every blocking syscall * path (futex wait, pipe retry, poll retry) so a cancel that arrives * before the target actually blocks still terminates the in-flight * syscall instead of silently racing past it. Cleared when the cancel * has been serviced (target's channel completed with -EINTR / woken). */ - private pendingCancels = new Set(); + private pendingCancels = new Set(); /** Profiling data: syscallNr → {count, totalTimeMs, retries} */ private profileData: Map | null = PROFILING ? new Map() : null; @@ -846,20 +1026,27 @@ export class CentralizedKernelWorker { recvPipeIdx: number; schedulePump: () => void; }>>(); - /** Per-process MAP_SHARED file-backed mappings: pid → Map */ - private sharedMappings = new Map>(); + /** Per-process MAP_SHARED mappings: pid → Map. */ + private sharedMappings = new Map>(); + /** Host-owned byte stores for anonymous MAP_SHARED mappings. */ + private anonymousSharedBackings = new Map(); + private nextAnonymousSharedBackingId = 1; + /** Stable host handles and page caches for file/POSIX MAP_SHARED objects. */ + private sharedMmapBackings = new Map(); + /** Prevent nested signal cleanup from releasing the same address space twice. */ + private sharedMemoryReleasePids = new Set(); + /** Process fd → resolved backing identity, including negative lookups. */ + private sharedMmapFdCache = new Map(); /** Host-side mirror of epoll interest lists: "pid:epfd" → interests. * Maintained by intercepting epoll_ctl results. Used by handleEpollPwait * to convert epoll_pwait to poll without calling kernel_handle_channel * (which crashes in Chrome for epoll_pwait due to a suspected V8 bug). */ private epollInterests = new Map>(); private lockTable: SharedLockTable | null = null; - /** Per-process shared memory mappings: pid → Map */ - private shmMappings = new Map>(); + /** Per-process SysV shared-memory attachments. */ + private shmMappings = new Map>(); + /** Authoritative segment version, incremented after each merged publication. */ + private shmSegmentVersions = new Map(); /** PTY index → pid mapping (for draining output after syscalls) */ private ptyIndexByPid = new Map(); @@ -949,9 +1136,7 @@ export class CentralizedKernelWorker { if (seconds > 0) { const timer = setTimeout(() => { this.alarmTimers.delete(pid); - if (this.processes.has(pid)) { - this.sendSignalToProcess(pid, SIGALRM); - } + this.sendSignalToProcess(pid, SIGALRM); }, seconds * 1000); this.alarmTimers.set(pid, timer); } @@ -1005,6 +1190,8 @@ export class CentralizedKernelWorker { // valueMs > 0 means armed (0 = disarm, kernel ensures >= 1ms for armed timers) const delay = Math.max(0, valueMs); const timeout = setTimeout(() => { + const current = this.posixTimers.get(key); + if (!current || current.timeout !== timeout) return; if (!this.processes.has(pid)) { this.posixTimers.delete(key); return; @@ -1020,9 +1207,13 @@ export class CentralizedKernelWorker { // Set up repeating interval if needed if (intervalMs > 0) { const iv = setInterval(() => { + const intervalEntry = this.posixTimers.get(key); + if (!intervalEntry || intervalEntry.interval !== iv) { + clearInterval(iv); + return; + } if (!this.processes.has(pid)) { - const entry = this.posixTimers.get(key); - if (entry?.interval) clearInterval(entry.interval); + clearInterval(iv); this.posixTimers.delete(key); return; } @@ -1037,7 +1228,11 @@ export class CentralizedKernelWorker { } }, intervalMs); const entry = this.posixTimers.get(key); - if (entry) entry.interval = iv; + if (entry?.timeout === timeout) { + entry.interval = iv; + } else { + clearInterval(iv); + } } else { this.posixTimers.delete(key); } @@ -1137,6 +1332,17 @@ export class CentralizedKernelWorker { ): void { if (!this.initialized) throw new Error("Kernel not initialized"); + if (options?.argv !== undefined || options?.env !== undefined) { + const metadataResult = this.validateExecMetadata( + options.argv ?? [], + options.env ?? [], + options.metadataPtrWidth ?? options.ptrWidth ?? 4, + ); + if (metadataResult < 0) { + throw new Error(`Process argv/environment exceeds exec metadata limits: errno ${-metadataResult}`); + } + } + // A fresh registration starts a new "generation" for this pid — even // if the same numeric pid was previously reaped (it can't be today // since nextChildPid is monotonic, but defensive), the new process @@ -1174,19 +1380,14 @@ export class CentralizedKernelWorker { } // Set process argv in kernel for /proc//cmdline - if (options?.argv && options.argv.length > 0) { - const setArgv = this.kernelInstance!.exports.kernel_set_process_argv as - ((pid: number, dataPtr: KernelPointer, dataLen: number) => number) | undefined; - if (setArgv) { - const encoder = new TextEncoder(); - const nullSep = options.argv.join("\0"); - const encoded = encoder.encode(nullSep); - // Write to kernel scratch area - const kernelMem = new Uint8Array(this.kernelMemory!.buffer); - const scratchOffset = this.scratchOffset!; - kernelMem.set(encoded, scratchOffset); - setArgv(pid, this.toKernelPtr(scratchOffset), encoded.length); - } + if (options?.argv !== undefined) { + this.replaceProcessMetadata(pid, PROCESS_METADATA_ARGV, options.argv); + } + + // Keep kernel-owned environment state synchronized with the process + // worker. This matters for exec even when the replacement envp is empty. + if (options?.env !== undefined) { + this.replaceProcessMetadata(pid, PROCESS_METADATA_ENVIRONMENT, options.env); } // Cap mmap address space. New hosts pass the process memory maximum here @@ -1249,6 +1450,98 @@ export class CentralizedKernelWorker { } } + /** + * Side-effect-free exec argv/environment validation. Call this before the + * irreversible exec commit so oversized metadata returns E2BIG to the old + * image instead of failing while the replacement worker is being installed. + */ + validateExecMetadata( + argv: readonly string[], + env: readonly string[], + ptrWidth: 4 | 8 = 4, + ): number { + const encoder = new TextEncoder(); + // Account for the null pointer terminating each vector even when it is + // explicitly empty. Pointer accounting both matches ARG_MAX semantics and + // bounds the number of zero-length entries without an arbitrary count cap. + let totalBytes = 2 * ptrWidth; + for (const value of [...argv, ...env]) { + const encodedLength = encoder.encode(value).byteLength; + if (encodedLength > CH_DATA_SIZE) return -E2BIG; + totalBytes += ptrWidth + encodedLength + 1; + if (!Number.isSafeInteger(totalBytes) || totalBytes > EXEC_METADATA_MAX_BYTES) { + return -E2BIG; + } + } + return 0; + } + + /** Whether this kernel supports lossless bounded argv+environment replacement. */ + supportsExecMetadataReplacement(): boolean { + const exports = this.kernelInstance?.exports; + return typeof exports?.kernel_clear_process_metadata === "function" + && typeof exports?.kernel_push_process_metadata_entry === "function"; + } + + /** Replace argv or environ using bounded, entry-at-a-time scratch copies. */ + private replaceProcessMetadata(pid: number, kind: number, values: readonly string[]): void { + const clear = this.kernelInstance!.exports.kernel_clear_process_metadata as + ((pid: number, kind: number) => number) | undefined; + const push = this.kernelInstance!.exports.kernel_push_process_metadata_entry as + ((pid: number, kind: number, dataPtr: KernelPointer, dataLen: number) => number) | undefined; + if (!clear || !push) { + // Additive ABI-16 compatibility for ordinary initial registrations: + // older kernels can still receive a small argv through their legacy + // aggregate setter. Exec preflight rejects before commit because that + // legacy surface cannot explicitly replace/clear the environment. + const setArgv = this.kernelInstance!.exports.kernel_set_process_argv as + ((pid: number, dataPtr: KernelPointer, dataLen: number) => number) | undefined; + if (kind !== PROCESS_METADATA_ARGV || !setArgv) { + throw new Error("Kernel missing bounded process metadata exports"); + } + const encoded = new TextEncoder().encode(values.join("\0")); + if (encoded.byteLength > CH_DATA_SIZE) { + throw new Error(`Legacy process argv exceeds bounded scratch transport: errno ${E2BIG}`); + } + new Uint8Array(this.kernelMemory!.buffer).set(encoded, this.scratchOffset); + const result = setArgv( + pid, + this.toKernelPtr(this.scratchOffset), + encoded.byteLength, + ); + if (result < 0) { + throw new Error(`Failed to replace process argv for pid ${pid}: errno ${-result}`); + } + return; + } + + const clearResult = clear(pid, kind); + if (clearResult < 0) { + throw new Error(`Failed to clear process metadata for pid ${pid}: errno ${-clearResult}`); + } + + const encoder = new TextEncoder(); + for (const value of values) { + const encoded = encoder.encode(value); + if (encoded.byteLength > CH_DATA_SIZE) { + throw new Error(`Process metadata entry exceeds bounded scratch transport: errno ${E2BIG}`); + } + // A preceding Rust push can allocate and grow kernel Wasm memory, + // detaching the old ArrayBuffer view. Refresh it for every entry. + const kernelMem = new Uint8Array(this.kernelMemory!.buffer); + kernelMem.set(encoded, this.scratchOffset); + const pushResult = push( + pid, + kind, + this.toKernelPtr(this.scratchOffset), + encoded.byteLength, + ); + if (pushResult < 0) { + throw new Error(`Failed to append process metadata for pid ${pid}: errno ${-pushResult}`); + } + } + } + /** * Provide data that will be returned when the process reads from stdin (fd 0). * Data is returned in chunks until exhausted, then EOF is returned. @@ -1356,13 +1649,14 @@ export class CentralizedKernelWorker { // pending we complete the sleep with EINTR so the glue can dispatch it. // Skipped pids (no signal queued) keep their original sleep deadline. const EINTR = 4; - for (const [pid, entry] of Array.from(this.pendingSleeps.entries())) { - if (!this.processes.has(pid)) continue; - this.dequeueSignalForDelivery(entry.channel); + for (const [sleepChannel, entry] of Array.from(this.pendingSleeps.entries())) { + if (!this.isRegisteredChannel(entry.channel)) continue; + this.dequeueSignalForDelivery(entry.channel, true); + if (this.finishSignalTermination(entry.channel)) continue; const view = new DataView(entry.channel.memory.buffer, entry.channel.channelOffset); if (view.getUint32(CH_SIG_SIGNUM, true) > 0) { clearTimeout(entry.timer); - this.pendingSleeps.delete(pid); + this.pendingSleeps.delete(sleepChannel); this.completeChannel( entry.channel, entry.syscallNr, entry.origArgs, SYSCALL_ARGS[entry.syscallNr], -1, EINTR, @@ -1560,6 +1854,10 @@ export class CentralizedKernelWorker { const registration = this.processes.get(pid); if (!registration) return; + // Shared backing publication and SysV detach require the process memory and + // kernel Process to remain available, so do this before either is removed. + this.releaseAllSharedMemoryForProcess(pid); + // Remove channels from active list this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); @@ -1574,6 +1872,7 @@ export class CentralizedKernelWorker { // Clean up pending pipe readers/writers this.cleanupPendingPipeReaders(pid); this.cleanupPendingPipeWriters(pid); + this.cancelPendingSleepsForProcess(pid); // Clean up socket timeout timers for this process for (const [ch, timer] of this.socketTimeoutTimers) { if (ch.pid === pid) { @@ -1595,6 +1894,7 @@ export class CentralizedKernelWorker { this.removeFromKernelProcessTable(pid); this.processes.delete(pid); + this.execHandoffPids?.delete(pid); this.stdinFinite.delete(pid); this.stdinBuffers.delete(pid); @@ -1646,9 +1946,19 @@ export class CentralizedKernelWorker { this.lockTable.removeLocksByPid(pid); } + private cancelPendingSleepsForProcess(pid: number): void { + for (const [channel, sleep] of this.pendingSleeps) { + if (channel.pid !== pid) continue; + clearTimeout(sleep.timer); + this.pendingSleeps.delete(channel); + } + } + deactivateProcess(pid: number): void { + this.releaseAllSharedMemoryForProcess(pid); this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); this.processes.delete(pid); + this.execHandoffPids?.delete(pid); this.stdinFinite.delete(pid); this.stdinBuffers.delete(pid); this.releaseAdvisoryLocksForPid(pid); @@ -1666,12 +1976,8 @@ export class CentralizedKernelWorker { this.posixTimers.delete(key); } } - // Cancel any pending sleep timer for this process - const sleepTimer = this.pendingSleeps.get(pid); - if (sleepTimer) { - clearTimeout(sleepTimer.timer); - this.pendingSleeps.delete(pid); - } + // Cancel pending sleeps for every thread in this process. + this.cancelPendingSleepsForProcess(pid); // Clean up pending poll retries this.cleanupPendingPollRetries(pid); // Clean up pending select retries @@ -1685,22 +1991,387 @@ export class CentralizedKernelWorker { this.hostReaped.delete(pid); } + /** + * Validate the exec caller and apply deferred posix_spawn file actions. + * This is the fallible kernel preflight; no image-owned state is discarded. + */ + kernelExecPrepare(pid: number, callerTid: number = pid): number { + const prepare = this.kernelInstance!.exports.kernel_exec_prepare as + ((pid: number, callerTid: number) => number) | undefined; + if (!prepare) return 0; + + const previousPid = this.currentHandlePid; + this.currentHandlePid = pid; + try { + return prepare(pid, callerTid); + } finally { + this.currentHandlePid = previousPid; + } + } + /** * Run kernel-side exec setup: close CLOEXEC fds, reset signal handlers. * Returns 0 on success, negative errno on failure. * Called by onExec callbacks after confirming the target program exists. */ - kernelExecSetup(pid: number): number { - const fn = this.kernelInstance!.exports.kernel_exec_setup as (pid: number) => number; - return fn(pid); + kernelExecSetup(pid: number, callerTid: number = pid): number { + const threadAware = this.kernelInstance!.exports.kernel_exec_setup_for_thread as + ((pid: number, callerTid: number) => number) | undefined; + const legacy = this.kernelInstance!.exports.kernel_exec_setup as (pid: number) => number; + const previousPid = this.currentHandlePid; + this.currentHandlePid = pid; + try { + const listenerWakeSnapshot = this.snapshotExecTcpListenerWakeIds(pid); + const result = threadAware ? threadAware(pid, callerTid) : legacy(pid); + if (result === 0) { + // This is post-commit bookkeeping. Let failures propagate to the + // worker entry's fatal exec boundary; returning to the discarded + // caller or continuing with stale host mirrors would both be false. + this.pruneExecFdMirrors(pid, listenerWakeSnapshot); + } + return result; + } finally { + this.currentHandlePid = previousPid; + } + } + + /** Snapshot stable accept-queue identities before CLOEXEC closes aliases. */ + private snapshotExecTcpListenerWakeIds(pid: number): Map { + const getAcceptWake = this.kernelInstance!.exports.kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + const snapshot = new Map(); + if (!getAcceptWake) return snapshot; + const remember = (port: number, fd: number, knownWakeIdx?: number) => { + // A target's stored queue token is its stable identity even after the + // numeric fd closes or is reused by a different listener. + const wakeIdx = knownWakeIdx ?? getAcceptWake(pid, fd); + if (wakeIdx >= 0) snapshot.set(`${port}:${fd}`, wakeIdx); + }; + + for (const [port, targets] of this.tcpListenerTargets) { + for (const target of targets) { + if (target.pid === pid) remember(port, target.fd, target.acceptWakeIdx); + } + } + const prefix = `${pid}:`; + for (const [key, listener] of this.tcpListeners) { + if (!key.startsWith(prefix)) continue; + const fd = Number(key.slice(prefix.length)); + const target = this.tcpListenerTargets.get(listener.port) + ?.find(entry => entry.pid === pid && entry.fd === fd); + remember(listener.port, fd, target?.acceptWakeIdx); + } + return snapshot; + } + + /** Resolve one listener identity in another process after fork/spawn actions. */ + private resolveInheritedListenerFd( + pid: number, + preferredFd: number, + wakeIdx?: number, + ): { fd: number; acceptWakeIdx?: number } | null { + const getAcceptWake = this.kernelInstance!.exports.kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + if (!getAcceptWake) { + return { + fd: preferredFd, + ...(wakeIdx !== undefined ? { acceptWakeIdx: wakeIdx } : {}), + }; + } + + const liveWakeIdx = getAcceptWake(pid, preferredFd); + if (wakeIdx === undefined) { + return liveWakeIdx >= 0 + ? { fd: preferredFd, acceptWakeIdx: liveWakeIdx } + : null; + } + if (liveWakeIdx === wakeIdx) { + return { fd: preferredFd, acceptWakeIdx: wakeIdx }; + } + + const findListenerFd = this.kernelInstance!.exports + .kernel_find_listener_fd_by_accept_wake as + ((pid: number, wakeIdx: number) => number) | undefined; + let resolvedFd = findListenerFd?.(pid, wakeIdx) ?? -1; + if (!findListenerFd) { + // Compatibility with ABI 16 kernels predating the additive resolver. + for (let fd = 0; fd < 1024; fd++) { + if (getAcceptWake(pid, fd) === wakeIdx) { + resolvedFd = fd; + break; + } + } + } + return resolvedFd >= 0 + ? { fd: resolvedFd, acceptWakeIdx: wakeIdx } + : null; + } + + /** + * Install host-only descriptor mirrors for a kernel child that already + * exists. This runs synchronously before async Worker launch so parent exec + * cannot close the final listener backend in the handoff window. + */ + private inheritHostFdMirrors( + parentPid: number, + childPid: number, + includeEpoll: boolean = true, + ): void { + const getAcceptWake = this.kernelInstance!.exports.kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + for (const [, targets] of this.tcpListenerTargets) { + for (const parentTarget of targets.filter(target => target.pid === parentPid)) { + const parentWakeIdx = parentTarget.acceptWakeIdx + ?? (() => { + const wakeIdx = getAcceptWake?.(parentPid, parentTarget.fd) ?? -1; + return wakeIdx >= 0 ? wakeIdx : undefined; + })(); + const childTarget = this.resolveInheritedListenerFd( + childPid, + parentTarget.fd, + parentWakeIdx, + ); + if (!childTarget + || targets.some(target => + target.pid === childPid && target.fd === childTarget.fd)) continue; + targets.push({ pid: childPid, ...childTarget }); + } + } + + if (!includeEpoll) return; + + const fdIsOpen = this.kernelInstance!.exports.kernel_fd_is_open as + ((pid: number, fd: number) => number) | undefined; + for (const [key, interests] of Array.from(this.epollInterests.entries())) { + if (!key.startsWith(`${parentPid}:`)) continue; + const epfd = Number(key.slice(key.indexOf(':') + 1)); + if (fdIsOpen && fdIsOpen(childPid, epfd) !== 1) continue; + this.epollInterests.set( + `${childPid}:${epfd}`, + interests + .filter(entry => !fdIsOpen || fdIsOpen(childPid, entry.fd) === 1) + .map(entry => ({ ...entry })), + ); + } + } + + /** Remove host-only child state after fork/spawn Worker launch fails. */ + private rollbackChildHostRegistration(childPid: number): void { + this.deactivateProcess(childPid); + for (const key of Array.from(this.epollInterests.keys())) { + if (key.startsWith(`${childPid}:`)) this.epollInterests.delete(key); + } + } + + /** Reconcile host-only fd mirrors after the kernel closes CLOEXEC fds. */ + private pruneExecFdMirrors(pid: number, listenerWakeSnapshot: Map): void { + const fdIsOpen = this.kernelInstance!.exports.kernel_fd_is_open as + ((pid: number, fd: number) => number) | undefined; + if (!fdIsOpen) return; + const open = (fd: number) => fdIsOpen(pid, fd) === 1; + const prefix = `${pid}:`; + const getAcceptWake = this.kernelInstance!.exports.kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + const findListenerFd = this.kernelInstance!.exports + .kernel_find_listener_fd_by_accept_wake as + ((pid: number, wakeIdx: number) => number) | undefined; + const aliasByWake = new Map(); + const resolveListenerFd = (port: number, oldFd: number): number | null => { + const wakeIdx = listenerWakeSnapshot.get(`${port}:${oldFd}`); + if (wakeIdx === undefined || !getAcceptWake) return open(oldFd) ? oldFd : null; + if (getAcceptWake(pid, oldFd) === wakeIdx) return oldFd; + if (aliasByWake.has(wakeIdx)) return aliasByWake.get(wakeIdx)!; + let candidate = findListenerFd?.(pid, wakeIdx) ?? -1; + // ABI 16 kernels built before the additive resolver export can still + // recover aliases within their historical default descriptor range. + if (!findListenerFd) { + for (let fd = 0; fd < 1024; fd++) { + if (getAcceptWake(pid, fd) === wakeIdx) { + candidate = fd; + break; + } + } + } + const alias = candidate >= 0 ? candidate : null; + aliasByWake.set(wakeIdx, alias); + return alias; + }; + + for (const [key, interests] of Array.from(this.epollInterests.entries())) { + if (!key.startsWith(prefix)) continue; + const epfd = Number(key.slice(prefix.length)); + if (!open(epfd)) { + this.epollInterests.delete(key); + } else { + // The current epoll model stores numeric fds rather than OFD identity. + // Dropping closed targets prevents later fd reuse from observing a + // stale registration; duplicate-fd retention remains a documented gap. + this.epollInterests.set(key, interests.filter(entry => open(entry.fd))); + } + } + + for (const [port, targets] of Array.from(this.tcpListenerTargets.entries())) { + const retained: Array<{ pid: number; fd: number; acceptWakeIdx?: number }> = []; + for (const target of targets) { + if (target.pid !== pid) { + retained.push(target); + continue; + } + const fd = resolveListenerFd(port, target.fd); + if (fd !== null && !retained.some(entry => entry.pid === pid && entry.fd === fd)) { + retained.push({ ...target, pid, fd }); + } + } + if (retained.length === 0) { + this.tcpListenerTargets.delete(port); + this.tcpListenerRRIndex.delete(port); + const virtualKey = this.tcpVirtualListenerKeys.get(port); + if (virtualKey) { + this.io.network?.closeTcpListener?.(virtualKey); + this.tcpVirtualListenerKeys.delete(port); + } + } else { + this.tcpListenerTargets.set(port, retained); + const oldIndex = this.tcpListenerRRIndex.get(port) ?? 0; + this.tcpListenerRRIndex.set(port, oldIndex % retained.length); + } + } + + const removedByPort = new Map(); + for (const [key, listener] of Array.from(this.tcpListeners.entries())) { + if (!key.startsWith(prefix)) continue; + const fd = Number(key.slice(prefix.length)); + const replacementFd = resolveListenerFd(listener.port, fd); + if (replacementFd === fd) continue; + this.tcpListeners.delete(key); + if (replacementFd === null) { + removedByPort.set(listener.port, listener); + } else { + const replacementKey = `${pid}:${replacementFd}`; + if (!this.tcpListeners.has(replacementKey)) { + this.tcpListeners.set(replacementKey, { ...listener, pid }); + } + } + } + for (const [port, listener] of removedByPort) { + const targets = this.tcpListenerTargets.get(port); + if (!targets || targets.length === 0) { + listener.server.close(); + const virtualKey = this.tcpVirtualListenerKeys.get(port); + if (virtualKey) { + this.io.network?.closeTcpListener?.(virtualKey); + this.tcpVirtualListenerKeys.delete(port); + } + } else { + const replacement = targets[0]!; + const replacementKey = `${replacement.pid}:${replacement.fd}`; + if (!this.tcpListeners.has(replacementKey)) { + this.tcpListeners.set(replacementKey, { ...listener, pid: replacement.pid }); + } + } + } + } + + /** Whether a file mapping has a real writable regular-file backing. */ + private fdSupportsMmapWriteback(pid: number, fd: number): boolean { + const supports = this.kernelInstance!.exports.kernel_fd_supports_mmap_writeback as + ((pid: number, fd: number) => number) | undefined; + // Older ABI-16 kernels predate capability classification. Preserve their + // existing msync behavior; exec itself is feature-gated on newer metadata + // exports, so it cannot hit the old device-preflush failure. + return supports ? supports(pid, fd) === 1 : true; + } + + /** + * Flush mappings owned by the address space that exec is about to discard. + * Tracking and SysV attachments remain intact until the kernel commit + * succeeds, so a failed exec can continue using the old address space. + */ + prepareAddressSpaceForExec(pid: number): number { + const registration = this.processes.get(pid); + const channel = registration?.channels[0]; + if (!channel) { + const hasShared = (this.sharedMappings.get(pid)?.size ?? 0) > 0; + const hasSysv = (this.shmMappings.get(pid)?.size ?? 0) > 0; + return hasShared || hasSysv ? -EIO : 0; + } + + try { + this.syncAnonymousSharedMappingsFromProcess(channel, { force: true }); + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + const shared = this.sharedMappings.get(pid); + if (shared) { + for (const [addr, mapping] of shared) { + if (!mapping.writable) continue; + if (mapping.backingKind === "file" && mapping.backingKey) { + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (backing && !this.flushSharedMmapBackingRange( + backing, + mapping.fileOffset, + mapping.len, + )) return -EIO; + continue; + } + if (mapping.backingKey) continue; + if (!this.pwriteFromProcessMemory( + channel, + mapping.fd, + addr, + mapping.len, + mapping.fileOffset, + )) return -EIO; + } + } + return this.syncSysvShmMappingsFromProcess(channel, { force: true }) ? 0 : -EIO; + } catch { + return -EIO; + } + } + + /** + * Forget mappings and detach SysV segments after the irreversible kernel + * exec commit. A failure here is post-commit and must be treated as fatal by + * the caller; returning to the discarded image is no longer possible. + */ + finalizeAddressSpaceForExec(pid: number): number { + const shared = this.sharedMappings.get(pid); + if (shared) { + for (const mapping of shared.values()) this.releaseSharedMapping(mapping); + this.sharedMappings.delete(pid); + } + this.invalidateSharedMmapFdCacheForPid(pid); + + const sysv = this.shmMappings.get(pid); + if (!sysv) return 0; + const detach = this.kernelInstance!.exports.kernel_ipc_shmdt as + ((shmid: number) => number) | undefined; + let result = 0; + try { + if (!detach) return -EIO; + this.withKernelCurrentPid(pid, () => { + for (const mapping of sysv.values()) { + if (detach(mapping.segId) < 0) result = -EIO; + } + }); + } catch { + result = -EIO; + } finally { + this.shmMappings.delete(pid); + } + return result; } /** * Remove old channel/registration state for a process about to exec. * Does NOT remove from kernel process table (exec keeps the same pid). - * Does NOT cancel timers (POSIX: timers are preserved across exec). - */ + * Preserves alarm()/ITIMER_REAL, but cancels timer_create() timers: POSIX + * keeps interval timers across exec and deletes per-process POSIX timers. + */ prepareProcessForExec(pid: number): void { + const registration = this.processes.get(pid); + (this.execHandoffPids ??= new Set()).add(pid); + if (registration) registration.channels = []; + // Remove channels from active list (stops listening on old memory) this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); @@ -1709,6 +2380,48 @@ export class CentralizedKernelWorker { this.cleanupPendingSelectRetries(pid); this.cleanupPendingPipeReaders(pid); this.cleanupPendingPipeWriters(pid); + + // Deferred wait/sleep/futex completions retain the discarded Memory and + // would otherwise be able to run after the same pid is re-registered. + this.waitingForChild = this.waitingForChild.filter( + (waiter) => waiter.parentPid !== pid, + ); + this.cancelPendingSleepsForProcess(pid); + for (const [channel, wait] of this.pendingFutexWaits) { + if (channel.pid !== pid) continue; + this.pendingFutexWaits.delete(channel); + // Release the waitAsync closure so it can observe that this channel is + // stale and drop its completion instead of retaining the old Memory. + try { + Atomics.notify(new Int32Array(channel.memory.buffer), wait.futexIndex, 1); + } catch { + // A detached/invalid discarded buffer needs no further cleanup. + } + } + for (const channel of this.pendingCancels) { + if (channel.pid === pid) this.pendingCancels.delete(channel); + } + + // Thread mailbox identity and fork/clear-TID metadata belong to the old + // image even though exec preserves the process id. + const channelPrefix = `${pid}:`; + for (const key of this.channelTids.keys()) { + if (key.startsWith(channelPrefix)) this.channelTids.delete(key); + } + for (const key of this.threadForkContexts.keys()) { + if (key.startsWith(channelPrefix)) this.threadForkContexts.delete(key); + } + for (const key of this.threadCtidPtrs.keys()) { + if (key.startsWith(channelPrefix)) this.threadCtidPtrs.delete(key); + } + + for (const [key, entry] of this.posixTimers) { + if (key.startsWith(`${pid}:`)) { + clearTimeout(entry.timeout); + if (entry.interval) clearInterval(entry.interval); + this.posixTimers.delete(key); + } + } for (const [ch, timer] of this.socketTimeoutTimers) { if (ch.pid === pid) { clearTimeout(timer); @@ -1716,8 +2429,20 @@ export class CentralizedKernelWorker { } } - // Remove process registration (new one will be added by registerProcess) - this.processes.delete(pid); + // Keep a zero-channel registration until the replacement is installed. + // Network endpoints use process presence as their liveness signal; deleting + // the pid across awaited worker termination would make UDP drop datagrams + // and could permanently evict this owner from a shared TCP listener. + } + + /** True while exec has committed but the replacement channel is not installed. */ + isExecHandoffActive(pid: number): boolean { + return this.execHandoffPids?.has(pid) ?? false; + } + + /** Release the exec guard only after the outer worker generation is installed. */ + finishProcessExecHandoff(pid: number): void { + this.execHandoffPids?.delete(pid); } /** @@ -1742,9 +2467,19 @@ export class CentralizedKernelWorker { tid?: number, threadFnPtr?: number, threadArgPtr?: number, + expectedMemory?: WebAssembly.Memory, ): void { + if (this.execHandoffPids?.has(pid)) { + throw new Error(`Process ${pid} is replacing its image`); + } + if (!this.isProcessExecutionActive(pid)) { + throw new Error(`Process ${pid} is not running`); + } const registration = this.processes.get(pid); if (!registration) throw new Error(`Process ${pid} not registered`); + if (expectedMemory && registration.memory !== expectedMemory) { + throw new Error(`Process ${pid} changed memory generation`); + } const channel: ChannelInfo = { pid, @@ -1792,6 +2527,13 @@ export class CentralizedKernelWorker { const registration = this.processes.get(pid); if (!registration) return; + for (const channel of registration.channels) { + if (channel.channelOffset !== channelOffset) continue; + const sleep = this.pendingSleeps.get(channel); + if (sleep) clearTimeout(sleep.timer); + this.pendingSleeps.delete(channel); + } + registration.channels = registration.channels.filter( (ch) => ch.channelOffset !== channelOffset, ); @@ -1802,11 +2544,71 @@ export class CentralizedKernelWorker { this.threadForkContexts.delete(`${pid}:${channelOffset}`); } + /** + * Return whether this exact channel object belongs to the pid's current + * registration. Exec deliberately reuses the numeric pid (and commonly the + * same channel offset), so pid existence alone cannot distinguish a stale + * waitAsync/timer continuation from the replacement image's channel. + */ + private isRegisteredChannel(channel: ChannelInfo): boolean { + const registration = this.processes.get(channel.pid); + return registration !== undefined + && registration.channels.includes(channel); + } + + /** + * Async continuations may run while an exact channel remains registered for + * orderly worker teardown even though its kernel Process is already dead. + */ + private isAsyncChannelProcessActive(channel: ChannelInfo): boolean { + if (!this.isRegisteredChannel(channel) || this.hostReaped?.has(channel.pid)) { + return false; + } + try { + if (this.getProcessExitSignal(channel.pid) > 0) { + this.handleProcessTerminated(channel); + return false; + } + } catch { + // Older compatible kernels lack the additive exit-signal query; channel + // identity remains the best available liveness evidence there. + } + return true; + } + + /** Public liveness guard for async Node/browser worker-entry continuations. */ + isProcessExecutionActive(pid: number): boolean { + if (this.hostReaped?.has(pid)) return false; + try { + // kernel_get_process_exit_signal returns -1 only while the Process is + // Running, 0 for a normal zombie, a positive signal for signal death, + // and a negative errno when the pid no longer exists. + return this.getProcessExitSignal(pid) === -1; + } catch { + return true; + } + } + + /** + * Decide whether an asynchronously created fork/spawn child may receive a + * host Worker. A child killed before registration remains a real, waitable + * kernel zombie; finalize its host-only state without rolling it back. + */ + shouldLaunchPendingChild(pid: number): boolean { + if (this.isProcessExecutionActive(pid)) return true; + this.finalizePendingChildTermination(pid); + return false; + } + /** * Listen for a syscall on a channel using Atomics.waitAsync. * When the process sets status to PENDING, we handle the syscall. */ private listenOnChannel(channel: ChannelInfo): void { + // A waitAsync continuation from the discarded exec image may run after a + // replacement registration with the same pid has been installed. + if (!this.isRegisteredChannel(channel)) return; + // Re-create Int32Array view in case memory was grown const i32View = new Int32Array(channel.memory.buffer, channel.channelOffset); channel.i32View = i32View; @@ -1823,7 +2625,7 @@ export class CentralizedKernelWorker { // batchSize=64), handle immediately for throughput. if (this.relistenBatchSize <= 1) { setImmediate(() => { - if (this.processes.has(channel.pid)) { + if (this.isRegisteredChannel(channel)) { this.handleSyscall(channel); } }); @@ -1841,8 +2643,8 @@ export class CentralizedKernelWorker { if (waitResult.async) { waitResult.value.then(() => { - // Check if still registered - if (!this.processes.has(channel.pid)) return; + // Check that this exact registration generation is still current. + if (!this.isRegisteredChannel(channel)) return; // Status changed — re-enter to check new value this.listenOnChannel(channel); }); @@ -2012,6 +2814,7 @@ export class CentralizedKernelWorker { } private handleSyscall(channel: ChannelInfo): void { + if (!this.isRegisteredChannel(channel)) return; try { if (PROFILING) { const pv = new DataView(channel.memory.buffer, channel.channelOffset); @@ -2031,8 +2834,10 @@ export class CentralizedKernelWorker { this._handleSyscallInner(channel); } catch (err) { console.error(`[handleSyscall] UNCAUGHT ERROR pid=${channel.pid}:`, err); - // Complete channel with EIO to unblock the process - this.completeChannelRaw(channel, -5, 5); + // Complete with EIO without re-entering the coherence path that just + // failed. Retrying a persistently unreadable backing here would throw a + // second time and leave the guest channel parked forever. + this.completeChannelRaw(channel, -EIO, EIO); this.relistenChannel(channel); } } @@ -2085,6 +2890,40 @@ export class CentralizedKernelWorker { logEntry = this.formatSyscallEntry(channel, syscallNr, origArgs); } + // Separate Wasm memories cannot observe MAP_SHARED/SysV writes directly. + // Treat every guest→kernel transition as a coherence boundary: merge only + // bytes changed since this process's snapshot, then import peer updates. + this.synchronizeSharedMemoryForBoundary(channel); + const mayFlushSharedBacking = (this.sharedMmapBackings?.size ?? 0) > 0; + const flushedSharedBacking = !mayFlushSharedBacking + || this.flushSharedMappingsBeforeFileSyscall(channel, syscallNr, origArgs); + if (mayFlushSharedBacking && this.hostReaped?.has(channel.pid)) return; + if (!flushedSharedBacking) { + this.completeChannel(channel, syscallNr, origArgs, undefined, -1, EIO); + return; + } + if ( + syscallNr === SYS_MPROTECT + && (origArgs[2] & PROT_WRITE) !== 0 + ) { + const protectionError = this.prepareFileSharedMappingsForWrite( + channel.pid, + origArgs[0] >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + ); + if (protectionError !== 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + protectionError, + ); + return; + } + } + // --- Intercept fork/exec/clone/exit before calling kernel --- // These syscalls need special async handling that can't go through // the blocking host_fork/host_exec imports. @@ -2412,16 +3251,106 @@ export class CentralizedKernelWorker { channel.readinessFinalCheck = false; } - // Write adjusted args to kernel scratch - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - for (let i = 0; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(adjustedArgs[i]), true); + let fileSharedMmapPreparation: FileSharedMmapPreparationResult | null = null; + if ( + syscallNr === SYS_MMAP + && (origArgs[1] >>> 0) > 0 + && (origArgs[3] & MAP_SHARED) !== 0 + && (origArgs[3] & MAP_ANONYMOUS) === 0 + && origArgs[4] >= 0 + ) { + const preparation = this.prepareSharedMmapFromFile(channel, origArgs); + if (this.hostReaped?.has(channel.pid)) return; + if (preparation.kind === "error") { + // Regular-file host setup is part of mmap. Fail before invoking the + // kernel so MAP_FIXED cannot destroy an existing interval first. + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + preparation.errno, + ); + return; + } + fileSharedMmapPreparation = preparation; + } + + try { + if (syscallNr === SYS_MREMAP) { + const preflightError = this.preflightFileSharedMremap(channel.pid, origArgs); + if (preflightError !== 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + preflightError, + ); + return; + } + } + + try { + if (syscallNr === SYS_MMAP && (origArgs[3] & MAP_FIXED) !== 0) { + if (!this.ensureFixedMmapProcessMemoryCapacity(channel, origArgs)) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + this.completeChannel(channel, syscallNr, origArgs, undefined, -1, ENOMEM); + return; + } + // Flush the replaced mapping while its kernel interval and process + // bytes are both still intact. + const flushedReplacement = this.flushSharedMappings(channel, [ + origArgs[0] >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + ]); + if (this.hostReaped?.has(channel.pid)) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + return; + } + if (!flushedReplacement) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + this.completeChannel(channel, syscallNr, origArgs, undefined, -1, EIO); + return; + } + } + + // Write adjusted args to kernel scratch + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + for (let i = 0; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(adjustedArgs[i]), true); + } + } catch (err) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + throw err; } // Call kernel_handle_channel const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); + try { + this.bindKernelTidForChannel(channel); + } catch (err) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + throw err; + } // DIAGNOSTIC: globalThis.__sysprof aggregates per-(pid,syscall_nr) // timing across kernel_handle_channel calls so we can dump a profile // afterward (via globalThis.__sysprofDump()). Off by default — flip on @@ -2450,6 +3379,10 @@ export class CentralizedKernelWorker { try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } catch (err) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } // If the kernel throws (e.g., invalid memory access), complete the // channel with -EIO to unblock the process rather than deadlocking. if (logging) console.error(logEntry + " = KERNEL THROW"); @@ -2476,16 +3409,64 @@ export class CentralizedKernelWorker { } } + // Stop signal death before any host postprocessing can re-enter the kernel + // or mutate state for an execution that must never resume. + if (this.getProcessExitSignal(channel.pid) > 0) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + this.handleProcessTerminated(channel); + return; + } + // Read return value and errno from kernel scratch - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + let retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + let errVal = kernelView.getUint32(CH_ERRNO, true); + if ( + syscallNr === SYS_MMAP + && fileSharedMmapPreparation?.kind === "prepared" + && !(retVal > 0 && (retVal >>> 0) !== 0xffffffff) + ) { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + + // MAP_FIXED's old interval was published and flushed before the kernel + // call. After success, detach its trackers before registering the new map. + if ( + syscallNr === SYS_MMAP && + retVal > 0 && + (origArgs[3] & MAP_FIXED) !== 0 + ) { + const replacementArgs = [ + retVal >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + ]; + this.cleanupSharedMappings(channel.pid, replacementArgs[0]!, replacementArgs[1]!); + } + if (syscallNr === SYS_MREMAP && retVal > 0) { + this.flushSharedMappings(channel, [ + origArgs[0] >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + ]); + if (this.hostReaped?.has(channel.pid)) return; + } // --- Process memory growth for brk/mmap/mremap --- // The kernel's ensure_memory_covers() grows the KERNEL's Wasm memory, not // the process's. We must grow the process's // WebAssembly.Memory here so the process can access the new addresses. if (retVal > 0) { - this.ensureProcessMemoryCovers(channel.pid, channel.memory, syscallNr, retVal, origArgs); + try { + this.ensureProcessMemoryCovers(channel.pid, channel.memory, syscallNr, retVal, origArgs); + } catch (err) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + throw err; + } } // --- DEBUG: detect memory operations in legacy high control pages --- @@ -2508,25 +3489,50 @@ export class CentralizedKernelWorker { console.error(`[BRK ALERT] pid=${channel.pid} brk returned 0x${(retVal >>> 0).toString(16)} — IN THREAD REGION!`); } - // --- File-backed mmap: populate mapped region with file data --- + // --- mmap backing: populate files and register shared-memory intervals --- if (syscallNr === SYS_MMAP && retVal > 0 && (retVal >>> 0) !== 0xffffffff) { const mmapFd = origArgs[4]; const mmapFlags = origArgs[3] >>> 0; - if (mmapFd >= 0 && (mmapFlags & MAP_ANONYMOUS) === 0) { - this.populateMmapFromFile(channel, retVal >>> 0, origArgs); - // Track MAP_SHARED file-backed mappings for msync writeback - if (mmapFlags & MAP_SHARED) { - const pageOffset = origArgs[5] >>> 0; - let pidMap = this.sharedMappings.get(channel.pid); - if (!pidMap) { - pidMap = new Map(); - this.sharedMappings.set(channel.pid, pidMap); + if ((mmapFlags & MAP_SHARED) !== 0 && (mmapFlags & MAP_ANONYMOUS) !== 0) { + this.trackAnonymousSharedMapping(channel, retVal >>> 0, origArgs); + } else if (mmapFd >= 0 && (mmapFlags & MAP_ANONYMOUS) === 0) { + if ((mmapFlags & MAP_SHARED) !== 0) { + const sharedResult = fileSharedMmapPreparation?.kind === "prepared" + ? this.registerPreparedSharedMmap( + channel, + retVal >>> 0, + fileSharedMmapPreparation.context, + ) + : fileSharedMmapPreparation?.kind === "unsupported" + ? fileSharedMmapPreparation + : this.mapSharedMmapFromFile(channel, retVal >>> 0, origArgs); + fileSharedMmapPreparation = null; + if (this.hostReaped?.has(channel.pid)) return; + if (sharedResult.kind === "unsupported") { + this.populateMmapFromFile(channel, retVal >>> 0, origArgs); + if (this.hostReaped?.has(channel.pid)) return; + } else if (sharedResult.kind === "error") { + // The kernel has already reserved the interval. Undo that + // allocation and report the host-backing failure truthfully; + // silently leaving an untracked MAP_SHARED mapping would lose + // writes and violate fd-close/fork coherence. + try { + this.runSyntheticMemorySyscall( + channel, + SYS_MUNMAP, + [retVal >>> 0, alignWasmPageLength(origArgs[1] >>> 0)], + ); + if (this.hostReaped?.has(channel.pid)) return; + } catch { + // Preserve the original mmap failure even if rollback itself + // cannot be completed. The guest must not observe success. + } + retVal = -1; + errVal = sharedResult.errno; } - pidMap.set(retVal >>> 0, { - fd: mmapFd, - fileOffset: pageOffset * 4096, - len: origArgs[1] >>> 0, - }); + } else { + this.populateMmapFromFile(channel, retVal >>> 0, origArgs); + if (this.hostReaped?.has(channel.pid)) return; } } // DRI bo mmap prime: the kernel's sys_mmap on /dev/dri/{render,card} @@ -2535,50 +3541,85 @@ export class CentralizedKernelWorker { // anonymous-mmap zero-fill is in place first. This is what // delivers the parent's writes to a child across PRIME // export → fork → PRIME import. No-op for non-DRI mmaps. - const mmapAddr = retVal >>> 0; - const boId = this.kernel.bos.findBindingByAddr(channel.pid, mmapAddr); - if (boId !== undefined) { - this.kernel.bos.primeBindFromSab(channel.pid, boId, channel.memory); + if (retVal > 0) { + const mmapAddr = retVal >>> 0; + const boId = this.kernel.bos.findBindingByAddr(channel.pid, mmapAddr); + if (boId !== undefined) { + this.kernel.bos.primeBindFromSab(channel.pid, boId, channel.memory); + } } } // --- msync: flush MAP_SHARED regions back to file --- if (syscallNr === SYS_MSYNC && retVal === 0) { - this.flushSharedMappings(channel, origArgs); + if (!this.flushSharedMappings(channel, origArgs)) { + retVal = -1; + errVal = EIO; + } + if (this.hostReaped?.has(channel.pid)) return; } // --- munmap: flush + clean up shared mapping tracking --- if (syscallNr === SYS_MUNMAP && retVal === 0) { - this.flushSharedMappings(channel, origArgs); - this.cleanupSharedMappings(channel.pid, origArgs[0] >>> 0, origArgs[1] >>> 0); - } - - // --- Signal-death check --- - // If deliver_pending_signals marked this process as Exited (e.g., abort() - // raises SIGABRT with default action Terminate), don't complete the channel. - // Instead, record signal-death wait status and terminate the worker. - const getExitStatus = this.kernelInstance!.exports - .kernel_get_process_exit_status as ((pid: number) => number) | undefined; - if (getExitStatus) { - const exitStatus = getExitStatus(channel.pid); - if (exitStatus >= 128) { - this.handleProcessTerminated(channel); - return; - } + const unmapArgs = [ + origArgs[0] >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + ]; + this.flushSharedMappings(channel, unmapArgs); + if (this.hostReaped?.has(channel.pid)) return; + this.cleanupSharedMappings(channel.pid, unmapArgs[0]!, unmapArgs[1]!); + } + + if (syscallNr === SYS_MREMAP && retVal > 0) { + this.remapSharedMapping( + channel.pid, + origArgs[0] >>> 0, + retVal >>> 0, + origArgs[2] >>> 0, + ); + } + if (syscallNr === SYS_MPROTECT && retVal === 0) { + this.updateSharedMappingProtection( + channel.pid, + origArgs[0] >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + (origArgs[2] & PROT_WRITE) !== 0, + ); + } + + if ((this.sharedMmapBackings?.size ?? 0) > 0) { + this.handleSharedMappingsAfterFileSyscall( + channel, + syscallNr, + origArgs, + retVal, + errVal, + ); + if (this.hostReaped?.has(channel.pid)) return; } // --- POSIX mqueue notification --- // After mq_timedsend, the kernel may have a pending notification (signal // to deliver when a message arrives on a previously empty queue). - if (syscallNr === SYS_MQ_TIMEDSEND && retVal === 0) { + const routedMqNotification = syscallNr === SYS_MQ_TIMEDSEND && retVal === 0; + if (routedMqNotification) { this.drainMqueueNotification(); + if (this.finishSignalTermination(channel)) return; } // --- Signal delivery --- // After each syscall, check if the kernel has a pending Handler signal. // If so, dequeue it and write delivery info to the process channel. // The glue code (channel_syscall.c) will invoke the handler after waking. - this.dequeueSignalForDelivery(channel); + // A successful mq_timedsend may synchronously route a notification to a + // different process, which resets the kernel's ambient TID to the shared + // signal context. Rebind only on that uncommon path; ordinary syscall + // completion stays free of another host-to-kernel call. + this.dequeueSignalForDelivery( + channel, + routedMqNotification, + ); + if (routedMqNotification && this.finishSignalTermination(channel)) return; // --- Blocking syscall handling --- // 1. EAGAIN: kernel returned EAGAIN for a blocking syscall. @@ -2631,17 +3672,33 @@ export class CentralizedKernelWorker { console.error(logEntry + this.formatSyscallReturn(syscallNr, retVal, errVal)); } this.completeChannel(channel, syscallNr, origArgs, argDescs, retVal, errVal); + } catch (err) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + throw err; + } } /** * Dequeue one pending Handler signal from the kernel and write delivery * info to the process channel. The glue code (channel_syscall.c) reads - * this after the syscall returns and invokes the handler. + * this after the syscall returns and invokes the handler. Returns the + * handler signal number, or zero when no caught handler was dequeued. */ - private dequeueSignalForDelivery(channel: ChannelInfo): void { + private dequeueSignalForDelivery( + channel: ChannelInfo, + bindTidForAsyncCompletion = false, + ): number { const dequeueSignal = this.kernelInstance!.exports .kernel_dequeue_signal as ((pid: number, outPtr: KernelPointer) => number) | undefined; - if (!dequeueSignal) return; + if (!dequeueSignal) return 0; + + // Normal syscall paths bind the channel before entering the kernel. Async + // completions can run after another thread changed the ambient TID, so + // those callers request an exact-channel rebind here. + if (bindTidForAsyncCompletion) this.bindKernelTidForChannel(channel); // Use the signal area in kernel scratch as the output buffer const sigOutOffset = this.scratchOffset + CH_SIG_BASE; @@ -2656,10 +3713,12 @@ export class CentralizedKernelWorker { kernelMem.subarray(sigOutOffset, sigOutOffset + 44), channel.channelOffset + CH_SIG_BASE, ); + return sigResult; } else { // Clear entire signal delivery area in process channel (48 bytes) const sigStart = channel.channelOffset + CH_SIG_BASE; new Uint8Array(channel.memory.buffer, sigStart, 48).fill(0); + return 0; } } @@ -2749,12 +3808,30 @@ export class CentralizedKernelWorker { } } + // Host-copied syscall output can itself target shared memory. Publish it + // before waking the process and import peer writes from the syscall window. + let completionRet = retVal; + let completionErrno = errVal; + try { + this.synchronizeSharedMemoryForBoundary(channel); + } catch (err) { + // Promise/timer continuations can complete outside handleSyscall's + // recovery frame. Preserve channel liveness even when a backing becomes + // persistently unreadable while the process is blocked. + console.error( + `[completeChannel] shared-memory synchronization failed for pid=${channel.pid}:`, + err, + ); + completionRet = -EIO; + completionErrno = EIO; + } + // Clear handling flag (channel is done — poller can pick it up for next syscall) channel.handling = false; // Write result to process channel - processView.setBigInt64(CH_RETURN, BigInt(retVal), true); - processView.setUint32(CH_ERRNO, errVal, true); + processView.setBigInt64(CH_RETURN, BigInt(completionRet), true); + processView.setUint32(CH_ERRNO, completionErrno, true); // Cancel any pending socket timeout timer for this channel this.clearSocketTimeout(channel); @@ -2865,6 +3942,7 @@ export class CentralizedKernelWorker { // Snapshot to handle mutations during iteration (addChannel/removeChannel) const channels = this.activeChannels.slice(); for (const channel of channels) { + if (!this.isRegisteredChannel(channel)) continue; if (channel.handling) continue; // Re-create view in case memory was grown const i32View = new Int32Array(channel.memory.buffer, channel.channelOffset); @@ -2881,7 +3959,7 @@ export class CentralizedKernelWorker { private relistenChannel(channel: ChannelInfo): void { // Clear handling flag so the poller can pick up this channel again channel.handling = false; - if (!this.processes.has(channel.pid)) return; + if (!this.isRegisteredChannel(channel)) return; // In polling mode, don't re-listen — the poller will pick up the next syscall if (this.usePolling) return; this.relistenCount++; @@ -2898,13 +3976,33 @@ export class CentralizedKernelWorker { * Complete a channel with just return value and errno (no scatter/gather). * Used for thread exit where we need to unblock the worker. */ - private completeChannelRaw(channel: ChannelInfo, retVal: number, errVal: number): void { + private completeChannelRaw( + channel: ChannelInfo, + retVal: number, + errVal: number, + ): void { + let completionRet = retVal; + let completionErrno = errVal; + try { + this.synchronizeSharedMemoryForBoundary(channel); + } catch (err) { + // Raw completion is the final liveness boundary for host-emulated and + // asynchronous syscalls. A coherence failure must be visible as EIO, + // but it must never prevent the sleeping worker from being notified. + console.error( + `[completeChannelRaw] shared-memory synchronization failed for pid=${channel.pid}:`, + err, + ); + completionRet = -EIO; + completionErrno = EIO; + } + // Clear handling flag (channel is done — poller can pick it up for next syscall) channel.handling = false; const processView = new DataView(channel.memory.buffer, channel.channelOffset); - processView.setBigInt64(CH_RETURN, BigInt(retVal), true); - processView.setUint32(CH_ERRNO, errVal, true); + processView.setBigInt64(CH_RETURN, BigInt(completionRet), true); + processView.setUint32(CH_ERRNO, completionErrno, true); // Cancel any pending socket timeout timer for this channel this.clearSocketTimeout(channel); @@ -2913,7 +4011,7 @@ export class CentralizedKernelWorker { // Clear any one-shot pthread cancel flag: it was meant for the // syscall we're completing now. Leaving it armed would let the next // blocking entry spuriously EINTR even if no further cancel arrived. - this.pendingCancels.delete(channel.channelOffset); + this.pendingCancels.delete(channel); const i32View = new Int32Array(channel.memory.buffer, channel.channelOffset); Atomics.store(i32View, CH_STATUS / 4, CH_COMPLETE); @@ -3012,7 +4110,7 @@ export class CentralizedKernelWorker { clearTimeout(entry.timer); } this.pendingPollRetries.delete(key); - if (this.processes.has(entry.channel.pid)) { + if (this.isRegisteredChannel(entry.channel)) { this.retrySyscall(entry.channel); } } @@ -3034,7 +4132,7 @@ export class CentralizedKernelWorker { clearTimeout(entry.timer); } this.pendingPollRetries.delete(key); - if (this.processes.has(pid)) { + if (this.isRegisteredChannel(entry.channel)) { this.retrySyscall(entry.channel); } } @@ -3065,14 +4163,14 @@ export class CentralizedKernelWorker { if (readers && readers.length > 0) { this.pendingPipeReaders.delete(pipeIdx); for (const reader of readers) { - if (this.processes.has(reader.pid)) { + if (this.isRegisteredChannel(reader.channel)) { this.retrySyscall(reader.channel); } } } // 2. Blocked pollers watching this pipe. Snapshot-and-skip-if-replaced: // retrySyscall runs synchronously and a re-parking wait re-inserts the - // same channelOffset key, which a raw for..of over the live Map would + // same exact-channel key, which a raw for..of over the live Map would // revisit forever (see wakeBlockedPoll / sendSignalToProcess). const pollMatches = Array.from(this.pendingPollRetries.entries()).filter( ([, e]) => @@ -3083,7 +4181,7 @@ export class CentralizedKernelWorker { if (this.pendingPollRetries.get(key) !== entry) continue; if (entry.timer !== null) clearTimeout(entry.timer); this.pendingPollRetries.delete(key); - if (this.processes.has(entry.channel.pid)) { + if (this.isRegisteredChannel(entry.channel)) { this.retrySyscall(entry.channel); } } @@ -3102,7 +4200,7 @@ export class CentralizedKernelWorker { if (writers && writers.length > 0) { this.pendingPipeWriters.delete(pipeIdx); for (const writer of writers) { - if (this.processes.has(writer.pid)) { + if (this.isRegisteredChannel(writer.channel)) { this.retrySyscall(writer.channel); } } @@ -3171,7 +4269,7 @@ export class CentralizedKernelWorker { if (readers && readers.length > 0) { this.pendingPipeReaders.delete(wakeIdx); for (const reader of readers) { - if (this.processes.has(reader.pid)) { + if (this.isRegisteredChannel(reader.channel)) { this.retrySyscall(reader.channel); } } @@ -3184,7 +4282,7 @@ export class CentralizedKernelWorker { if (writers && writers.length > 0) { this.pendingPipeWriters.delete(wakeIdx); for (const writer of writers) { - if (this.processes.has(writer.pid)) { + if (this.isRegisteredChannel(writer.channel)) { this.retrySyscall(writer.channel); } } @@ -3253,7 +4351,7 @@ export class CentralizedKernelWorker { if (this.pendingPollRetries.get(key) !== entry) continue; this.pendingPollRetries.delete(key); if (entry.timer !== null) clearTimeout(entry.timer); - if (this.processes.has(entry.channel.pid)) { + if (this.isRegisteredChannel(entry.channel)) { this.retrySyscall(entry.channel); } } @@ -3298,7 +4396,7 @@ export class CentralizedKernelWorker { entry.timer = setTimeout(() => { if (this.pendingPollRetries.get(key) !== entry) return; this.pendingPollRetries.delete(key); - if (this.processes.has(entry.channel.pid)) { + if (this.isRegisteredChannel(entry.channel)) { this.retrySyscall(entry.channel); } }, retryMs); @@ -3322,7 +4420,7 @@ export class CentralizedKernelWorker { entry.timer = setTimeout(() => { if (this.pendingSelectRetries.get(key) !== entry) return; this.pendingSelectRetries.delete(key); - if (!this.processes.has(entry.channel.pid)) return; + if (!this.isRegisteredChannel(entry.channel)) return; if (entry.syscallNr === SYS_SELECT) { this.handleSelect(entry.channel, entry.origArgs); } else { @@ -3366,7 +4464,7 @@ export class CentralizedKernelWorker { this.pendingSelectRetries.clear(); for (const [_key, entry] of pollEntries) { - if (!this.processes.has(entry.channel.pid)) continue; + if (!this.isRegisteredChannel(entry.channel)) continue; if (entry.timer !== null) { clearTimeout(entry.timer); } @@ -3374,7 +4472,7 @@ export class CentralizedKernelWorker { } for (const [, entry] of selectEntries) { - if (!this.processes.has(entry.channel.pid)) continue; + if (!this.isRegisteredChannel(entry.channel)) continue; // Cancel both setTimeout and setImmediate handles (one will be a no-op) clearTimeout(entry.timer); clearImmediate(entry.timer); @@ -3394,7 +4492,7 @@ export class CentralizedKernelWorker { this.pendingPipeReaders.clear(); for (const [, readers] of pipeEntries) { for (const reader of readers) { - if (this.processes.has(reader.pid)) { + if (this.isRegisteredChannel(reader.channel)) { this.retrySyscall(reader.channel); } } @@ -3408,7 +4506,7 @@ export class CentralizedKernelWorker { this.pendingPipeWriters.clear(); for (const [, writers] of writerEntries) { for (const writer of writers) { - if (this.processes.has(writer.pid)) { + if (this.isRegisteredChannel(writer.channel)) { this.retrySyscall(writer.channel); } } @@ -3467,19 +4565,19 @@ export class CentralizedKernelWorker { channel.readinessDeadline = undefined; channel.readinessFinalCheck = undefined; - const pollEntry = this.pendingPollRetries.get(channel.channelOffset); - if (pollEntry?.channel === channel) { + const pollEntry = this.pendingPollRetries.get(channel); + if (pollEntry) { if (pollEntry.timer !== null) clearTimeout(pollEntry.timer); - this.pendingPollRetries.delete(channel.channelOffset); + this.pendingPollRetries.delete(channel); } - const selectEntry = this.pendingSelectRetries.get(channel.channelOffset); - if (selectEntry?.channel === channel) { + const selectEntry = this.pendingSelectRetries.get(channel); + if (selectEntry) { if (selectEntry.timer !== null) { clearTimeout(selectEntry.timer); clearImmediate(selectEntry.timer); } - this.pendingSelectRetries.delete(channel.channelOffset); + this.pendingSelectRetries.delete(channel); } } @@ -3567,7 +4665,7 @@ export class CentralizedKernelWorker { // dispatcher in _handleSyscallInner for cases where the target had // already pushed a syscall onto the channel but the host hadn't yet // started the blocking wait when the cancel arrived. - this.pendingCancels.add(target.channelOffset); + this.pendingCancels.add(target); // If the target has already parked in a tracked blocking wait, wake // it so its natural completion path runs and the guest sees the @@ -3578,7 +4676,7 @@ export class CentralizedKernelWorker { // 1) Futex wait — Atomics.notify wakes the in-flight waitAsync, which // calls complete() and completeChannelRaw naturally. - const futexEntry = this.pendingFutexWaits.get(target.channelOffset); + const futexEntry = this.pendingFutexWaits.get(target); if (futexEntry) { const tgtMemView = new Int32Array(target.memory.buffer); Atomics.notify(tgtMemView, futexEntry.futexIndex, 1); @@ -3588,21 +4686,21 @@ export class CentralizedKernelWorker { // 2) Poll/ppoll retry timer — cancelling the timer and kicking a // retry lets handleBlockingRetry see pendingCancels and complete // with -EINTR the same way an unblocked syscall entry would. - const pollEntry = this.pendingPollRetries.get(target.channelOffset); + const pollEntry = this.pendingPollRetries.get(target); if (pollEntry) { if (pollEntry.timer !== null) clearTimeout(pollEntry.timer); - this.pendingPollRetries.delete(target.channelOffset); + this.pendingPollRetries.delete(target); this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); this.relistenChannel(target); return; } // 3) Select/pselect retry timer. - const selEntry = this.pendingSelectRetries.get(target.channelOffset); - if (selEntry && selEntry.channel === target) { + const selEntry = this.pendingSelectRetries.get(target); + if (selEntry) { clearTimeout(selEntry.timer); clearImmediate(selEntry.timer); - this.pendingSelectRetries.delete(target.channelOffset); + this.pendingSelectRetries.delete(target); this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); this.relistenChannel(target); return; @@ -3710,7 +4808,7 @@ export class CentralizedKernelWorker { syscallNr: number, origArgs: number[], ): void { - if (!this.processes.has(channel.pid)) return; + if (!this.isRegisteredChannel(channel)) return; // Futex wait: use Atomics.waitAsync on the target address in process memory if (syscallNr === SYS_FUTEX) { @@ -3733,7 +4831,7 @@ export class CentralizedKernelWorker { const waitResult = Atomics.waitAsync(i32View, index, expectedVal); if (waitResult.async) { waitResult.value.then(() => { - if (this.processes.has(channel.pid)) { + if (this.isRegisteredChannel(channel)) { this.retrySyscall(channel); } }); @@ -3792,13 +4890,14 @@ export class CentralizedKernelWorker { // Pure sleep: no fds to poll, just wait for timeout const remainingMs = Math.max(deadline - Date.now(), 1); const timer = setTimeout(() => { - this.pendingPollRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { + if (this.pendingPollRetries.get(channel)?.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { channel.readinessFinalCheck = true; this.retrySyscall(channel); } }, remainingMs); - this.pendingPollRetries.set(channel.channelOffset, { + this.pendingPollRetries.set(channel, { timer, channel, pipeIndices, @@ -3810,8 +4909,10 @@ export class CentralizedKernelWorker { } const retryFn = () => { - this.pendingPollRetries.delete(channel.channelOffset); - if (!this.processes.has(channel.pid)) return; + const pending = this.pendingPollRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (!this.isRegisteredChannel(channel)) return; // Always run the kernel once at the deadline. If it still reports // EAGAIN, the branch above completes the wait with 0. this.retrySyscall(channel); @@ -3834,7 +4935,7 @@ export class CentralizedKernelWorker { ? (deadline > 0 ? Math.min(deadline - Date.now(), 10) : 10) : (deadline > 0 ? Math.min(deadline - Date.now(), 50) : 50); const timer = setTimeout(retryFn, Math.max(retryMs, 1)); - this.pendingPollRetries.set(channel.channelOffset, { + this.pendingPollRetries.set(channel, { timer, channel, pipeIndices, @@ -3860,7 +4961,7 @@ export class CentralizedKernelWorker { // cross-process signals are rare, and immediate delivery for kill() // works via scheduleWakeBlockedRetries. setTimeout(() => { - if (this.processes.has(channel.pid)) { + if (this.isRegisteredChannel(channel)) { this.retrySyscall(channel); } }, 500); @@ -3876,7 +4977,7 @@ export class CentralizedKernelWorker { this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN_ERRNO); } else { setTimeout(() => { - if (this.processes.has(channel.pid)) { + if (this.isRegisteredChannel(channel)) { this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN_ERRNO); } }, timeoutMs); @@ -3938,10 +5039,11 @@ export class CentralizedKernelWorker { const timeoutMs = Number(getTimeout(channel.pid, fd, isRecv)); if (timeoutMs > 0) { const timer = setTimeout(() => { + if (this.socketTimeoutTimers.get(channel) !== timer) return; this.socketTimeoutTimers.delete(channel); // Remove from pending pipe readers if registered this.removePendingPipeReader(channel); - if (this.processes.has(channel.pid)) { + if (this.isRegisteredChannel(channel)) { this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, ETIMEDOUT); } }, timeoutMs); @@ -4016,13 +5118,15 @@ export class CentralizedKernelWorker { const acceptIdx = getAcceptWakeIdx(channel.pid, fd); if (acceptIdx >= 0) { const retryFn = () => { - this.pendingPollRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { + const pending = this.pendingPollRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { this.retrySyscall(channel); } }; const timer = setTimeout(retryFn, 10); - this.pendingPollRetries.set(channel.channelOffset, { + this.pendingPollRetries.set(channel, { timer, channel, pipeIndices: [], @@ -4046,13 +5150,15 @@ export class CentralizedKernelWorker { // Register in pendingPollRetries so wakeAllBlockedRetries can cancel // the timer and retry immediately when state changes. const retryFn = () => { - this.pendingPollRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { + const pending = this.pendingPollRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (this.isAsyncChannelProcessActive(channel)) { this.retrySyscall(channel); } }; const timer = setTimeout(retryFn, 10); - this.pendingPollRetries.set(channel.channelOffset, { + this.pendingPollRetries.set(channel, { timer, channel, pipeIndices: [], @@ -4065,17 +5171,16 @@ export class CentralizedKernelWorker { * args still in the process channel. */ private retrySyscall(channel: ChannelInfo): void { + // Deferred retry callbacks can outlive an exec image. Never consult or + // mutate the replacement generation through a discarded channel object. + if (!this.isRegisteredChannel(channel)) return; + // Check if the process was killed by a signal while blocking. // This handles cases like sigsuspend + cross-process SIGABRT where // deliver_pending_signals marks the target as Exited. - const getExitStatus = this.kernelInstance!.exports - .kernel_get_process_exit_status as ((pid: number) => number) | undefined; - if (getExitStatus) { - const exitStatus = getExitStatus(channel.pid); - if (exitStatus >= 128) { - this.handleProcessTerminated(channel); - return; - } + if (this.getProcessExitSignal(channel.pid) > 0) { + this.handleProcessTerminated(channel); + return; } // The process channel still has the original args (we never wrote a response). @@ -4114,12 +5219,14 @@ export class CentralizedKernelWorker { if (delayMs > 0) { const timer = setTimeout(() => { - this.pendingSleeps.delete(channel.pid); - if (this.processes.has(channel.pid)) { + const pending = this.pendingSleeps.get(channel); + if (pending?.timer !== timer || pending.channel !== channel) return; + this.pendingSleeps.delete(channel); + if (this.isRegisteredChannel(channel)) { this.completeSleepWithSignalCheck(channel, syscallNr, origArgs, retVal, errVal); } }, delayMs); - this.pendingSleeps.set(channel.pid, { timer, channel, syscallNr, origArgs, retVal, errVal }); + this.pendingSleeps.set(channel, { timer, channel, syscallNr, origArgs, retVal, errVal }); return true; } @@ -4138,7 +5245,8 @@ export class CentralizedKernelWorker { errVal: number, ): void { // Check if a signal became pending during the sleep - this.dequeueSignalForDelivery(channel); + this.dequeueSignalForDelivery(channel, true); + if (this.finishSignalTermination(channel)) return; // If a signal was dequeued, return EINTR instead of success const processView = new DataView(channel.memory.buffer, channel.channelOffset); @@ -4201,6 +5309,8 @@ export class CentralizedKernelWorker { this.currentHandlePid = 0; } + if (this.finishSignalTermination(channel)) return; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); @@ -4290,13 +5400,14 @@ export class CentralizedKernelWorker { const remainingMs = finite ? Math.max(deadline - Date.now(), 1) : -1; const timer = finite ? setTimeout(() => { - this.pendingSelectRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { + if (this.pendingSelectRetries.get(channel)?.timer !== timer) return; + this.pendingSelectRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); } }, remainingMs) : (null as any); - this.pendingSelectRetries.set(channel.channelOffset, { + this.pendingSelectRetries.set(channel, { timer, channel, origArgs, @@ -4373,6 +5484,7 @@ export class CentralizedKernelWorker { } this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; // EAGAIN retry for blocking select. Mirrors handlePselect6. if (retVal === -1 && errVal === EAGAIN) { @@ -4386,14 +5498,16 @@ export class CentralizedKernelWorker { return; } const retryFn = () => { - this.pendingSelectRetries.delete(channel.channelOffset); - if (!this.processes.has(channel.pid)) return; + const pending = this.pendingSelectRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingSelectRetries.delete(channel); + if (!this.isRegisteredChannel(channel)) return; this.handleSelect(channel, origArgs); }; const finite = timeoutMs > 0; const remainingMs = finite ? Math.max(deadline - Date.now(), 1) : 50; const timer = setTimeout(retryFn, Math.min(remainingMs, 50)); - this.pendingSelectRetries.set(channel.channelOffset, { + this.pendingSelectRetries.set(channel, { timer, channel, origArgs, deadline, needsSignalSafeWake: false, syscallNr: SYS_SELECT, }); @@ -4523,6 +5637,7 @@ export class CentralizedKernelWorker { // Handle signal delivery this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; // Handle EAGAIN retry for blocking select if (retVal === -1 && errVal === EAGAIN) { @@ -4547,19 +5662,20 @@ export class CentralizedKernelWorker { if (timeoutMs > 0) { const remainingMs = Math.max(deadline - Date.now(), 1); const timer = setTimeout(() => { - this.pendingSelectRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { + if (this.pendingSelectRetries.get(channel)?.timer !== timer) return; + this.pendingSelectRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { channel.readinessFinalCheck = true; this.handlePselect6(channel, origArgs); } }, remainingMs); - this.pendingSelectRetries.set(channel.channelOffset, { + this.pendingSelectRetries.set(channel, { timer, channel, origArgs, deadline, needsSignalSafeWake, syscallNr: SYS_PSELECT6, }); } else { // Infinite timeout with nfds=0: wait for signal delivery. // No timer — wakeAllBlockedRetries will trigger the retry. - this.pendingSelectRetries.set(channel.channelOffset, { + this.pendingSelectRetries.set(channel, { timer: null as any, channel, origArgs, deadline: -1, needsSignalSafeWake, syscallNr: SYS_PSELECT6, }); @@ -4569,13 +5685,15 @@ export class CentralizedKernelWorker { // For finite timeout with actual fds, track the deadline const retryFn = () => { - this.pendingSelectRetries.delete(channel.channelOffset); - if (!this.processes.has(channel.pid)) return; + const pending = this.pendingSelectRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingSelectRetries.delete(channel); + if (!this.isRegisteredChannel(channel)) return; this.handlePselect6(channel, origArgs); }; const remainingMs = deadline > 0 ? Math.max(deadline - Date.now(), 1) : 50; const timer = setTimeout(retryFn, Math.min(remainingMs, 50)); - this.pendingSelectRetries.set(channel.channelOffset, { + this.pendingSelectRetries.set(channel, { timer, channel, origArgs, deadline, needsSignalSafeWake, syscallNr: SYS_PSELECT6, }); return; @@ -4618,6 +5736,8 @@ export class CentralizedKernelWorker { this.currentHandlePid = 0; } + if (this.finishSignalTermination(channel)) return; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); @@ -4678,6 +5798,8 @@ export class CentralizedKernelWorker { this.currentHandlePid = 0; } + if (this.finishSignalTermination(channel)) return; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); @@ -4711,6 +5833,18 @@ export class CentralizedKernelWorker { this.completeChannel(channel, SYS_EPOLL_CTL, origArgs, undefined, retVal, errVal); } + /** Complete or reap an epoll wait when its kernel signal boundary fired. */ + private completeEpollSignalOutcome(channel: ChannelInfo): boolean { + const deliveredSignal = this.dequeueSignalForDelivery(channel, true); + if (this.finishSignalTermination(channel)) return true; + if (deliveredSignal > 0) { + this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); + this.relistenChannel(channel); + return true; + } + return false; + } + /** * Handle epoll_pwait / epoll_wait entirely on the host side. * Converts the epoll interest list to a poll syscall, calls @@ -4740,6 +5874,10 @@ export class CentralizedKernelWorker { } if (interests.length === 0) { + // No poll call follows for an empty interest set, so explicitly service + // the signal boundary before parking or returning a timeout result. + if (this.completeEpollSignalOutcome(channel)) return; + // No interests registered — return 0 immediately for timeout=0, // or block (EAGAIN) for non-zero timeout. if (timeoutMs === 0) { @@ -4754,14 +5892,16 @@ export class CentralizedKernelWorker { } // For non-zero timeout with no interests, retry with delay to avoid starvation const retryFn = () => { - this.pendingPollRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { + const pending = this.pendingPollRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { this.handleEpollPwait(channel, syscallNr, origArgs); } }; const retryMs = deadline > 0 ? Math.min(Math.max(deadline - Date.now(), 1), 10) : 10; const timer = setTimeout(retryFn, retryMs); - this.pendingPollRetries.set(channel.channelOffset, { + this.pendingPollRetries.set(channel, { timer, channel, pipeIndices: [], @@ -4831,8 +5971,14 @@ export class CentralizedKernelWorker { const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); - // Handle signal delivery - this.dequeueSignalForDelivery(channel); + // This host-side emulation performs a nonblocking poll and owns the + // wait/retry loop, so it must preserve the syscall-boundary signal + // outcome that kernel_handle_channel would normally return to the guest. + // A default terminating action leaves an exited kernel Process and must + // reap the worker without waking guest code. A caught handler interrupts + // epoll with EINTR so the glue can run the copied handler metadata before + // the application decides whether to restart the wait. + if (this.completeEpollSignalOutcome(channel)) return; // If poll returned error (not EAGAIN), propagate it if (retVal < 0 && errVal !== EAGAIN) { @@ -4892,14 +6038,16 @@ export class CentralizedKernelWorker { const { pipeIndices, acceptIndices } = this.resolveEpollReadinessIndices(channel.pid); const retryFn = () => { - this.pendingPollRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { + const pending = this.pendingPollRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { this.handleEpollPwait(channel, syscallNr, origArgs); } }; const retryMs = deadline > 0 ? Math.min(Math.max(deadline - Date.now(), 1), 10) : 10; const timer = setTimeout(retryFn, retryMs); - this.pendingPollRetries.set(channel.channelOffset, { + this.pendingPollRetries.set(channel, { timer, channel, pipeIndices, @@ -5082,6 +6230,8 @@ export class CentralizedKernelWorker { this.currentHandlePid = 0; } + if (this.finishSignalTermination(channel)) return; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); @@ -5090,6 +6240,9 @@ export class CentralizedKernelWorker { return; } + this.handleSharedMappingsAfterFileSyscall( + channel, syscallNr, origArgs, retVal, errVal, + ); this.completeChannel(channel, syscallNr, origArgs, undefined, retVal, errVal); } else { // Slow path: total data exceeds scratch buffer. Issue individual SYS_WRITEV @@ -5144,6 +6297,8 @@ export class CentralizedKernelWorker { this.currentHandlePid = 0; } + if (this.finishSignalTermination(channel)) return; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); @@ -5169,6 +6324,10 @@ export class CentralizedKernelWorker { return; } + this.handleSharedMappingsAfterFileSyscall( + channel, syscallNr, origArgs, totalWritten, 0, + ); + this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalWritten, 0); this.relistenChannel(channel); } @@ -5220,6 +6379,10 @@ export class CentralizedKernelWorker { } catch (err) { console.error(`[handleLargeWrite] kernel threw for pid=${channel.pid}:`, err); if (totalWritten > 0) { + this.handleSharedMappingsAfterFileSyscall( + channel, syscallNr, origArgs, totalWritten, 0, + ); + this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalWritten, 0); } else { this.completeChannelRaw(channel, -5, 5); // -EIO @@ -5230,11 +6393,17 @@ export class CentralizedKernelWorker { this.currentHandlePid = 0; } + if (this.finishSignalTermination(channel)) return; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); if (retVal === -1 && errVal === EAGAIN) { if (totalWritten > 0) { + this.handleSharedMappingsAfterFileSyscall( + channel, syscallNr, origArgs, totalWritten, 0, + ); + this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalWritten, 0); this.relistenChannel(channel); return; @@ -5245,6 +6414,10 @@ export class CentralizedKernelWorker { if (errVal !== 0 || retVal <= 0) { if (totalWritten > 0) { + this.handleSharedMappingsAfterFileSyscall( + channel, syscallNr, origArgs, totalWritten, 0, + ); + this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalWritten, 0); } else { this.completeChannelRaw(channel, retVal, errVal); @@ -5261,6 +6434,11 @@ export class CentralizedKernelWorker { } this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + this.handleSharedMappingsAfterFileSyscall( + channel, syscallNr, origArgs, totalWritten, 0, + ); + this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalWritten, 0); this.relistenChannel(channel); } @@ -5307,6 +6485,7 @@ export class CentralizedKernelWorker { } catch (err) { console.error(`[handleLargeRead] kernel threw for pid=${channel.pid}:`, err); if (totalRead > 0) { + this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalRead, 0); } else { this.completeChannelRaw(channel, -5, 5); // -EIO @@ -5317,11 +6496,14 @@ export class CentralizedKernelWorker { this.currentHandlePid = 0; } + if (this.finishSignalTermination(channel)) return; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); if (retVal === -1 && errVal === EAGAIN) { if (totalRead > 0) { + this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalRead, 0); this.relistenChannel(channel); return; @@ -5332,6 +6514,7 @@ export class CentralizedKernelWorker { if (errVal !== 0 || retVal <= 0) { if (totalRead > 0) { + this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalRead, 0); } else { this.completeChannelRaw(channel, retVal, errVal); @@ -5354,6 +6537,8 @@ export class CentralizedKernelWorker { } this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalRead, 0); this.relistenChannel(channel); } @@ -5438,6 +6623,8 @@ export class CentralizedKernelWorker { this.currentHandlePid = 0; } + if (this.finishSignalTermination(channel)) return; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); @@ -5510,6 +6697,8 @@ export class CentralizedKernelWorker { this.currentHandlePid = 0; } + if (this.finishSignalTermination(channel)) return; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); @@ -5669,6 +6858,8 @@ export class CentralizedKernelWorker { this.currentHandlePid = 0; } + if (this.finishSignalTermination(channel)) return; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); @@ -5801,6 +6992,8 @@ export class CentralizedKernelWorker { this.currentHandlePid = 0; } + if (this.finishSignalTermination(channel)) return; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); @@ -5872,16 +7065,33 @@ export class CentralizedKernelWorker { } const parentPid = channel.pid; - // Skip pids that are already registered (e.g., pid 3 is nginx master) - while (this.processes.has(this.nextChildPid)) { - this.nextChildPid++; + // Publish the parent's private views before creating any kernel child. + // A backing refresh can fail; keeping this fallible work ahead of + // kernel_fork_process avoids leaking a committed child/zombie or reserved + // pthread slot when fork must report EIO. + this.syncAnonymousSharedMappingsFromProcess(channel, { force: true }); + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + if (!this.syncSysvShmMappingsFromProcess(channel, { force: true })) { + this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, EIO); + return; } - const childPid = this.nextChildPid++; - // Clone the Process in the kernel's ProcessTable + // The host knows about live workers, while the kernel also owns zombie and + // limbo records until they are reaped. Retry candidates rejected with + // EEXIST so fork cannot collide with a kernel-owned pid that has no live + // host registration. const kernelForkProcess = this.kernelInstance!.exports.kernel_fork_process as (parentPid: number, childPid: number) => number; - const forkResult = kernelForkProcess(parentPid, childPid); + let childPid = 0; + let forkResult = -EEXIST; + for (let attempts = 0; attempts < 4096; attempts++) { + while (this.processes.has(this.nextChildPid)) { + this.nextChildPid++; + } + childPid = this.nextChildPid++; + forkResult = kernelForkProcess(parentPid, childPid); + if (forkResult === 0 || -forkResult !== EEXIST) break; + } if (forkResult < 0) { // Fork failed in kernel (e.g., ESRCH, ENOMEM) this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, (-forkResult) >>> 0); @@ -5936,38 +7146,47 @@ export class CentralizedKernelWorker { } } - // Call the async fork handler to spawn child Worker - this.callbacks.onFork(parentPid, childPid, channel.memory, threadFork).then((childChannelOffsets) => { - if (!this.processes.has(parentPid)) return; - - // Inherit TCP listener targets: if parent listens on a port, register - // the child as an additional target (fork children share listening sockets) - for (const [port, targets] of this.tcpListenerTargets) { - const parentTarget = targets.find(t => t.pid === parentPid); - if (parentTarget && !targets.some(t => t.pid === childPid)) { - targets.push({ pid: childPid, fd: parentTarget.fd }); - } + // The kernel child is real before its host Worker launches. Install its + // host-only fd mirrors synchronously so a sibling exec cannot remove the + // parent's last listener and close the shared backend during onFork's + // async worker setup. pickListenerTarget still ignores the child until + // onFork registers its process memory. + const removeProcess = this.kernelInstance!.exports.kernel_remove_process as + (pid: number) => number; + const rollbackFork = (err?: unknown) => { + if (err !== undefined) { + console.error(`[kernel-worker] fork worker launch failed: ${String(err)}`); } - - // Inherit epoll interest lists from parent - for (const [key, interests] of this.epollInterests) { - if (key.startsWith(`${parentPid}:`)) { - const epfd = key.slice(key.indexOf(':') + 1); - this.epollInterests.set(`${childPid}:${epfd}`, interests.map(e => ({ ...e }))); - } + try { this.rollbackChildHostRegistration(childPid); } catch { /* best-effort */ } + try { removeProcess(childPid); } catch { /* best-effort */ } + if (this.isAsyncChannelProcessActive(channel)) { + this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, 12); } + }; + + let launch: Promise; + try { + this.inheritHostFdMirrors(parentPid, childPid); + launch = Promise.resolve( + this.callbacks.onFork(parentPid, childPid, channel.memory, threadFork), + ); + } catch (err) { + rollbackFork(err); + return; + } + + // Call the async fork handler to spawn child Worker. + launch.then((_childChannelOffsets) => { + this.finalizePendingChildTermination(childPid); + + // A sibling may have committed exec while the child worker launched. + // The child is already real and still inherits host mirrors; only the + // discarded caller's channel completion must be suppressed. + if (!this.isAsyncChannelProcessActive(channel)) return; // Complete parent's channel with child PID this.completeChannel(channel, SYS_FORK, _origArgs, undefined, childPid, 0); - }).catch(() => { - // Fork failed — remove child from kernel ProcessTable - const removeProcess = this.kernelInstance!.exports.kernel_remove_process as - (pid: number) => number; - removeProcess(childPid); - - // Return -ENOMEM to parent - this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, 12); - }); + }).catch(rollbackFork); } /** @@ -6064,6 +7283,7 @@ export class CentralizedKernelWorker { }; resolveSpawnProgram().then((resolved) => { + if (!this.isAsyncChannelProcessActive(channel)) return; if (!resolved) { this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 2); // ENOENT return; @@ -6078,6 +7298,7 @@ export class CentralizedKernelWorker { channel, origArgs, parentPid, pidOutPtr, blobBytes, blobLen, launchArgv, envp, programBytes, ); }).catch((err) => { + if (!this.isAsyncChannelProcessActive(channel)) return; console.error(`[kernel] spawn resolve error for parent ${parentPid}:`, err); this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 5); // EIO }); @@ -6125,26 +7346,49 @@ export class CentralizedKernelWorker { // parent can't collide with the kernel's allocation. if (childPid >= this.nextChildPid) this.nextChildPid = childPid + 1; + const removeProcess = this.kernelInstance!.exports.kernel_remove_process as + (pid: number) => number; + const rollbackSpawn = (errno: number, err?: unknown) => { + if (err !== undefined) { + console.error(`[kernel] spawn error for parent ${parentPid}:`, err); + } + try { this.rollbackChildHostRegistration(childPid); } catch { /* best-effort */ } + try { removeProcess(childPid); } catch { /* best-effort */ } + if (this.isAsyncChannelProcessActive(channel)) { + this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, errno); + } + }; + + // posix_spawn clones listener sockets after applying fd actions. Install + // those mirrors before async Worker launch so parent exec cannot close the + // shared backend. Epoll backing tables are not yet cloned by spawn_child, + // so only listener mirrors are inherited here. + let launch: Promise; + try { + this.inheritHostFdMirrors(parentPid, childPid, false); + launch = Promise.resolve( + this.callbacks.onSpawn!(childPid, programBytes, argv, envp), + ); + } catch (err) { + rollbackSpawn(5, err); + return; + } + // ── Launch the worker async (with already-resolved bytes) ── - this.callbacks.onSpawn!(childPid, programBytes, argv, envp).then((rc) => { + launch.then((rc) => { if (rc < 0) { - const removeProcess = this.kernelInstance!.exports.kernel_remove_process as - (pid: number) => number; - removeProcess(childPid); - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, (-rc) >>> 0); + rollbackSpawn((-rc) >>> 0); return; } + this.finalizePendingChildTermination(childPid); + if (!this.isAsyncChannelProcessActive(channel)) return; // Write the child pid through pid_out_ptr in caller memory. if (pidOutPtr !== 0) { new DataView(channel.memory.buffer).setInt32(pidOutPtr, childPid, true); } this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, 0, 0); }).catch((err) => { - console.error(`[kernel] spawn error for parent ${parentPid}:`, err); - const removeProcess = this.kernelInstance!.exports.kernel_remove_process as - (pid: number) => number; - removeProcess(childPid); - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 5); // EIO + rollbackSpawn(5, err); // EIO }); } @@ -6163,24 +7407,100 @@ export class CentralizedKernelWorker { } /** - * Read a null-terminated array of string pointers from process memory. - * Each element is a 32-bit pointer to a null-terminated string. + * Read an exec pathname without allowing the generic C-string helper's + * bounded scan to turn an overlong or inaccessible pathname into a + * different, truncated path. + */ + private readExecPathFromProcess( + mem: Uint8Array, + ptr: number, + ): { value: string } | { errno: number } { + if (!Number.isSafeInteger(ptr) || ptr <= 0 || ptr >= mem.byteLength) { + return { errno: EFAULT }; + } + + const available = mem.byteLength - ptr; + const scanLength = Math.min(available, EXEC_PATH_MAX_BYTES); + let byteLength = 0; + while (byteLength < scanLength && mem[ptr + byteLength] !== 0) { + byteLength++; + } + if (byteLength === scanLength) { + return { errno: available >= EXEC_PATH_MAX_BYTES ? ENAMETOOLONG : EFAULT }; + } + + return { + // .slice() copies from SharedArrayBuffer for TextDecoder compatibility. + value: new TextDecoder().decode(mem.slice(ptr, ptr + byteLength)), + }; + } + + /** + * Read a null-terminated exec argv/envp pointer array without truncation. + * Each entry may occupy one bounded scratch transfer. The advertised + * ARG_MAX budget, including pointer entries, bounds the scan without an + * unrelated argument-count limit. */ - private readStringArrayFromProcess(mem: Uint8Array, arrayPtr: number, ptrWidth: 4 | 8 = 4): string[] { - if (arrayPtr === 0) return []; - const result: string[] = []; + private readStringArrayFromProcess( + mem: Uint8Array, + arrayPtr: number, + ptrWidth: 4 | 8 = 4, + ): { values: string[] } | { errno: number } { + if (arrayPtr === 0) return { values: [] }; + const values: string[] = []; const view = new DataView(mem.buffer, mem.byteOffset, mem.byteLength); - for (let i = 0; i < 1024; i++) { + // Reserve the list's terminating null pointer up front. Every non-null + // entry consumes at least ptrWidth + one NUL byte, so this byte budget also + // provides a finite loop bound for arrays containing empty strings. + let representedBytes = ptrWidth; + for (let i = 0; representedBytes <= EXEC_METADATA_MAX_BYTES; i++) { + const pointerOffset = arrayPtr + i * ptrWidth; + if (!Number.isSafeInteger(pointerOffset) || pointerOffset < 0 + || pointerOffset + ptrWidth > view.byteLength) { + return { errno: EFAULT }; + } let strPtr: number; if (ptrWidth === 8) { - strPtr = Number(view.getBigUint64(arrayPtr + i * 8, true)); + const rawPtr = view.getBigUint64(pointerOffset, true); + if (rawPtr > BigInt(Number.MAX_SAFE_INTEGER)) return { errno: EFAULT }; + strPtr = Number(rawPtr); } else { - strPtr = view.getUint32(arrayPtr + i * 4, true); + strPtr = view.getUint32(pointerOffset, true); + } + if (strPtr === 0) return { values }; + if (strPtr < 0 || strPtr >= mem.byteLength) return { errno: EFAULT }; + + const scanLength = Math.min(mem.byteLength - strPtr, CH_DATA_SIZE + 1); + let byteLength = 0; + while (byteLength < scanLength && mem[strPtr + byteLength] !== 0) { + byteLength++; + } + if (byteLength === scanLength) { + return { errno: scanLength > CH_DATA_SIZE ? E2BIG : EFAULT }; + } + if (byteLength > CH_DATA_SIZE) return { errno: E2BIG }; + + representedBytes += ptrWidth + byteLength + 1; + if (!Number.isSafeInteger(representedBytes) + || representedBytes > EXEC_METADATA_MAX_BYTES) { + return { errno: E2BIG }; } - if (strPtr === 0) break; - result.push(this.readCStringFromProcess(mem, strPtr)); + + // .slice() copies from SharedArrayBuffer for TextDecoder compatibility. + values.push(new TextDecoder().decode(mem.slice(strPtr, strPtr + byteLength))); } - return result; + return { errno: E2BIG }; + } + + /** Complete a failed async exec only if the old image is still Running. */ + private finishFailedExec( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + errno: number, + ): void { + if (!this.isAsyncChannelProcessActive(channel)) return; + this.completeChannel(channel, syscallNr, origArgs, undefined, -1, errno); } /** @@ -6192,9 +7512,24 @@ export class CentralizedKernelWorker { // Read path (arg 0), argv (arg 1), envp (arg 2) from process memory const pw = this.getPtrWidth(channel.pid); - let path = this.readCStringFromProcess(processMem, origArgs[0]); - const argv = this.readStringArrayFromProcess(processMem, origArgs[1], pw); - const envp = this.readStringArrayFromProcess(processMem, origArgs[2], pw); + const pathResult = this.readExecPathFromProcess(processMem, origArgs[0]); + if ("errno" in pathResult) { + this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, pathResult.errno); + return; + } + let path = pathResult.value; + const argvResult = this.readStringArrayFromProcess(processMem, origArgs[1], pw); + const envResult = this.readStringArrayFromProcess(processMem, origArgs[2], pw); + if ("errno" in argvResult) { + this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, argvResult.errno); + return; + } + if ("errno" in envResult) { + this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, envResult.errno); + return; + } + const argv = argvResult.values; + const envp = envResult.values; // Resolve relative exec paths against process CWD (not initial KERNEL_CWD). // Critical for posix_spawn with chdir file actions where child CWD != parent CWD. @@ -6211,19 +7546,20 @@ export class CentralizedKernelWorker { // program doesn't exist, allowing posix_spawnp/execvpe PATH search to retry. // kernel_exec_setup and prepareProcessForExec are deferred until after // onExec confirms the program exists (returns 0). - this.callbacks.onExec(channel.pid, path, argv, envp).then((result) => { + const callerTid = this.channelTids.get(`${channel.pid}:${channel.channelOffset}`) ?? channel.pid; + this.callbacks.onExec(channel.pid, path, argv, envp, callerTid).then((result) => { if (result < 0) { // Exec failed (e.g. ENOENT) — process is still alive. // Complete the channel so the calling process can handle the error // (e.g., __execvpe tries the next PATH entry). - this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, (-result) >>> 0); + this.finishFailedExec(channel, SYS_EXECVE, origArgs, (-result) >>> 0); } // On success (result === 0), execve doesn't return — the Worker has been // reinitialized with the new program via registerProcess. The old channel // is dead (prepareProcessForExec removed it in onExec). }).catch((err) => { console.error(`[kernel] exec error for pid ${channel.pid}:`, err); - this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, 5); // EIO + this.finishFailedExec(channel, SYS_EXECVE, origArgs, 5); // EIO }); } @@ -6265,9 +7601,24 @@ export class CentralizedKernelWorker { // Read path from process memory const pw = this.getPtrWidth(channel.pid); - const pathStr = this.readCStringFromProcess(processMem, origArgs[1]); - const argv = this.readStringArrayFromProcess(processMem, origArgs[2], pw); - const envp = this.readStringArrayFromProcess(processMem, origArgs[3], pw); + const pathResult = this.readExecPathFromProcess(processMem, origArgs[1]); + if ("errno" in pathResult) { + this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, pathResult.errno); + return; + } + const pathStr = pathResult.value; + const argvResult = this.readStringArrayFromProcess(processMem, origArgs[2], pw); + const envResult = this.readStringArrayFromProcess(processMem, origArgs[3], pw); + if ("errno" in argvResult) { + this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, argvResult.errno); + return; + } + if ("errno" in envResult) { + this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, envResult.errno); + return; + } + const argv = argvResult.values; + const envp = envResult.values; let execPath: string; @@ -6315,13 +7666,14 @@ export class CentralizedKernelWorker { return; } - this.callbacks.onExec(channel.pid, execPath, argv, envp).then((result) => { + const callerTid = this.channelTids.get(`${channel.pid}:${channel.channelOffset}`) ?? channel.pid; + this.callbacks.onExec(channel.pid, execPath, argv, envp, callerTid).then((result) => { if (result < 0) { - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, (-result) >>> 0); + this.finishFailedExec(channel, SYS_EXECVEAT, origArgs, (-result) >>> 0); } }).catch((err) => { console.error(`[kernel] execveat error for pid ${channel.pid}:`, err); - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, 5); // EIO + this.finishFailedExec(channel, SYS_EXECVEAT, origArgs, 5); // EIO }); } @@ -6406,18 +7758,17 @@ export class CentralizedKernelWorker { this.callbacks.onClone( channel.pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, channel.memory, ).then((assignedTid) => { - if (!this.processes.has(channel.pid)) { - if (ctidPtr !== 0) { - this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); - } - return; - } + // prepareProcessForExec already removed the old generation's metadata. + // A stale continuation must not delete a same pid/tid key now owned by + // the replacement image. + if (!this.isAsyncChannelProcessActive(channel)) return; if (assignedTid !== tid && ctidPtr !== 0) { this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); this.threadCtidPtrs.set(`${channel.pid}:${assignedTid}`, ctidPtr); } this.completeChannel(channel, SYS_CLONE, origArgs, undefined, assignedTid, 0); }).catch((err) => { + if (!this.isAsyncChannelProcessActive(channel)) return; if (ctidPtr !== 0) { this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); } @@ -6472,6 +7823,14 @@ export class CentralizedKernelWorker { return; } + // Publish and detach while the process still owns its descriptors and + // before waking a parent waiter. Duplicate exit syscalls are harmless. + this.releaseAllSharedMemoryForProcess(channel.pid); + if (this.getProcessExitSignal(channel.pid) > 0) { + if (!this.hostReaped.has(channel.pid)) this.handleProcessTerminated(channel); + return; + } + // Run the kernel's exit path so it closes all FDs (including pipe // write ends). kernel_exit calls sys_exit then traps — catch the trap. { @@ -6537,20 +7896,19 @@ export class CentralizedKernelWorker { // so a recycled pid (currently impossible with monotonic nextChildPid, // but defensive) starts fresh. if (this.hostReaped.has(exitingPid)) return; + // Mark the transition before publishing shared mappings. A final writeback + // can itself cross the kernel and rediscover the same Exited process; the + // early guard prevents recursive termination cleanup. this.hostReaped.add(exitingPid); + this.releaseAllSharedMemoryForProcess(exitingPid); this.notifyParentOfExitedProcess(exitingPid); - // Clean up per-process state - this.sharedMappings.delete(exitingPid); - // Do NOT complete the channel — the worker is blocked on Atomics.wait // and waking it would cause the C code to continue executing. // onExit will terminate the worker. if (this.callbacks.onExit) { - const getExitStatus = this.kernelInstance!.exports - .kernel_get_process_exit_status as ((pid: number) => number) | undefined; - const exitStatus = getExitStatus ? getExitStatus(exitingPid) : -1; - this.callbacks.onExit(exitingPid, exitStatus >= 128 ? exitStatus : -1); + const signal = this.getProcessExitSignal(exitingPid); + this.callbacks.onExit(exitingPid, signal > 0 ? 128 + signal : -1); } } @@ -6583,8 +7941,8 @@ export class CentralizedKernelWorker { ((pid: number, signum: number) => number) | undefined; if (markSignaled && markSignaled(pid, signum) < 0) return; this.hostReaped.add(pid); + this.releaseAllSharedMemoryForProcess(pid); this.notifyParentOfExitedProcess(pid); - this.sharedMappings.delete(pid); } /** @@ -6595,26 +7953,20 @@ export class CentralizedKernelWorker { * at the kernel level but can leave the host-side blocked wait queue * asleep — wait4(-1) then blocks forever. * - * The kernel sets exit_status to 128 + signum for default Terminate - * actions. Anything < 0 means the process is still alive. + * The kernel exposes the termination signal separately from the normal exit + * status, so exit codes 128..255 cannot be mistaken for signal death. */ private reapKilledProcessesAfterSyscall(): void { - const getExitStatus = this.kernelInstance!.exports - .kernel_get_process_exit_status as ((pid: number) => number) | undefined; - if (!getExitStatus) return; - // Snapshot the registered pids so we can mutate this.processes safely // inside the loop (handleProcessTerminated calls onExit which can // remove entries). const pids = Array.from(this.processes.keys()); for (const pid of pids) { - const status = getExitStatus(pid); - if (status < 128) continue; // still alive, or normally exiting via SYS_EXIT + if (this.getProcessExitSignal(pid) <= 0) continue; if (this.hostReaped.has(pid)) continue; // already reaped this generation // Cancel any pending blocking-syscall timers — the process is gone. - const ps = this.pendingSleeps.get(pid); - if (ps) { clearTimeout(ps.timer); this.pendingSleeps.delete(pid); } + this.cancelPendingSleepsForProcess(pid); const proc = this.processes.get(pid); const ch = proc?.channels[0]; @@ -6624,9 +7976,62 @@ export class CentralizedKernelWorker { if (ch) this.handleProcessTerminated(ch); } } - /** Track pids the host has already reaped (prevents double-reaping - * when reapKilledProcessesAfterSyscall is called multiple times for - * the same already-Exited process). Cleared when the pid is + + private getProcessExitSignal(pid: number): number { + const getExitSignal = this.kernelInstance!.exports + .kernel_get_process_exit_signal as ((pid: number) => number) | undefined; + // Older compatible kernels without the additive query cannot prove that a + // registered execution is dead, so preserve the historical live result. + return getExitSignal?.(pid) ?? -1; + } + + /** Stop a channel boundary when signal delivery transitioned its process to Exited. */ + private finishSignalTermination(channel: ChannelInfo): boolean { + if (this.getProcessExitSignal(channel.pid) <= 0) return false; + this.cancelPendingSleepsForProcess(channel.pid); + this.handleProcessTerminated(channel); + return true; + } + + /** + * Finalize a signal death that occurred while exec had no registered host + * channel. The kernel Process is already an Exited zombie; this performs the + * parent notification and host exit callback exactly once. The caller must + * not install a replacement worker when the returned signal is positive. + */ + finalizeExecHandoffTermination(pid: number): number { + const signal = this.getProcessExitSignal(pid); + if (signal <= 0) return signal; + if (this.hostReaped.has(pid)) return signal; + + this.hostReaped.add(pid); + this.releaseAllSharedMemoryForProcess(pid); + this.notifyParentOfExitedProcess(pid); + if (this.callbacks.onExit) { + this.callbacks.onExit(pid, 128 + signal); + } + return signal; + } + + /** + * Finalize a signal that reached a fork/spawn child while its async Worker + * launch had no dispatchable channel. The child remains a real zombie for + * parent wait semantics, but eager host fd mirrors must be retired. + */ + finalizePendingChildTermination(pid: number): number { + const exitSignal = this.finalizeExecHandoffTermination(pid); + if (exitSignal !== -1) { + this.cleanupTcpListeners(pid); + for (const key of Array.from(this.epollInterests.keys())) { + if (key.startsWith(`${pid}:`)) this.epollInterests.delete(key); + } + } + return exitSignal; + } + + /** Track pids the host has already reaped (prevents double-reaping + * when reapKilledProcessesAfterSyscall is called multiple times for + * the same already-Exited process). Cleared when the pid is * re-allocated by a fresh fork+register. */ private hostReaped = new Set(); @@ -6725,7 +8130,8 @@ export class CentralizedKernelWorker { // so we must check for pending signals here. Without this, cross-process // signals (e.g., kill from child to parent) are lost — the signal is queued // in the kernel but never dequeued for the blocked parent. - this.dequeueSignalForDelivery(channel); + this.dequeueSignalForDelivery(channel, true); + if (this.finishSignalTermination(channel)) return; this.completeChannel(channel, SYS_WAIT4, origArgs, undefined, retVal, errVal); } @@ -6753,7 +8159,8 @@ export class CentralizedKernelWorker { if (!(waiter.options & WNOWAIT)) { this.consumeExitedChild(parentPid, poll.childPid); } - this.dequeueSignalForDelivery(waiter.channel); + this.dequeueSignalForDelivery(waiter.channel, true); + if (this.finishSignalTermination(waiter.channel)) return; this.completeChannel(waiter.channel, SYS_WAITID, waiter.origArgs, undefined, 0, 0); } else { // wait4: write wstatus, consume zombie @@ -6915,8 +8322,8 @@ export class CentralizedKernelWorker { // syscall with EINTR lets the guest's post-__testcancel() pick up // the flag and exit. The deferred-cancel guest overlay treats this // return value like any other EINTR and checks self->cancel. - if (this.pendingCancels.has(channel.channelOffset)) { - this.pendingCancels.delete(channel.channelOffset); + if (this.pendingCancels.has(channel)) { + this.pendingCancels.delete(channel); this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); this.relistenChannel(channel); return; @@ -6962,8 +8369,8 @@ export class CentralizedKernelWorker { if (settled) return; settled = true; if (timer !== undefined) clearTimeout(timer); - this.pendingFutexWaits.delete(channel.channelOffset); - if (!this.processes.has(channel.pid)) return; + this.pendingFutexWaits.delete(channel); + if (!this.isRegisteredChannel(channel)) return; this.completeChannelRaw(channel, retVal, errVal); channel.consecutiveSyscalls = 0; // genuinely blocked — reset this.relistenChannel(channel); @@ -6972,7 +8379,7 @@ export class CentralizedKernelWorker { // Track the wait so SYS_THREAD_CANCEL can force-wake this channel // by firing Atomics.notify on the futex address. The waitAsync // Promise resolves naturally and complete() runs above. - this.pendingFutexWaits.set(channel.channelOffset, { channel, futexIndex: index }); + this.pendingFutexWaits.set(channel, { futexIndex: index }); waitResult.value.then(() => { complete(0, 0); @@ -7084,8 +8491,10 @@ export class CentralizedKernelWorker { private sendSignalToProcess(targetPid: number, signum: number): void { if (!this.kernelInstance || !this.kernelMemory) return; - // Verify the target process exists in the kernel - if (!this.processes.has(targetPid)) return; + // Do not gate on the host registration map: exec temporarily removes the + // old worker registration while the same kernel Process (and its alarm) + // remains alive. Queuing directly in the ProcessTable prevents a timer + // that expires in that handoff window from being lost. const kernelView = new DataView(this.kernelMemory.buffer, this.scratchOffset); // Write SYS_KILL into scratch: kill(targetPid, signum) @@ -7111,22 +8520,46 @@ export class CentralizedKernelWorker { } catch (err) { // Non-fatal — signal delivery is best-effort from the host side console.error(`[sendSignalToProcess] kernel threw for pid=${targetPid} sig=${signum}: ${err}`); + return; } finally { this.currentHandlePid = 0; } - // Check if the signal is deliverable (not blocked by the process) - const isBlocked = (this.kernelInstance!.exports.kernel_is_signal_blocked as - (pid: number, signum: number) => number)(targetPid, signum); - if (isBlocked) return; + // Default terminating actions are applied inside kernel_handle_channel. + // Retire a newly exited worker before considering any blocking-channel + // wakeup; guest code must not resume after signal death. + this.reapKilledProcessesAfterSyscall(); + if (this.getProcessExitSignal(targetPid) > 0) return; + + // Select the exact eligible thread before waking a per-thread blocking + // channel. A process-level "some thread accepts this" answer is not enough: + // waking a different sleeper can complete its nanosleep early while leaving + // the shared signal pending for the intended thread. + const pickSignalTarget = this.kernelInstance!.exports + .kernel_pick_signal_target_tid as + (pid: number, signum: number) => number; + const targetTid = pickSignalTarget(targetPid, signum); + if (targetTid <= 0) return; + + // Ignored and default-ignore signals are consumed inside the kernel. Do + // not shorten a sleep merely because its mask would have accepted a + // signal that is no longer pending. + const threadHasDeliverable = this.kernelInstance!.exports + .kernel_thread_has_deliverable as + (pid: number, tid: number) => number; + if (threadHasDeliverable(targetPid, targetTid) <= 0) return; // Signal is deliverable — wake any blocking syscall for this process // 1. Pending sleep (nanosleep, usleep, clock_nanosleep) - const pendingSleep = this.pendingSleeps.get(targetPid); - if (pendingSleep) { + const pendingSleepMatch = Array.from(this.pendingSleeps.entries()).find( + ([channel]) => channel.pid === targetPid + && this.guestTidForChannel(channel) === targetTid, + ); + if (pendingSleepMatch) { + const [sleepChannel, pendingSleep] = pendingSleepMatch; clearTimeout(pendingSleep.timer); - this.pendingSleeps.delete(targetPid); + this.pendingSleeps.delete(sleepChannel); this.completeSleepWithSignalCheck( pendingSleep.channel, pendingSleep.syscallNr, pendingSleep.origArgs, pendingSleep.retVal, pendingSleep.errVal, @@ -7137,7 +8570,7 @@ export class CentralizedKernelWorker { // Snapshot-and-skip-if-replaced: retrySyscall runs handleSyscall // synchronously, and a non-interruptible blocking wait (notably // accept(), which has no EINTR path) re-inserts the SAME - // channelOffset key via pendingPollRetries.set when it re-parks on + // exact-channel key via pendingPollRetries.set when it re-parks on // EAGAIN. JS Map iterators are not snapshots — a deleted-then- // reinserted key reappears at the tail and the raw for..of would // revisit it forever, livelocking the whole kernel worker thread. @@ -7184,6 +8617,31 @@ export class CentralizedKernelWorker { // addresses — otherwise the process gets "memory access out of bounds". // ----------------------------------------------------------------------- + private ensureFixedMmapProcessMemoryCapacity( + channel: ChannelInfo, + origArgs: number[], + ): boolean { + const addr = origArgs[0] >>> 0; + const len = origArgs[1] >>> 0; + const end = addr + len; + if (!Number.isSafeInteger(end) || end < addr) return false; + const before = channel.memory.buffer.byteLength; + if (end <= before) return true; + try { + const ptrWidth = this.processes.get(channel.pid)?.ptrWidth ?? 4; + growMemoryToCover(channel.memory, end, ptrWidth); + if (channel.memory.buffer.byteLength < end) return false; + // Growth appends zero pages and does not overwrite the MAP_FIXED target. + // Rebind only consumers whose cached view was detached by memory.grow. + this.kernel.framebuffers.rebindMemory(channel.pid); + return true; + } catch { + // Memory.grow itself is irreversible if a later step fails, but it never + // mutates the old fixed interval. Capacity failure stays pre-kernel. + return false; + } + } + private ensureProcessMemoryCovers( pid: number, processMemory: WebAssembly.Memory, @@ -7305,177 +8763,2161 @@ export class CentralizedKernelWorker { } } + private trackAnonymousSharedMapping( + channel: ChannelInfo, + mapAddr: number, + origArgs: number[], + ): void { + const len = origArgs[1] >>> 0; + if (len === 0) return; + const processMem = new Uint8Array(channel.memory.buffer); + if (mapAddr + len > processMem.length) return; + + const key = `anon:${channel.pid}:${mapAddr}:${this.nextAnonymousSharedBackingId++}`; + const initial = processMem.slice(mapAddr, mapAddr + len); + this.anonymousSharedBackings.set(key, { + key, + bytes: initial.slice(), + refCount: 1, + version: 0, + }); + + let pidMap = this.sharedMappings.get(channel.pid); + if (!pidMap) { + pidMap = new Map(); + this.sharedMappings.set(channel.pid, pidMap); + } + pidMap.set(mapAddr, { + fd: -1, + fileOffset: 0, + len, + writable: (origArgs[2] & PROT_WRITE) !== 0, + backingKind: "anonymous", + backingKey: key, + snapshot: initial, + seenVersion: 0, + }); + } + + private synchronizeSharedMemoryForBoundary( + process: Pick, + ): void { + const registration = this.processes?.get(process.pid); + if (registration && registration.memory !== process.memory) return; + if (this.processes && !registration) return; + if ( + (this.sharedMappings?.size ?? 0) === 0 + && (this.shmMappings?.size ?? 0) === 0 + ) return; + this.syncAnonymousSharedMappingsFromProcess(process); + this.syncFileSharedMappingsFromProcess(process); + this.syncSysvShmMappingsFromProcess(process); + } + /** - * Populate a file-backed mmap region by reading from the file fd via pread. - * Called after the kernel allocates the anonymous region and the host zeroes it. - * Reads in CH_DATA_SIZE chunks using the kernel's pread handler. + * Merge this process's anonymous MAP_SHARED writes into host-owned backings, + * then import the complete authoritative result. `seenVersion` is advanced + * only after both steps, so a stale process that publishes a disjoint write + * cannot accidentally mark unseen peer bytes as observed. */ - private populateMmapFromFile( + private syncAnonymousSharedMappingsFromProcess( + process: Pick, + options: { force?: boolean } = {}, + ): void { + const pidMap = this.sharedMappings?.get(process.pid); + if (!pidMap) return; + const processMem = new Uint8Array(process.memory.buffer); + + for (const [mapAddr, mapping] of pidMap) { + if (!mapping.backingKey || !mapping.snapshot) continue; + const backing = this.anonymousSharedBackings?.get(mapping.backingKey); + if (!backing || mapAddr + mapping.len > processMem.length) continue; + const wasStale = (mapping.seenVersion ?? 0) !== backing.version; + // A sole current observer can defer scanning its private Wasm memory, + // but a sole *stale* observer must still import a publication made by a + // child or peer before that other mapping detached. + if (!options.force && backing.refCount <= 1 && !wasStale) continue; + + let changed = false; + if (mapping.writable) { + for (let offset = 0; offset < mapping.len; offset += 4096) { + const len = Math.min(4096, mapping.len - offset); + if (!this.rangeDiffersFromSnapshot( + processMem, + mapAddr + offset, + mapping.snapshot, + offset, + len, + )) continue; + if (this.mergeChangedByteRuns( + processMem, + mapAddr + offset, + mapping.snapshot, + offset, + backing.bytes, + mapping.fileOffset + offset, + len, + )) changed = true; + } + } + if (changed) backing.version++; + + // A publisher may itself have been stale. Always reconcile after a + // publication, rather than assigning the new version to a partial view. + if (changed || wasStale) { + const latest = backing.bytes.slice( + mapping.fileOffset, + mapping.fileOffset + mapping.len, + ); + processMem.set(latest, mapAddr); + mapping.snapshot = latest; + } + mapping.seenVersion = backing.version; + } + } + + private mapSharedMmapFromFile( channel: ChannelInfo, - mmapAddr: number, + mapAddr: number, origArgs: number[], - ): void { + ): FileSharedMmapResult { + if ((origArgs[1] >>> 0) === 0) return { kind: "mapped" }; + const preparation = this.prepareSharedMmapFromFile(channel, origArgs); + if (preparation.kind !== "prepared") return preparation; + return this.registerPreparedSharedMmap( + channel, + mapAddr, + preparation.context, + ); + } + + /** + * Resolve, open, verify, and initially load a regular-file backing before + * the kernel mutates the address space. In particular, MAP_FIXED must not + * destroy its old interval and only then discover that host setup failed. + */ + private prepareSharedMmapFromFile( + channel: ChannelInfo, + origArgs: number[], + ): FileSharedMmapPreparationResult { const fd = origArgs[4]; - const mapLen = origArgs[1]; - // musl sends page offset (off / 4096) as arg[5] + const len = origArgs[1] >>> 0; const pageOffset = origArgs[5]; - let fileOffset = pageOffset * 4096; + const fileOffset = pageOffset * FILE_PAGE_SIZE; + if ( + !Number.isSafeInteger(pageOffset) + || pageOffset < 0 + || !Number.isSafeInteger(fileOffset) + ) return { kind: "error", errno: EINVAL }; + const writable = (origArgs[2] & PROT_WRITE) !== 0; + + const statResult = this.getFdStatForSharedMapping(channel, fd); + if (statResult.kind === "error") return statResult; + const stat = statResult.value; + if ((stat.mode & 0o170000) !== 0o100000) return { kind: "unsupported" }; + + const pathResult = this.getFdPathForSharedMapping(channel, fd); + if (pathResult.kind === "error") return pathResult; + const path = pathResult.value; + if (path.startsWith("memfd:")) { + // MemFd is fstat-regular but has no host pathname/handle to reopen. A + // coherent shared MemFd mapping needs a kernel-owned backing bridge; + // reject it deliberately rather than producing an accidental open EIO + // or an untracked MAP_SHARED mapping. MAP_PRIVATE keeps its fd-pread path. + return { kind: "error", errno: ENOTSUP }; + } + const accessResult = this.getFdAccessModeForSharedMapping(channel, fd); + if (accessResult.kind === "error") return accessResult; + const accessMode = accessResult.value; + // POSIX file mappings require a readable descriptor. A shared writable + // mapping additionally requires O_RDWR; the kernel's capability export + // confirms that writes reach persistent host storage rather than a device + // or an in-kernel synthetic object. + if (accessMode === O_WRONLY) return { kind: "error", errno: EACCES }; + const writeAllowed = accessMode === O_RDWR + && this.fdSupportsMmapWriteback(channel.pid, fd); + if (writable && !writeAllowed) return { kind: "error", errno: EACCES }; + + const keyResult = this.resolveSharedMmapBackingKey(stat, path); + if (keyResult.kind === "error") return keyResult; + const key = keyResult.value; + // Preserve the fd's lifetime capability, not merely the initial + // protection. An O_RDWR fd mapped PROT_READ may be upgraded after the fd + // and pathname disappear; its stable handle must already support writes. + const backingResult = this.getOrCreateSharedMmapBacking( + key, + path, + writeAllowed, + ); + if (backingResult.kind === "error") return backingResult; + const backing = backingResult.value; + + try { + // A sole existing observer normally defers publication to avoid scanning + // its mapping on every syscall. Before another mapping joins, publish + // every existing observer so the new mapping starts from the latest + // shared state rather than the last persisted/cache snapshot. + this.publishSharedMmapBackingObservers(backing); + this.ensureSharedMmapBackingRangeLoaded(backing, fileOffset, len); + } catch (err) { + this.discardUnreferencedSharedMmapBacking(backing); + return { kind: "error", errno: this.sharedMmapErrno(err) }; + } + + // Reserve the backing across the kernel call. MAP_FIXED cleanup may drop + // the last old mapping of this same file before the new tracker installs. + backing.refCount++; + + return { + kind: "prepared", + context: { + fd, + fileOffset, + len, + writable, + writeAllowed, + backing, + }, + }; + } + + /** Install metadata after a successful kernel mmap using preflight state. */ + private registerPreparedSharedMmap( + channel: ChannelInfo, + mapAddr: number, + context: PreparedFileSharedMmap, + ): FileSharedMmapResult { + const { fd, fileOffset, len, writable, writeAllowed, backing } = context; + try { + const processMem = new Uint8Array(channel.memory.buffer); + if (mapAddr + len > processMem.length) { + this.releasePreparedSharedMmap(context); + return { kind: "error", errno: EIO }; + } + // Re-read the authoritative cache here rather than storing preflight + // bytes: MAP_FIXED first flushes the replaced interval, which may refer + // to this same backing and advance it after preparation. + const initial = this.readSharedMmapBackingRange(backing, fileOffset, len); + processMem.set(initial, mapAddr); + let pidMap = this.sharedMappings.get(channel.pid); + if (!pidMap) { + pidMap = new Map(); + this.sharedMappings.set(channel.pid, pidMap); + } + // The preflight reservation becomes this mapping's reference. + this.sharedMmapFdCache.set( + this.sharedMmapFdCacheKey(channel.pid, fd), + { backingKey: backing.key }, + ); + pidMap.set(mapAddr, { + fd, + fileOffset, + len, + writable, + writeAllowed, + backingKind: "file", + backingKey: backing.key, + snapshot: initial, + seenVersion: backing.version, + }); + return { kind: "mapped" }; + } catch (err) { + this.releasePreparedSharedMmap(context); + return { kind: "error", errno: this.sharedMmapErrno(err) }; + } + } + + /** Resolve a backend-qualified stable identity; pathname identity is unsafe. */ + private resolveSharedMmapBackingKey( + stat: SharedMmapFdStat, + path: string, + ): SharedMmapHostResult { + try { + const key = this.io.fileIdentity?.(path, stat.dev, stat.ino) ?? null; + return key + ? { kind: "ok", value: key } + : { kind: "error", errno: ENOTSUP }; + } catch (err) { + return { kind: "error", errno: this.sharedMmapErrno(err) }; + } + } + private getFdStatForSharedMapping( + channel: Pick, + fd: number, + ): SharedMmapHostResult { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; + const statPtr = this.scratchOffset + CH_DATA; const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const kernelMem = new Uint8Array(this.kernelMemory!.buffer); - const dataStart = this.scratchOffset + CH_DATA; + kernelView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Fstat, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + CH_ARG_SIZE, BigInt(statPtr), true); + for (let i = 2; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } - let written = 0; - while (written < mapLen) { - const chunkSize = Math.min(CH_DATA_SIZE, mapLen - written); + const previousPid = this.currentHandlePid; + this.currentHandlePid = channel.pid; + try { + this.bindKernelTidForChannel(channel as ChannelInfo); + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } catch { + return { kind: "error", errno: EIO }; + } finally { + this.currentHandlePid = previousPid; + } + if (this.finishSignalTermination(channel as ChannelInfo)) { + return { kind: "error", errno: EINTR_ERRNO }; + } + const resultView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const result = Number(resultView.getBigInt64(CH_RETURN, true)); + const errno = resultView.getUint32(CH_ERRNO, true); + if (result !== 0 || errno !== 0) { + return { + kind: "error", + errno: errno || (result < -1 ? -result : EIO), + }; + } - // Set up pread syscall in kernel scratch: - // SYS_PREAD (64): (fd, buf_ptr, count, offset_lo, offset_hi) - kernelView.setUint32(CH_SYSCALL, SYS_PREAD, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(fd), true); // fd - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); // buf_ptr (kernel memory) - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkSize), true); // count - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset & 0xffffffff), true); // offset_lo - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(Math.floor(fileOffset / 0x100000000) | 0), true); // offset_hi - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); + const statView = new DataView(this.kernelMemory!.buffer, statPtr); + const dev = statView.getBigUint64(0, true); + const ino = statView.getBigUint64(8, true); + const mode = statView.getUint32(16, true); + const size64 = statView.getBigUint64(32, true); + return { + kind: "ok", + value: { + dev, + ino, + size: size64 > BigInt(Number.MAX_SAFE_INTEGER) + ? Number.MAX_SAFE_INTEGER + : Number(size64), + mode, + }, + }; + } - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } catch { - break; // pread failed, leave rest as zeros + private getFdPathForSharedMapping( + channel: Pick, + fd: number, + ): SharedMmapHostResult { + const getFdPath = this.kernelInstance!.exports.kernel_get_fd_path as + ((pid: number, fd: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; + if (!getFdPath) return { kind: "error", errno: ENOSYS }; + const ptr = this.scratchOffset + CH_DATA; + let len: number; + try { + len = getFdPath( + channel.pid, + fd, + this.toKernelPtr(ptr), + Math.min(4096, CH_DATA_SIZE), + ); + } catch { + return { kind: "error", errno: EIO }; + } + if (len < 0) return { kind: "error", errno: -len }; + if (len === 0) return { kind: "error", errno: ENOENT }; + return { + kind: "ok", + value: new TextDecoder().decode( + new Uint8Array(this.kernelMemory!.buffer).slice(ptr, ptr + len), + ), + }; + } + + private getFdAccessModeForSharedMapping( + channel: Pick, + fd: number, + ): SharedMmapHostResult { + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + kernelView.setUint32(CH_SYSCALL, SYS_FCNTL, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + CH_ARG_SIZE, BigInt(F_GETFL), true); + for (let i = 2; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } + + const previousPid = this.currentHandlePid; + this.currentHandlePid = channel.pid; + try { + this.bindKernelTidForChannel(channel as ChannelInfo); + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } catch { + return { kind: "error", errno: EIO }; + } finally { + this.currentHandlePid = previousPid; + } + if (this.finishSignalTermination(channel as ChannelInfo)) { + return { kind: "error", errno: EINTR_ERRNO }; + } + const resultView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const result = Number(resultView.getBigInt64(CH_RETURN, true)); + const errno = resultView.getUint32(CH_ERRNO, true); + if (result < 0 || errno !== 0) { + return { + kind: "error", + errno: errno || (result < -1 ? -result : EIO), + }; + } + return { kind: "ok", value: result & O_ACCMODE }; + } + + private getOrCreateSharedMmapBacking( + key: string, + path: string, + openWritable: boolean, + ): SharedMmapHostResult { + const existing = this.sharedMmapBackings.get(key); + if (existing) { + if (openWritable && !existing.writable) { + const replacement = this.openSharedMmapBackingHandle(path, true, key); + if (replacement.kind === "error") return replacement; + try { this.io.close(existing.handle); } catch {} + existing.handle = replacement.value.handle; + existing.writable = true; + existing.size = replacement.value.size; + existing.sizeValid = true; + } else { + const errno = this.revalidateSharedMmapBacking(existing); + if (errno !== 0) return { kind: "error", errno }; + } + existing.path = path; + return { kind: "ok", value: existing }; + } + + const opened = this.openSharedMmapBackingHandle(path, openWritable, key); + if (opened.kind === "error") return opened; + const backing: SharedMmapBacking = { + key, + path, + handle: opened.value.handle, + writable: openWritable, + size: opened.value.size, + sizeValid: true, + pages: new Map(), + dirtyPages: new Set(), + refCount: 0, + version: 0, + }; + this.sharedMmapBackings.set(key, backing); + this.invalidateSharedMmapFdCache(); + return { kind: "ok", value: backing }; + } + + private openSharedMmapBackingHandle( + path: string, + writable: boolean, + expectedKey?: string, + ): SharedMmapHostResult { + let handle: number; + try { + handle = this.io.open(path, writable ? O_RDWR : O_RDONLY, 0); + } catch (err) { + return { kind: "error", errno: this.sharedMmapErrno(err) }; + } + try { + const stat = this.io.fstat(handle); + if ((stat.mode & 0o170000) !== 0o100000) throw new Error("not a regular file"); + if (!Number.isSafeInteger(stat.size) || stat.size < 0) { + throw new Error("invalid file size"); + } + if (expectedKey) { + const actual = this.resolveSharedMmapBackingKey({ + dev: BigInt(stat.dev), + ino: BigInt(stat.ino), + mode: stat.mode, + size: stat.size, + }, path); + if (actual.kind === "error") { + const err = new Error("stable file identity unavailable") as Error & { code: number }; + err.code = actual.errno; + throw err; + } + if (actual.value !== expectedKey) throw new Error("file identity changed"); } - this.currentHandlePid = 0; + return { kind: "ok", value: { handle, size: stat.size } }; + } catch (err) { + try { this.io.close(handle); } catch {} + return { kind: "error", errno: this.sharedMmapErrno(err) }; + } + } - const bytesRead = Number(kernelView.getBigInt64(CH_RETURN, true)); - if (bytesRead <= 0) break; // EOF or error + private revalidateSharedMmapBacking(backing: SharedMmapBacking): number { + try { + const stat = this.io.fstat(backing.handle); + if (!Number.isSafeInteger(stat.size) || stat.size < 0) { + backing.sizeValid = false; + return EIO; + } + if ((stat.mode & 0o170000) !== 0o100000) { + backing.sizeValid = false; + return EIO; + } + const actual = this.resolveSharedMmapBackingKey({ + dev: BigInt(stat.dev), + ino: BigInt(stat.ino), + mode: stat.mode, + size: stat.size, + }, backing.path); + if (actual.kind === "error" || actual.value !== backing.key) { + backing.sizeValid = false; + return actual.kind === "error" ? actual.errno : EIO; + } + backing.size = stat.size; + backing.sizeValid = true; + return 0; + } catch (err) { + backing.sizeValid = false; + return this.sharedMmapErrno(err); + } + } - // Copy from kernel scratch data area to process memory - const processMem = new Uint8Array(channel.memory.buffer); - processMem.set( - kernelMem.subarray(dataStart, dataStart + bytesRead), - mmapAddr + written, + private sharedMmapErrno(err: unknown): number { + const mapped = negErrno(err); + return mapped < 0 ? -mapped : mapped || EIO; + } + + private discardUnreferencedSharedMmapBacking(backing: SharedMmapBacking): void { + if (backing.refCount !== 0 || this.sharedMmapBackings.get(backing.key) !== backing) return; + // A zero-reference backing can remain after a failed final writeback. + // Preserve its dirty pages and stable handle for a later same-object map + // rather than silently discarding acknowledged MAP_SHARED stores. + if (backing.dirtyPages.size > 0) return; + try { this.io.close(backing.handle); } catch {} + this.sharedMmapBackings.delete(backing.key); + this.invalidateSharedMmapFdCache(); + } + + private ensureSharedMmapBackingRangeLoaded( + backing: SharedMmapBacking, + offset: number, + len: number, + ): void { + if (len <= 0) return; + const firstPage = Math.floor(offset / FILE_PAGE_SIZE); + const lastPage = Math.floor((offset + len - 1) / FILE_PAGE_SIZE); + for (let page = firstPage; page <= lastPage; page++) { + this.ensureSharedMmapBackingPageLoaded(backing, page); + } + } + + private ensureSharedMmapBackingPageLoaded( + backing: SharedMmapBacking, + page: number, + ): Uint8Array { + const existing = backing.pages.get(page); + if (existing) return existing; + if (!backing.sizeValid) { + const errno = this.revalidateSharedMmapBacking(backing); + if (errno !== 0) { + const err = new Error("Cannot determine MAP_SHARED backing size") as + Error & { code: number }; + err.code = errno; + throw err; + } + } + const loaded = this.readSharedMmapBackingPage(backing, page); + backing.pages.set(page, loaded); + return loaded; + } + + private readSharedMmapBackingPage(backing: SharedMmapBacking, page: number): Uint8Array { + const bytes = new Uint8Array(FILE_PAGE_SIZE); + if (!backing.sizeValid) throw new Error("Unknown MAP_SHARED backing size"); + const pageOffset = page * FILE_PAGE_SIZE; + const readable = Math.max(0, Math.min(FILE_PAGE_SIZE, backing.size - pageOffset)); + if (readable === 0) return bytes; + let total = 0; + while (total < readable) { + const remaining = readable - total; + const read = this.io.read( + backing.handle, + bytes.subarray(total), + pageOffset + total, + remaining, + ); + if (read <= 0 || read > remaining) { + // fstat declared these bytes readable. A premature EOF means the file + // raced with this snapshot (or the backend violated count semantics), + // so zero-filling would manufacture data and must fail coherently. + throw new Error(`Invalid MAP_SHARED backing read length: ${read}`); + } + total += read; + } + return bytes; + } + + private readSharedMmapBackingRange( + backing: SharedMmapBacking, + offset: number, + len: number, + ): Uint8Array { + const result = new Uint8Array(len); + let copied = 0; + while (copied < len) { + const absolute = offset + copied; + const page = Math.floor(absolute / FILE_PAGE_SIZE); + const pageOffset = absolute % FILE_PAGE_SIZE; + const count = Math.min(FILE_PAGE_SIZE - pageOffset, len - copied); + result.set( + this.ensureSharedMmapBackingPageLoaded(backing, page) + .subarray(pageOffset, pageOffset + count), + copied, ); + copied += count; + } + return result; + } - written += bytesRead; - fileOffset += bytesRead; + private copyRangeToSharedMmapBacking( + backing: SharedMmapBacking, + offset: number, + bytes: Uint8Array, + markDirty: boolean, + ): void { + let copied = 0; + while (copied < bytes.length) { + const absolute = offset + copied; + const page = Math.floor(absolute / FILE_PAGE_SIZE); + const pageOffset = absolute % FILE_PAGE_SIZE; + const count = Math.min(FILE_PAGE_SIZE - pageOffset, bytes.length - copied); + const wasDirty = backing.dirtyPages.has(page); + this.ensureSharedMmapBackingPageLoaded(backing, page).set( + bytes.subarray(copied, copied + count), + pageOffset, + ); + if (markDirty) backing.dirtyPages.add(page); + else if (!wasDirty) backing.dirtyPages.delete(page); + copied += count; + } + } - if (bytesRead < chunkSize) break; // short read = EOF + private syncFileSharedMappingsFromProcess( + process: Pick, + options: { force?: boolean } = {}, + ): void { + const mappings = this.sharedMappings?.get(process.pid); + if (!mappings) return; + const processMem = new Uint8Array(process.memory.buffer); + const candidates: Array<{ + mapAddr: number; + mapping: SharedMmapMapping; + backing: SharedMmapBacking; + snapshot: Uint8Array; + }> = []; + + for (const [mapAddr, mapping] of mappings) { + if (mapping.backingKind !== "file" || !mapping.backingKey || !mapping.snapshot) continue; + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (!backing || mapAddr + mapping.len > processMem.length) continue; + const wasStale = (mapping.seenVersion ?? 0) !== backing.version; + if (!options.force && backing.refCount <= 1 && !wasStale) continue; + candidates.push({ mapAddr, mapping, backing, snapshot: mapping.snapshot }); + } + + // Publish every alias before refreshing any alias. A one-pass + // publish-and-refresh loop can leave an earlier alias stale when a later + // alias advances the same backing during this boundary. + for (const { mapAddr, mapping, backing, snapshot } of candidates) { + let changed = false; + if (mapping.writable) { + for (let offset = 0; offset < mapping.len; offset += FILE_PAGE_SIZE) { + const len = Math.min(FILE_PAGE_SIZE, mapping.len - offset); + if (!this.rangeDiffersFromSnapshot( + processMem, + mapAddr + offset, + snapshot, + offset, + len, + )) continue; + if (this.mergeChangedFileMappingRuns( + backing, + processMem, + mapAddr + offset, + snapshot, + offset, + mapping.fileOffset + offset, + len, + )) changed = true; + } + } + if (changed) backing.version++; + } + + // Read every final snapshot before mutating process memory. If one backing + // becomes unreadable, this boundary fails without partially refreshing a + // subset of the process's aliases. + const refreshes = candidates + .filter(({ mapping, backing }) => + (mapping.seenVersion ?? 0) !== backing.version) + .map(({ mapAddr, mapping, backing }) => ({ + mapAddr, + mapping, + backing, + latest: this.readSharedMmapBackingRange( + backing, + mapping.fileOffset, + mapping.len, + ), + })); + for (const { mapAddr, mapping, backing, latest } of refreshes) { + processMem.set(latest, mapAddr); + mapping.snapshot = latest; + mapping.seenVersion = backing.version; + } + } + + /** Force all current mappings to publish before a new observer or fd read. */ + private publishSharedMmapBackingObservers(backing: SharedMmapBacking): void { + if (backing.refCount <= 0) return; + const observerPids = new Set(); + for (const [pid, mappings] of this.sharedMappings) { + for (const mapping of mappings.values()) { + if (mapping.backingKind === "file" && mapping.backingKey === backing.key) { + observerPids.add(pid); + break; + } + } + } + for (const pid of observerPids) { + const registration = this.processes.get(pid); + if (!registration) { + throw new Error(`Missing process memory for MAP_SHARED observer ${pid}`); + } + this.syncFileSharedMappingsFromProcess(registration, { force: true }); + } + } + + private mergeChangedFileMappingRuns( + backing: SharedMmapBacking, + source: Uint8Array, + sourceOffset: number, + snapshot: Uint8Array, + snapshotOffset: number, + backingOffset: number, + len: number, + ): boolean { + let changed = false; + let i = 0; + while (i < len) { + while (i < len && source[sourceOffset + i] === snapshot[snapshotOffset + i]) i++; + if (i >= len) break; + const start = i; + do { i++; } while ( + i < len && source[sourceOffset + i] !== snapshot[snapshotOffset + i] + ); + this.copyRangeToSharedMmapBacking( + backing, + backingOffset + start, + source.subarray(sourceOffset + start, sourceOffset + i), + true, + ); + changed = true; + } + return changed; + } + + private flushSharedMmapBackingRange( + backing: SharedMmapBacking, + offset: number, + len: number, + ): boolean { + if (len <= 0 || backing.dirtyPages.size === 0) return true; + if (!backing.sizeValid) return false; + const requestedEnd = offset + len; + const end = Math.min(requestedEnd, backing.size); + let success = true; + for (const page of Array.from(backing.dirtyPages).sort((a, b) => a - b)) { + const pageStart = page * FILE_PAGE_SIZE; + const pageEnd = pageStart + FILE_PAGE_SIZE; + if (pageStart >= backing.size) { + // Writes beyond EOF are not permitted to grow a mapped file. Drop the + // unrepresentable dirty bytes when this flush covers their page. + if (pageStart < requestedEnd && pageEnd > offset) { + backing.dirtyPages.delete(page); + } + continue; + } + if (pageStart >= end || pageEnd <= offset) continue; + const writeStart = Math.max(offset, pageStart); + const validPageEnd = Math.min(pageEnd, backing.size); + const writeEnd = Math.min(end, validPageEnd); + const source = this.ensureSharedMmapBackingPageLoaded(backing, page).subarray( + writeStart - pageStart, + writeEnd - pageStart, + ); + if (!this.writeAllToSharedMmapBacking(backing, source, writeStart)) { + success = false; + continue; + } + if (writeStart === pageStart && writeEnd === validPageEnd) { + backing.dirtyPages.delete(page); + } + } + return success; + } + + private writeAllToSharedMmapBacking( + backing: SharedMmapBacking, + source: Uint8Array, + offset: number, + ): boolean { + let written = 0; + while (written < source.length) { + try { + const count = this.io.write( + backing.handle, + source.subarray(written), + offset + written, + source.length - written, + ); + if (count <= 0) return false; + written += count; + } catch { + return false; + } + } + return true; + } + + private flushSharedMappingsBeforeFileSyscall( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + ): boolean { + if ((this.sharedMmapBackings?.size ?? 0) === 0) return true; + try { + if (syscallNr === SYS_TRUNCATE) { + const path = this.resolveSharedMmapPath(channel, origArgs[0]); + return path.kind === "error" + || this.flushSharedBackingForPath(path.value); + } + if (syscallNr === SYS_OPEN || syscallNr === SYS_OPENAT) { + const flags = syscallNr === SYS_OPEN ? origArgs[1] : origArgs[2]; + if ((flags & O_TRUNC) !== 0) { + const path = this.resolveSharedMmapPath( + channel, + syscallNr === SYS_OPEN ? origArgs[0] : origArgs[1], + syscallNr === SYS_OPENAT ? origArgs[0] : AT_FDCWD, + ); + return path.kind === "error" + || this.flushSharedBackingForPath(path.value); + } + } + if ( + syscallNr === SYS_MMAP + && (origArgs[3] & MAP_SHARED) === 0 + && (origArgs[3] & MAP_ANONYMOUS) === 0 + && origArgs[4] >= 0 + ) { + // MAP_PRIVATE is populated through the guest fd's pread path after the + // kernel reserves memory. Publish and persist any dirty shared view of + // that file first so the private snapshot does not start stale. + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + return this.flushSharedBackingForFd(channel, origArgs[4]); + } + if (syscallNr === SYS_SENDFILE) { + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + return this.flushSharedBackingForFd(channel, origArgs[0]) + && this.flushSharedBackingForFd(channel, origArgs[1]); + } + if (syscallNr === SYS_COPY_FILE_RANGE || syscallNr === SYS_SPLICE) { + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + return this.flushSharedBackingForFd(channel, origArgs[0]) + && this.flushSharedBackingForFd(channel, origArgs[2]); + } + if (!this.syscallTouchesFdStorageBeforeKernel(syscallNr)) return true; + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + return this.flushSharedBackingForFd(channel, origArgs[0]); + } catch { + return false; + } + } + + private syscallTouchesFdStorageBeforeKernel(syscallNr: number): boolean { + return syscallNr === SYS_READ + || syscallNr === SYS_PREAD + || syscallNr === SYS_READV + || syscallNr === SYS_PREADV + || syscallNr === SYS_WRITE + || syscallNr === SYS_PWRITE + || syscallNr === SYS_WRITEV + || syscallNr === SYS_PWRITEV + || syscallNr === SYS_FSYNC + || syscallNr === SYS_FDATASYNC + || syscallNr === SYS_FTRUNCATE + || syscallNr === SYS_FALLOCATE; + } + + private flushSharedBackingForFd(channel: ChannelInfo, fd: number): boolean { + if (fd < 0) return true; + const backing = this.findSharedMmapBackingForFd(channel, fd); + if (!backing) return true; + this.publishSharedMmapBackingObservers(backing); + const flushed = this.flushSharedMmapBackingRange( + backing, + 0, + Number.MAX_SAFE_INTEGER, + ); + if (flushed && backing.refCount === 0) { + this.discardUnreferencedSharedMmapBacking(backing); + } + return flushed; + } + + private resolveSharedMmapPath( + channel: Pick, + pathPtr: number, + dirfd: number = AT_FDCWD, + ): SharedMmapHostResult { + try { + const memory = new Uint8Array(channel.memory.buffer); + if (pathPtr <= 0 || pathPtr >= memory.length) { + return { kind: "error", errno: EFAULT }; + } + const limit = Math.min(memory.length, pathPtr + 4096); + let end = pathPtr; + while (end < limit && memory[end] !== 0) end++; + if (end === limit) return { kind: "error", errno: ENAMETOOLONG }; + // Chrome's TextDecoder rejects views backed by SharedArrayBuffer. Copy + // the guest pathname into ordinary host memory before decoding. + const pathBytes = new Uint8Array(end - pathPtr); + pathBytes.set(memory.subarray(pathPtr, end)); + const path = new TextDecoder().decode(pathBytes); + if (!path) return { kind: "error", errno: ENOENT }; + if (path.startsWith("/")) { + return { kind: "ok", value: this.normalizeSharedMmapPath(path) }; + } + + let base: string; + if (dirfd !== AT_FDCWD) { + const baseResult = this.getFdPathForSharedMapping(channel, dirfd); + if (baseResult.kind === "error") return baseResult; + base = baseResult.value; + } else { + const getCwd = this.kernelInstance!.exports.kernel_get_cwd as + ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; + if (!getCwd) return { kind: "error", errno: ENOSYS }; + const cwdLen = getCwd( + channel.pid, + this.toKernelPtr(this.scratchOffset), + Math.min(4096, CH_DATA_SIZE), + ); + if (cwdLen < 0) return { kind: "error", errno: -cwdLen }; + if (cwdLen === 0) return { kind: "error", errno: ENOENT }; + base = new TextDecoder().decode( + new Uint8Array(this.kernelMemory!.buffer) + .slice(this.scratchOffset, this.scratchOffset + cwdLen), + ); + } + return { + kind: "ok", + value: this.normalizeSharedMmapPath(`${base}/${path}`), + }; + } catch (err) { + return { kind: "error", errno: this.sharedMmapErrno(err) }; + } + } + + private normalizeSharedMmapPath(path: string): string { + const normalized: string[] = []; + for (const component of path.split("/")) { + if (!component || component === ".") continue; + if (component === "..") normalized.pop(); + else normalized.push(component); + } + return `/${normalized.join("/")}`; + } + + private findSharedMmapBackingForPath(path: string): SharedMmapBacking | null { + if (this.sharedMmapBackings.size === 0) return null; + try { + const stat = this.io.stat(path); + if ((stat.mode & 0o170000) !== 0o100000) return null; + const key = this.io.fileIdentity?.( + path, + BigInt(stat.dev), + BigInt(stat.ino), + ) ?? null; + return key ? this.sharedMmapBackings.get(key) ?? null : null; + } catch { + // The kernel remains authoritative for the pathname error. If the path + // cannot identify an existing backing, there is nothing safe to flush. + return null; + } + } + + private flushSharedBackingForPath(path: string): boolean { + const backing = this.findSharedMmapBackingForPath(path); + if (!backing) return true; + this.publishSharedMmapBackingObservers(backing); + const flushed = this.flushSharedMmapBackingRange( + backing, + 0, + Number.MAX_SAFE_INTEGER, + ); + if (flushed && backing.refCount === 0) { + this.discardUnreferencedSharedMmapBacking(backing); + } + return flushed; + } + + private handleSharedMappingsAfterFileSyscall( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + retVal: number, + errVal: number, + ): void { + if ((this.sharedMmapBackings?.size ?? 0) === 0) return; + if (errVal !== 0) return; + if ((syscallNr === SYS_OPEN || syscallNr === SYS_OPENAT) && retVal >= 0) { + this.invalidateSharedMmapFdCache(channel.pid, retVal); + const flags = syscallNr === SYS_OPEN ? origArgs[1] : origArgs[2]; + if ((flags & O_TRUNC) !== 0) { + this.reloadSharedMmapBackingForFd(channel, retVal, 0); + } + return; + } + if (syscallNr === SYS_CLOSE && retVal === 0) { + this.invalidateSharedMmapFdCache(channel.pid, origArgs[0]); + return; + } + if (syscallNr === SYS_DUP && retVal >= 0) { + this.invalidateSharedMmapFdCache(channel.pid, retVal); + return; + } + if ((syscallNr === SYS_DUP2 || syscallNr === SYS_DUP3) && retVal >= 0) { + this.invalidateSharedMmapFdCache(channel.pid, origArgs[1]); + return; + } + if (syscallNr === SYS_FCNTL && retVal >= 0) { + const cmd = origArgs[1] >>> 0; + if (cmd === F_DUPFD || cmd === F_DUPFD_CLOEXEC || cmd === F_DUPFD_CLOFORK) { + this.invalidateSharedMmapFdCache(channel.pid, retVal); + return; + } + } + if (syscallNr === SYS_PWRITE && retVal > 0) { + this.updateSharedMmapBackingFromProcessBuffer( + channel, + origArgs[0], + origArgs[1] >>> 0, + retVal, + origArgs[3], + ); + return; + } + if (syscallNr === SYS_WRITE && retVal > 0) { + this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + return; + } + if ((syscallNr === SYS_WRITEV || syscallNr === SYS_PWRITEV) && retVal > 0) { + this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + return; + } + if (syscallNr === SYS_SENDFILE && retVal > 0) { + this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + return; + } + if ( + (syscallNr === SYS_COPY_FILE_RANGE || syscallNr === SYS_SPLICE) + && retVal > 0 + ) { + this.reloadSharedMmapBackingForFd(channel, origArgs[2]); + return; + } + if (syscallNr === SYS_FTRUNCATE && retVal === 0) { + this.reloadSharedMmapBackingForFd(channel, origArgs[0], origArgs[1]); + return; + } + if (syscallNr === SYS_FALLOCATE && retVal === 0) { + this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + return; + } + if (syscallNr === SYS_TRUNCATE && retVal === 0) { + const path = this.resolveSharedMmapPath(channel, origArgs[0]); + if (path.kind === "ok") { + this.reloadSharedMmapBackingForPath(path.value, origArgs[1]); + } + } + } + + private updateSharedMmapBackingFromProcessBuffer( + channel: ChannelInfo, + fd: number, + ptr: number, + len: number, + offset: number, + ): void { + if (len <= 0) return; + const backing = this.findSharedMmapBackingForFd(channel, fd); + if (!backing) return; + if ( + !Number.isSafeInteger(offset) + || offset < 0 + || !Number.isSafeInteger(offset + len) + ) { + backing.sizeValid = false; + this.invalidateSharedMmapBackingPages(backing); + return; + } + if (this.revalidateSharedMmapBacking(backing) !== 0) { + this.invalidateSharedMmapBackingPages(backing); + return; + } + const processMem = new Uint8Array(channel.memory.buffer); + if (ptr + len > processMem.length) { + this.reloadSharedMmapBackingRange(backing, offset, len); + return; + } + try { + this.copyRangeToSharedMmapBacking( + backing, + offset, + processMem.subarray(ptr, ptr + len), + false, + ); + backing.version++; + } catch { + this.invalidateSharedMmapBackingRange(backing, offset, len); + } + } + + private reloadSharedMmapBackingForFd( + channel: ChannelInfo, + fd: number, + exactSize?: number, + ): boolean { + const backing = this.findSharedMmapBackingForFd(channel, fd); + if (!backing) return true; + return this.reloadSharedMmapBacking(backing, exactSize); + } + + private reloadSharedMmapBackingForPath( + path: string, + exactSize?: number, + ): boolean { + const backing = this.findSharedMmapBackingForPath(path); + if (!backing) return true; + return this.reloadSharedMmapBacking(backing, exactSize); + } + + private reloadSharedMmapBacking( + backing: SharedMmapBacking, + exactSize?: number, + ): boolean { + if (exactSize !== undefined && Number.isSafeInteger(exactSize) && exactSize >= 0) { + backing.size = exactSize; + backing.sizeValid = true; + } else if (this.revalidateSharedMmapBacking(backing) !== 0) { + this.invalidateSharedMmapBackingPages(backing); + return false; + } + if (backing.pages.size === 0) { + backing.version++; + return true; + } + const loadedPages = Array.from(backing.pages.keys()); + const replacements = new Map(); + try { + for (const page of loadedPages) { + replacements.set(page, this.readSharedMmapBackingPage(backing, page)); + } + } catch { + this.invalidateSharedMmapBackingPages(backing, loadedPages); + return false; + } + for (const [page, bytes] of replacements) { + backing.pages.set(page, bytes); + backing.dirtyPages.delete(page); + } + backing.version++; + return true; + } + + private reloadSharedMmapBackingRange( + backing: SharedMmapBacking, + offset: number, + len: number, + ): boolean { + if (len <= 0) return true; + const firstPage = Math.floor(offset / FILE_PAGE_SIZE); + const lastPage = Math.floor((offset + len - 1) / FILE_PAGE_SIZE); + const replacements = new Map(); + try { + for (let page = firstPage; page <= lastPage; page++) { + if (!backing.pages.has(page)) continue; + replacements.set(page, this.readSharedMmapBackingPage(backing, page)); + } + } catch { + this.invalidateSharedMmapBackingPages( + backing, + Array.from({ length: lastPage - firstPage + 1 }, (_, index) => firstPage + index), + ); + return false; + } + for (const [page, bytes] of replacements) { + backing.pages.set(page, bytes); + backing.dirtyPages.delete(page); + } + if (replacements.size > 0) backing.version++; + return true; + } + + private invalidateSharedMmapBackingRange( + backing: SharedMmapBacking, + offset: number, + len: number, + ): void { + if (len <= 0) return; + const firstPage = Math.floor(offset / FILE_PAGE_SIZE); + const lastPage = Math.floor((offset + len - 1) / FILE_PAGE_SIZE); + this.invalidateSharedMmapBackingPages( + backing, + Array.from({ length: lastPage - firstPage + 1 }, (_, index) => firstPage + index), + ); + } + + private invalidateSharedMmapBackingPages( + backing: SharedMmapBacking, + pages: Iterable = Array.from(backing.pages.keys()), + ): void { + for (const page of pages) { + // Direct-storage syscalls preflush dirty mappings. Preserve any dirty + // page if a focused caller bypassed that contract; clean stale pages are + // removed so completion-boundary refresh must reread them. + if (!backing.dirtyPages.has(page)) backing.pages.delete(page); + } + // Even when no page was loaded, mapped snapshots must be treated as stale. + backing.version++; + } + + private findSharedMmapBackingForFd( + channel: ChannelInfo, + fd: number, + ): SharedMmapBacking | null { + if (this.sharedMmapBackings.size === 0 || fd < 0) return null; + const cacheKey = this.sharedMmapFdCacheKey(channel.pid, fd); + const cached = this.sharedMmapFdCache.get(cacheKey); + if (cached !== undefined) { + return cached.backingKey + ? this.sharedMmapBackings.get(cached.backingKey) ?? null + : null; + } + + const statResult = this.getFdStatForSharedMapping(channel, fd); + if (statResult.kind === "error") { + if (statResult.errno === EBADF) { + this.sharedMmapFdCache.set(cacheKey, { backingKey: null }); + } + return null; + } + if ((statResult.value.mode & 0o170000) !== 0o100000) { + this.sharedMmapFdCache.set(cacheKey, { backingKey: null }); + return null; + } + const pathResult = this.getFdPathForSharedMapping(channel, fd); + const keyResult = pathResult.kind === "ok" + ? this.resolveSharedMmapBackingKey(statResult.value, pathResult.value) + : pathResult; + if (keyResult.kind === "error") { + if (keyResult.errno === EBADF || keyResult.errno === ENOTSUP) { + this.sharedMmapFdCache.set(cacheKey, { backingKey: null }); + } + return null; + } + const backing = this.sharedMmapBackings.get(keyResult.value); + if (backing) { + this.sharedMmapFdCache.set(cacheKey, { backingKey: backing.key }); + return backing; + } + this.sharedMmapFdCache.set(cacheKey, { backingKey: null }); + return null; + } + + private sharedMmapFdCacheKey(pid: number, fd: number): string { + return `${pid}:${fd}`; + } + + private invalidateSharedMmapFdCache(pid?: number, fd?: number): void { + if (pid === undefined || fd === undefined) { + this.sharedMmapFdCache.clear(); + return; + } + this.sharedMmapFdCache.delete(this.sharedMmapFdCacheKey(pid, fd)); + } + + private invalidateSharedMmapFdCacheForPid(pid: number): void { + if (!this.sharedMmapFdCache) return; + const prefix = `${pid}:`; + for (const key of this.sharedMmapFdCache.keys()) { + if (key.startsWith(prefix)) this.sharedMmapFdCache.delete(key); + } + } + + private releaseFileSharedMapping(mapping: SharedMmapMapping): void { + if (mapping.backingKind !== "file" || !mapping.backingKey) return; + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (!backing) return; + this.releaseSharedMmapBackingReference(backing); + } + + private releasePreparedSharedMmap(context: PreparedFileSharedMmap): void { + this.releaseSharedMmapBackingReference(context.backing); + } + + private releaseSharedMmapBackingReference(backing: SharedMmapBacking): void { + backing.refCount = Math.max(0, backing.refCount - 1); + if (backing.refCount > 0) return; + if (!this.flushSharedMmapBackingRange(backing, 0, Number.MAX_SAFE_INTEGER)) { + // Keep the stable handle and dirty cache available for a later mapping + // of the same object. Closing here would irreversibly lose dirty bytes. + return; + } + try { this.io.close(backing.handle); } catch {} + this.sharedMmapBackings.delete(backing.key); + this.invalidateSharedMmapFdCache(); + } + + private mergeChangedByteRuns( + source: Uint8Array, + sourceOffset: number, + snapshot: Uint8Array, + snapshotOffset: number, + destination: Uint8Array, + destinationOffset: number, + len: number, + ): boolean { + let changed = false; + let i = 0; + while (i < len) { + while (i < len && source[sourceOffset + i] === snapshot[snapshotOffset + i]) i++; + if (i >= len) break; + const start = i; + do { i++; } while ( + i < len && source[sourceOffset + i] !== snapshot[snapshotOffset + i] + ); + destination.set(source.subarray(sourceOffset + start, sourceOffset + i), destinationOffset + start); + changed = true; + } + return changed; + } + + private rangeDiffersFromSnapshot( + source: Uint8Array, + sourceOffset: number, + snapshot: Uint8Array, + snapshotOffset: number, + len: number, + ): boolean { + const sourceByteOffset = source.byteOffset + sourceOffset; + const snapshotByteOffset = snapshot.byteOffset + snapshotOffset; + if (((sourceByteOffset | snapshotByteOffset | len) & 3) === 0) { + const sourceWords = new Uint32Array(source.buffer, sourceByteOffset, len / 4); + const snapshotWords = new Uint32Array(snapshot.buffer, snapshotByteOffset, len / 4); + for (let i = 0; i < sourceWords.length; i++) { + if (sourceWords[i] !== snapshotWords[i]) return true; + } + return false; + } + for (let i = 0; i < len; i++) { + if (source[sourceOffset + i] !== snapshot[snapshotOffset + i]) return true; + } + return false; + } + + private releaseAnonymousSharedMapping(mapping: SharedMmapMapping): void { + if (!mapping.backingKey) return; + const backing = this.anonymousSharedBackings?.get(mapping.backingKey); + if (!backing) return; + backing.refCount = Math.max(0, backing.refCount - 1); + if (backing.refCount === 0) this.anonymousSharedBackings.delete(backing.key); + } + + private releaseSharedMapping(mapping: SharedMmapMapping): void { + if (mapping.backingKind === "file") this.releaseFileSharedMapping(mapping); + else this.releaseAnonymousSharedMapping(mapping); + } + + /** + * Inherit host-side shared-memory metadata after the child process memory has + * been registered, but before its Worker starts executing. + */ + inheritProcessSharedMappings(parentPid: number, childPid: number): void { + const child = this.processes.get(childPid); + if (!child) throw new Error(`Process ${childPid} is not registered`); + + try { + const parentMap = this.sharedMappings.get(parentPid); + if (parentMap) { + const childMem = new Uint8Array(child.memory.buffer); + const childMap = new Map(); + // Install incrementally so the outer rollback can release references if + // a later mapping or SysV attachment fails. + this.sharedMappings.set(childPid, childMap); + for (const [mapAddr, mapping] of parentMap) { + if (!mapping.backingKey) continue; + const anonymousBacking = mapping.backingKind !== "file" + ? this.anonymousSharedBackings.get(mapping.backingKey) + : undefined; + const fileBacking = mapping.backingKind === "file" + ? this.sharedMmapBackings.get(mapping.backingKey) + : undefined; + if ((!anonymousBacking && !fileBacking) || mapAddr + mapping.len > childMem.length) { + throw new Error(`Cannot inherit shared mapping at 0x${mapAddr.toString(16)}`); + } + const latest = anonymousBacking + ? anonymousBacking.bytes.slice( + mapping.fileOffset, + mapping.fileOffset + mapping.len, + ) + : this.readSharedMmapBackingRange( + fileBacking!, + mapping.fileOffset, + mapping.len, + ); + childMem.set(latest, mapAddr); + const version = anonymousBacking?.version ?? fileBacking!.version; + if (anonymousBacking) anonymousBacking.refCount++; + else fileBacking!.refCount++; + childMap.set(mapAddr, { + ...mapping, + snapshot: latest, + seenVersion: version, + }); + } + if (childMap.size === 0) this.sharedMappings.delete(childPid); + } + + this.inheritSysvShmMappings(parentPid, childPid); + } catch (err) { + this.releaseAllSharedMemoryForProcess(childPid, false); + throw err; + } + } + + /** + * Populate a file-backed mmap region by reading from the file fd via pread. + * Called after the kernel allocates the anonymous region and the host zeroes it. + * Reads in CH_DATA_SIZE chunks using the kernel's pread handler. + */ + private populateMmapFromFile( + channel: ChannelInfo, + mmapAddr: number, + origArgs: number[], + ): void { + const fd = origArgs[4]; + const mapLen = origArgs[1]; + // musl sends page offset (off / 4096) as arg[5] + const pageOffset = origArgs[5]; + let fileOffset = pageOffset * 4096; + + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const kernelMem = new Uint8Array(this.kernelMemory!.buffer); + const dataStart = this.scratchOffset + CH_DATA; + + let written = 0; + while (written < mapLen) { + const chunkSize = Math.min(CH_DATA_SIZE, mapLen - written); + + // Set up pread syscall in kernel scratch: + // SYS_PREAD (64): (fd, buf_ptr, count, signed i64 offset) + kernelView.setUint32(CH_SYSCALL, SYS_PREAD, true); + kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(fd), true); // fd + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); // buf_ptr (kernel memory) + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkSize), true); // count + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); + kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); + + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } catch { + break; // pread failed, leave rest as zeros + } finally { + this.currentHandlePid = 0; + } + if (this.finishSignalTermination(channel)) return; + + const bytesRead = Number(kernelView.getBigInt64(CH_RETURN, true)); + if (bytesRead <= 0) break; // EOF or error + + // Copy from kernel scratch data area to process memory + const processMem = new Uint8Array(channel.memory.buffer); + processMem.set( + kernelMem.subarray(dataStart, dataStart + bytesRead), + mmapAddr + written, + ); + + written += bytesRead; + fileOffset += bytesRead; + + if (bytesRead < chunkSize) break; // short read = EOF + } + } + + /** + * Flush MAP_SHARED regions that overlap the msync range back to the file. + * Reads from process memory and writes to the file via pwrite. + */ + private flushSharedMappings( + channel: ChannelInfo, + origArgs: number[], + ): boolean { + // msync/munmap/MAP_FIXED are explicit publication points for anonymous + // mappings too, including the single-observer-before-fork case. + try { + this.syncAnonymousSharedMappingsFromProcess(channel, { force: true }); + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + } catch { + return false; + } + + const syncAddr = origArgs[0] >>> 0; + const syncLen = origArgs[1] >>> 0; + const pidMap = this.sharedMappings.get(channel.pid); + if (!pidMap || pidMap.size === 0) return true; + + const syncEnd = syncAddr + syncLen; + let success = true; + + for (const [mapAddr, mapping] of pidMap) { + const mapEnd = mapAddr + mapping.len; + // Check overlap + if (mapAddr >= syncEnd || mapEnd <= syncAddr) continue; + + // Compute overlap region + const flushStart = Math.max(syncAddr, mapAddr); + const flushEnd = Math.min(syncEnd, mapEnd); + const flushLen = flushEnd - flushStart; + if (flushLen <= 0) continue; + + // File offset for the flush region + const fileOffsetBase = mapping.fileOffset + (flushStart - mapAddr); + + if (mapping.backingKind === "file" && mapping.backingKey) { + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (!backing || !this.flushSharedMmapBackingRange( + backing, + fileOffsetBase, + flushLen, + )) success = false; + continue; + } + if (!mapping.writable) continue; + if (mapping.backingKey) continue; + + // Compatibility for pre-page-cache tracking in focused exec harnesses. + if (!this.pwriteFromProcessMemory( + channel, mapping.fd, flushStart, flushLen, fileOffsetBase, + )) success = false; + } + return success; + } + + /** + * Write data from process memory to a file via kernel pwrite syscalls. + */ + private pwriteFromProcessMemory( + channel: ChannelInfo, + fd: number, + processAddr: number, + len: number, + fileOffset: number, + ): boolean { + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + const dataStart = this.scratchOffset + CH_DATA; + + if (processAddr + len > channel.memory.buffer.byteLength) return false; + const previousPid = this.currentHandlePid; + try { + let written = 0; + while (written < len) { + const chunkSize = Math.min(CH_DATA_SIZE, len - written); + // pwrite can grow an in-kernel Vec and therefore the kernel Wasm + // memory. Reacquire scratch views for every chunk; a view cached + // across kernel_handle_channel may have been detached by memory.grow. + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const kernelMem = new Uint8Array(this.kernelMemory!.buffer); + + // Copy chunk from process memory to kernel scratch data area + const processMem = new Uint8Array(channel.memory.buffer); + kernelMem.set( + processMem.subarray(processAddr + written, processAddr + written + chunkSize), + dataStart, + ); + + // Set up pwrite syscall in kernel scratch: + // SYS_PWRITE (65): (fd, buf_ptr, count, signed i64 offset) + const curOffset = fileOffset + written; + kernelView.setUint32(CH_SYSCALL, SYS_PWRITE, true); + kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkSize), true); + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(curOffset), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); + kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); + + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + if (this.finishSignalTermination(channel)) return false; + + const resultView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const bytesWritten = Number(resultView.getBigInt64(CH_RETURN, true)); + if (bytesWritten <= 0 || bytesWritten > chunkSize) return false; + + written += bytesWritten; + if (bytesWritten < chunkSize) return false; + } + return written === len; + } catch { + return false; + } finally { + this.currentHandlePid = previousPid; + } + } + + /** + * Remove shared mapping entries that overlap the munmap range. + */ + private cleanupSharedMappings(pid: number, addr: number, len: number): void { + const pidMap = this.sharedMappings.get(pid); + if (!pidMap) return; + + const unmapEnd = addr + len; + for (const [mapAddr, mapping] of Array.from(pidMap.entries())) { + const mapEnd = mapAddr + mapping.len; + const overlapStart = Math.max(addr, mapAddr); + const overlapEnd = Math.min(unmapEnd, mapEnd); + if (overlapStart >= overlapEnd) continue; + + if (overlapStart <= mapAddr && overlapEnd >= mapEnd) { + this.releaseSharedMapping(mapping); + pidMap.delete(mapAddr); + continue; + } + + if (overlapStart <= mapAddr) { + const trim = overlapEnd - mapAddr; + pidMap.delete(mapAddr); + mapping.fileOffset += trim; + mapping.len = mapEnd - overlapEnd; + if (mapping.snapshot) mapping.snapshot = mapping.snapshot.slice(trim); + if (mapping.len > 0) pidMap.set(overlapEnd, mapping); + else this.releaseSharedMapping(mapping); + continue; + } + + if (overlapEnd >= mapEnd) { + mapping.len = overlapStart - mapAddr; + if (mapping.snapshot) mapping.snapshot = mapping.snapshot.slice(0, mapping.len); + continue; + } + + const leftLen = overlapStart - mapAddr; + const rightSkip = overlapEnd - mapAddr; + const rightMapping: SharedMmapMapping = { + ...mapping, + fileOffset: mapping.fileOffset + rightSkip, + len: mapEnd - overlapEnd, + ...(mapping.snapshot ? { snapshot: mapping.snapshot.slice(rightSkip) } : {}), + }; + mapping.len = leftLen; + if (mapping.snapshot) mapping.snapshot = mapping.snapshot.slice(0, leftLen); + if (mapping.backingKey) { + const backing = mapping.backingKind === "file" + ? this.sharedMmapBackings.get(mapping.backingKey) + : this.anonymousSharedBackings.get(mapping.backingKey); + if (backing) backing.refCount++; + } + pidMap.set(overlapEnd, rightMapping); + } + + if (pidMap.size === 0) { + this.sharedMappings.delete(pid); + } + } + + private preflightFileSharedMremap(pid: number, origArgs: number[]): number { + const oldAddr = origArgs[0] >>> 0; + const newLen = origArgs[2] >>> 0; + const mapping = this.sharedMappings.get(pid)?.get(oldAddr); + if ( + !mapping + || mapping.backingKind !== "file" + || newLen <= mapping.len + ) return 0; + if (!mapping.backingKey) return EIO; + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (!backing) return EIO; + try { + this.ensureSharedMmapBackingRangeLoaded( + backing, + mapping.fileOffset + mapping.len, + newLen - mapping.len, + ); + return 0; + } catch { + // The kernel has not moved or resized anything yet; the old mapping and + // tracker remain authoritative and can continue after the failed call. + return EIO; + } + } + + private remapSharedMapping( + pid: number, + oldAddr: number, + newAddr: number, + newLen: number, + ): void { + const pidMap = this.sharedMappings.get(pid); + const mapping = pidMap?.get(oldAddr); + if (!pidMap || !mapping) return; + + pidMap.delete(oldAddr); + if (mapping.backingKey && mapping.snapshot) { + const registration = this.processes.get(pid); + const fileBacking = mapping.backingKind === "file" + ? this.sharedMmapBackings.get(mapping.backingKey) + : undefined; + const anonymousBacking = mapping.backingKind !== "file" + ? this.anonymousSharedBackings.get(mapping.backingKey) + : undefined; + if (fileBacking && registration) { + this.ensureSharedMmapBackingRangeLoaded( + fileBacking, + mapping.fileOffset, + newLen, + ); + const latest = this.readSharedMmapBackingRange( + fileBacking, + mapping.fileOffset, + newLen, + ); + new Uint8Array(registration.memory.buffer).set(latest, newAddr); + mapping.snapshot = latest; + mapping.seenVersion = fileBacking.version; + } else if (anonymousBacking && registration) { + const required = mapping.fileOffset + newLen; + if (required > anonymousBacking.bytes.length) { + const grown = new Uint8Array(required); + grown.set(anonymousBacking.bytes); + const processMem = new Uint8Array(registration.memory.buffer); + if (newAddr + newLen <= processMem.length && newLen > mapping.len) { + grown.set( + processMem.subarray(newAddr + mapping.len, newAddr + newLen), + mapping.fileOffset + mapping.len, + ); + } + anonymousBacking.bytes = grown; + anonymousBacking.version++; + } + const latest = anonymousBacking.bytes.slice( + mapping.fileOffset, + mapping.fileOffset + newLen, + ); + new Uint8Array(registration.memory.buffer).set(latest, newAddr); + mapping.snapshot = latest; + mapping.seenVersion = anonymousBacking.version; + } else { + mapping.snapshot = mapping.snapshot.slice(0, newLen); + } } + mapping.len = newLen; + pidMap.set(newAddr, mapping); } /** - * Flush MAP_SHARED regions that overlap the msync range back to the file. - * Reads from process memory and writes to the file via pwrite. + * Validate a PROT_WRITE upgrade before the kernel's no-op mprotect reports + * success. Read-only file mappings may be upgraded only when the original + * guest fd was a writable regular-file description; the stable host handle + * is upgraded at the same boundary so later dirty-page flushes cannot fail + * merely because the mapping was initially PROT_READ. */ - private flushSharedMappings( - channel: ChannelInfo, - origArgs: number[], - ): void { - const syncAddr = origArgs[0] >>> 0; - const syncLen = origArgs[1] >>> 0; - const pidMap = this.sharedMappings.get(channel.pid); - if (!pidMap || pidMap.size === 0) return; - - const syncEnd = syncAddr + syncLen; + private prepareFileSharedMappingsForWrite( + pid: number, + addr: number, + len: number, + ): number { + const pidMap = this.sharedMappings.get(pid); + if (!pidMap || len === 0) return 0; + const protectEnd = addr + len; for (const [mapAddr, mapping] of pidMap) { + if (mapping.backingKind !== "file") continue; const mapEnd = mapAddr + mapping.len; - // Check overlap - if (mapAddr >= syncEnd || mapEnd <= syncAddr) continue; - - // Compute overlap region - const flushStart = Math.max(syncAddr, mapAddr); - const flushEnd = Math.min(syncEnd, mapEnd); - const flushLen = flushEnd - flushStart; - if (flushLen <= 0) continue; - - // File offset for the flush region - const fileOffsetBase = mapping.fileOffset + (flushStart - mapAddr); - - // Read from process memory and write to file via pwrite - this.pwriteFromProcessMemory( - channel, mapping.fd, flushStart, flushLen, fileOffsetBase, + if (mapEnd <= addr || mapAddr >= protectEnd) continue; + if (mapping.writeAllowed !== true) return EACCES; + if (!mapping.backingKey) return EIO; + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (!backing) return EIO; + if (backing.writable) continue; + + const replacement = this.openSharedMmapBackingHandle( + backing.path, + true, + backing.key, ); + if (replacement.kind === "error") return replacement.errno; + const oldHandle = backing.handle; + backing.handle = replacement.value.handle; + backing.writable = true; + backing.size = replacement.value.size; + backing.sizeValid = true; + try { this.io.close(oldHandle); } catch {} } + return 0; } - /** - * Write data from process memory to a file via kernel pwrite syscalls. - */ - private pwriteFromProcessMemory( - channel: ChannelInfo, - fd: number, - processAddr: number, + /** Keep writeback eligibility aligned with successful mprotect ranges. */ + private updateSharedMappingProtection( + pid: number, + addr: number, len: number, - fileOffset: number, + writable: boolean, ): void { - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const kernelMem = new Uint8Array(this.kernelMemory!.buffer); - const dataStart = this.scratchOffset + CH_DATA; + const pidMap = this.sharedMappings.get(pid); + // Writeback eligibility is monotonic: bytes dirtied while writable still + // need flushing after a later read-only downgrade. Track this at mapping + // granularity so mremap can continue moving one coherent interval. + if (!pidMap || len === 0 || !writable) return; + const protectEnd = addr + len; - let written = 0; - while (written < len) { - const chunkSize = Math.min(CH_DATA_SIZE, len - written); + for (const [mapAddr, mapping] of pidMap) { + const mapEnd = mapAddr + mapping.len; + if (mapEnd <= addr || mapAddr >= protectEnd) continue; + mapping.writable = true; + } + } - // Copy chunk from process memory to kernel scratch data area - const processMem = new Uint8Array(channel.memory.buffer); - kernelMem.set( - processMem.subarray(processAddr + written, processAddr + written + chunkSize), - dataStart, - ); + private withKernelCurrentPid(pid: number, operation: () => T): T { + const setCurrentPid = this.kernelInstance!.exports.kernel_set_current_pid as + ((pid: number) => void) | undefined; + const previousPid = this.currentHandlePid; + this.currentHandlePid = pid; + if (setCurrentPid) setCurrentPid(pid); + try { + return operation(); + } finally { + this.currentHandlePid = previousPid; + if (setCurrentPid) setCurrentPid(previousPid); + } + } - // Set up pwrite syscall in kernel scratch: - // SYS_PWRITE (65): (fd, buf_ptr, count, offset_lo, offset_hi) - const curOffset = fileOffset + written; - kernelView.setUint32(CH_SYSCALL, SYS_PWRITE, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkSize), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(curOffset & 0xffffffff), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(Math.floor(curOffset / 0x100000000) | 0), true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); + private hasPeerSysvShmMapping(pid: number, mapAddr: number, segId: number): boolean { + for (const [otherPid, mappings] of this.shmMappings) { + for (const [otherAddr, mapping] of mappings) { + if (mapping.segId !== segId) continue; + if (otherPid === pid && otherAddr === mapAddr) continue; + return true; + } + } + return false; + } - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } catch { - break; + private syncSysvShmMappingsFromProcess( + process: Pick, + options: { force?: boolean } = {}, + ): boolean { + const pidMap = this.shmMappings?.get(process.pid); + if (!pidMap) return true; + const processMem = new Uint8Array(process.memory.buffer); + let success = true; + this.withKernelCurrentPid(process.pid, () => { + for (const [mapAddr, mapping] of pidMap) { + if (!options.force + && !this.hasPeerSysvShmMapping(process.pid, mapAddr, mapping.segId)) continue; + if (!this.mergeAndRefreshSysvShmMapping(processMem, mapAddr, mapping)) success = false; } - this.currentHandlePid = 0; + }); + return success; + } + + /** Publish all current attachments before a new observer joins a segment. */ + private syncSysvShmSegmentFromMappedProcesses(segId: number): void { + for (const [pid, mappings] of this.shmMappings) { + const registration = this.processes.get(pid); + if (!registration) continue; + const processMem = new Uint8Array(registration.memory.buffer); + this.withKernelCurrentPid(pid, () => { + for (const [mapAddr, mapping] of mappings) { + if (mapping.segId === segId) { + this.mergeAndRefreshSysvShmMapping(processMem, mapAddr, mapping); + } + } + }); + } + } + + private mappingDiffersFromSnapshot( + processMem: Uint8Array, + mapAddr: number, + snapshot: Uint8Array, + len: number, + ): boolean { + for (let offset = 0; offset < len; offset += 4096) { + const chunkLen = Math.min(4096, len - offset); + if (this.rangeDiffersFromSnapshot( + processMem, + mapAddr + offset, + snapshot, + offset, + chunkLen, + )) return true; + } + return false; + } - const bytesWritten = Number(kernelView.getBigInt64(CH_RETURN, true)); - if (bytesWritten <= 0) break; + private mergeAndRefreshSysvShmMapping( + processMem: Uint8Array, + mapAddr: number, + mapping: SysvShmMapping, + ): boolean { + if (mapAddr + mapping.size > processMem.length) return false; + const currentVersion = this.shmSegmentVersions.get(mapping.segId) ?? 0; + const locallyChanged = !mapping.readOnly && this.mappingDiffersFromSnapshot( + processMem, + mapAddr, + mapping.snapshot, + mapping.size, + ); + if (!locallyChanged && mapping.seenVersion === currentVersion) return true; + + const authoritative = this.readSysvShmRange(mapping.segId, 0, mapping.size); + if (!authoritative) return false; + let published = false; + let success = true; + if (locallyChanged) { + for (let offset = 0; offset < mapping.size; offset += 4096) { + const chunkLen = Math.min(4096, mapping.size - offset); + if (!this.rangeDiffersFromSnapshot( + processMem, + mapAddr + offset, + mapping.snapshot, + offset, + chunkLen, + )) continue; + let i = 0; + while (i < chunkLen) { + while ( + i < chunkLen + && processMem[mapAddr + offset + i] === mapping.snapshot[offset + i] + ) i++; + if (i >= chunkLen) break; + const start = i; + do { i++; } while ( + i < chunkLen + && processMem[mapAddr + offset + i] !== mapping.snapshot[offset + i] + ); + const bytes = processMem.subarray( + mapAddr + offset + start, + mapAddr + offset + i, + ); + if (!this.writeSysvShmRange(mapping.segId, offset + start, bytes)) { + success = false; + break; + } + authoritative.set(bytes, offset + start); + published = true; + } + if (!success) break; + } + } - written += bytesWritten; - if (bytesWritten < chunkSize) break; + if (published) { + this.shmSegmentVersions.set(mapping.segId, currentVersion + 1); } + processMem.set(authoritative, mapAddr); + mapping.snapshot = authoritative; + mapping.seenVersion = this.shmSegmentVersions.get(mapping.segId) ?? currentVersion; + return success; } - /** - * Remove shared mapping entries that overlap the munmap range. - */ - private cleanupSharedMappings(pid: number, addr: number, len: number): void { - const pidMap = this.sharedMappings.get(pid); - if (!pidMap) return; + private readSysvShmRange(segId: number, offset: number, len: number): Uint8Array | null { + const readChunk = this.kernelInstance!.exports.kernel_ipc_shm_read_chunk as + ((shmid: number, offset: number, outPtr: KernelPointer, maxLen: number) => number) | undefined; + if (!readChunk) return null; + const result = new Uint8Array(len); + let transferred = 0; + while (transferred < len) { + const toRead = Math.min(CH_DATA_SIZE, len - transferred); + const chunkPtr = this.scratchOffset + CH_DATA; + const nRead = readChunk( + segId, + offset + transferred, + this.toKernelPtr(chunkPtr), + toRead, + ); + if (nRead < 0 || nRead > toRead) return null; + if (nRead === 0) break; + result.set( + new Uint8Array(this.kernelMemory!.buffer, chunkPtr, nRead), + transferred, + ); + transferred += nRead; + } + return result; + } - const unmapEnd = addr + len; - for (const [mapAddr, mapping] of pidMap) { - const mapEnd = mapAddr + mapping.len; - // Remove if fully contained in unmap range - if (mapAddr >= addr && mapEnd <= unmapEnd) { - pidMap.delete(mapAddr); + private writeSysvShmRange(segId: number, offset: number, bytes: Uint8Array): boolean { + const writeChunk = this.kernelInstance!.exports.kernel_ipc_shm_write_chunk as + ((shmid: number, offset: number, dataPtr: KernelPointer, dataLen: number) => number) | undefined; + if (!writeChunk) return false; + let transferred = 0; + while (transferred < bytes.length) { + const toWrite = Math.min(CH_DATA_SIZE, bytes.length - transferred); + const chunkPtr = this.scratchOffset + CH_DATA; + new Uint8Array(this.kernelMemory!.buffer).set( + bytes.subarray(transferred, transferred + toWrite), + chunkPtr, + ); + const written = writeChunk( + segId, + offset + transferred, + this.toKernelPtr(chunkPtr), + toWrite, + ); + if (written <= 0 || written > toWrite) return false; + transferred += written; + } + return true; + } + + private inheritSysvShmMappings(parentPid: number, childPid: number): void { + const parentMap = this.shmMappings.get(parentPid); + if (!parentMap || parentMap.size === 0) return; + const child = this.processes.get(childPid); + if (!child) throw new Error(`Process ${childPid} is not registered`); + const kernelShmat = this.kernelInstance!.exports.kernel_ipc_shmat as + ((shmid: number, shmaddr: number, flags: number) => number) | undefined; + const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as + ((shmid: number) => number) | undefined; + if (!kernelShmat || !kernelShmdt) throw new Error("Kernel lacks SysV SHM inheritance exports"); + + const childMem = new Uint8Array(child.memory.buffer); + const childMap = new Map(); + this.withKernelCurrentPid(childPid, () => { + try { + for (const [mapAddr, mapping] of parentMap) { + if (mapAddr + mapping.size > childMem.length) { + throw new Error(`Cannot inherit SysV mapping at 0x${mapAddr.toString(16)}`); + } + const result = kernelShmat( + mapping.segId, + mapAddr, + mapping.readOnly ? SHM_RDONLY : 0, + ); + if (result < 0 || result !== mapping.size) { + throw new Error(`SysV shmat inheritance failed for segment ${mapping.segId}`); + } + const latest = this.readSysvShmRange(mapping.segId, 0, mapping.size); + if (!latest) { + kernelShmdt(mapping.segId); + throw new Error(`Cannot read inherited SysV segment ${mapping.segId}`); + } + childMem.set(latest, mapAddr); + childMap.set(mapAddr, { + ...mapping, + snapshot: latest, + seenVersion: this.shmSegmentVersions.get(mapping.segId) ?? mapping.seenVersion, + }); + } + } catch (err) { + for (const mapping of childMap.values()) kernelShmdt(mapping.segId); + childMap.clear(); + throw err; } + }); + if (childMap.size > 0) this.shmMappings.set(childPid, childMap); + } + + private releaseAllSysvShmMappingsForProcess( + pid: number, + publish: boolean = true, + ): void { + const pidMap = this.shmMappings?.get(pid); + if (!pidMap) return; + const registration = this.processes.get(pid); + if (publish && registration) { + this.syncSysvShmMappingsFromProcess(registration, { force: true }); + } + const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as + ((shmid: number) => number) | undefined; + if (kernelShmdt) { + this.withKernelCurrentPid(pid, () => { + for (const mapping of pidMap.values()) kernelShmdt(mapping.segId); + }); } + this.shmMappings.delete(pid); + } - if (pidMap.size === 0) { - this.sharedMappings.delete(pid); + private releaseAllSharedMemoryForProcess(pid: number, publish: boolean = true): void { + const releasing = this.sharedMemoryReleasePids ??= new Set(); + if (releasing.has(pid)) return; + releasing.add(pid); + try { + const registration = this.processes?.get(pid); + const channel = registration?.channels?.[0]; + if (publish && registration) { + // Teardown must continue even if one backing is no longer readable or + // writable. File backings retain dirty pages on failed final writeback; + // SysV/anonymous cleanup must not be skipped because of that failure. + try { + this.syncAnonymousSharedMappingsFromProcess(registration, { force: true }); + } catch {} + try { + this.syncFileSharedMappingsFromProcess(registration, { force: true }); + } catch {} + try { + this.syncSysvShmMappingsFromProcess(registration, { force: true }); + } catch {} + if (channel) { + const mappings = this.sharedMappings.get(pid); + if (mappings) { + for (const [addr, mapping] of mappings) { + if (!mapping.writable) continue; + if (mapping.backingKind === "file" && mapping.backingKey) { + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (backing) this.flushSharedMmapBackingRange( + backing, + mapping.fileOffset, + mapping.len, + ); + continue; + } + if (mapping.backingKey) continue; + this.pwriteFromProcessMemory( + channel, + mapping.fd, + addr, + mapping.len, + mapping.fileOffset, + ); + } + } + } + } + + const mappings = this.sharedMappings?.get(pid); + if (mappings) { + for (const mapping of mappings.values()) this.releaseSharedMapping(mapping); + this.sharedMappings?.delete(pid); + } + this.invalidateSharedMmapFdCacheForPid(pid); + if (this.shmMappings) this.releaseAllSysvShmMappingsForProcess(pid, false); + } finally { + releasing.delete(pid); } } @@ -7699,6 +11141,67 @@ export class CentralizedKernelWorker { * Start a TCP server for a listening socket, bridging real TCP connections * into the kernel's pipe-buffer-backed accept path. */ + private reconcileReusedTcpListenerKey( + pid: number, + fd: number, + newPort: number, + oldTarget: TcpListenerTarget | undefined, + existing: TcpListenerBridge, + ): TcpListenerBridge | undefined { + const oldPort = existing.port; + const oldTargets = this.tcpListenerTargets.get(oldPort) ?? []; + const retained = oldTargets.filter(target => + !(target.pid === pid && target.fd === fd)); + const oldAlias = oldTarget?.acceptWakeIdx !== undefined + ? this.resolveInheritedListenerFd(pid, fd, oldTarget.acceptWakeIdx) + : null; + if (oldAlias && oldAlias.fd !== fd + && !retained.some(target => + target.pid === pid && target.fd === oldAlias.fd)) { + retained.push({ pid, ...oldAlias }); + } + + if (retained.length === 0) { + this.tcpListenerTargets.delete(oldPort); + if (oldPort !== newPort) { + this.tcpListenerRRIndex.delete(oldPort); + const virtualKey = this.tcpVirtualListenerKeys.get(oldPort); + if (virtualKey) { + this.io.network?.closeTcpListener?.(virtualKey); + this.tcpVirtualListenerKeys.delete(oldPort); + } + } + } else { + this.tcpListenerTargets.set(oldPort, retained); + const oldIndex = this.tcpListenerRRIndex.get(oldPort) ?? 0; + this.tcpListenerRRIndex.set(oldPort, oldIndex % retained.length); + } + + const oldKey = `${pid}:${fd}`; + this.tcpListeners.delete(oldKey); + if (oldAlias && oldAlias.fd !== fd) { + const aliasKey = `${pid}:${oldAlias.fd}`; + if (!this.tcpListeners.has(aliasKey)) { + this.tcpListeners.set(aliasKey, existing); + } + } else if (retained.length > 0) { + const replacement = retained[0]!; + const replacementKey = `${replacement.pid}:${replacement.fd}`; + if (!this.tcpListeners.has(replacementKey)) { + this.tcpListeners.set(replacementKey, { + ...existing, + pid: replacement.pid, + }); + } + } else if (oldPort !== newPort) { + existing.server.close(); + } + + // A listener on the same port can keep using the already-bound Node + // server while the target identity moves to the newly reused fd. + return oldPort === newPort ? existing : undefined; + } + private startTcpListener( pid: number, fd: number, @@ -7706,8 +11209,30 @@ export class CentralizedKernelWorker { addr: [number, number, number, number] = [0, 0, 0, 0], ): void { const key = `${pid}:${fd}`; - // Avoid duplicate listeners on the same pid:fd - if (this.tcpListeners.has(key)) return; + const getAcceptWake = this.kernelInstance!.exports.kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + const liveWakeIdx = getAcceptWake?.(pid, fd) ?? -1; + let reusableListener: TcpListenerBridge | undefined; + const existing = this.tcpListeners.get(key); + if (existing) { + const existingTarget = this.tcpListenerTargets.get(existing.port) + ?.find(target => target.pid === pid && target.fd === fd); + const existingWakeIdx = existingTarget?.acceptWakeIdx; + if ((existingWakeIdx === undefined && existing.port === port) + || (existingWakeIdx !== undefined && existingWakeIdx === liveWakeIdx)) { + if (existingTarget && existingWakeIdx === undefined && liveWakeIdx >= 0) { + existingTarget.acceptWakeIdx = liveWakeIdx; + } + return; + } + reusableListener = this.reconcileReusedTcpListenerKey( + pid, + fd, + port, + existingTarget, + existing, + ); + } // Register this pid:fd as a target for this port (needed for both // Node.js TCP bridging and browser service worker bridging via @@ -7718,7 +11243,11 @@ export class CentralizedKernelWorker { } const targets = this.tcpListenerTargets.get(port)!; if (!targets.some(t => t.pid === pid && t.fd === fd)) { - targets.push({ pid, fd }); + targets.push({ + pid, + fd, + ...(liveWakeIdx >= 0 ? { acceptWakeIdx: liveWakeIdx } : {}), + }); } if (this.io.network?.listenTcp && !this.tcpVirtualListenerKeys.has(port)) { @@ -7747,6 +11276,14 @@ export class CentralizedKernelWorker { } if (!this.netModule) return; // Not in Node.js environment — no real TCP server + if (reusableListener) { + this.tcpListeners.set(key, { + ...reusableListener, + pid, + port, + }); + return; + } // If another process already has a TCP server on this port, share it for (const [, listener] of this.tcpListeners) { @@ -7795,10 +11332,9 @@ export class CentralizedKernelWorker { const alive = targets.filter(t => this.processes.has(t.pid)); if (alive.length === 0) return null; - // Update targets list to remove dead processes - if (alive.length !== targets.length) { - this.tcpListenerTargets.set(port, alive); - } + // Do not prune unregistered targets here: a fork/spawn child owns its + // kernel listener before async Worker registration completes. Explicit + // process teardown removes truly dead targets. // If there are fork children among targets, prefer them over the original // listener (the master doesn't accept connections, workers do). @@ -7936,7 +11472,7 @@ export class CentralizedKernelWorker { if (entry.channel.pid !== pid) continue; if (entry.timer !== null) clearTimeout(entry.timer); this.pendingPollRetries.delete(key); - if (this.processes.has(pid)) this.retrySyscall(entry.channel); + if (this.isRegisteredChannel(entry.channel)) this.retrySyscall(entry.channel); break; } } @@ -8535,7 +12071,6 @@ export class CentralizedKernelWorker { } } this.tcpConnections.delete(pid); - this.shmMappings.delete(pid); } // ========================================================================= @@ -8657,89 +12192,149 @@ export class CentralizedKernelWorker { this.relistenChannel(channel); } - /** shmat: allocate address via kernel mmap, copy segment data to process memory */ - private handleIpcShmat(channel: ChannelInfo, args: number[]): void { - const [shmid, _shmaddr, _flags] = args; - - // Set current pid for kernel_ipc_* exports - const setCurrentPid = this.kernelInstance!.exports.kernel_set_current_pid as ((pid: number) => void) | undefined; - if (setCurrentPid) setCurrentPid(channel.pid); - - const kernelShmat = this.kernelInstance!.exports.kernel_ipc_shmat as (shmid: number, shmaddr: number, flags: number) => number; - const sizeOrErr = kernelShmat(shmid, _shmaddr, _flags); - if (sizeOrErr < 0) { - this.completeChannelRaw(channel, sizeOrErr, -sizeOrErr); - this.relistenChannel(channel); - return; - } - const size = sizeOrErr; - - // Synthesize mmap to allocate virtual address space for this pid + private runSyntheticMemorySyscall( + channel: ChannelInfo, + syscallNr: number, + args: number[], + ): { retVal: number; errVal: number } { const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - kernelView.setUint32(CH_SYSCALL, SYS_MMAP, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(0), true); // addr hint = NULL - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(size), true); // length - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(3), true); // prot = PROT_READ|PROT_WRITE - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(0x22), true); // flags = MAP_PRIVATE|MAP_ANONYMOUS - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(-1), true); // fd = -1 - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); // offset = 0 - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + for (let i = 0; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(args[i] ?? 0), true); + } + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + const previousPid = this.currentHandlePid; this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } catch (err) { - console.error(`[handleIpcShmat] mmap failed for pid=${channel.pid}:`, err); - this.completeChannelRaw(channel, -12, 12); // ENOMEM - this.relistenChannel(channel); - return; } finally { - this.currentHandlePid = 0; + this.currentHandlePid = previousPid; + } + if (this.finishSignalTermination(channel)) { + return { retVal: -EINTR_ERRNO, errVal: EINTR_ERRNO }; } + const resultView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + return { + retVal: Number(resultView.getBigInt64(CH_RETURN, true)), + errVal: resultView.getUint32(CH_ERRNO, true), + }; + } - const addr = Number(kernelView.getBigInt64(CH_RETURN, true)); - if (addr < 0) { - this.completeChannelRaw(channel, -12, 12); // ENOMEM + /** shmat: allocate a process interval and attach it to authoritative bytes. */ + private handleIpcShmat(channel: ChannelInfo, args: number[]): void { + const [shmid, shmaddr, flags] = args; + + // A previously sole observer may not have published at ordinary boundaries. + // Force it current before this new attachment reads the segment. + this.syncSysvShmSegmentFromMappedProcesses(shmid); + + const kernelShmat = this.kernelInstance!.exports.kernel_ipc_shmat as + (shmid: number, shmaddr: number, flags: number) => number; + const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as + (shmid: number) => number; + const sizeOrErr = this.withKernelCurrentPid( + channel.pid, + () => kernelShmat(shmid, shmaddr, flags), + ); + if (sizeOrErr < 0) { + this.completeChannelRaw(channel, sizeOrErr, -sizeOrErr); this.relistenChannel(channel); return; } + const size = sizeOrErr; - // Grow process memory to cover the allocated address - this.ensureProcessMemoryCovers(channel.pid, channel.memory, SYS_MMAP, addr, [0, size, 3, 0x22, -1, 0]); + const readOnly = (flags & SHM_RDONLY) !== 0; + const prot = readOnly ? PROT_READ : PROT_READ | PROT_WRITE; + let allocatedAddr: number | null = null; + const rollback = () => { + if (allocatedAddr !== null) { + try { this.runSyntheticMemorySyscall(channel, SYS_MUNMAP, [allocatedAddr, size]); } catch {} + if (this.hostReaped?.has(channel.pid)) return; + } + try { + this.withKernelCurrentPid(channel.pid, () => kernelShmdt(shmid)); + } catch {} + }; - // Transfer segment data from kernel to process memory via read_chunk - const readChunk = this.kernelInstance!.exports.kernel_ipc_shm_read_chunk as - (shmid: number, offset: number, outPtr: KernelPointer, maxLen: number) => number; - const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const chunkSize = CH_DATA_SIZE; - const chunkPtr = this.scratchOffset + CH_DATA; - let transferred = 0; - while (transferred < size) { - const remaining = size - transferred; - const toRead = Math.min(remaining, chunkSize); - const nRead = readChunk(shmid, transferred, this.toKernelPtr(chunkPtr), toRead); - if (nRead <= 0) break; - processMem.set(kernelMem.subarray(chunkPtr, chunkPtr + nRead), (addr >>> 0) + transferred); - transferred += nRead; - } + try { + const mmap = this.runSyntheticMemorySyscall(channel, SYS_MMAP, [ + shmaddr >>> 0, + size, + prot, + 0x22, // MAP_PRIVATE | MAP_ANONYMOUS: host supplies sharing. + -1, + 0, + ]); + if (this.hostReaped?.has(channel.pid)) return; + if (mmap.retVal < 0) { + rollback(); + if (this.hostReaped?.has(channel.pid)) return; + const errno = mmap.errVal || ENOMEM; + this.completeChannelRaw(channel, -errno, errno); + this.relistenChannel(channel); + return; + } + allocatedAddr = mmap.retVal >>> 0; + // Unlike mmap, a non-null shmat address is not merely a fallback hint. + if (shmaddr !== 0 && allocatedAddr !== (shmaddr >>> 0)) { + rollback(); + if (this.hostReaped?.has(channel.pid)) return; + this.completeChannelRaw(channel, -EINVAL, EINVAL); + this.relistenChannel(channel); + return; + } - // Track the mapping for shmdt - let pidMappings = this.shmMappings.get(channel.pid); - if (!pidMappings) { - pidMappings = new Map(); - this.shmMappings.set(channel.pid, pidMappings); + this.ensureProcessMemoryCovers( + channel.pid, + channel.memory, + SYS_MMAP, + allocatedAddr, + [shmaddr, size, prot, 0x22, -1, 0], + ); + const snapshot = this.withKernelCurrentPid( + channel.pid, + () => this.readSysvShmRange(shmid, 0, size), + ); + const processMem = new Uint8Array(channel.memory.buffer); + if (!snapshot || allocatedAddr + size > processMem.length) { + rollback(); + if (this.hostReaped?.has(channel.pid)) return; + this.completeChannelRaw(channel, -EIO, EIO); + this.relistenChannel(channel); + return; + } + processMem.set(snapshot, allocatedAddr); + + let pidMappings = this.shmMappings.get(channel.pid); + if (!pidMappings) { + pidMappings = new Map(); + this.shmMappings.set(channel.pid, pidMappings); + } + pidMappings.set(allocatedAddr, { + segId: shmid, + size, + readOnly, + snapshot, + seenVersion: this.shmSegmentVersions.get(shmid) ?? 0, + }); + } catch (err) { + console.error(`[handleIpcShmat] mmap failed for pid=${channel.pid}:`, err); + rollback(); + if (this.hostReaped?.has(channel.pid)) return; + this.completeChannelRaw(channel, -ENOMEM, ENOMEM); + this.relistenChannel(channel); + return; } - pidMappings.set(addr >>> 0, { segId: shmid, size }); - this.completeChannelRaw(channel, addr, 0); + this.completeChannelRaw(channel, allocatedAddr!, 0); this.relistenChannel(channel); } - /** shmdt: copy process memory back to segment, untrack mapping */ + /** shmdt: publish this attachment, detach exactly once, and unmap it. */ private handleIpcShmdt(channel: ChannelInfo, args: number[]): void { - const addr = args[0]; + const addr = args[0] >>> 0; const pidMappings = this.shmMappings.get(channel.pid); if (!pidMappings) { this.completeChannelRaw(channel, -22, 22); // EINVAL @@ -8753,37 +12348,38 @@ export class CentralizedKernelWorker { return; } - // Set current pid for kernel exports - const setCurrentPid = this.kernelInstance!.exports.kernel_set_current_pid as ((pid: number) => void) | undefined; - if (setCurrentPid) setCurrentPid(channel.pid); - - // Sync process memory back to kernel segment via write_chunk - const writeChunk = this.kernelInstance!.exports.kernel_ipc_shm_write_chunk as - (shmid: number, offset: number, dataPtr: KernelPointer, dataLen: number) => number; const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const chunkSize = CH_DATA_SIZE; - const chunkPtr = this.scratchOffset + CH_DATA; - let transferred = 0; - while (transferred < mapping.size) { - const remaining = mapping.size - transferred; - const toWrite = Math.min(remaining, chunkSize); - kernelMem.set(processMem.subarray(addr + transferred, addr + transferred + toWrite), chunkPtr); - const nWritten = writeChunk(mapping.segId, transferred, this.toKernelPtr(chunkPtr), toWrite); - if (nWritten <= 0) break; - transferred += nWritten; + const synced = this.withKernelCurrentPid( + channel.pid, + () => this.mergeAndRefreshSysvShmMapping(processMem, addr, mapping), + ); + if (!synced) { + this.completeChannelRaw(channel, -EIO, EIO); + this.relistenChannel(channel); + return; } - // Kernel-side detach bookkeeping - const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as (shmid: number) => number; - const result = kernelShmdt(mapping.segId); - - pidMappings.delete(addr); + const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as + (shmid: number) => number; + const result = this.withKernelCurrentPid( + channel.pid, + () => kernelShmdt(mapping.segId), + ); if (result < 0) { this.completeChannelRaw(channel, result, -result); } else { - this.completeChannelRaw(channel, 0, 0); + pidMappings.delete(addr); + if (pidMappings.size === 0) this.shmMappings.delete(channel.pid); + let unmapFailed = false; + try { + const unmap = this.runSyntheticMemorySyscall(channel, SYS_MUNMAP, [addr, mapping.size]); + if (this.hostReaped?.has(channel.pid)) return; + unmapFailed = unmap.retVal < 0; + } catch { + unmapFailed = true; + } + this.completeChannelRaw(channel, unmapFailed ? -EIO : 0, unmapFailed ? EIO : 0); } this.relistenChannel(channel); } diff --git a/host/src/kernel.ts b/host/src/kernel.ts index 19fb9f319..21ddfec85 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -97,7 +97,73 @@ function clampU16(value: number): number { * numeric codes from SFSError (2, 17, etc.). * Returns -EIO for unknown errors. */ -function negErrno(err: unknown): number { +const NEG_ERRNO_BY_NAME: Readonly> = { + EPERM: -1, + ENOENT: -2, + ESRCH: -3, + EINTR: -4, + EIO: -5, + ENXIO: -6, + E2BIG: -7, + ENOEXEC: -8, + EBADF: -9, + ECHILD: -10, + EAGAIN: -11, + EWOULDBLOCK: -11, + ENOMEM: -12, + EACCES: -13, + EFAULT: -14, + EBUSY: -16, + EEXIST: -17, + EXDEV: -18, + ENODEV: -19, + ENOTDIR: -20, + EISDIR: -21, + EINVAL: -22, + ENFILE: -23, + EMFILE: -24, + ENOTTY: -25, + ETXTBSY: -26, + EFBIG: -27, + ENOSPC: -28, + ESPIPE: -29, + EROFS: -30, + EMLINK: -31, + EPIPE: -32, + ERANGE: -34, + EDEADLK: -35, + ENAMETOOLONG: -36, + ENOSYS: -38, + ENOTEMPTY: -39, + ELOOP: -40, + ENOMSG: -42, + EIDRM: -43, + ENODATA: -61, + EOVERFLOW: -75, + ENOTSOCK: -88, + EDESTADDRREQ: -89, + EMSGSIZE: -90, + EPROTOTYPE: -91, + ENOPROTOOPT: -92, + EPROTONOSUPPORT: -93, + EOPNOTSUPP: -95, + ENOTSUP: -95, + EAFNOSUPPORT: -97, + EADDRINUSE: -98, + EADDRNOTAVAIL: -99, + ENETUNREACH: -101, + ECONNABORTED: -103, + ECONNRESET: -104, + EISCONN: -106, + ENOTCONN: -107, + ESHUTDOWN: -108, + ETIMEDOUT: -110, + ECONNREFUSED: -111, + EALREADY: -114, + EINPROGRESS: -115, +}; + +export function negErrno(err: unknown): number { if (err && typeof err === "object" && "code" in err) { const code = (err as { code: string | number }).code; // Numeric errno (e.g. SFSError from MemoryFileSystem/SharedFS) @@ -105,46 +171,24 @@ function negErrno(err: unknown): number { if (typeof code === "number" && code !== 0) { return code < 0 ? code : -code; } - // String error code (Node.js fs errors) - switch (code) { - case "ENOENT": return -2; - case "EACCES": return -13; - case "EPERM": return -1; - case "EEXIST": return -17; - case "ENOTDIR": return -20; - case "EISDIR": return -21; - case "EINVAL": return -22; - case "ENOSPC": return -28; - case "EROFS": return -30; - case "ENOTEMPTY": return -39; - case "ELOOP": return -40; - case "ENAMETOOLONG": return -36; - case "EBADF": return -9; - case "EMFILE": return -24; - case "ENFILE": return -23; - case "EBUSY": return -16; - case "EXDEV": return -18; - case "ENODEV": return -19; - case "EFAULT": return -14; - case "ETXTBSY": return -26; + if (typeof code === "string") { + const mapped = NEG_ERRNO_BY_NAME[code]; + if (mapped !== undefined) return mapped; + } + } + if (err && typeof err === "object" && "errno" in err) { + const errno = (err as { errno: unknown }).errno; + if (typeof errno === "number" && Number.isInteger(errno) && errno !== 0) { + return errno < 0 ? errno : -errno; } } // Check error message for errno names (e.g. plain Error("ENOENT") from DeviceFS) if (err instanceof Error) { - const msg = err.message; - if (msg.startsWith("ENOENT")) return -2; - if (msg.startsWith("EACCES")) return -13; - if (msg.startsWith("EPERM")) return -1; - if (msg.startsWith("EEXIST")) return -17; - if (msg.startsWith("ENOTDIR")) return -20; - if (msg.startsWith("EISDIR")) return -21; - if (msg.startsWith("EINVAL")) return -22; - if (msg.startsWith("ENOSPC")) return -28; - if (msg.startsWith("ENOTEMPTY")) return -39; - if (msg.startsWith("EBADF")) return -9; - if (msg.startsWith("ENOSYS")) return -38; - if (msg.startsWith("ENXIO")) return -6; - if (msg.startsWith("EXDEV")) return -18; + const name = /^([A-Z][A-Z0-9_]*)\b/.exec(err.message)?.[1]; + if (name !== undefined) { + const mapped = NEG_ERRNO_BY_NAME[name]; + if (mapped !== undefined) return mapped; + } } return -5; // EIO } diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 35660f278..3ce9eeb1f 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -18,7 +18,12 @@ import { readFileSync, existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { CAPTURED_STDIO, CentralizedKernelWorker, TERMINAL_STDIO } from "./kernel-worker"; +import { + CAPTURED_STDIO, + CentralizedKernelWorker, + isCurrentProcessGeneration, + TERMINAL_STDIO, +} from "./kernel-worker"; import type { ForkFromThreadContext, ResolvedSpawnProgram, @@ -42,7 +47,7 @@ import { NodeWorkerAdapter } from "./worker-adapter"; import { ThreadPageAllocator } from "./thread-allocator"; import { patchWasmForThread } from "./worker-main"; import { ThreadExitCoordinator } from "./thread-exit-coordinator"; -import { detectPtrWidth, extractHeapBase, isWasmModuleBytes } from "./constants"; +import { detectPtrWidth, extractAbiVersion, extractHeapBase, isWasmModuleBytes } from "./constants"; import { CH_TOTAL_SIZE, DEFAULT_MAX_PAGES, PAGES_PER_THREAD, WASM_PAGE_SIZE } from "./constants"; import { classifiedSignalOrFallback, @@ -50,7 +55,10 @@ import { signalExitStatus, SIGSEGV, } from "./trap-signals"; -import { threadWorkerFailureDisposition } from "./thread-worker-disposition"; +import { + removeThreadWorkerRegistryEntry, + threadWorkerFailureDisposition, +} from "./thread-worker-disposition"; import { computeProcessMemoryLayout, createProcessMemory, @@ -107,7 +115,7 @@ interface ProcessInfo { threadAllocator: ThreadPageAllocator; } const processes = new Map(); -const processTeardowns = new Map>(); +const processTeardowns = new Map>(); const reportedExits = new Set(); // Workers terminated by the kernel-worker entry itself (handleExit / @@ -173,6 +181,9 @@ async function terminateThreadWorkers(pid: number): Promise { const threads = threadWorkers.get(pid); if (!threads) return; threadWorkers.delete(pid); + for (const thread of threads) { + intentionallyTerminated.add(thread.worker as object); + } for (const t of threads) { await (t.termination ?? terminateTrackedWorker(t.worker)); threadExits.release(pid, t.channelOffset); @@ -219,24 +230,29 @@ async function finalizeProcessWorker( exitStatus: number, crashSignum: number = signalFromExitStatus(exitStatus) ?? SIGSEGV, ): Promise { + if (intentionallyTerminated.has(worker as object)) return; const cur = processes.get(pid); - if (cur && cur.worker === worker) { - // Synthesize a signal-style reap *before* `deactivateProcess` in - // case the worker died without sending SYS_EXIT_GROUP (uncaught - // wasm trap, instantiation failure → `{type:"error"}` path). - // Without this, a concurrent waitpid in the parent blocks until - // destroy because the kernel never marked the child as a zombie. - // Idempotent via `hostReaped`: when the kernel already processed - // a clean SYS_EXIT_GROUP for this pid, this is a no-op. - try { kernelWorker.notifyHostProcessCrashed(pid, crashSignum); } catch { /* best-effort */ } - try { kernelWorker.deactivateProcess(pid); } catch { /* best-effort */ } - processes.delete(pid); - threadModuleCache.delete(pid); - ptyByPid.delete(pid); - await terminateThreadWorkers(pid); - await terminateTrackedWorker(worker); - } + if (!cur || cur.worker !== worker) return; + + // Synthesize a signal-style reap *before* `deactivateProcess` in + // case the worker died without sending SYS_EXIT_GROUP (uncaught + // wasm trap, instantiation failure → `{type:"error"}` path). + // Without this, a concurrent waitpid in the parent blocks until + // destroy because the kernel never marked the child as a zombie. + // Idempotent via `hostReaped`: when the kernel already processed + // a clean SYS_EXIT_GROUP for this pid, this is a no-op. + try { kernelWorker.notifyHostProcessCrashed(pid, crashSignum); } catch { /* best-effort */ } + try { kernelWorker.deactivateProcess(pid); } catch { /* best-effort */ } + processes.delete(pid); + threadModuleCache.delete(pid); + ptyByPid.delete(pid); + + // Report while this worker is still known to be the current generation. + // Its asynchronous termination must not report an exit for an exec + // replacement that has since reused the pid. reportProcessExit(pid, exitStatus); + await terminateThreadWorkers(pid); + await terminateTrackedWorker(worker); } function processWorkerErrorDisposition(reason: string | undefined): { @@ -262,6 +278,8 @@ function finalizeProcessWorkerError( worker: ReturnType, message: string | undefined, ): void { + if (intentionallyTerminated.has(worker as object)) return; + if (processes.get(pid)?.worker !== worker) return; const errBytes = new TextEncoder().encode(`[process-worker] ${message ?? "unknown error"}\n`); post({ type: "stderr", pid, data: errBytes }); const { exitStatus, signum } = processWorkerErrorDisposition(message); @@ -274,6 +292,8 @@ function finalizeUnexpectedWorkerError( label: string, err: unknown, ): void { + if (intentionallyTerminated.has(worker as object)) return; + if (processes.get(pid)?.worker !== worker) return; const message = err instanceof Error ? (err.message ?? String(err)) : String(err); const errBytes = new TextEncoder().encode(`[kernel-worker] pid=${pid}: ${label}: ${message}\n`); post({ type: "stderr", pid, data: errBytes }); @@ -611,11 +631,17 @@ async function handleInit(msg: InitMessage) { post({ type: "proc_event", kind: "spawn", pid: childPid, ppid: parentPid }); return handleFork(parentPid, childPid, parentMemory, threadFork); }, - onExec: async (pid, path, argv, envp) => { - const result = await handleExec(pid, path, argv, envp); + onExec: async (pid, path, argv, envp, callerTid) => { + const previousWorker = processes.get(pid)?.worker; + const result = await handleExec(pid, path, argv, envp, callerTid); // Notify after handleExec refreshes kernel-side Process.argv so - // process-table consumers don't refetch stale command names. - if (result === 0) post({ type: "proc_event", kind: "exec", pid }); + // process-table consumers don't refetch stale command names. A + // post-commit signal death also returns 0 because the old syscall can + // no longer return; only emit exec when a replacement was installed. + const installedWorker = processes.get(pid)?.worker; + if (result === 0 && installedWorker && installedWorker !== previousWorker) { + post({ type: "proc_event", kind: "exec", pid }); + } return result; }, onResolveSpawn: handlePosixSpawnResolve, @@ -765,11 +791,14 @@ async function handleFork( ): Promise { const parentInfo = processes.get(parentPid); const parentProgram = parentInfo?.programBytes; - if (!parentProgram) throw new Error(`Unknown parent pid ${parentPid}`); + if (!parentProgram || parentInfo.memory !== parentMemory) { + throw new Error(`Unknown parent generation for pid ${parentPid}`); + } if (!parentInfo.programModule) { parentInfo.programModule = await WebAssembly.compile(parentProgram); } + if (!kernelWorker.shouldLaunchPendingChild(childPid)) return []; const ptrWidth = parentInfo.ptrWidth; const parentBuf = new Uint8Array(parentMemory.buffer); @@ -791,6 +820,7 @@ async function handleFork( maxAddr: childLayout.maxAddr, mmapBase: childLayout.mmapBase, }); + kernelWorker.inheritProcessSharedMappings(parentPid, childPid); const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; const forkBufAddr = threadFork @@ -846,87 +876,173 @@ async function handleExec( path: string, argv: string[], envp: string[], + callerTid: number, ): Promise { + const initiatingInfo = processes.get(pid); + if (!initiatingInfo) return -3; // ESRCH + if (!kernelWorker.supportsExecMetadataReplacement()) return -38; // ENOSYS + const resolved = await resolveExecutableForLaunch(path, argv); if (!resolved) return -2; // ENOENT if ("errno" in resolved) return -resolved.errno; const { programBytes, argv: launchArgv } = resolved; + try { + await WebAssembly.compile(programBytes); + } catch { + return -8; // ENOEXEC: reject malformed modules before changing old state + } + const declaredAbi = extractAbiVersion(programBytes); + if (declaredAbi !== null && declaredAbi !== kernelWorker.getKernelAbiVersion()) { + return -8; // ENOEXEC: known ABI mismatch is a truthful launch failure + } const newPtrWidth = detectPtrWidth(programBytes); - const setupResult = kernelWorker.kernelExecSetup(pid); - if (setupResult < 0) return setupResult; + const metadataResult = kernelWorker.validateExecMetadata( + launchArgv, + envp, + initiatingInfo.ptrWidth, + ); + if (metadataResult < 0) return metadataResult; + let prepared: ReturnType; + try { + prepared = createFreshProcessMemory(pid, programBytes, newPtrWidth); + } catch { + return -12; // ENOMEM before the exec commit point + } - kernelWorker.prepareProcessForExec(pid); + // Resolution/compilation yielded to the event loop. The numeric pid may + // now name a replacement generation; a stale continuation must not commit + // exec state against it. + if (processes.get(pid) !== initiatingInfo + || kernelWorker.isExecHandoffActive(pid) + || !kernelWorker.isProcessExecutionActive(pid)) return -3; // ESRCH + const prepareResult = kernelWorker.kernelExecPrepare(pid, callerTid); + if (prepareResult < 0) return prepareResult; + const addressSpaceResult = kernelWorker.prepareAddressSpaceForExec(pid); + if (addressSpaceResult < 0) return addressSpaceResult; + let replacementWorker: ReturnType | undefined; + try { + const setupResult = kernelWorker.kernelExecSetup(pid, callerTid); + if (setupResult < 0) return setupResult; + + // From this point onward the old image cannot resume. Invalidate its + // channels and async continuations immediately, before any other + // post-commit operation can fail or yield. + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + } + kernelWorker.prepareProcessForExec(pid); - const oldInfo = processes.get(pid); - if (oldInfo?.worker) { - intentionallyTerminated.add(oldInfo.worker as object); - await oldInfo.worker.terminate().catch(() => {}); - } + const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(pid); + if (finalizeResult < 0) { + throw new Error("failed to detach the discarded address space"); + } - const { - memory: newMemory, - layout: newLayout, - threadAllocator: newThreadAllocator, - } = createFreshProcessMemory(pid, programBytes, newPtrWidth); - const newChannelOffset = newLayout.channelOffset; + await terminateThreadWorkers(pid); + if (initiatingInfo.worker) { + await initiatingInfo.worker.terminate().catch(() => {}); + } + if (kernelWorker.finalizeExecHandoffTermination(pid) > 0) return 0; - kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { - skipKernelCreate: true, - ptrWidth: newPtrWidth, - brkBase: newLayout.brkBase, - mmapBase: newLayout.mmapBase, - maxAddr: newLayout.maxAddr, - // Refresh kernel-side Process.argv so /proc//cmdline reflects - // the post-exec image, not the parent's argv. Mirrors the browser - // handleExec fix. - argv: launchArgv, - }); + const { + memory: newMemory, + layout: newLayout, + threadAllocator: newThreadAllocator, + } = prepared; + const newChannelOffset = newLayout.channelOffset; - // Clear thread module cache — new program binary is different - threadModuleCache.delete(pid); + const initData: CentralizedWorkerInitMessage = { + type: "centralized_init", + pid, + ppid: 0, + programBytes, + memory: newMemory, + channelOffset: newChannelOffset, + argv: launchArgv, + env: envp, + ptrWidth: newPtrWidth, + kernelAbiVersion: kernelWorker.getKernelAbiVersion(), + }; - const initData: CentralizedWorkerInitMessage = { - type: "centralized_init", - pid, - ppid: 0, - programBytes, - memory: newMemory, - channelOffset: newChannelOffset, - argv: launchArgv, - env: envp, - ptrWidth: newPtrWidth, - kernelAbiVersion: kernelWorker.getKernelAbiVersion(), - }; + kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { + skipKernelCreate: true, + ptrWidth: newPtrWidth, + metadataPtrWidth: initiatingInfo.ptrWidth, + brkBase: newLayout.brkBase, + mmapBase: newLayout.mmapBase, + maxAddr: newLayout.maxAddr, + // Refresh kernel-side Process.argv and environment so procfs and + // kernel APIs reflect the replacement image. + argv: launchArgv, + env: envp, + }); + replacementWorker = workerAdapter.createWorker(initData); - const newWorker = workerAdapter.createWorker(initData); - processes.set(pid, { - memory: newMemory, - programBytes, - worker: newWorker, - channelOffset: newChannelOffset, - ptrWidth: newPtrWidth, - layout: newLayout, - threadAllocator: newThreadAllocator, - }); + // Clear thread module cache — new program binary is different + threadModuleCache.delete(pid); - newWorker.on("error", (err: Error) => finalizeUnexpectedWorkerError(pid, newWorker, "exec worker error", err)); + processes.set(pid, { + memory: newMemory, + programBytes, + worker: replacementWorker, + channelOffset: newChannelOffset, + ptrWidth: newPtrWidth, + layout: newLayout, + threadAllocator: newThreadAllocator, + }); - // Forward worker-main top-level errors (instantiation failures, - // uncaught wasm traps) so the host learns the process died — same - // wiring as handleSpawn. - newWorker.on("message", (raw: unknown) => { - const m = raw as { type: string; pid?: number; message?: string; status?: number }; - if (m.type === "error" && m.pid === pid) { - finalizeProcessWorkerError(pid, newWorker, m.message); - } else if (m.type === "exit" && m.pid === pid) { - void finalizeProcessWorker(pid, newWorker, m.status ?? 0); - } - }); + replacementWorker.on("error", (err: Error) => + finalizeUnexpectedWorkerError(pid, replacementWorker!, "exec worker error", err)); + + // Forward worker-main top-level errors (instantiation failures, + // uncaught wasm traps) so the host learns the process died — same + // wiring as handleSpawn. + replacementWorker.on("message", (raw: unknown) => { + const m = raw as { type: string; pid?: number; message?: string; status?: number }; + if (m.type === "error" && m.pid === pid) { + finalizeProcessWorkerError(pid, replacementWorker!, m.message); + } else if (m.type === "exit" && m.pid === pid) { + void finalizeProcessWorker(pid, replacementWorker!, m.status ?? 0); + } + }); - installCrashSafetyNet(newWorker, pid); + installCrashSafetyNet(replacementWorker, pid); + kernelWorker.finishProcessExecHandoff(pid); + return 0; + } catch (err) { + // A kernel trap can leave the commit point uncertain. We cannot safely + // return to the caller, so invalidate the old generation before yielding + // and report a truthful signal death. + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + } + try { + kernelWorker.prepareProcessForExec(pid); + } catch { + // Continue with best-effort process death below. + } + if (replacementWorker && processes.get(pid)?.worker !== replacementWorker) { + await terminateTrackedWorker(replacementWorker); + } - return 0; + const message = err instanceof Error ? err.message : String(err); + try { + post({ + type: "stderr", + pid, + data: new TextEncoder().encode(`[exec] post-commit transition failed: ${message}\n`), + }); + } catch { + // A closed host port must not prevent kernel-side reap. + } + try { kernelWorker.notifyHostProcessCrashed(pid, SIGSEGV); } catch { /* best-effort */ } + try { + handleExit(pid, signalExitStatus(SIGSEGV)); + } catch { + try { kernelWorker.deactivateProcess(pid); } catch { /* best-effort */ } + } + return 0; + } } /** @@ -975,6 +1091,12 @@ async function handlePosixSpawn( argv: string[], envp: string[], ): Promise { + // The kernel child is authoritative as soon as kernel_spawn_process returns. + // A group-directed signal may already have terminated it before the host + // installs a Worker. Preserve the successful spawn and its waitable zombie, + // but do not resurrect it by registering a new execution generation. + if (!kernelWorker.shouldLaunchPendingChild(childPid)) return 0; + post({ type: "proc_event", kind: "spawn", pid: childPid }); const ptrWidth = detectPtrWidth(programBytes); @@ -1050,11 +1172,26 @@ async function handleClone( // Auto-compile thread module if not already cached per-PID let threadModule = threadModuleCache.get(pid); + let cacheCompiledModule = false; if (!threadModule) { const patched = patchWasmForThread(processInfo.programBytes); threadModule = await WebAssembly.compile(patched); - threadModuleCache.set(pid, threadModule); + cacheCompiledModule = true; + } + + // Compilation yields. A sibling pthread may have committed exec while this + // clone continuation was suspended; never attach the old program/Memory to + // the replacement process that now owns the same numeric pid. + if (!isCurrentProcessGeneration( + processes, + pid, + processInfo, + memory, + kernelWorker.isExecHandoffActive(pid), + ) || !kernelWorker.isProcessExecutionActive(pid)) { + throw new Error(`Process ${pid} changed generation during clone`); } + if (cacheCompiledModule) threadModuleCache.set(pid, threadModule); let alloc: ReturnType; try { @@ -1071,7 +1208,12 @@ async function handleClone( // Register fnPtr/argPtr so that handleFork can route a fork() from // this thread back through its entry point (see ForkFromThreadContext // in kernel-worker.ts). - kernelWorker.addChannel(pid, alloc.channelOffset, tid, fnPtr, argPtr); + try { + kernelWorker.addChannel(pid, alloc.channelOffset, tid, fnPtr, argPtr, memory); + } catch (err) { + processInfo.threadAllocator.free(alloc.basePage); + throw err; + } const threadInitData: CentralizedThreadInitMessage = { type: "centralized_thread_init", @@ -1102,17 +1244,23 @@ async function handleClone( }; threadWorkers.get(pid)!.push(threadEntry); + const belongsToCurrentProcessImage = () => + isCurrentProcessGeneration( + processes, + pid, + processInfo, + memory, + kernelWorker.isExecHandoffActive(pid), + ); let reclaimed = false; const reclaimThread = () => { if (reclaimed) return; reclaimed = true; processInfo.threadAllocator.free(alloc.basePage); - threadExits.release(pid, alloc.channelOffset); - const threads = threadWorkers.get(pid); - if (threads) { - const idx = threads.indexOf(threadEntry); - if (idx >= 0) threads.splice(idx, 1); + if (belongsToCurrentProcessImage()) { + threadExits.release(pid, alloc.channelOffset); } + removeThreadWorkerRegistryEntry(threadWorkers, pid, threadEntry); }; const terminateThreadEntry = (): Promise => { if (!threadEntry.termination) { @@ -1122,7 +1270,14 @@ async function handleClone( }; threadExits.register(pid, alloc.channelOffset, terminateThreadEntry); + const isCurrentThreadGeneration = () => + !intentionallyTerminated.has(threadWorker as object) + && belongsToCurrentProcessImage(); const failThread = (reason: string) => { + if (!isCurrentThreadGeneration()) { + void terminateThreadEntry(); + return; + } const text = `[kernel-worker] pid=${pid} tid=${tid}: ${reason}\n`; post({ type: "stderr", pid, data: new TextEncoder().encode(text) }); const disposition = threadWorkerFailureDisposition(reason); @@ -1136,6 +1291,10 @@ async function handleClone( threadWorker.on("message", (msg: unknown) => { const m = msg as WorkerToHostMessage; if (m.type === "thread_exit") { + if (!isCurrentThreadGeneration()) { + void terminateThreadEntry(); + return; + } void terminateThreadEntry(); } else if (m.type === "error") { failThread(m.message); @@ -1151,18 +1310,24 @@ function handleThreadExit(pid: number, channelOffset: number): boolean { } function handleExit(pid: number, exitStatus: number): void { - void finishProcessExit(pid, exitStatus); + void finishProcessExit(pid, exitStatus, processes.get(pid)?.worker); } -async function finishProcessExit(pid: number, exitStatus: number): Promise { - const existingTeardown = processTeardowns.get(pid); +async function finishProcessExit( + pid: number, + exitStatus: number, + expectedWorker = processes.get(pid)?.worker, +): Promise { + if (!expectedWorker) return; + const info = processes.get(pid); + if (!info || info.worker !== expectedWorker) return; + + const existingTeardown = processTeardowns.get(expectedWorker); if (existingTeardown) { reportProcessExit(pid, exitStatus); return; } - const info = processes.get(pid); - const teardown = (async () => { // Keep the pid registered until the process worker is gone. musl's // _Exit() loops on SYS_exit after SYS_exit_group returns; while worker @@ -1170,7 +1335,11 @@ async function finishProcessExit(pid: number, exitStatus: number): Promise // completions, otherwise the worker can park in Atomics.wait with no // registered listener left to wake it. await terminateThreadWorkers(pid); - if (info?.worker) await terminateTrackedWorker(info.worker); + await terminateTrackedWorker(expectedWorker); + + // Exec may have installed a replacement while old worker termination was + // settling. Never apply pid-wide cleanup to a different generation. + if (processes.get(pid)?.worker !== expectedWorker) return; // Deactivate process (zombie until reaped or destroy) after worker // termination so no further guest syscalls can arrive on its channel. @@ -1180,7 +1349,7 @@ async function finishProcessExit(pid: number, exitStatus: number): Promise threadModuleCache.delete(pid); ptyByPid.delete(pid); })(); - processTeardowns.set(pid, teardown); + processTeardowns.set(expectedWorker, teardown); // The process is already a kernel-side zombie at this point. Report the // exit before worker-thread teardown so a slow termination cannot make @@ -1191,7 +1360,7 @@ async function finishProcessExit(pid: number, exitStatus: number): Promise try { await teardown; } finally { - processTeardowns.delete(pid); + processTeardowns.delete(expectedWorker); } } diff --git a/host/src/platform/node.ts b/host/src/platform/node.ts index 8bf55dce2..a5f82b367 100644 --- a/host/src/platform/node.ts +++ b/host/src/platform/node.ts @@ -142,6 +142,15 @@ export class NodePlatformIO implements PlatformIO { return this.metadata.toStatResult(fs.fstatSync(handle)); } + fileIdentity(_path: string, dev: bigint, ino: bigint): string | null { + // Native inode numbers are filesystem-scoped and therefore preserve + // aliases reached through separate hard-link paths. An absent inode is + // not a stable object identity; callers must reject rather than fall back + // to a pathname that can be renamed or reused. + if (ino <= 0n || dev < 0n) return null; + return `node:${dev}:${ino}`; + } + stat(path: string): StatResult { return this.metadata.toStatResult(fs.statSync(this.rewritePath(path))); } diff --git a/host/src/thread-worker-disposition.ts b/host/src/thread-worker-disposition.ts index 187ed26a6..777a006f3 100644 --- a/host/src/thread-worker-disposition.ts +++ b/host/src/thread-worker-disposition.ts @@ -13,6 +13,21 @@ export type ThreadWorkerFailureDisposition = kind: "host-thread-failure"; }; +/** Remove one worker generation and retire an empty per-process registry slot. */ +export function removeThreadWorkerRegistryEntry( + registry: Map, + pid: number, + entry: T, +): boolean { + const entries = registry.get(pid); + if (!entries) return false; + const index = entries.indexOf(entry); + if (index < 0) return false; + entries.splice(index, 1); + if (entries.length === 0) registry.delete(pid); + return true; +} + function signalFromExitStatus(exitStatus: number): number | null { return exitStatus >= 128 ? (exitStatus - 128) & 0x7f : null; } diff --git a/host/src/types.ts b/host/src/types.ts index 5d83da83c..d12a201c9 100644 --- a/host/src/types.ts +++ b/host/src/types.ts @@ -58,6 +58,17 @@ export interface PlatformIO { seek(handle: number, offset: number, whence: number): number; fstat(handle: number): StatResult; + /** + * Qualify a filesystem-reported inode within this PlatformIO instance. + * + * The path is used only to select the owning mount/backend; callers may + * pass the remembered path of an unlinked or renamed open file. Equal + * identities must name the same underlying file object, including through + * hard links. Return null when the backend cannot promise stable object + * identity (for example, a backend that reports no inode number). + */ + fileIdentity?(path: string, dev: bigint, ino: bigint): string | null; + // Path-based operations stat(path: string): StatResult; lstat(path: string): StatResult; diff --git a/host/src/vfs/vfs.ts b/host/src/vfs/vfs.ts index 708f8e7b4..6f2484ffc 100644 --- a/host/src/vfs/vfs.ts +++ b/host/src/vfs/vfs.ts @@ -4,6 +4,7 @@ import type { FileSystemBackend, MountConfig, TimeProvider } from "./types"; interface MountEntry { prefix: string; backend: FileSystemBackend; + backendId: number; } interface HandleInfo { @@ -29,11 +30,24 @@ export class VirtualPlatformIO implements PlatformIO { network?: NetworkIO; constructor(mounts: MountConfig[], time: TimeProvider) { + // Scope inode numbers to the backend object that owns them. Assigning the + // id per backend (rather than per mount point) keeps aliases intact when + // one backend is deliberately exposed at more than one mount point. + const backendIds = new Map(); + let nextBackendId = 1; this.mounts = mounts - .map((m) => ({ - prefix: normalizeMountPoint(m.mountPoint), - backend: m.backend, - })) + .map((m) => { + let backendId = backendIds.get(m.backend); + if (backendId === undefined) { + backendId = nextBackendId++; + backendIds.set(m.backend, backendId); + } + return { + prefix: normalizeMountPoint(m.mountPoint), + backend: m.backend, + backendId, + }; + }) .sort((a, b) => b.prefix.length - a.prefix.length); this.time = time; if (this.mounts.length === 0) { @@ -43,16 +57,25 @@ export class VirtualPlatformIO implements PlatformIO { private resolve(path: string): { backend: FileSystemBackend; + backendId: number; relativePath: string; } { for (const m of this.mounts) { if (m.prefix === "/") { - return { backend: m.backend, relativePath: path }; + return { + backend: m.backend, + backendId: m.backendId, + relativePath: path, + }; } if (path === m.prefix || path.startsWith(m.prefix + "/")) { let rel = path.slice(m.prefix.length); if (!rel.startsWith("/")) rel = "/" + rel; - return { backend: m.backend, relativePath: rel }; + return { + backend: m.backend, + backendId: m.backendId, + relativePath: rel, + }; } } throw new Error(`ENOENT: no mount for path: ${path}`); @@ -82,6 +105,12 @@ export class VirtualPlatformIO implements PlatformIO { return info; } + fileIdentity(path: string, dev: bigint, ino: bigint): string | null { + if (ino <= 0n || dev < 0n) return null; + const { backendId } = this.resolve(path); + return `vfs:${backendId}:${dev}:${ino}`; + } + // --- File handle operations --- open(path: string, flags: number, mode: number): number { diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index 23da5131f..923443c49 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -10,7 +10,14 @@ import type { CentralizedThreadInitMessage, WorkerToHostMessage, } from "./worker-protocol"; -import { DynamicLinker, type LoadedSharedLibrary } from "./dylink"; +import { + DynamicLinker, + FORK_CAP_DYLINK_MAIN, + forkInstrumentRoleAvailable, + readForkInstrumentCapabilityClaim, + type LoadedSharedLibrary, + type SideModuleForkState, +} from "./dylink"; import { extractAbiVersion } from "./constants"; import { ABI_SYSCALLS, @@ -180,6 +187,12 @@ export interface DlopenSupport { * fork-child path AFTER setupChannelBase and BEFORE the wpk_fork * rewind into _start. */ replayDlopens: () => void; + /** Finish the one active side-module unwind after the main image unwinds. */ + completeSideModuleForkUnwind: () => void; + /** Begin the active side-module rewind after fork-child dlopen replay. */ + beginSideModuleForkRewind: () => void; + /** Reject a leaked active side-module identity on a normal main return. */ + assertNoActiveSideModuleFork: () => void; } /** @@ -200,16 +213,22 @@ function buildDlopenImports( getStackPointer: () => WebAssembly.Global | undefined, getInstance: () => WebAssembly.Instance | undefined, ptrWidth: 4 | 8, + mainHasDylinkForkRole: boolean, ): DlopenSupport { let linker: DynamicLinker | null = null; const loadedLibraries = new Map(); + let activeSideFork: SideModuleForkState | null = null; const decoder = new TextDecoder(); const encoder = new TextEncoder(); const n = (v: number | bigint): number => typeof v === "bigint" ? Number(v) : v; const forkBufAddr = channelOffset - FORK_BUF_SIZE; const headOffset = ptrWidth === 8 ? DLOPEN_HEAD_OFFSET_WASM64 : DLOPEN_HEAD_OFFSET_WASM32; + const sideForkOffset = ptrWidth === 8 + ? DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM64 + : DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM32; const headSlot = forkBufAddr - headOffset; + const activeSideForkSlot = forkBufAddr - sideForkOffset; const entrySize = ptrWidth === 8 ? DLOPEN_ENTRY_SIZE_WASM64 : DLOPEN_ENTRY_SIZE_WASM32; const readPtr = (view: DataView, addr: number): number => @@ -218,6 +237,7 @@ function buildDlopenImports( if (ptrWidth === 8) view.setBigUint64(addr, BigInt(value), true); else view.setUint32(addr, value, true); }; + const linkerAllocations = new Map(); // The kernel mmap allocator. Shared with the linker, but also used // directly by persistArchiveEntry to obtain blocks for the archive. @@ -245,7 +265,37 @@ function buildDlopenImports( if (err || result < 0) { throw new Error(`dlopen: mmap(${requested}) failed errno=${err || -result}`); } - return alignUp(n(result), Math.max(align, 1)); + const aligned = alignUp(n(result), Math.max(align, 1)); + linkerAllocations.set(aligned, { rawAddr: n(result), length: requested }); + return aligned; + }; + + const deallocateMemory = (addr: number, _size: number): void => { + const allocation = linkerAllocations.get(addr); + if (!allocation) { + throw new Error(`dlopen rollback: unknown allocation 0x${addr.toString(16)}`); + } + const view = new DataView(memory.buffer); + const base = channelOffset; + view.setInt32(base + CH_SYSCALL, ABI_SYSCALLS.Munmap, true); + view.setBigInt64(base + CH_ARGS + 0 * CH_ARG_SIZE, BigInt(allocation.rawAddr), true); + view.setBigInt64(base + CH_ARGS + 1 * CH_ARG_SIZE, BigInt(allocation.length), true); + for (let i = 2; i < 6; i++) { + view.setBigInt64(base + CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } + + const i32 = new Int32Array(memory.buffer); + Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING); + Atomics.notify(i32, (base + CH_STATUS) / 4, 1); + while (Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok") { /* wait */ } + + const result = Number(view.getBigInt64(base + CH_RETURN, true)); + const err = view.getUint32(base + CH_ERRNO, true); + Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_IDLE); + if (err || result < 0) { + throw new Error(`dlopen rollback: munmap failed errno=${err || -result}`); + } + linkerAllocations.delete(addr); }; const getLinker = (): DynamicLinker => { @@ -275,14 +325,68 @@ function buildDlopenImports( } } + const mainModuleSymbols = new Set(globalSymbols.keys()); + const mainFork = inst?.exports.fork; + const mainForkState = inst?.exports.wpk_fork_state; + const sideModuleFork = mainHasDylinkForkRole + && typeof mainFork === "function" + && typeof mainForkState === "function" + ? { + setActiveFork: (state: SideModuleForkState) => { + const persisted = readPtr(new DataView(memory.buffer), activeSideForkSlot); + if (activeSideFork || persisted !== 0) { + throw new Error( + `${state.name}: nested or concurrent side-module fork is unsupported`, + ); + } + activeSideFork = state; + writePtr(new DataView(memory.buffer), activeSideForkSlot, state.forkBufAddr); + }, + clearActiveFork: (state: SideModuleForkState) => { + const view = new DataView(memory.buffer); + const persisted = readPtr(view, activeSideForkSlot); + if ( + !activeSideFork + || activeSideFork.name !== state.name + || activeSideFork.instance !== state.instance + || activeSideFork.forkBufAddr !== state.forkBufAddr + || persisted !== state.forkBufAddr + ) { + throw new Error(`${state.name}: stale side-module fork identity during rewind`); + } + activeSideFork = null; + writePtr(view, activeSideForkSlot, 0); + }, + invokeMainFork: (expectedStateAfter: 0 | 1): number => { + const result = Number((mainFork as () => number)()); + const actualState = Number((mainForkState as () => number)()); + if (actualState !== expectedStateAfter) { + throw new Error( + `main-module fork transition ended in state ${actualState}; ` + + `expected ${expectedStateAfter}`, + ); + } + return result; + }, + } + : undefined; + linker = new DynamicLinker({ memory, table, stackPointer: sp, allocateMemory, + deallocateMemory, globalSymbols, got: new Map(), loadedLibraries, + mainModuleSymbols, + sideModuleFork, + sideModuleForkUnavailableReason: !mainHasDylinkForkRole + ? "main module lacks the versioned dlopen-main fork capability; rebuild it with the current wasm-fork-instrument" + : sideModuleFork + ? undefined + : "main module does not export the fork trampoline and wpk_fork_state required for side-module fork", }); return linker; }; @@ -291,7 +395,13 @@ function buildDlopenImports( // entry is one mmap block: struct, then name UTF-8 (padded to 8-byte // alignment), then the side-module wasm bytes. Pointers are absolute // — fork's memcpy preserves the parent's address space. - const persistArchiveEntry = (name: string, bytes: Uint8Array, memoryBase: number): void => { + const persistArchiveEntry = ( + name: string, + bytes: Uint8Array, + memoryBase: number, + tableBase: number, + sideForkBufAddr: number, + ): void => { const nameBytes = encoder.encode(name); const nameLen = nameBytes.length; const nameAligned = (nameLen + 7) & ~7; @@ -309,6 +419,8 @@ function buildDlopenImports( view.setBigUint64(entry + 24, BigInt(bytesPtr), true); view.setBigUint64(entry + 32, BigInt(bytes.length), true); view.setBigUint64(entry + 40, BigInt(memoryBase), true); + view.setBigUint64(entry + 48, BigInt(tableBase), true); + view.setBigUint64(entry + 56, BigInt(sideForkBufAddr), true); } else { view.setUint32(entry + 0, 0, true); view.setUint32(entry + 4, namePtr, true); @@ -316,6 +428,8 @@ function buildDlopenImports( view.setUint32(entry + 12, bytesPtr, true); view.setUint32(entry + 16, bytes.length, true); view.setUint32(entry + 20, memoryBase, true); + view.setUint32(entry + 24, tableBase, true); + view.setUint32(entry + 28, sideForkBufAddr, true); } new Uint8Array(memory.buffer, namePtr, nameLen).set(nameBytes); @@ -349,7 +463,14 @@ function buildDlopenImports( const lk = getLinker(); while (cursor !== 0) { - let next: number, namePtr: number, nameLen: number, bytesPtr: number, bytesLen: number, memoryBase: number; + let next: number; + let namePtr: number; + let nameLen: number; + let bytesPtr: number; + let bytesLen: number; + let memoryBase: number; + let tableBase: number; + let sideForkBufAddr: number; if (ptrWidth === 8) { next = Number(view.getBigUint64(cursor + 0, true)); namePtr = Number(view.getBigUint64(cursor + 8, true)); @@ -357,6 +478,8 @@ function buildDlopenImports( bytesPtr = Number(view.getBigUint64(cursor + 24, true)); bytesLen = Number(view.getBigUint64(cursor + 32, true)); memoryBase = Number(view.getBigUint64(cursor + 40, true)); + tableBase = Number(view.getBigUint64(cursor + 48, true)); + sideForkBufAddr = Number(view.getBigUint64(cursor + 56, true)); } else { next = view.getUint32(cursor + 0, true); namePtr = view.getUint32(cursor + 4, true); @@ -364,6 +487,8 @@ function buildDlopenImports( bytesPtr = view.getUint32(cursor + 12, true); bytesLen = view.getUint32(cursor + 16, true); memoryBase = view.getUint32(cursor + 20, true); + tableBase = view.getUint32(cursor + 24, true); + sideForkBufAddr = view.getUint32(cursor + 28, true); } // Copy name + bytes out of shared memory before passing to @@ -376,15 +501,95 @@ function buildDlopenImports( const bytesCopy = new Uint8Array(new Uint8Array(memory.buffer, bytesPtr, bytesLen)); // DynamicLinker.dlopenSync returns 0 on error, >0 on success. - const handle = lk.dlopenSync(name, bytesCopy, { memoryBase }); + const handle = lk.dlopenSync(name, bytesCopy, { + memoryBase, + tableBase, + forkBufAddr: sideForkBufAddr || undefined, + }); if (handle === 0) { throw new Error(`dlopen(${name}): ${lk.dlerror() || "unknown"}`); } + if (sideForkBufAddr !== 0) { + const loaded = loadedLibraries.get(name); + if (!loaded || loaded.forkBufAddr !== sideForkBufAddr) { + throw new Error(`${name}: fork replay restored a mismatched save buffer`); + } + } cursor = next; } }; + const findActiveSideFork = (): SideModuleForkState | null => { + const persisted = readPtr(new DataView(memory.buffer), activeSideForkSlot); + if (persisted === 0) { + if (activeSideFork) { + throw new Error(`${activeSideFork.name}: active side fork lost its persisted identity`); + } + return null; + } + if (activeSideFork) { + if (activeSideFork.forkBufAddr !== persisted) { + throw new Error(`${activeSideFork.name}: active side fork buffer identity changed`); + } + return activeSideFork; + } + + const matches = Array.from(loadedLibraries.values()).filter( + (loaded) => loaded.forkBufAddr === persisted, + ); + if (matches.length !== 1) { + throw new Error( + `fork replay could not resolve active side-module buffer 0x${persisted.toString(16)}`, + ); + } + const loaded = matches[0]!; + activeSideFork = { + name: loaded.name, + instance: loaded.instance, + forkBufAddr: persisted, + }; + return activeSideFork; + }; + + const sideForkState = (state: SideModuleForkState): number => + Number((state.instance.exports.wpk_fork_state as () => number)()); + + const completeSideModuleForkUnwind = (): void => { + const state = findActiveSideFork(); + if (!state) return; + if (sideForkState(state) !== 1) { + throw new Error(`${state.name}: expected UNWINDING before side-module unwind completion`); + } + (state.instance.exports.wpk_fork_unwind_end as () => void)(); + if (sideForkState(state) !== 0) { + throw new Error(`${state.name}: side-module unwind did not return to NORMAL`); + } + }; + + const beginSideModuleForkRewind = (): void => { + const state = findActiveSideFork(); + if (!state) return; + if (sideForkState(state) !== 0) { + throw new Error(`${state.name}: expected NORMAL before side-module rewind`); + } + (state.instance.exports.wpk_fork_rewind_begin as (addr: number) => void)( + state.forkBufAddr, + ); + if (sideForkState(state) !== 2) { + throw new Error(`${state.name}: side-module rewind did not enter REWINDING`); + } + }; + + const assertNoActiveSideModuleFork = (): void => { + const persisted = readPtr(new DataView(memory.buffer), activeSideForkSlot); + if (activeSideFork || persisted !== 0) { + throw new Error( + `${activeSideFork?.name ?? "unknown side module"}: main image returned with an active side-module fork`, + ); + } + }; + const imports: Record = { __wasm_dlopen: (bytesPtr: number, bytesLen: number, namePtr: number, nameLen: number): number => { @@ -408,7 +613,13 @@ function buildDlopenImports( if (!loaded) { throw new Error(`__wasm_dlopen(${name}): handle=${handle} but loadedLibraries lookup failed`); } - persistArchiveEntry(name, bytesCopy, loaded.memoryBase); + persistArchiveEntry( + name, + bytesCopy, + loaded.memoryBase, + loaded.tableBase, + loaded.forkBufAddr ?? 0, + ); } return handle; }, @@ -437,7 +648,13 @@ function buildDlopenImports( }, }; - return { imports, replayDlopens }; + return { + imports, + replayDlopens, + completeSideModuleForkUnwind, + beginSideModuleForkRewind, + assertNoActiveSideModuleFork, + }; } /** @@ -694,8 +911,10 @@ const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; // wpk_fork rewind. const DLOPEN_HEAD_OFFSET_WASM32 = 12; const DLOPEN_HEAD_OFFSET_WASM64 = 24; -const DLOPEN_ENTRY_SIZE_WASM32 = 24; -const DLOPEN_ENTRY_SIZE_WASM64 = 48; +const DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM32 = 16; +const DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM64 = 32; +const DLOPEN_ENTRY_SIZE_WASM32 = 32; +const DLOPEN_ENTRY_SIZE_WASM64 = 64; const WPK_FORK_EXPORTS = [ "wpk_fork_unwind_begin", @@ -878,6 +1097,11 @@ export async function centralizedWorkerMain( // and reject stale legacy fork artifacts before they can run. const moduleExports = WebAssembly.Module.exports(module); const hasForkInstrumentation = hasCompleteForkInstrumentation(moduleExports, pid); + const forkCapabilityClaim = readForkInstrumentCapabilityClaim(module); + const hasDylinkForkRole = forkInstrumentRoleAvailable( + forkCapabilityClaim, + FORK_CAP_DYLINK_MAIN, + ); // Fork state — captured by kernel_fork closure let forkResult = 0; const forkBufAddr = channelOffset - FORK_BUF_SIZE; @@ -915,6 +1139,7 @@ export async function centralizedWorkerMain( () => processInstance?.exports.__stack_pointer as WebAssembly.Global | undefined, () => processInstance ?? undefined, ptrWidth, + hasDylinkForkRole, ); const importObject = buildImportObject(module, memory, kernelImports, channelOffset, dlopenSupport.imports, () => processInstance ?? undefined, ptrWidth); @@ -1000,6 +1225,7 @@ export async function centralizedWorkerMain( } replayedForkChildDlopens = true; } + dlopenSupport.beginSideModuleForkRewind(); needsRewind = false; } @@ -1019,6 +1245,7 @@ export async function centralizedWorkerMain( if (forkState === 1) { // Unwind completed (fork) — finalize and send SYS_FORK. unwindEnd(); + dlopenSupport.completeSideModuleForkUnwind(); // Send SYS_FORK through the channel now that memory has the // fork save buffer populated (saved_globals + frames). @@ -1032,6 +1259,7 @@ export async function centralizedWorkerMain( } // Normal return — program finished + dlopenSupport.assertNoActiveSideModuleFork(); if (kernelExitStatus === null) { kernelImports.kernel_exit(0); exitCode = kernelExitStatus ?? 0; @@ -1066,6 +1294,7 @@ export async function centralizedWorkerMain( () => processInstance?.exports.__stack_pointer as WebAssembly.Global | undefined, () => processInstance ?? undefined, ptrWidth, + false, ); const importObject = buildImportObject(module, memory, kernelImports, channelOffset, dlopenSupport.imports, () => processInstance ?? undefined, ptrWidth); diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index 5aefc8557..1241e68b9 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -27,6 +27,7 @@ import type { PlatformIO } from "../src/types"; const __dirname = dirname(fileURLToPath(import.meta.url)); const MAX_PAGES = 16384; +const SIGSEGV = 11; const CH_TOTAL_SIZE = 72 + 65536; function createSharedProcessMemory( @@ -330,6 +331,7 @@ async function runOnMainThread(options: RunProgramOptions): Promise { - kernelWorker.unregisterProcess(childPid); + const finalizeChildWorkerError = (reason: unknown): void => { + // Match the production hosts: an unexpected worker failure is a + // signal-style process death, not an unregister that makes the + // child disappear while its parent remains blocked in waitpid(). + // The worker identity guard also prevents a late event from an old + // generation tearing down a replacement process after exec. + if (workers.get(childPid) !== childWorker) return; + const message = reason instanceof Error ? reason.message : String(reason); + stderr += `[fork child ${childPid}] ${message}\n`; + try { kernelWorker.notifyHostProcessCrashed(childPid, SIGSEGV); } catch { /* best-effort */ } + try { kernelWorker.deactivateProcess(childPid); } catch { /* best-effort */ } workers.delete(childPid); + processProgramBytes.delete(childPid); processLayouts.delete(childPid); threadAllocators.delete(childPid); processPtrWidths.delete(childPid); + childWorker.terminate().catch(() => {}); + }; + childWorker.on("error", finalizeChildWorkerError); + childWorker.on("message", (msg: unknown) => { + const m = msg as WorkerToHostMessage; + if (m.type === "error" && m.pid === childPid) { + finalizeChildWorkerError(m.message); + } }); return [childChannelOffset]; }, - onExec: async (execPid, path, argv, envp) => { + onExec: async (execPid, path, argv, envp, callerTid) => { const wasmPath = options.execPrograms?.get(path); if (!wasmPath) return -2; + if (!kernelWorker.supportsExecMetadataReplacement()) return -38; const newProgramBytes = loadProgramWasm(wasmPath); const newPtrWidth = detectPtrWidth(newProgramBytes); - - const setupResult = kernelWorker.kernelExecSetup(execPid); - if (setupResult < 0) return setupResult; - - kernelWorker.prepareProcessForExec(execPid); - - const oldWorker = workers.get(execPid); - if (oldWorker) { - await oldWorker.terminate().catch(() => {}); - workers.delete(execPid); - } + const sourcePtrWidth = processPtrWidths.get(execPid) ?? newPtrWidth; + const metadataResult = kernelWorker.validateExecMetadata(argv, envp, sourcePtrWidth); + if (metadataResult < 0) return metadataResult; const { memory: newMemory, @@ -407,44 +420,95 @@ async function runOnMainThread(options: RunProgramOptions): Promise { - console.error(`[exec] worker error for pid ${execPid}:`, err); - }); + const prepareResult = kernelWorker.kernelExecPrepare(execPid, callerTid); + if (prepareResult < 0) return prepareResult; + const addressSpaceResult = kernelWorker.prepareAddressSpaceForExec(execPid); + if (addressSpaceResult < 0) return addressSpaceResult; + let replacementWorker: ReturnType | undefined; + try { + const setupResult = kernelWorker.kernelExecSetup(execPid, callerTid); + if (setupResult < 0) return setupResult; + kernelWorker.prepareProcessForExec(execPid); + + const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(execPid); + if (finalizeResult < 0) { + throw new Error("failed to detach the discarded address space"); + } - return 0; + const oldWorker = workers.get(execPid); + if (oldWorker) { + await oldWorker.terminate().catch(() => {}); + workers.delete(execPid); + } + if (kernelWorker.finalizeExecHandoffTermination(execPid) > 0) return 0; + + kernelWorker.registerProcess(execPid, newMemory, [newChannelOffset], { + skipKernelCreate: true, + ptrWidth: newPtrWidth, + metadataPtrWidth: sourcePtrWidth, + brkBase: newLayout.brkBase, + mmapBase: newLayout.mmapBase, + maxAddr: newLayout.maxAddr, + argv, + env: envp, + }); + processProgramBytes.set(execPid, newProgramBytes); + processLayouts.set(execPid, newLayout); + threadAllocators.set(execPid, newThreadAllocator); + processPtrWidths.set(execPid, newPtrWidth); + + const initData: CentralizedWorkerInitMessage = { + type: "centralized_init", + pid: execPid, + ppid: 0, + programBytes: newProgramBytes, + memory: newMemory, + channelOffset: newChannelOffset, + argv, + env: envp, + ptrWidth: newPtrWidth, + }; + + replacementWorker = workerAdapter.createWorker(initData); + workers.set(execPid, replacementWorker); + replacementWorker.on("error", (err: Error) => { + console.error(`[exec] worker error for pid ${execPid}:`, err); + }); + kernelWorker.finishProcessExecHandoff(execPid); + return 0; + } catch (err) { + try { kernelWorker.prepareProcessForExec(execPid); } catch { /* best-effort */ } + if (replacementWorker && workers.get(execPid) !== replacementWorker) { + await replacementWorker.terminate().catch(() => {}); + } + const currentWorker = workers.get(execPid); + if (currentWorker) { + await currentWorker.terminate().catch(() => {}); + workers.delete(execPid); + } + try { kernelWorker.notifyHostProcessCrashed(execPid, SIGSEGV); } catch { /* best-effort */ } + try { kernelWorker.deactivateProcess(execPid); } catch { /* best-effort */ } + processProgramBytes.delete(execPid); + processLayouts.delete(execPid); + threadAllocators.delete(execPid); + processPtrWidths.delete(execPid); + const message = err instanceof Error ? err.message : String(err); + stderr += `[exec] post-commit transition failed: ${message}\n`; + if (execPid === pid) resolveExit(128 + SIGSEGV); + return 0; + } }, onClone: async (clonePid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, memory) => { const threadAllocator = threadAllocators.get(clonePid); if (!threadAllocator) throw new Error(`Unknown thread allocator for pid ${clonePid}`); const clonePtrWidth = processPtrWidths.get(clonePid) ?? ptrWidth; const alloc = threadAllocator.allocate(memory); - kernelWorker.addChannel(clonePid, alloc.channelOffset, tid, fnPtr, argPtr); + try { + kernelWorker.addChannel(clonePid, alloc.channelOffset, tid, fnPtr, argPtr, memory); + } catch (err) { + threadAllocator.free(alloc.basePage); + throw err; + } const threadInitData: CentralizedThreadInitMessage = { type: "centralized_thread_init", diff --git a/host/test/datagram-wakeup.test.ts b/host/test/datagram-wakeup.test.ts index dbf943d18..35293d7c0 100644 --- a/host/test/datagram-wakeup.test.ts +++ b/host/test/datagram-wakeup.test.ts @@ -45,14 +45,14 @@ describe("datagram send-state wakeups", () => { const fallback = vi.fn(); const timer = setTimeout(fallback, 1); - worker.pendingPollRetries.set(channel.channelOffset, { + worker.pendingPollRetries.set(channel, { timer, channel, pipeIndices: [], deadline: Date.now() + 1, isWriteRetry: true, }); - worker.pendingPollRetries.set(pollChannel.channelOffset, { + worker.pendingPollRetries.set(pollChannel, { timer: null, channel: pollChannel, pipeIndices: [], @@ -66,8 +66,8 @@ describe("datagram send-state wakeups", () => { expect(worker.retrySyscall).toHaveBeenCalledOnce(); expect(worker.retrySyscall).toHaveBeenCalledWith(channel); - expect(worker.pendingPollRetries.has(channel.channelOffset)).toBe(false); - expect(worker.pendingPollRetries.has(pollChannel.channelOffset)).toBe(true); + expect(worker.pendingPollRetries.has(channel)).toBe(false); + expect(worker.pendingPollRetries.has(pollChannel)).toBe(true); expect(worker.scheduleWakeBlockedRetries).not.toHaveBeenCalled(); expect(worker.scheduleWakeBlockedRetriesDeferred).toHaveBeenCalledOnce(); diff --git a/host/test/dylink.test.ts b/host/test/dylink.test.ts index c9ad7bc05..32ca00438 100644 --- a/host/test/dylink.test.ts +++ b/host/test/dylink.test.ts @@ -3,7 +3,21 @@ */ import { describe, it, expect } from "vitest"; -import { parseDylinkSection, loadSharedLibrary, loadSharedLibrarySync, DynamicLinker, type LoadSharedLibraryOptions } from "../src/dylink.ts"; +import { + parseDylinkSection, + loadSharedLibrary, + loadSharedLibrarySync, + DynamicLinker, + FORK_CAP_DYLINK_MAIN, + FORK_CAP_SIDE_ENTRY, + FORK_CAPABILITIES_SECTION, + FORK_CAPABILITIES_VERSION, + forkInstrumentRoleAvailable, + readForkInstrumentCapabilityClaim, + readForkInstrumentCapabilities, + type LoadSharedLibraryOptions, + type SideModuleForkState, +} from "../src/dylink.ts"; import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; @@ -31,6 +45,64 @@ function buildSharedLib(source: string, name: string): Uint8Array { return new Uint8Array(readFileSync(soPath)); } +/** Compile a tiny side module and prepend the required first dylink.0 section. */ +function appendCustomSection(module: Uint8Array, name: string, data: Uint8Array): Uint8Array { + const nameBytes = new TextEncoder().encode(name); + const payloadSize = 1 + nameBytes.length + data.length; + if (nameBytes.length >= 128 || payloadSize >= 128) { + throw new Error("test custom section helper only supports one-byte LEB lengths"); + } + const section = new Uint8Array(2 + payloadSize); + section[0] = 0; + section[1] = payloadSize; + section[2] = nameBytes.length; + section.set(nameBytes, 3); + section.set(data, 3 + nameBytes.length); + const out = new Uint8Array(module.length + section.length); + out.set(module); + out.set(section, module.length); + return out; +} + +function buildDylinkWat( + wat: string, + name: string, + forkCapabilities?: number, + tableSize = 0, + memorySize = 0, +): Uint8Array { + const dir = join(tmpdir(), "wasm-dylink-wat-test"); + mkdirSync(dir, { recursive: true }); + const watPath = join(dir, `${name}.wat`); + const wasmPath = join(dir, `${name}.wasm`); + writeFileSync(watPath, wat); + execFileSync("wat2wasm", ["--enable-threads", watPath, "-o", wasmPath], { + stdio: "pipe", + }); + const module = new Uint8Array(readFileSync(wasmPath)); + const dylinkName = new TextEncoder().encode("dylink.0"); + // Memory-info subsection: size=0, align=0, table size=0, table align=0. + const payload = new Uint8Array(1 + dylinkName.length + 6); + payload[0] = dylinkName.length; + payload.set(dylinkName, 1); + payload.set([1, 4, memorySize, 0, tableSize, 0], 1 + dylinkName.length); + const section = new Uint8Array(2 + payload.length); + section[0] = 0; + section[1] = payload.length; + section.set(payload, 2); + const out = new Uint8Array(module.length + section.length); + out.set(module.subarray(0, 8), 0); + out.set(section, 8); + out.set(module.subarray(8), 8 + section.length); + return forkCapabilities === undefined + ? out + : appendCustomSection( + out, + FORK_CAPABILITIES_SECTION, + new Uint8Array([FORK_CAPABILITIES_VERSION, forkCapabilities]), + ); +} + describe.skipIf(!hasCompiler())("dylink.0 parser", () => { it("parses a simple shared library", () => { const wasmBytes = buildSharedLib( @@ -259,6 +331,372 @@ describe.skipIf(!hasCompiler())("synchronous loading (loadSharedLibrarySync)", ( const square = lib.exports.square as Function; expect(square(7)).toBe(49); }); + +}); + +function createSideForkLoadOptions(): LoadSharedLibraryOptions { + return { + memory: new WebAssembly.Memory({ initial: 1, maximum: 100, shared: true }), + table: new WebAssembly.Table({ initial: 1, element: "anyfunc" }), + stackPointer: new WebAssembly.Global({ value: "i32", mutable: true }, 65536), + heapPointer: { value: 1024 }, + globalSymbols: new Map(), + got: new Map(), + loadedLibraries: new Map(), + }; +} + +describe("side-module fork contract", () => { + it("rejects an uninstrumented side module that imports fork", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (func (export "side_fork") (result i32) call $fork)) + `, "side-fork-uninstrumented"); + const options = createSideForkLoadOptions(); + options.sideModuleFork = { + setActiveFork: () => {}, + clearActiveFork: () => {}, + invokeMainFork: () => 0, + }; + + expect(() => loadSharedLibrarySync("libbadfork.so", wasmBytes, options)) + .toThrow(/requires complete side-module instrumentation/); + }); + + it("applies the generated ABI transition to a legacy five-export side artifact", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (func (export "wpk_fork_unwind_begin") (param i32)) + (func (export "wpk_fork_unwind_end")) + (func (export "wpk_fork_rewind_begin") (param i32)) + (func (export "wpk_fork_rewind_end")) + (func (export "wpk_fork_state") (result i32) i32.const 0) + (func (export "side_fork") (result i32) call $fork)) + `, "side-fork-generic"); + const options = createSideForkLoadOptions(); + options.sideModuleFork = { + setActiveFork: () => {}, + clearActiveFork: () => {}, + invokeMainFork: () => 0, + }; + + const load = () => loadSharedLibrarySync("liblegacyfork.so", wasmBytes, options); + const legacyAllowed = forkInstrumentRoleAvailable( + { present: false, flags: 0 }, + FORK_CAP_SIDE_ENTRY, + ); + if (legacyAllowed) { + expect(load).not.toThrow(); + } else { + expect(load).toThrow(/versioned side-entry capability/); + } + }); + + it("makes missing side and main role claims mandatory at ABI 17", () => { + const wasmBytes = buildDylinkWat(` + (module (import "env" "memory" (memory 1 100 shared))) + `, "legacy-capability-absence"); + const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); + const claim = readForkInstrumentCapabilityClaim(module); + + expect(claim).toEqual({ present: false, flags: 0 }); + expect(forkInstrumentRoleAvailable(claim, FORK_CAP_SIDE_ENTRY, 16)).toBe(true); + expect(forkInstrumentRoleAvailable(claim, FORK_CAP_DYLINK_MAIN, 16)).toBe(true); + expect(forkInstrumentRoleAvailable(claim, FORK_CAP_SIDE_ENTRY, 17)).toBe(false); + expect(forkInstrumentRoleAvailable(claim, FORK_CAP_DYLINK_MAIN, 17)).toBe(false); + expect(forkInstrumentRoleAvailable( + { present: true, flags: FORK_CAP_SIDE_ENTRY }, + FORK_CAP_DYLINK_MAIN, + 16, + )).toBe(false); + }); + + it("rejects a marker-present artifact that does not claim side-entry coverage", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (func (export "wpk_fork_unwind_begin") (param i32)) + (func (export "wpk_fork_unwind_end")) + (func (export "wpk_fork_rewind_begin") (param i32)) + (func (export "wpk_fork_rewind_end")) + (func (export "wpk_fork_state") (result i32) i32.const 0) + (func (export "side_fork") (result i32) call $fork)) + `, "side-fork-wrong-marker", 0); + const options = createSideForkLoadOptions(); + options.sideModuleFork = { + setActiveFork: () => {}, + clearActiveFork: () => {}, + invokeMainFork: () => 0, + }; + + expect(() => loadSharedLibrarySync("libwrongmarker.so", wasmBytes, options)) + .toThrow(/versioned side-entry capability/); + }); + + it("reads the versioned side-entry capability independently", () => { + const wasmBytes = buildDylinkWat(` + (module (import "env" "memory" (memory 1 100 shared))) + `, "side-capability-marker", FORK_CAP_SIDE_ENTRY); + const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); + expect(readForkInstrumentCapabilityClaim(module)).toEqual({ + present: true, + flags: FORK_CAP_SIDE_ENTRY, + }); + expect(readForkInstrumentCapabilities(module)).toBe(FORK_CAP_SIDE_ENTRY); + }); + + it("rejects a malformed marker even during the ABI-16 compatibility window", () => { + const base = buildDylinkWat(` + (module (import "env" "memory" (memory 1 100 shared))) + `, "malformed-capability-marker"); + const wasmBytes = appendCustomSection( + base, + FORK_CAPABILITIES_SECTION, + new Uint8Array([FORK_CAPABILITIES_VERSION]), + ); + const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); + + expect(() => readForkInstrumentCapabilityClaim(module)) + .toThrow(/malformed kandelo\.wpk_fork\.capabilities custom section/); + }); + + it("reports an explicit stale-main diagnostic for a valid side artifact", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (func (export "wpk_fork_unwind_begin") (param i32)) + (func (export "wpk_fork_unwind_end")) + (func (export "wpk_fork_rewind_begin") (param i32)) + (func (export "wpk_fork_rewind_end")) + (func (export "wpk_fork_state") (result i32) i32.const 0) + (func (export "side_fork") (result i32) call $fork)) + `, "side-with-stale-main", FORK_CAP_SIDE_ENTRY); + const options = createSideForkLoadOptions(); + options.sideModuleForkUnavailableReason = + "main module lacks the versioned dlopen-main fork capability; rebuild it"; + + expect(() => loadSharedLibrarySync("libside.so", wasmBytes, options)) + .toThrow(/main module lacks the versioned dlopen-main fork capability; rebuild it/); + }); + + it("drives repeated instrumented side-module forks through exact states", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (global $state (mut i32) (i32.const 0)) + (global $buf (mut i32) (i32.const 0)) + (func (export "wpk_fork_unwind_begin") (param $addr i32) + local.get $addr + global.set $buf + i32.const 1 + global.set $state) + (func (export "wpk_fork_unwind_end") + i32.const 0 + global.set $state) + (func (export "wpk_fork_rewind_begin") (param $addr i32) + local.get $addr + global.set $buf + i32.const 2 + global.set $state) + (func (export "wpk_fork_rewind_end") + i32.const 0 + global.set $state) + (func (export "wpk_fork_state") (result i32) + global.get $state) + (func (export "side_fork_with_local") (result i32) + i32.const 41 + call $fork + i32.add)) + `, "side-fork-instrumented", FORK_CAP_SIDE_ENTRY); + const options = createSideForkLoadOptions(); + let forkResult = 0; + let active: SideModuleForkState | null = null; + options.sideModuleFork = { + setActiveFork: (state) => { + expect(active).toBeNull(); + active = state; + }, + clearActiveFork: (state) => { + expect(active).toBe(state); + active = null; + }, + invokeMainFork: () => forkResult, + }; + + const lib = loadSharedLibrarySync("libsidefork.so", wasmBytes, options); + const sideFork = lib.exports.side_fork_with_local as () => number; + const state = lib.instance.exports.wpk_fork_state as () => number; + const unwindEnd = lib.instance.exports.wpk_fork_unwind_end as () => void; + const rewindBegin = lib.instance.exports.wpk_fork_rewind_begin as (addr: number) => void; + + for (const expectedForkResult of [101, 202]) { + forkResult = 0; + expect(sideFork()).toBe(41); + expect(state()).toBe(1); + expect(active?.forkBufAddr).toBe(lib.forkBufAddr); + + unwindEnd(); + forkResult = expectedForkResult; + rewindBegin(lib.forkBufAddr!); + expect(sideFork()).toBe(41 + expectedForkResult); + expect(state()).toBe(0); + expect(active).toBeNull(); + } + }); + + it("allows independent extensions but rejects visible side-to-side fork nesting", () => { + const options = createSideForkLoadOptions(); + options.sideModuleFork = { + setActiveFork: () => {}, + clearActiveFork: () => {}, + invokeMainFork: () => 0, + }; + const provider = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "provider_value") (result i32) i32.const 7)) + `, "fork-provider"); + loadSharedLibrarySync("libprovider.so", provider, options); + + const independentFork = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (global $state (mut i32) (i32.const 0)) + (func (export "wpk_fork_unwind_begin") (param i32) + i32.const 1 global.set $state) + (func (export "wpk_fork_unwind_end") i32.const 0 global.set $state) + (func (export "wpk_fork_rewind_begin") (param i32) + i32.const 2 global.set $state) + (func (export "wpk_fork_rewind_end") i32.const 0 global.set $state) + (func (export "wpk_fork_state") (result i32) global.get $state) + (func (export "side_fork") (result i32) call $fork)) + `, "independent-fork-side", FORK_CAP_SIDE_ENTRY); + loadSharedLibrarySync("libindependent-fork.so", independentFork, options); + + const visibleConsumer = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "side_fork" (func $side_fork (result i32))) + (func (export "nested") (result i32) call $side_fork)) + `, "visible-side-consumer"); + expect(() => loadSharedLibrarySync("libnested.so", visibleConsumer, options)) + .toThrow(/fork-capable side-module nesting/); + }); +}); + +describe("dylink symbol interposition", () => { + it("preserves first-definition GOT bindings for duplicate function and data exports", () => { + const firstBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "duplicate_function") (result i32) i32.const 1) + (global (export "duplicate_data") i32 (i32.const 12))) + `, "first-duplicate-exports", undefined, 0, 16); + const secondBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "duplicate_function") (result i32) i32.const 2) + (global (export "duplicate_data") i32 (i32.const 28))) + `, "second-duplicate-exports", undefined, 0, 16); + const options = createSideForkLoadOptions(); + const functionGot = new WebAssembly.Global({ value: "i32", mutable: true }, 0); + const dataGot = new WebAssembly.Global({ value: "i32", mutable: true }, 0); + options.got.set("duplicate_function", functionGot); + options.got.set("duplicate_data", dataGot); + + const first = loadSharedLibrarySync("libfirst.so", firstBytes, options); + const firstFunctionBinding = options.globalSymbols.get("duplicate_function"); + const firstDataBinding = options.globalSymbols.get("duplicate_data"); + const firstFunctionGot = functionGot.value; + const firstDataGot = dataGot.value; + const tableLengthAfterFirst = options.table.length; + + const second = loadSharedLibrarySync("libsecond.so", secondBytes, options); + + expect(options.globalSymbols.get("duplicate_function")).toBe(firstFunctionBinding); + expect(options.globalSymbols.get("duplicate_data")).toBe(firstDataBinding); + expect(functionGot.value).toBe(firstFunctionGot); + expect(dataGot.value).toBe(firstDataGot); + expect((first.exports.duplicate_function as () => number)()).toBe(1); + expect((second.exports.duplicate_function as () => number)()).toBe(2); + expect((second.exports.duplicate_data as WebAssembly.Global).value) + .not.toBe((first.exports.duplicate_data as WebAssembly.Global).value); + expect(options.table.length).toBe(tableLengthAfterFirst + 1); + expect(options.table.get(options.table.length - 1)) + .toBe(second.exports.duplicate_function); + }); +}); + +describe("dylink replay layout and rollback", () => { + it("pads replay to the exact parent table base and rejects overshoot", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "value") (result i32) i32.const 9)) + `, "replay-table-base"); + const parent = createSideForkLoadOptions(); + parent.table.grow(4); + const parentLib = loadSharedLibrarySync("liblayout.so", wasmBytes, parent); + expect(parentLib.tableBase).toBe(5); + + const child = createSideForkLoadOptions(); + const childLib = loadSharedLibrarySync("liblayout.so", wasmBytes, child, { + memoryBase: parentLib.memoryBase, + tableBase: parentLib.tableBase, + }); + expect(childLib.tableBase).toBe(parentLib.tableBase); + expect(child.table.length).toBe(parent.table.length); + + const overshot = createSideForkLoadOptions(); + overshot.table.grow(parentLib.tableBase); + expect(() => loadSharedLibrarySync("liblayout.so", wasmBytes, overshot, { + memoryBase: parentLib.memoryBase, + tableBase: parentLib.tableBase, + })).toThrow(/past parent base/); + }); + + it("clears failed-load table entries and records the surviving gap", () => { + const options = createSideForkLoadOptions(); + const deallocated: Array<{ addr: number; size: number }> = []; + options.allocateMemory = () => 0x2000; + options.deallocateMemory = (addr, size) => deallocated.push({ addr, size }); + const invalid = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "missing" (func $missing)) + (func (export "never") call $missing)) + `, "failed-table-growth", undefined, 2, 16); + expect(() => loadSharedLibrarySync("libfailed.so", invalid, options)).toThrow(); + expect(options.table.length).toBe(3); + expect(options.table.get(1)).toBeNull(); + expect(options.table.get(2)).toBeNull(); + expect(options.loadedLibraries.size).toBe(0); + expect(deallocated).toEqual([{ addr: 0x2000, size: 16 }]); + + const valid = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "survivor") (result i32) i32.const 1)) + `, "surviving-after-failure"); + const survivor = loadSharedLibrarySync("libsurvivor.so", valid, options); + expect(survivor.tableBase).toBe(3); + + const child = createSideForkLoadOptions(); + const replayed = loadSharedLibrarySync("libsurvivor.so", valid, child, { + memoryBase: survivor.memoryBase, + tableBase: survivor.tableBase, + }); + expect(replayed.tableBase).toBe(3); + expect(child.table.length).toBe(options.table.length); + }); }); describe.skipIf(!hasCompiler())("DynamicLinker", () => { diff --git a/host/test/exec-state-tracking.test.ts b/host/test/exec-state-tracking.test.ts new file mode 100644 index 000000000..55c08205b --- /dev/null +++ b/host/test/exec-state-tracking.test.ts @@ -0,0 +1,1047 @@ +import { describe, expect, it, vi } from "vitest"; +import { + CentralizedKernelWorker, + isCurrentProcessGeneration, +} from "../src/kernel-worker"; +import { + ABI_SYSCALLS, + CH_ARG_SIZE, + CH_ARGS, + CH_DATA_SIZE, + CH_RETURN, +} from "../src/generated/abi"; + +describe("exec host-state transition", () => { + it("rejects an async continuation from a replaced process generation", () => { + const oldMemory = new WebAssembly.Memory({ initial: 1 }); + const newMemory = new WebAssembly.Memory({ initial: 1 }); + const oldGeneration = { memory: oldMemory }; + const newGeneration = { memory: newMemory }; + const processes = new Map([[7, oldGeneration]]); + + expect(isCurrentProcessGeneration( + processes, + 7, + oldGeneration, + oldMemory, + )).toBe(true); + processes.set(7, newGeneration); + expect(isCurrentProcessGeneration( + processes, + 7, + oldGeneration, + oldMemory, + )).toBe(false); + expect(isCurrentProcessGeneration( + processes, + 7, + newGeneration, + oldMemory, + )).toBe(false); + expect(isCurrentProcessGeneration( + processes, + 7, + newGeneration, + newMemory, + true, + )).toBe(false); + }); + + it("drops discarded-image async and thread-channel state", () => { + vi.useFakeTimers(); + try { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const otherMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const mainChannel = createChannel(7, memory, 0); + const threadChannel = createChannel(7, memory, 256); + const otherChannel = createChannel(8, otherMemory, 0); + const sleepTimer = setTimeout(() => {}, 60_000); + const threadSleepTimer = setTimeout(() => {}, 60_000); + const otherSleepTimer = setTimeout(() => {}, 60_000); + const worker = createWorker({ + processes: new Map([ + [7, { channels: [mainChannel, threadChannel], memory }], + [8, { channels: [otherChannel], memory: otherMemory }], + ]), + activeChannels: [mainChannel, threadChannel, otherChannel], + waitingForChild: [ + { parentPid: 7, channel: mainChannel }, + { parentPid: 8, channel: otherChannel }, + ], + pendingSleeps: new Map([ + [mainChannel, { timer: sleepTimer, channel: mainChannel }], + [threadChannel, { timer: threadSleepTimer, channel: threadChannel }], + [otherChannel, { timer: otherSleepTimer, channel: otherChannel }], + ]), + pendingFutexWaits: new Map([ + [threadChannel, { futexIndex: 4 }], + [otherChannel, { futexIndex: 5 }], + ]), + pendingCancels: new Set([threadChannel, otherChannel]), + channelTids: new Map([ + ["7:256", 11], + ["8:0", 8], + ]), + threadForkContexts: new Map([ + ["7:256", { fnPtr: 1, argPtr: 2 }], + ["8:0", { fnPtr: 3, argPtr: 4 }], + ]), + threadCtidPtrs: new Map([ + ["7:11", 0x1000], + ["8:8", 0x2000], + ]), + }); + const notify = vi.spyOn(Atomics, "notify"); + + worker.prepareProcessForExec(7); + + expect(worker.processes.has(7)).toBe(true); + expect(worker.processes.get(7).channels).toEqual([]); + expect(worker.isExecHandoffActive(7)).toBe(true); + expect(() => worker.addChannel(7, 512)).toThrow(/replacing its image/); + expect(worker.processes.has(8)).toBe(true); + expect(worker.activeChannels).toEqual([otherChannel]); + expect(worker.waitingForChild).toEqual([ + { parentPid: 8, channel: otherChannel }, + ]); + expect(worker.pendingSleeps.has(mainChannel)).toBe(false); + expect(worker.pendingSleeps.has(threadChannel)).toBe(false); + expect(worker.pendingSleeps.has(otherChannel)).toBe(true); + expect(worker.pendingFutexWaits.has(threadChannel)).toBe(false); + expect(worker.pendingFutexWaits.has(otherChannel)).toBe(true); + expect(worker.pendingCancels.has(threadChannel)).toBe(false); + expect(worker.pendingCancels.has(otherChannel)).toBe(true); + expect(worker.channelTids.has("7:256")).toBe(false); + expect(worker.channelTids.get("8:0")).toBe(8); + expect(worker.threadForkContexts.has("7:256")).toBe(false); + expect(worker.threadForkContexts.has("8:0")).toBe(true); + expect(worker.threadCtidPtrs.has("7:11")).toBe(false); + expect(worker.threadCtidPtrs.get("8:8")).toBe(0x2000); + expect(notify).toHaveBeenCalledWith( + expect.any(Int32Array), + 4, + 1, + ); + clearTimeout(otherSleepTimer); + } finally { + vi.restoreAllMocks(); + vi.useRealTimers(); + } + }); + + it("rejects an old-memory clone after replacement registration", () => { + const oldMemory = new WebAssembly.Memory({ initial: 1 }); + const newMemory = new WebAssembly.Memory({ initial: 1 }); + const worker = createWorker({ + processes: new Map([[7, { channels: [], memory: newMemory }]]), + }); + + expect(() => worker.addChannel(7, 512, 11, 1, 2, oldMemory)) + .toThrow(/changed memory generation/); + expect(worker.processes.get(7).channels).toEqual([]); + }); + + it("keeps concurrent sleeps independent across one process's threads", async () => { + vi.useFakeTimers(); + try { + const memory = new WebAssembly.Memory({ initial: 3, maximum: 3, shared: true }); + const mainChannel = createChannel(7, memory, 0); + const threadChannel = createChannel(7, memory, 0x10000); + const completeSleep = vi.fn(); + const worker = createWorker({ + processes: new Map([[7, { + channels: [mainChannel, threadChannel], + memory, + }]]), + completeSleepWithSignalCheck: completeSleep, + }); + + expect(worker.handleSleepDelay( + mainChannel, ABI_SYSCALLS.Usleep, [50_000], 0, 0, + )).toBe(true); + expect(worker.handleSleepDelay( + threadChannel, ABI_SYSCALLS.Usleep, [10_000], 0, 0, + )).toBe(true); + expect(worker.pendingSleeps.size).toBe(2); + + await vi.advanceTimersByTimeAsync(10); + expect(completeSleep).toHaveBeenCalledTimes(1); + expect(completeSleep).toHaveBeenLastCalledWith( + threadChannel, ABI_SYSCALLS.Usleep, [10_000], 0, 0, + ); + expect(worker.pendingSleeps.has(mainChannel)).toBe(true); + + await vi.advanceTimersByTimeAsync(40); + expect(completeSleep).toHaveBeenCalledTimes(2); + expect(completeSleep).toHaveBeenLastCalledWith( + mainChannel, ABI_SYSCALLS.Usleep, [50_000], 0, 0, + ); + expect(worker.pendingSleeps.size).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("drops a stale retry before consulting replacement process state", () => { + const oldMemory = new WebAssembly.Memory({ initial: 1 }); + const newMemory = new WebAssembly.Memory({ initial: 1 }); + const oldChannel = createChannel(7, oldMemory, 0); + const newChannel = createChannel(7, newMemory, 0); + const getProcessExitSignal = vi.fn(() => 11); + const handleProcessTerminated = vi.fn(); + const handleSyscall = vi.fn(); + const worker = createWorker({ + processes: new Map([[7, { channels: [newChannel], memory: newMemory }]]), + getProcessExitSignal, + handleProcessTerminated, + handleSyscall, + }); + + worker.retrySyscall(oldChannel); + + expect(getProcessExitSignal).not.toHaveBeenCalled(); + expect(handleProcessTerminated).not.toHaveBeenCalled(); + expect(handleSyscall).not.toHaveBeenCalled(); + }); + + it("does not wake a signal-dead image after async exec failure", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + const channel = createChannel(7, memory, 0); + const handleProcessTerminated = vi.fn(); + const completeChannel = vi.fn(); + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + getProcessExitSignal: vi.fn(() => 11), + handleProcessTerminated, + completeChannel, + }); + + worker.finishFailedExec(channel, 211, [0, 0, 0], 3); + + expect(handleProcessTerminated).toHaveBeenCalledWith(channel); + expect(completeChannel).not.toHaveBeenCalled(); + }); + + it("does not wake a normally reaped image after async exec failure", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + const channel = createChannel(7, memory, 0); + const getProcessExitSignal = vi.fn(() => 0); + const completeChannel = vi.fn(); + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + hostReaped: new Set([7]), + getProcessExitSignal, + completeChannel, + }); + + worker.finishFailedExec(channel, 211, [0, 0, 0], 3); + + expect(getProcessExitSignal).not.toHaveBeenCalled(); + expect(completeChannel).not.toHaveBeenCalled(); + }); + + it("does not create a spawn child after async resolution loses its parent channel", async () => { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const channel = createChannel(7, memory, 0); + const bytes = new Uint8Array(memory.buffer); + const pathPtr = 0x100; + const path = new TextEncoder().encode("/bin/child"); + bytes.set(path, pathPtr); + const blobPtr = 0x200; + bytes.fill(0, blobPtr, blobPtr + 40); + let resolveProgram!: (value: ArrayBuffer) => void; + const program = new Promise((resolve) => { + resolveProgram = resolve; + }); + const kernelSpawn = vi.fn(() => 100); + const onSpawn = vi.fn(async () => 0); + const completeChannel = vi.fn(); + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + callbacks: { + onResolveSpawn: vi.fn(() => program), + onSpawn, + }, + completeChannel, + kernelInstance: { + exports: { kernel_spawn_process: kernelSpawn }, + }, + }); + + worker.handleSpawn(channel, [pathPtr, path.length, blobPtr, 40, 0, 0]); + worker.processes.get(7).channels = []; + resolveProgram(new ArrayBuffer(8)); + await Promise.resolve(); + await Promise.resolve(); + + expect(kernelSpawn).not.toHaveBeenCalled(); + expect(onSpawn).not.toHaveBeenCalled(); + expect(completeChannel).not.toHaveBeenCalled(); + }); + + it("keeps a created spawn child but suppresses stale parent completion", async () => { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const channel = createChannel(7, memory, 0); + let finishSpawn!: (result: number) => void; + const spawned = new Promise((resolve) => { + finishSpawn = resolve; + }); + const kernelSpawn = vi.fn(() => 100); + const removeProcess = vi.fn(); + const completeChannel = vi.fn(); + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + callbacks: { onSpawn: vi.fn(() => spawned) }, + completeChannel, + kernelMemory: new WebAssembly.Memory({ initial: 1 }), + scratchOffset: 0, + toKernelPtr: (value: number) => value, + kernelInstance: { + exports: { + kernel_spawn_process: kernelSpawn, + kernel_remove_process: removeProcess, + }, + }, + }); + + worker.handleSpawnAfterResolve( + channel, + [0, 0, 0, 40, 0, 0], + 7, + 0, + new Uint8Array(40), + 40, + [], + [], + new ArrayBuffer(8), + ); + worker.processes.get(7).channels = []; + finishSpawn(0); + await Promise.resolve(); + await Promise.resolve(); + + expect(kernelSpawn).toHaveBeenCalled(); + expect(removeProcess).not.toHaveBeenCalled(); + expect(completeChannel).not.toHaveBeenCalled(); + }); + + it("installs spawn-child listener mirrors before async worker launch", async () => { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const channel = createChannel(7, memory, 0); + let finishSpawn!: (result: number) => void; + const spawned = new Promise((resolve) => { + finishSpawn = resolve; + }); + const close = vi.fn(); + const listener = { + server: { close }, + pid: 7, + port: 8080, + connections: new Set(), + }; + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + callbacks: { onSpawn: vi.fn(() => spawned) }, + completeChannel: vi.fn(), + kernelMemory: new WebAssembly.Memory({ initial: 1 }), + scratchOffset: 0, + toKernelPtr: (value: number) => value, + kernelInstance: { + exports: { + kernel_spawn_process: () => 100, + kernel_remove_process: vi.fn(), + kernel_get_fd_accept_wake_idx: (_pid: number, fd: number) => + fd === 4 ? 41 : -1, + }, + }, + tcpListenerTargets: new Map([[8080, [{ + pid: 7, + fd: 4, + acceptWakeIdx: 41, + }]]]), + tcpListenerRRIndex: new Map([[8080, 0]]), + tcpListeners: new Map([["7:4", listener]]), + }); + + worker.handleSpawnAfterResolve( + channel, + [0, 0, 0, 40, 0, 0], + 7, + 0, + new Uint8Array(40), + 40, + [], + [], + new ArrayBuffer(8), + ); + + expect(worker.tcpListenerTargets.get(8080)).toContainEqual({ + pid: 100, + fd: 4, + acceptWakeIdx: 41, + }); + worker.cleanupTcpListeners(7); + expect(close).not.toHaveBeenCalled(); + finishSpawn(0); + await Promise.resolve(); + }); + + it("drops a stale channel listener after the pid is re-registered", async () => { + const oldMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const newMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const oldChannel = createChannel(7, oldMemory, 0); + const newChannel = createChannel(7, newMemory, 0); + const worker = createWorker({ + processes: new Map([[7, { channels: [oldChannel], memory: oldMemory }]]), + activeChannels: [oldChannel], + usePolling: false, + relistenBatchSize: 64, + }); + const handleSyscall = vi.fn(); + worker.handleSyscall = handleSyscall; + + let wake!: (value: "ok") => void; + const waited = new Promise<"ok">((resolve) => { wake = resolve; }); + const waitAsync = vi.spyOn(Atomics, "waitAsync").mockReturnValue({ + async: true, + value: waited, + } as any); + try { + worker.listenOnChannel(oldChannel); + worker.processes.set(7, { channels: [newChannel], memory: newMemory }); + worker.activeChannels = [newChannel]; + wake("ok"); + await waited; + await Promise.resolve(); + + expect(waitAsync).toHaveBeenCalledTimes(1); + expect(handleSyscall).not.toHaveBeenCalled(); + + // Even if the discarded mailbox becomes pending later, entering the + // listener directly cannot dispatch it into the replacement process. + Atomics.store(oldChannel.i32View, 0, 1); + worker.listenOnChannel(oldChannel); + expect(handleSyscall).not.toHaveBeenCalled(); + } finally { + waitAsync.mockRestore(); + } + }); + + it("keeps replacement retry state when an old-generation timer fires", () => { + vi.useFakeTimers(); + try { + const oldMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const newMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const oldChannel = createChannel(7, oldMemory, 0); + const newChannel = createChannel(7, newMemory, 0); + const worker = createWorker({ + processes: new Map([[7, { channels: [oldChannel], memory: oldMemory }]]), + kernelInstance: { exports: {} }, + profileData: null, + }); + worker.retrySyscall = vi.fn(); + + worker.handleBlockingRetry(oldChannel, 999, [0, 0, 0, 0, 0, 0]); + vi.advanceTimersByTime(5); + + worker.processes.set(7, { channels: [newChannel], memory: newMemory }); + worker.handleBlockingRetry(newChannel, 999, [0, 0, 0, 0, 0, 0]); + expect(worker.pendingPollRetries.has(oldChannel)).toBe(true); + expect(worker.pendingPollRetries.has(newChannel)).toBe(true); + + vi.advanceTimersByTime(5); + expect(worker.pendingPollRetries.has(oldChannel)).toBe(false); + expect(worker.pendingPollRetries.has(newChannel)).toBe(true); + expect(worker.retrySyscall).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("accepts bounded metadata above 64 KiB and rejects truthful overflows", () => { + const worker = createWorker({}); + const aboveHistoricalLimit = Array.from({ length: 20 }, () => "x".repeat(4096)); + + expect(worker.validateExecMetadata(["program"], aboveHistoricalLimit)).toBe(0); + expect(worker.validateExecMetadata(["x".repeat(65_537)], [])).toBe(-7); + expect(worker.validateExecMetadata([], Array.from({ length: 1024 }, () => "x".repeat(4096)))) + .toBe(-7); + }); + + it("accounts ARG_MAX using the exec caller's pointer width", () => { + const worker = createWorker({}); + const nearBoundary = Array(8192).fill("x".repeat(504)); + + expect(worker.validateExecMetadata(nearBoundary, [], 4)).toBe(0); + expect(worker.validateExecMetadata(nearBoundary, [], 8)).toBe(-7); + }); + + it("reads long exec metadata without truncation and rejects oversized entries", () => { + const memory = new WebAssembly.Memory({ initial: 2, maximum: 2, shared: true }); + const bytes = new Uint8Array(memory.buffer); + const view = new DataView(memory.buffer); + const arrayPtr = 0x100; + const stringPtr = 0x1000; + view.setUint32(arrayPtr, stringPtr, true); + view.setUint32(arrayPtr + 4, 0, true); + bytes.fill("a".charCodeAt(0), stringPtr, stringPtr + 5000); + bytes[stringPtr + 5000] = 0; + const worker = createWorker({}); + + const parsed = worker.readStringArrayFromProcess(bytes, arrayPtr, 4); + expect(parsed).toEqual({ values: ["a".repeat(5000)] }); + + bytes.fill("b".charCodeAt(0), stringPtr, stringPtr + 65_537); + bytes[stringPtr + 65_537] = 0; + expect(worker.readStringArrayFromProcess(bytes, arrayPtr, 4)).toEqual({ errno: 7 }); + }); + + it("accepts a pointer array whose terminator follows 1024 entries", () => { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const bytes = new Uint8Array(memory.buffer); + const view = new DataView(memory.buffer); + const arrayPtr = 0x100; + const stringPtr = 0x2000; + bytes[stringPtr] = "x".charCodeAt(0); + bytes[stringPtr + 1] = 0; + for (let i = 0; i < 1024; i++) { + view.setUint32(arrayPtr + i * 4, stringPtr, true); + } + view.setUint32(arrayPtr + 1024 * 4, 0, true); + const worker = createWorker({}); + + const parsed = worker.readStringArrayFromProcess(bytes, arrayPtr, 4); + expect("values" in parsed && parsed.values).toHaveLength(1024); + }); + + it("rejects overlong or inaccessible exec paths instead of truncating them", () => { + const memory = new WebAssembly.Memory({ initial: 2, maximum: 2, shared: true }); + const bytes = new Uint8Array(memory.buffer); + const pathPtr = 0x1000; + const worker = createWorker({}); + + bytes.fill("a".charCodeAt(0), pathPtr, pathPtr + 4095); + bytes[pathPtr + 4095] = 0; + expect(worker.readExecPathFromProcess(bytes, pathPtr)).toEqual({ + value: "a".repeat(4095), + }); + + bytes.fill("b".charCodeAt(0), pathPtr, pathPtr + 4096); + bytes[pathPtr + 4096] = 0; + expect(worker.readExecPathFromProcess(bytes, pathPtr)).toEqual({ errno: 36 }); + + bytes[bytes.byteLength - 1] = "c".charCodeAt(0); + expect(worker.readExecPathFromProcess(bytes, bytes.byteLength - 1)).toEqual({ errno: 14 }); + expect(worker.readExecPathFromProcess(bytes, 0)).toEqual({ errno: 14 }); + }); + + it("replaces metadata entry by entry and clears an empty environment", () => { + const kernelMemory = new WebAssembly.Memory({ initial: 2 }); + const scratchOffset = 1024; + const clears: Array<[number, number]> = []; + const pushes: Array<{ pid: number; kind: number; bytes: Uint8Array }> = []; + const worker = createWorker({ + kernelMemory, + scratchOffset, + toKernelPtr: (value: number) => value, + kernelInstance: { + exports: { + kernel_clear_process_metadata: (pid: number, kind: number) => { + clears.push([pid, kind]); + return 0; + }, + kernel_push_process_metadata_entry: ( + pid: number, + kind: number, + ptr: number, + len: number, + ) => { + pushes.push({ + pid, + kind, + bytes: new Uint8Array(kernelMemory.buffer, ptr, len).slice(), + }); + if (pushes.length === 1) kernelMemory.grow(1); + return 0; + }, + }, + }, + }); + + worker.replaceProcessMetadata(7, 0, ["program", ""]); + worker.replaceProcessMetadata(7, 1, []); + + expect(clears).toEqual([[7, 0], [7, 1]]); + expect(pushes.map(entry => ({ + pid: entry.pid, + kind: entry.kind, + value: new TextDecoder().decode(entry.bytes), + }))).toEqual([ + { pid: 7, kind: 0, value: "program" }, + { pid: 7, kind: 0, value: "" }, + ]); + }); + + it("feature-detects metadata replacement and retains legacy small argv", () => { + const kernelMemory = new WebAssembly.Memory({ initial: 1 }); + const setArgv = vi.fn((_pid: number, _ptr: number, len: number) => { + expect(new TextDecoder().decode( + new Uint8Array(kernelMemory.buffer, 0, len), + )).toBe("program\0arg"); + return 0; + }); + const worker = createWorker({ + kernelMemory, + scratchOffset: 0, + toKernelPtr: (value: number) => value, + kernelInstance: { + exports: { kernel_set_process_argv: setArgv }, + }, + }); + + expect(worker.supportsExecMetadataReplacement()).toBe(false); + worker.replaceProcessMetadata(7, 0, ["program", "arg"]); + expect(setArgv).toHaveBeenCalled(); + expect(() => worker.replaceProcessMetadata(7, 1, [])) + .toThrow(/missing bounded process metadata exports/); + }); + + it("flushes file-backed mappings before commit and forgets them afterward", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + const channel = { pid: 7, memory }; + const flush = vi.fn(() => true); + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + sharedMappings: new Map([[7, new Map([ + [0x1000, { fd: 4, fileOffset: 0x2000, len: 0x3000, writable: true }], + ])]]), + pwriteFromProcessMemory: flush, + }); + + expect(worker.prepareAddressSpaceForExec(7)).toBe(0); + + expect(flush).toHaveBeenCalledWith(channel, 4, 0x1000, 0x3000, 0x2000); + expect(worker.sharedMappings.has(7)).toBe(true); + expect(worker.finalizeAddressSpaceForExec(7)).toBe(0); + expect(worker.sharedMappings.has(7)).toBe(false); + }); + + it("retains mapping trackers when a pre-commit flush fails", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + const worker = createWorker({ + processes: new Map([[7, { channels: [{ pid: 7, memory }], memory }]]), + sharedMappings: new Map([[7, new Map([ + [0x1000, { fd: 4, fileOffset: 0, len: 0x1000, writable: true }], + ])]]), + pwriteFromProcessMemory: vi.fn(() => false), + }); + + expect(worker.prepareAddressSpaceForExec(7)).toBe(-5); + expect(worker.sharedMappings.has(7)).toBe(true); + }); + + it("does not flush read-only shared mappings during exec", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + const flush = vi.fn(() => false); + const worker = createWorker({ + processes: new Map([[7, { channels: [{ pid: 7, memory }], memory }]]), + sharedMappings: new Map([[7, new Map([ + [0x1000, { fd: 4, fileOffset: 0, len: 0x1000, writable: false }], + ])]]), + pwriteFromProcessMemory: flush, + }); + + expect(worker.prepareAddressSpaceForExec(7)).toBe(0); + expect(flush).not.toHaveBeenCalled(); + }); + + it("tracks mmap writeback only for kernel-classified writable regular fds", () => { + const worker = createWorker({ + kernelInstance: { + exports: { + kernel_fd_supports_mmap_writeback: (_pid: number, fd: number) => + fd === 4 ? 1 : 0, + }, + }, + }); + + expect(worker.fdSupportsMmapWriteback(7, 4)).toBe(true); + expect(worker.fdSupportsMmapWriteback(7, 5)).toBe(false); + }); + + it("reacquires pwrite scratch views after kernel memory growth", () => { + const processMemory = new WebAssembly.Memory({ initial: 2 }); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 4 }); + const channel = { pid: 7, memory: processMemory }; + let calls = 0; + const worker = createWorker({ + currentHandlePid: 0, + kernelMemory, + scratchOffset: 0, + toKernelPtr: (value: number) => value, + bindKernelTidForChannel: vi.fn(), + kernelInstance: { + exports: { + kernel_handle_channel: () => { + const args = new DataView(kernelMemory.buffer); + const requested = Number(args.getBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + true, + )); + kernelMemory.grow(1); + new DataView(kernelMemory.buffer).setBigInt64( + CH_RETURN, + BigInt(requested), + true, + ); + calls++; + }, + }, + }, + }); + + expect(worker.pwriteFromProcessMemory( + channel, + 4, + 0x1000, + CH_DATA_SIZE + 4, + 0, + )).toBe(true); + expect(calls).toBe(2); + expect(worker.currentHandlePid).toBe(0); + }); + + it("copies SysV mappings before commit and detaches them afterward", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + new Uint8Array(memory.buffer, 0x1000, 4).set([1, 2, 3, 4]); + const kernelMemory = new WebAssembly.Memory({ initial: 2 }); + const writeChunk = vi.fn(() => 4); + const readChunk = vi.fn((_id: number, _offset: number, outPtr: number, len: number) => { + new Uint8Array(kernelMemory.buffer, outPtr, len).fill(0); + return len; + }); + const detach = vi.fn(() => 0); + const worker = createWorker({ + processes: new Map([[7, { channels: [{ pid: 7, memory }], memory }]]), + shmMappings: new Map([[7, new Map([ + [0x1000, { + segId: 3, + size: 4, + readOnly: false, + snapshot: new Uint8Array(4), + seenVersion: 0, + }], + ])]]), + shmSegmentVersions: new Map([[3, 0]]), + currentHandlePid: 0, + kernelMemory, + scratchOffset: 0, + getKernelMem: () => new Uint8Array(kernelMemory.buffer), + toKernelPtr: (value: number) => value, + kernelInstance: { + exports: { + kernel_set_current_pid: vi.fn(), + kernel_ipc_shm_read_chunk: readChunk, + kernel_ipc_shm_write_chunk: writeChunk, + kernel_ipc_shmdt: detach, + }, + }, + }); + + expect(worker.prepareAddressSpaceForExec(7)).toBe(0); + expect(writeChunk).toHaveBeenCalledWith(3, 0, 72, 4); + expect(detach).not.toHaveBeenCalled(); + expect(worker.shmMappings.has(7)).toBe(true); + + expect(worker.finalizeAddressSpaceForExec(7)).toBe(0); + expect(detach).toHaveBeenCalledWith(3); + expect(worker.shmMappings.has(7)).toBe(false); + }); + + it("validates the caller before setup and prunes closed epoll mirrors", () => { + let ambientPid = 0; + let preparedCaller = 0; + const openFds = new Set([6, 8]); + const worker = createWorker({ + currentHandlePid: 0, + kernelInstance: { + exports: { + kernel_exec_prepare: (_pid: number, tid: number) => { + ambientPid = worker.currentHandlePid; + preparedCaller = tid; + return 0; + }, + kernel_exec_setup_for_thread: (_pid: number, _tid: number) => { + ambientPid = worker.currentHandlePid; + return 0; + }, + kernel_exec_setup: () => 0, + kernel_fd_is_open: (_pid: number, fd: number) => openFds.has(fd) ? 1 : 0, + }, + }, + epollInterests: new Map([ + ["7:6", [ + { fd: 8, events: 1, data: 11n }, + { fd: 9, events: 1, data: 12n }, + ]], + ["7:10", []], + ]), + }); + + expect(worker.kernelExecPrepare(7, 11)).toBe(0); + expect(preparedCaller).toBe(11); + expect(ambientPid).toBe(7); + expect(worker.currentHandlePid).toBe(0); + expect(worker.kernelExecSetup(7, 11)).toBe(0); + expect(ambientPid).toBe(7); + expect(worker.currentHandlePid).toBe(0); + expect(worker.epollInterests.get("7:6")).toEqual([ + { fd: 8, events: 1, data: 11n }, + ]); + expect(worker.epollInterests.has("7:10")).toBe(false); + }); + + it("remaps a TCP listener mirror to its surviving fd alias", () => { + let committed = false; + const close = vi.fn(); + const listener = { + server: { close }, + pid: 7, + port: 8080, + connections: new Set(), + }; + const worker = createWorker({ + currentHandlePid: 0, + kernelInstance: { + exports: { + kernel_exec_setup_for_thread: () => { + committed = true; + return 0; + }, + kernel_exec_setup: () => 0, + kernel_fd_is_open: (_pid: number, fd: number) => committed && fd === 2048 ? 1 : 0, + kernel_get_fd_accept_wake_idx: (_pid: number, fd: number) => { + if (fd === 2048) return 41; + return !committed && fd === 4 ? 41 : -1; + }, + kernel_find_listener_fd_by_accept_wake: (_pid: number, wakeIdx: number) => + committed && wakeIdx === 41 ? 2048 : -1, + }, + }, + tcpListenerTargets: new Map([[8080, [{ pid: 7, fd: 4 }]]]), + tcpListenerRRIndex: new Map([[8080, 0]]), + tcpListeners: new Map([["7:4", listener]]), + }); + + expect(worker.kernelExecSetup(7, 7)).toBe(0); + expect(worker.tcpListenerTargets.get(8080)).toEqual([{ pid: 7, fd: 2048 }]); + expect(worker.tcpListeners.has("7:4")).toBe(false); + expect(worker.tcpListeners.get("7:2048")).toEqual(listener); + expect(close).not.toHaveBeenCalled(); + }); + + it("remaps a listener after its original mirrored fd was already closed", () => { + const listener = { + server: { close: vi.fn() }, + pid: 7, + port: 8080, + connections: new Set(), + }; + const worker = createWorker({ + currentHandlePid: 0, + kernelInstance: { + exports: { + kernel_exec_setup_for_thread: () => 0, + kernel_exec_setup: () => 0, + kernel_fd_is_open: (_pid: number, fd: number) => fd === 2048 ? 1 : 0, + kernel_get_fd_accept_wake_idx: (_pid: number, fd: number) => + fd === 2048 ? 41 : -1, + kernel_find_listener_fd_by_accept_wake: (_pid: number, wakeIdx: number) => + wakeIdx === 41 ? 2048 : -1, + }, + }, + tcpListenerTargets: new Map([[8080, [{ + pid: 7, + fd: 4, + acceptWakeIdx: 41, + }]]]), + tcpListenerRRIndex: new Map([[8080, 0]]), + tcpListeners: new Map([["7:4", listener]]), + }); + + expect(worker.kernelExecSetup(7, 7)).toBe(0); + expect(worker.tcpListenerTargets.get(8080)).toEqual([{ + pid: 7, + fd: 2048, + acceptWakeIdx: 41, + }]); + expect(worker.tcpListeners.get("7:2048")).toEqual(listener); + expect(listener.server.close).not.toHaveBeenCalled(); + }); + + it("keeps pending child listener targets during async worker launch", () => { + const targets = [ + { pid: 7, fd: 4 }, + { pid: 8, fd: 4 }, + ]; + const worker = createWorker({ + processes: new Map([[7, { channels: [], memory: new WebAssembly.Memory({ initial: 1 }) }]]), + tcpListenerTargets: new Map([[8080, targets]]), + tcpListenerRRIndex: new Map([[8080, 0]]), + }); + + expect(worker.pickListenerTarget(8080)).toEqual({ pid: 7, fd: 4 }); + expect(worker.tcpListenerTargets.get(8080)).toEqual(targets); + }); + + it("reconciles a reused listener fd without losing its surviving alias", () => { + const listener = { + server: { close: vi.fn() }, + pid: 7, + port: 8080, + connections: new Set(), + }; + const worker = createWorker({ + kernelInstance: { + exports: { + kernel_get_fd_accept_wake_idx: (_pid: number, fd: number) => + fd === 4 ? 99 : fd === 6 ? 41 : -1, + kernel_find_listener_fd_by_accept_wake: (_pid: number, wakeIdx: number) => + wakeIdx === 41 ? 6 : wakeIdx === 99 ? 4 : -1, + }, + }, + tcpListenerTargets: new Map([[8080, [{ + pid: 7, + fd: 4, + acceptWakeIdx: 41, + }]]]), + tcpListenerRRIndex: new Map([[8080, 0]]), + tcpListeners: new Map([["7:4", listener]]), + netModule: null, + }); + + worker.startTcpListener(7, 4, 9090); + + expect(worker.tcpListenerTargets.get(8080)).toEqual([{ + pid: 7, + fd: 6, + acceptWakeIdx: 41, + }]); + expect(worker.tcpListenerTargets.get(9090)).toEqual([{ + pid: 7, + fd: 4, + acceptWakeIdx: 99, + }]); + expect(worker.tcpListeners.get("7:6")).toEqual(listener); + expect(listener.server.close).not.toHaveBeenCalled(); + }); + + it("finalizes signal death during the exec handoff exactly once", () => { + const notifyParent = vi.fn(); + const onExit = vi.fn(); + const worker = createWorker({ + hostReaped: new Set(), + callbacks: { onExit }, + notifyParentOfExitedProcess: notifyParent, + kernelInstance: { + exports: { + kernel_get_process_exit_signal: () => 15, + }, + }, + sharedMappings: new Map([[7, new Map([[0x1000, { fd: 4 }]])]]), + }); + + expect(worker.finalizeExecHandoffTermination(7)).toBe(15); + expect(worker.finalizeExecHandoffTermination(7)).toBe(15); + expect(notifyParent).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(7, 143); + expect(worker.sharedMappings.has(7)).toBe(false); + }); + + it("does not launch a signal-dead pending child or roll back its zombie", () => { + const notifyParent = vi.fn(); + const onExit = vi.fn(); + const cleanupTcpListeners = vi.fn(); + const removeProcess = vi.fn(); + const worker = createWorker({ + hostReaped: new Set(), + callbacks: { onExit }, + notifyParentOfExitedProcess: notifyParent, + cleanupTcpListeners, + kernelInstance: { + exports: { + kernel_get_process_exit_signal: (pid: number) => { + if (pid === 8) return 9; + if (pid === 9) return -1; + if (pid === 10) return 0; + return -3; + }, + kernel_remove_process: removeProcess, + }, + }, + epollInterests: new Map([ + ["8:4", [{ fd: 6, events: 1, data: 1n }]], + ["9:4", [{ fd: 6, events: 1, data: 2n }]], + ["10:4", [{ fd: 6, events: 1, data: 3n }]], + ["11:4", [{ fd: 6, events: 1, data: 4n }]], + ]), + }); + + expect(worker.shouldLaunchPendingChild(8)).toBe(false); + expect(worker.shouldLaunchPendingChild(9)).toBe(true); + expect(worker.shouldLaunchPendingChild(10)).toBe(false); + expect(worker.shouldLaunchPendingChild(11)).toBe(false); + expect(notifyParent).toHaveBeenCalledWith(8); + expect(onExit).toHaveBeenCalledWith(8, 137); + expect(cleanupTcpListeners.mock.calls).toEqual([[8], [10], [11]]); + expect(worker.epollInterests.has("8:4")).toBe(false); + expect(worker.epollInterests.has("9:4")).toBe(true); + expect(worker.epollInterests.has("10:4")).toBe(false); + expect(worker.epollInterests.has("11:4")).toBe(false); + expect(removeProcess).not.toHaveBeenCalled(); + }); +}); + +function createWorker(overrides: Record): any { + return Object.assign(Object.create(CentralizedKernelWorker.prototype), { + processes: new Map(), + activeChannels: [], + execHandoffPids: new Set(), + waitingForChild: [], + pendingSleeps: new Map(), + pendingPollRetries: new Map(), + pendingSelectRetries: new Map(), + pendingPipeReaders: new Map(), + pendingPipeWriters: new Map(), + pendingFutexWaits: new Map(), + pendingCancels: new Set(), + socketTimeoutTimers: new Map(), + posixTimers: new Map(), + channelTids: new Map(), + threadForkContexts: new Map(), + threadCtidPtrs: new Map(), + sharedMappings: new Map(), + shmMappings: new Map(), + epollInterests: new Map(), + tcpListenerTargets: new Map(), + tcpListenerRRIndex: new Map(), + tcpVirtualListenerKeys: new Map(), + tcpListeners: new Map(), + tcpConnections: new Map(), + hostReaped: new Set(), + callbacks: {}, + io: { network: undefined }, + ...overrides, + }); +} + +function createChannel(pid: number, memory: WebAssembly.Memory, channelOffset: number): any { + return { + pid, + memory, + channelOffset, + i32View: new Int32Array(memory.buffer, channelOffset), + consecutiveSyscalls: 0, + }; +} diff --git a/host/test/file-shared-memory.test.ts b/host/test/file-shared-memory.test.ts new file mode 100644 index 000000000..fb25a9e5d --- /dev/null +++ b/host/test/file-shared-memory.test.ts @@ -0,0 +1,1334 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ABI_SYSCALLS, + CH_ARGS, + CH_ARG_SIZE, + CH_ERRNO, + CH_RETURN, + CH_STATUS, + CH_SYSCALL, + CHANNEL_STATUS_COMPLETE, +} from "../src/generated/abi"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +const MAP_SHARED = 1; +const MAP_PRIVATE = 2; +const MAP_FIXED = 0x10; +const MAP_ANONYMOUS = 0x20; +const PROT_READ = 1; +const PROT_WRITE = 2; +const REGULAR_MODE = 0o100644; +const SYS_COPY_FILE_RANGE = 290; +const SYS_SPLICE = 291; + +function memory(): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); +} + +function createFileHarness() { + const pids = [71, 72, 73]; + const memories = new Map(pids.map((pid) => [pid, memory()])); + const channels = new Map(pids.map((pid) => { + const processMemory = memories.get(pid)!; + return [pid, { + pid, + memory: processMemory, + channelOffset: 0, + i32View: new Int32Array(processMemory.buffer, 0, 1), + consecutiveSyscalls: 0, + }]; + })); + const storage = new Uint8Array(16 * 1024); + storage.set(Array.from({ length: 64 }, (_, i) => i + 1)); + let logicalSize = storage.length; + let nextHandle = 100; + const liveHandles = new Set(); + const open = vi.fn((_path: string, _flags: number) => { + const handle = nextHandle++; + liveHandles.add(handle); + return handle; + }); + const close = vi.fn((handle: number) => { + liveHandles.delete(handle); + return 0; + }); + const io = { + open, + close, + read: vi.fn((handle: number, out: Uint8Array, offset: number | null, len: number) => { + if (!liveHandles.has(handle)) throw new Error("closed stable handle"); + const start = offset ?? 0; + if (start >= logicalSize) return 0; + const count = Math.min(len, logicalSize - start); + out.set(storage.subarray(start, start + count)); + return count; + }), + write: vi.fn((handle: number, input: Uint8Array, offset: number | null, len: number) => { + if (!liveHandles.has(handle)) throw new Error("closed stable handle"); + const start = offset ?? 0; + const count = Math.min(len, storage.length - start); + storage.set(input.subarray(0, count), start); + logicalSize = Math.max(logicalSize, start + count); + return count; + }), + fstat: vi.fn((handle: number) => { + if (!liveHandles.has(handle)) throw new Error("closed stable handle"); + return { + dev: 7, + ino: 99, + mode: REGULAR_MODE, + nlink: 1, + uid: 0, + gid: 0, + size: logicalSize, + atimeMs: 0, + mtimeMs: 0, + ctimeMs: 0, + }; + }), + stat: vi.fn((_path: string) => ({ + dev: 7, + ino: 99, + mode: REGULAR_MODE, + nlink: 1, + uid: 0, + gid: 0, + size: logicalSize, + atimeMs: 0, + mtimeMs: 0, + ctimeMs: 0, + })), + fileIdentity: vi.fn((_path: string, dev: bigint, ino: bigint) => + ino === 0n ? null : `test:${dev}:${ino}`), + }; + const fdIdentity = new Map([ + [4, "/dev/shm/php-cache"], + [9, "/dev/shm/php-cache"], + ]); + const processes = new Map(pids.map((pid) => [pid, { + pid, + memory: memories.get(pid)!, + channels: [channels.get(pid)!], + }])); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + io, + processes, + sharedMappings: new Map(), + anonymousSharedBackings: new Map(), + sharedMmapBackings: new Map(), + sharedMmapFdCache: new Map(), + shmMappings: new Map(), + shmSegmentVersions: new Map(), + fdSupportsMmapWriteback: vi.fn(() => true), + getFdAccessModeForSharedMapping: vi.fn(() => ({ kind: "ok", value: 2 })), + getFdStatForSharedMapping: vi.fn((_channel: unknown, fd: number) => { + return fdIdentity.has(fd) + ? { + kind: "ok", + value: { + dev: 7n, + ino: 99n, + size: logicalSize, + mode: REGULAR_MODE, + }, + } + : { kind: "error", errno: 9 }; + }), + getFdPathForSharedMapping: vi.fn((_channel: unknown, fd: number) => + fdIdentity.has(fd) + ? { kind: "ok", value: fdIdentity.get(fd)! } + : { kind: "error", errno: 9 }), + }) as CentralizedKernelWorker; + + const mapResult = ( + pid: number, + fd: number, + addr: number, + len = 4096, + prot = PROT_WRITE, + ) => + (kw as any).mapSharedMmapFromFile( + channels.get(pid), + addr, + [0, len, prot, MAP_SHARED, fd, 0], + ) as { kind: "mapped" | "unsupported" | "error"; errno?: number }; + const map = ( + pid: number, + fd: number, + addr: number, + len = 4096, + prot = PROT_WRITE, + ) => mapResult(pid, fd, addr, len, prot).kind === "mapped"; + + return { + channels, + close, + fdIdentity, + io, + kw, + logicalSize: () => logicalSize, + map, + mapResult, + memories, + open, + pids, + setLogicalSize: (size: number) => { logicalSize = size; }, + storage, + }; +} + +type FileHarness = ReturnType; + +function configureKernelSyscallHarness(h: FileHarness, pid: number) { + const kernelHandle = vi.fn(); + const completeChannel = vi.fn(); + Object.assign(h.kw as any, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + kernelMemory: new WebAssembly.Memory({ initial: 2 }), + scratchOffset: 0, + kernelInstance: { exports: { kernel_handle_channel: kernelHandle } }, + formatSyscallEntry: vi.fn(() => "memory syscall"), + synchronizeSharedMemoryForBoundary: vi.fn(), + flushSharedMappingsBeforeFileSyscall: vi.fn(() => true), + completeChannel, + }); + return { completeChannel, kernelHandle }; +} + +function writeChannelSyscall( + channel: any, + syscallNr: number, + args: number[], +): void { + const view = new DataView(channel.memory.buffer, channel.channelOffset); + view.setUint32(CH_SYSCALL, syscallNr, true); + for (let i = 0; i < args.length; i++) { + view.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(args[i]), true); + } +} + +describe("file/POSIX MAP_SHARED page cache", () => { + it("reuses a prepared backing when registering the successful mmap", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const args = [0, 4096, PROT_WRITE, MAP_SHARED, 4, 0]; + const preparation = (h.kw as any).prepareSharedMmapFromFile( + h.channels.get(pid), + args, + ); + expect(preparation.kind).toBe("prepared"); + + expect((h.kw as any).registerPreparedSharedMmap( + h.channels.get(pid), + 0x1000, + preparation.context, + )).toEqual({ kind: "mapped" }); + expect(h.open).toHaveBeenCalledTimes(1); + expect((h.kw as any).getFdStatForSharedMapping).toHaveBeenCalledTimes(1); + expect((h.kw as any).getFdPathForSharedMapping).toHaveBeenCalledTimes(1); + expect((h.kw as any).getFdAccessModeForSharedMapping).toHaveBeenCalledTimes(1); + }); + + it("reserves a same-file backing across MAP_FIXED replacement cleanup", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + const preparation = (h.kw as any).prepareSharedMmapFromFile( + h.channels.get(pid), + [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 9, 0], + ); + expect(preparation.kind).toBe("prepared"); + expect(backing.refCount).toBe(2); + + (h.kw as any).cleanupSharedMappings(pid, addr, 4096); + expect(backing.refCount).toBe(1); + expect((h.kw as any).sharedMmapBackings.get(backing.key)).toBe(backing); + expect(h.close).not.toHaveBeenCalledWith(backing.handle); + + expect((h.kw as any).registerPreparedSharedMmap( + h.channels.get(pid), + addr, + preparation.context, + )).toEqual({ kind: "mapped" }); + expect(backing.refCount).toBe(1); + expect(h.open).toHaveBeenCalledTimes(1); + }); + + it("fails MAP_FIXED preflight before invoking the destructive kernel mmap", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const channel = h.channels.get(pid)!; + const originalMapping = { + fd: 9, + fileOffset: 0, + len: 4096, + writable: true, + }; + const originalMap = new Map([[addr, originalMapping]]); + (h.kw as any).sharedMappings.set(pid, originalMap); + h.open.mockImplementationOnce(() => { + throw Object.assign(new Error("stable open failed"), { code: "EACCES" }); + }); + + const kernelHandle = vi.fn(); + const completeChannel = vi.fn(); + Object.assign(h.kw as any, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + kernelMemory: new WebAssembly.Memory({ initial: 2 }), + scratchOffset: 0, + kernelInstance: { exports: { kernel_handle_channel: kernelHandle } }, + formatSyscallEntry: vi.fn(() => "mmap"), + synchronizeSharedMemoryForBoundary: vi.fn(), + flushSharedMappingsBeforeFileSyscall: vi.fn(() => true), + completeChannel, + }); + + const args = [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 4, 0]; + const view = new DataView(channel.memory.buffer, channel.channelOffset); + view.setUint32(CH_SYSCALL, ABI_SYSCALLS.Mmap, true); + for (let i = 0; i < args.length; i++) { + view.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(args[i]), true); + } + + (h.kw as any)._handleSyscallInner(channel); + + expect(kernelHandle).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Mmap, + args, + undefined, + -1, + 13, + ); + expect((h.kw as any).sharedMappings.get(pid)).toBe(originalMap); + expect((h.kw as any).sharedMappings.get(pid).get(addr)).toBe(originalMapping); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + }); + + it("keeps MAP_FIXED intact when the old overlapping mapping cannot flush", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const channel = h.channels.get(pid)!; + expect(h.map(pid, 4, addr)).toBe(true); + const originalMap = (h.kw as any).sharedMappings.get(pid); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + const flush = vi.fn(() => false); + Object.assign(h.kw as any, { flushSharedMappings: flush }); + const { completeChannel, kernelHandle } = configureKernelSyscallHarness(h, pid); + const args = [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 9, 0]; + writeChannelSyscall(channel, ABI_SYSCALLS.Mmap, args); + + (h.kw as any)._handleSyscallInner(channel); + + expect(flush).toHaveBeenCalledWith(channel, [addr, 65536]); + expect(kernelHandle).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, ABI_SYSCALLS.Mmap, args, undefined, -1, 5, + ); + expect((h.kw as any).sharedMappings.get(pid)).toBe(originalMap); + expect(backing.refCount).toBe(1); + expect(h.close).not.toHaveBeenCalledWith(backing.handle); + }); + + it("fails MAP_FIXED before the kernel when process memory cannot cover it", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const oldAddr = 0x1000; + const fixedAddr = 0x10000; + const channel = h.channels.get(pid)!; + expect(h.map(pid, 4, oldAddr)).toBe(true); + const originalMap = (h.kw as any).sharedMappings.get(pid); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + const flush = vi.fn(() => true); + Object.assign(h.kw as any, { flushSharedMappings: flush }); + const { completeChannel, kernelHandle } = configureKernelSyscallHarness(h, pid); + const args = [fixedAddr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 9, 0]; + writeChannelSyscall(channel, ABI_SYSCALLS.Mmap, args); + + (h.kw as any)._handleSyscallInner(channel); + + expect(kernelHandle).not.toHaveBeenCalled(); + expect(flush).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, ABI_SYSCALLS.Mmap, args, undefined, -1, 12, + ); + expect((h.kw as any).sharedMappings.get(pid)).toBe(originalMap); + expect(backing.refCount).toBe(1); + expect(h.close).not.toHaveBeenCalledWith(backing.handle); + }); + + it("releases a prepared reservation when pre-kernel MAP_FIXED work throws", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const channel = h.channels.get(pid)!; + expect(h.map(pid, 4, addr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + Object.assign(h.kw as any, { + flushSharedMappings: vi.fn(() => { + throw new Error("pre-kernel flush threw"); + }), + }); + const { kernelHandle } = configureKernelSyscallHarness(h, pid); + writeChannelSyscall( + channel, + ABI_SYSCALLS.Mmap, + [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 9, 0], + ); + + expect(() => (h.kw as any)._handleSyscallInner(channel)) + .toThrow(/pre-kernel flush threw/); + expect(kernelHandle).not.toHaveBeenCalled(); + expect(backing.refCount).toBe(1); + expect(h.close).not.toHaveBeenCalledWith(backing.handle); + }); + + it("fails file mremap expansion before the kernel when a new page cannot load", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const channel = h.channels.get(pid)!; + expect(h.map(pid, 4, addr)).toBe(true); + const originalMapping = (h.kw as any).sharedMappings.get(pid).get(addr); + h.io.read.mockImplementationOnce(() => { + throw new Error("extension read failed"); + }); + const { completeChannel, kernelHandle } = configureKernelSyscallHarness(h, pid); + const args = [addr, 4096, 8192, 1, 0, 0]; + writeChannelSyscall(channel, ABI_SYSCALLS.Mremap, args); + + (h.kw as any)._handleSyscallInner(channel); + + expect(kernelHandle).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, ABI_SYSCALLS.Mremap, args, undefined, -1, 5, + ); + expect((h.kw as any).sharedMappings.get(pid).get(addr)).toBe(originalMapping); + expect(originalMapping.len).toBe(4096); + }); + + it("shares one inode backing across separate fds and preserves disjoint writes", () => { + const h = createFileHarness(); + const [firstPid, secondPid] = h.pids; + const firstAddr = 0x1000; + const secondAddr = 0x3000; + expect(h.map(firstPid, 4, firstAddr)).toBe(true); + expect(h.map(secondPid, 9, secondAddr)).toBe(true); + expect(h.open).toHaveBeenCalledTimes(1); + expect((h.kw as any).sharedMmapBackings.size).toBe(1); + + const first = new Uint8Array(h.memories.get(firstPid)!.buffer); + const second = new Uint8Array(h.memories.get(secondPid)!.buffer); + first[firstAddr + 11] = 0xa1; + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(firstPid), + ); + second[secondAddr + 29] = 0xb2; + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(secondPid), + ); + + expect(second[secondAddr + 11]).toBe(0xa1); + expect(second[secondAddr + 29]).toBe(0xb2); + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(firstPid), + ); + expect(first[firstAddr + 29]).toBe(0xb2); + }); + + it("publishes a sole observer before a second mapping joins its backing", () => { + const h = createFileHarness(); + const [firstPid, secondPid] = h.pids; + const firstAddr = 0x1000; + const secondAddr = 0x3000; + expect(h.map(firstPid, 4, firstAddr)).toBe(true); + new Uint8Array(h.memories.get(firstPid)!.buffer)[firstAddr + 37] = 0xd7; + + expect(h.map(secondPid, 9, secondAddr)).toBe(true); + + expect(new Uint8Array(h.memories.get(secondPid)!.buffer)[secondAddr + 37]) + .toBe(0xd7); + }); + + it("converges overlapping aliases after one coherence boundary", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const firstAddr = 0x1000; + const secondAddr = 0x3000; + expect(h.map(pid, 4, firstAddr)).toBe(true); + expect(h.map(pid, 9, secondAddr)).toBe(true); + const process = new Uint8Array(h.memories.get(pid)!.buffer); + process[firstAddr + 11] = 0xa1; + process[secondAddr + 29] = 0xb2; + + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + ); + + expect(process[firstAddr + 11]).toBe(0xa1); + expect(process[firstAddr + 29]).toBe(0xb2); + expect(process[secondAddr + 11]).toBe(0xa1); + expect(process[secondAddr + 29]).toBe(0xb2); + }); + + it("inherits file mappings across fork using the same stable backing", () => { + const h = createFileHarness(); + const [parentPid, , childPid] = h.pids; + const addr = 0x1800; + expect(h.map(parentPid, 4, addr)).toBe(true); + const parent = new Uint8Array(h.memories.get(parentPid)!.buffer); + parent[addr + 17] = 0x77; + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(parentPid), + { force: true }, + ); + + h.kw.inheritProcessSharedMappings(parentPid, childPid); + const child = new Uint8Array(h.memories.get(childPid)!.buffer); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + expect(child[addr + 17]).toBe(0x77); + expect(backing.refCount).toBe(2); + expect((h.kw as any).sharedMappings.get(childPid).size).toBe(1); + expect((h.kw as any).sharedMmapFdCache.has(`${childPid}:4`)).toBe(false); + }); + + it("keeps the mapping alive after the guest closes its original fd", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x2000; + expect(h.map(pid, 4, addr)).toBe(true); + const stableHandle = Array.from((h.kw as any).sharedMmapBackings.values())[0].handle; + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Close, [4], 0, 0, + ); + h.fdIdentity.delete(4); + new Uint8Array(h.memories.get(pid)!.buffer)[addr + 23] = 0xc3; + (h.kw as any).flushSharedMappings(h.channels.get(pid), [addr, 4096]); + + expect(h.storage[23]).toBe(0xc3); + expect(h.close).not.toHaveBeenCalledWith(stableHandle); + (h.kw as any).cleanupSharedMappings(pid, addr, 4096); + expect(h.close).toHaveBeenCalledWith(stableHandle); + }); + + it("refreshes mappings after direct pwrite and ftruncate", () => { + const h = createFileHarness(); + const [writerPid, readerPid] = h.pids; + const writerAddr = 0x1000; + const readerAddr = 0x3000; + expect(h.map(writerPid, 4, writerAddr)).toBe(true); + expect(h.map(readerPid, 9, readerAddr)).toBe(true); + const writer = new Uint8Array(h.memories.get(writerPid)!.buffer); + const reader = new Uint8Array(h.memories.get(readerPid)!.buffer); + const sourcePtr = 0x7000; + writer.set([0xde, 0xad], sourcePtr); + h.storage.set([0xde, 0xad], 20); // kernel pwrite already changed the file + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(writerPid), + ABI_SYSCALLS.Pwrite, + [4, sourcePtr, 2, 20], + 2, + 0, + ); + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(readerPid), + ); + expect(Array.from(reader.subarray(readerAddr + 20, readerAddr + 22))) + .toEqual([0xde, 0xad]); + + h.setLogicalSize(16); // kernel ftruncate already changed the file + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(writerPid), ABI_SYSCALLS.Ftruncate, [4, 16], 0, 0, + ); + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(readerPid), + ); + expect(reader[readerAddr + 20]).toBe(0); + }); + + it("flushes mapped bytes before direct reads and reloads after direct writes", () => { + const h = createFileHarness(); + const [writerPid, readerPid] = h.pids; + const writerAddr = 0x1000; + const readerAddr = 0x3000; + expect(h.map(writerPid, 4, writerAddr)).toBe(true); + expect(h.map(readerPid, 9, readerAddr)).toBe(true); + const writer = new Uint8Array(h.memories.get(writerPid)!.buffer); + const reader = new Uint8Array(h.memories.get(readerPid)!.buffer); + + writer[writerAddr + 35] = 0xd1; + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(writerPid), ABI_SYSCALLS.Pread, [4], + )).toBe(true); + expect(h.storage[35]).toBe(0xd1); + + h.storage[41] = 0xe2; // the kernel's direct write already completed + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(writerPid), ABI_SYSCALLS.Write, [4], 1, 0, + ); + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(readerPid), + ); + expect(reader[readerAddr + 41]).toBe(0xe2); + }); + + it("publishes dirty shared bytes before a private mmap reads the file", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + new Uint8Array(h.memories.get(pid)!.buffer)[addr + 43] = 0xe3; + + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(pid), + ABI_SYSCALLS.Mmap, + [0, 4096, PROT_READ, MAP_PRIVATE, 4, 0], + )).toBe(true); + + expect(h.storage[43]).toBe(0xe3); + }); + + it("keeps truncate, fallocate, and O_TRUNC coherent with mapped files", () => { + const path = "/dev/shm/php-cache"; + const pathBytes = new TextEncoder().encode(`${path}\0`); + + const truncate = createFileHarness(); + const truncatePid = truncate.pids[0]; + const truncateAddr = 0x1000; + const truncatePathPtr = 0x7000; + expect(truncate.map(truncatePid, 4, truncateAddr)).toBe(true); + const truncateMemory = new Uint8Array( + truncate.memories.get(truncatePid)!.buffer, + ); + truncateMemory.set(pathBytes, truncatePathPtr); + truncateMemory[truncateAddr + 47] = 0xa7; + expect((truncate.kw as any).flushSharedMappingsBeforeFileSyscall( + truncate.channels.get(truncatePid), + ABI_SYSCALLS.Truncate, + [truncatePathPtr, 16], + )).toBe(true); + expect(truncate.storage[47]).toBe(0xa7); + truncate.setLogicalSize(16); + (truncate.kw as any).handleSharedMappingsAfterFileSyscall( + truncate.channels.get(truncatePid), + ABI_SYSCALLS.Truncate, + [truncatePathPtr, 16], + 0, + 0, + ); + const truncateBacking = Array.from( + (truncate.kw as any).sharedMmapBackings.values(), + )[0]; + expect(truncateBacking.size).toBe(16); + + const fallocate = createFileHarness(); + const fallocatePid = fallocate.pids[0]; + expect(fallocate.map(fallocatePid, 4, 0x1000)).toBe(true); + new Uint8Array(fallocate.memories.get(fallocatePid)!.buffer)[0x1000 + 53] + = 0xb8; + expect((fallocate.kw as any).flushSharedMappingsBeforeFileSyscall( + fallocate.channels.get(fallocatePid), + ABI_SYSCALLS.Fallocate, + [4, 0, 0, 20_000], + )).toBe(true); + expect(fallocate.storage[53]).toBe(0xb8); + fallocate.setLogicalSize(20_000); + (fallocate.kw as any).handleSharedMappingsAfterFileSyscall( + fallocate.channels.get(fallocatePid), + ABI_SYSCALLS.Fallocate, + [4, 0, 0, 20_000], + 0, + 0, + ); + expect(Array.from((fallocate.kw as any).sharedMmapBackings.values())[0].size) + .toBe(20_000); + + const openTruncate = createFileHarness(); + const openPid = openTruncate.pids[0]; + const openPathPtr = 0x7000; + expect(openTruncate.map(openPid, 4, 0x1000)).toBe(true); + const openMemory = new Uint8Array(openTruncate.memories.get(openPid)!.buffer); + openMemory.set(pathBytes, openPathPtr); + openMemory[0x1000 + 59] = 0xc9; + expect((openTruncate.kw as any).flushSharedMappingsBeforeFileSyscall( + openTruncate.channels.get(openPid), + ABI_SYSCALLS.Open, + [openPathPtr, 0o1002, 0], + )).toBe(true); + expect(openTruncate.storage[59]).toBe(0xc9); + openTruncate.setLogicalSize(0); + (openTruncate.kw as any).handleSharedMappingsAfterFileSyscall( + openTruncate.channels.get(openPid), + ABI_SYSCALLS.Open, + [openPathPtr, 0o1002, 0], + 9, + 0, + ); + expect(Array.from( + (openTruncate.kw as any).sharedMmapBackings.values(), + )[0].size).toBe(0); + }); + + it("copies shared-memory pathnames before browser TextDecoder decoding", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const pathPtr = 0x7000; + const path = "/dev/shm/php-cache"; + new Uint8Array(h.memories.get(pid)!.buffer).set( + new TextEncoder().encode(`${path}\0`), + pathPtr, + ); + + const originalDecode = TextDecoder.prototype.decode; + const decode = vi.spyOn(TextDecoder.prototype, "decode").mockImplementation( + function (this: TextDecoder, input?: any, options?: any) { + if (ArrayBuffer.isView(input) + && input.buffer instanceof SharedArrayBuffer) { + throw new TypeError("browser TextDecoder rejects shared views"); + } + return originalDecode.call(this, input, options); + }, + ); + + try { + expect((h.kw as any).resolveSharedMmapPath( + h.channels.get(pid), + pathPtr, + )).toEqual({ kind: "ok", value: path }); + } finally { + decode.mockRestore(); + } + }); + + it("balances backing references across partial unmap splits", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr, 3 * 4096)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + expect(backing.refCount).toBe(1); + + (h.kw as any).cleanupSharedMappings(pid, addr + 4096, 4096); + expect(backing.refCount).toBe(2); + expect((h.kw as any).sharedMappings.get(pid).size).toBe(2); + + (h.kw as any).cleanupSharedMappings(pid, addr, 3 * 4096); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + expect(h.close).toHaveBeenCalledTimes(1); + }); + + it("keeps one backing reference while mremap moves and grows a mapping", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const oldAddr = 0x1000; + const newAddr = 0x5000; + expect(h.map(pid, 4, oldAddr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + + (h.kw as any).remapSharedMapping(pid, oldAddr, newAddr, 8192); + expect(backing.refCount).toBe(1); + expect((h.kw as any).sharedMappings.get(pid).has(oldAddr)).toBe(false); + expect((h.kw as any).sharedMappings.get(pid).get(newAddr).len).toBe(8192); + expect(new Uint8Array(h.memories.get(pid)!.buffer)[newAddr]).toBe(1); + + (h.kw as any).cleanupSharedMappings(pid, newAddr, 8192); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + }); + + it("rolls back inherited file references when a later mapping is invalid", () => { + const h = createFileHarness(); + const [parentPid, , childPid] = h.pids; + const addr = 0x1000; + expect(h.map(parentPid, 4, addr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + (h.kw as any).sharedMappings.get(parentPid).set(addr + 0x2000, { + fd: 10, + fileOffset: 0, + len: 4096, + writable: true, + backingKind: "file", + backingKey: "missing", + snapshot: new Uint8Array(4096), + seenVersion: 0, + }); + + expect(() => h.kw.inheritProcessSharedMappings(parentPid, childPid)).toThrow(); + expect(backing.refCount).toBe(1); + expect((h.kw as any).sharedMappings.has(childPid)).toBe(false); + }); + + it("publishes file mappings before committing a kernel fork child", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const channel = h.channels.get(pid)!; + expect(h.map(pid, 4, 0x1000)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + (h.kw as any).invalidateSharedMmapBackingPages(backing); + h.io.read.mockImplementation(() => { + throw new Error("fork publication failed"); + }); + const kernelForkProcess = vi.fn(() => 0); + Object.assign(h.kw as any, { + callbacks: { onFork: vi.fn() }, + kernelInstance: { exports: { kernel_fork_process: kernelForkProcess } }, + nextChildPid: 100, + }); + + expect(() => (h.kw as any).handleFork(channel, [])).toThrow( + /fork publication failed/, + ); + expect(kernelForkProcess).not.toHaveBeenCalled(); + }); + + it("reports stable-open and initial-read failures without leaking a backing", () => { + const openFailure = createFileHarness(); + openFailure.open.mockImplementationOnce(() => { + throw new Error("open failed"); + }); + expect(openFailure.mapResult(openFailure.pids[0], 4, 0x1000)) + .toEqual({ kind: "error", errno: 5 }); + expect((openFailure.kw as any).sharedMmapBackings.size).toBe(0); + + const readFailure = createFileHarness(); + readFailure.io.read.mockImplementationOnce(() => { + throw new Error("read failed"); + }); + expect(readFailure.mapResult(readFailure.pids[0], 4, 0x1000)) + .toEqual({ kind: "error", errno: 5 }); + expect((readFailure.kw as any).sharedMmapBackings.size).toBe(0); + expect(readFailure.close).toHaveBeenCalledTimes(1); + + const identityRace = createFileHarness(); + identityRace.io.fstat.mockReturnValueOnce({ + dev: 8, + ino: 100, + mode: REGULAR_MODE, + nlink: 1, + uid: 0, + gid: 0, + size: identityRace.logicalSize(), + atimeMs: 0, + mtimeMs: 0, + ctimeMs: 0, + }); + expect(identityRace.mapResult(identityRace.pids[0], 4, 0x1000)) + .toEqual({ kind: "error", errno: 5 }); + expect((identityRace.kw as any).sharedMmapBackings.size).toBe(0); + expect(identityRace.close).toHaveBeenCalledTimes(1); + + const writeOnly = createFileHarness(); + (writeOnly.kw as any).getFdAccessModeForSharedMapping.mockReturnValue({ + kind: "ok", + value: 1, + }); + expect(writeOnly.mapResult(writeOnly.pids[0], 4, 0x1000, 4096, PROT_READ)) + .toEqual({ kind: "error", errno: 13 }); + expect(writeOnly.open).not.toHaveBeenCalled(); + }); + + it("preserves metadata and stable-open errno values", () => { + const invalidFd = createFileHarness(); + expect(invalidFd.mapResult(invalidFd.pids[0], 77, 0x1000)) + .toEqual({ kind: "error", errno: 9 }); + expect(invalidFd.open).not.toHaveBeenCalled(); + + for (const [code, errno] of [ + ["EMFILE", 24], + ["ENOENT", 2], + ["EROFS", 30], + ] as const) { + const h = createFileHarness(); + h.open.mockImplementationOnce(() => { + throw Object.assign(new Error(code), { code }); + }); + expect(h.mapResult(h.pids[0], 4, 0x1000)) + .toEqual({ kind: "error", errno }); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + } + }); + + it("assembles backing pages across short positive reads", () => { + const h = createFileHarness(); + const read = h.io.read.getMockImplementation()!; + h.io.read.mockImplementation((handle, out, offset, len) => + read(handle, out, offset, Math.min(len, 17))); + + expect(h.map(h.pids[0], 4, 0x1000)).toBe(true); + + expect(h.io.read.mock.calls.length).toBeGreaterThan(1); + expect(Array.from( + new Uint8Array(h.memories.get(h.pids[0])!.buffer) + .subarray(0x1000, 0x1040), + )).toEqual(Array.from({ length: 64 }, (_, i) => i + 1)); + }); + + it("guards mprotect write upgrades with a lifetime-stable writable handle", () => { + const denied = createFileHarness(); + (denied.kw as any).fdSupportsMmapWriteback.mockReturnValue(false); + expect(denied.map(denied.pids[0], 4, 0x1000, 4096, PROT_READ)).toBe(true); + expect((denied.kw as any).prepareFileSharedMappingsForWrite( + denied.pids[0], 0x1000, 4096, + )).toBe(13); + expect(denied.open).toHaveBeenCalledTimes(1); + + const allowed = createFileHarness(); + expect(allowed.map(allowed.pids[0], 4, 0x1000, 4096, PROT_READ)).toBe(true); + const stableHandle = Array.from( + (allowed.kw as any).sharedMmapBackings.values(), + )[0].handle; + // Simulate close(fd) followed by unlink/rename: reopening the original + // pathname would now fail, but the O_RDWR stable handle must suffice. + (allowed.kw as any).handleSharedMappingsAfterFileSyscall( + allowed.channels.get(allowed.pids[0]), ABI_SYSCALLS.Close, [4], 0, 0, + ); + allowed.fdIdentity.delete(4); + allowed.open.mockImplementation(() => { + throw new Error("path no longer exists"); + }); + expect((allowed.kw as any).prepareFileSharedMappingsForWrite( + allowed.pids[0], 0x1000, 4096, + )).toBe(0); + const backing = Array.from((allowed.kw as any).sharedMmapBackings.values())[0]; + expect(backing.writable).toBe(true); + expect(allowed.open.mock.calls.map((call) => call[1])).toEqual([2]); + expect(allowed.close).not.toHaveBeenCalledWith(stableHandle); + }); + + it("retains negative fd identities until that descriptor is created", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + expect(h.map(pid, 4, 0x1000)).toBe(true); + const stat = (h.kw as any).getFdStatForSharedMapping; + const callsBefore = stat.mock.calls.length; + + expect((h.kw as any).findSharedMmapBackingForFd(h.channels.get(pid), 77)) + .toBeNull(); + expect((h.kw as any).findSharedMmapBackingForFd(h.channels.get(pid), 77)) + .toBeNull(); + expect(stat.mock.calls.length).toBe(callsBefore + 1); + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Getpid, [], pid, 0, + ); + expect((h.kw as any).findSharedMmapBackingForFd(h.channels.get(pid), 77)) + .toBeNull(); + expect(stat.mock.calls.length).toBe(callsBefore + 1); + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Open, [], 77, 0, + ); + expect((h.kw as any).findSharedMmapBackingForFd(h.channels.get(pid), 77)) + .toBeNull(); + expect(stat.mock.calls.length).toBe(callsBefore + 2); + }); + + it("preserves an unrelated dirty byte across a sub-page direct pwrite", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + const process = new Uint8Array(h.memories.get(pid)!.buffer); + process[addr + 11] = 0xa1; + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + { force: true }, + ); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + expect(backing.dirtyPages.has(0)).toBe(true); + + const sourcePtr = 0x7000; + process[sourcePtr] = 0xb2; + h.storage[29] = 0xb2; + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, sourcePtr, 1, 29], 1, 0, + ); + + expect(backing.dirtyPages.has(0)).toBe(true); + expect(backing.pages.get(0)[11]).toBe(0xa1); + expect(backing.pages.get(0)[29]).toBe(0xb2); + expect((h.kw as any).flushSharedMmapBackingRange(backing, 0, 4096)).toBe(true); + expect(h.storage[11]).toBe(0xa1); + expect(h.storage[29]).toBe(0xb2); + }); + + it("clips msync and final unmap writeback to a 100-byte EOF", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + h.setLogicalSize(100); + expect(h.map(pid, 4, addr)).toBe(true); + const process = new Uint8Array(h.memories.get(pid)!.buffer); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + expect(backing.size).toBe(100); + + process[addr + 50] = 0xa5; + process[addr + 150] = 0xf1; + expect((h.kw as any).flushSharedMappings( + h.channels.get(pid), [addr, 4096], + )).toBe(true); + expect(h.logicalSize()).toBe(100); + expect(h.storage[50]).toBe(0xa5); + expect(h.io.write.mock.calls.at(-1)?.[3]).toBe(100); + + process[addr + 60] = 0xb6; + process[addr + 160] = 0xf2; + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + { force: true }, + ); + (h.kw as any).cleanupSharedMappings(pid, addr, 4096); + expect(h.logicalSize()).toBe(100); + expect(h.storage[60]).toBe(0xb6); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + }); + + it("refreshes authoritative size after direct extension and truncate", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const sourcePtr = 0x7000; + h.setLogicalSize(100); + expect(h.map(pid, 4, addr)).toBe(true); + const process = new Uint8Array(h.memories.get(pid)!.buffer); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + + process.set([0xde, 0xad], sourcePtr); + h.storage.set([0xde, 0xad], 150); + h.setLogicalSize(152); + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, sourcePtr, 2, 150], 2, 0, + ); + expect(backing.size).toBe(152); + + h.setLogicalSize(50); + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Ftruncate, [4, 50], 0, 0, + ); + expect(backing.size).toBe(50); + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + ); + expect(process[addr + 80]).toBe(0); + + h.setLogicalSize(200); + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Ftruncate, [4, 200], 0, 0, + ); + expect(backing.size).toBe(200); + }); + + it("refreshes size after direct and in-kernel copy mutations", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + expect(h.map(pid, 4, 0x1000)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + const cases = [ + [ABI_SYSCALLS.Write, [4], 120], + [ABI_SYSCALLS.Writev, [4], 130], + [ABI_SYSCALLS.Pwritev, [4], 140], + [ABI_SYSCALLS.Sendfile, [4, 9], 150], + [SYS_COPY_FILE_RANGE, [4, 0, 9, 0], 160], + [SYS_SPLICE, [4, 0, 9, 0], 170], + ] as const; + for (const [syscallNr, args, size] of cases) { + h.setLogicalSize(size); + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), syscallNr, args, 1, 0, + ); + expect(backing.size).toBe(size); + } + }); + + it("publishes mapped input before copy_file_range and splice", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + const process = new Uint8Array(h.memories.get(pid)!.buffer); + + for (const [index, syscallNr] of [ + SYS_COPY_FILE_RANGE, + SYS_SPLICE, + ].entries()) { + const offset = 61 + index; + process[addr + offset] = 0xd0 + index; + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(pid), + syscallNr, + [4, 0, 9, 0, 1, 0], + )).toBe(true); + expect(h.storage[offset]).toBe(0xd0 + index); + } + }); + + it("invalidates stale pages after reread failure and recovers at completion", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + const mapping = (h.kw as any).sharedMappings.get(pid).get(addr); + h.storage[25] = 0xd5; + h.io.read.mockImplementationOnce(() => { + throw new Error("one-shot reread failure"); + }); + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Write, [4], 1, 0, + ); + expect(backing.pages.has(0)).toBe(false); + expect(mapping.seenVersion).toBeLessThan(backing.version); + + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + ); + expect(backing.pages.has(0)).toBe(true); + expect(new Uint8Array(h.memories.get(pid)!.buffer)[addr + 25]).toBe(0xd5); + expect(mapping.seenVersion).toBe(backing.version); + }); + + it("keeps persistently unreadable direct-write cache pages invalid", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + const mapping = (h.kw as any).sharedMappings.get(pid).get(addr); + h.storage[26] = 0xe6; + h.io.read.mockImplementation(() => { + throw new Error("persistent reread failure"); + }); + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Write, [4], 1, 0, + ); + expect(() => (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + )).toThrow(/persistent reread failure/); + expect(backing.pages.has(0)).toBe(false); + expect(mapping.seenVersion).toBeLessThan(backing.version); + }); + + it("completes EIO when recovery follows a persistent refresh failure", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const channel = h.channels.get(pid)!; + expect(h.map(pid, 4, addr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + (h.kw as any).invalidateSharedMmapBackingPages(backing); + h.io.read.mockImplementation(() => { + throw new Error("persistent refresh failure"); + }); + + const kernelHandle = vi.fn(); + const relistenChannel = vi.fn(); + Object.assign(h.kw as any, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + kernelMemory: new WebAssembly.Memory({ initial: 2 }), + scratchOffset: 0, + kernelInstance: { exports: { kernel_handle_channel: kernelHandle } }, + clearSocketTimeout: vi.fn(), + clearReadinessWait: vi.fn(), + pendingCancels: new Set(), + relistenChannel, + }); + writeChannelSyscall(channel, ABI_SYSCALLS.Getpid, []); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + (h.kw as any).handleSyscall(channel); + + consoleError.mockRestore(); + const view = new DataView(channel.memory.buffer, channel.channelOffset); + expect(kernelHandle).not.toHaveBeenCalled(); + expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(-5); + expect(view.getUint32(CH_ERRNO, true)).toBe(5); + expect(Atomics.load(channel.i32View, CH_STATUS / 4)) + .toBe(CHANNEL_STATUS_COMPLETE); + expect(relistenChannel).toHaveBeenCalledWith(channel); + }); + + it("keeps asynchronous normal completion live after coherence failure", () => { + const h = createFileHarness(); + const channel = h.channels.get(h.pids[0])!; + const relistenChannel = vi.fn(); + Object.assign(h.kw as any, { + synchronizeSharedMemoryForBoundary: vi.fn(() => { + throw new Error("asynchronous refresh failure"); + }), + clearSocketTimeout: vi.fn(), + clearReadinessWait: vi.fn(), + drainAllPtyOutputs: vi.fn(), + flushTcpSendPipes: vi.fn(), + drainAndProcessWakeupEvents: vi.fn(), + relistenChannel, + }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + (h.kw as any).completeChannel( + channel, + ABI_SYSCALLS.Getpid, + [], + undefined, + channel.pid, + 0, + ); + + consoleError.mockRestore(); + const view = new DataView(channel.memory.buffer, channel.channelOffset); + expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(-5); + expect(view.getUint32(CH_ERRNO, true)).toBe(5); + expect(Atomics.load(channel.i32View, CH_STATUS / 4)) + .toBe(CHANNEL_STATUS_COMPLETE); + expect(relistenChannel).toHaveBeenCalledWith(channel); + }); + + it("uses the full safe pwrite offset and rejects invalid negative offsets", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const sourcePtr = 0x7000; + const offset = 0x1_0000_0010; + h.setLogicalSize(offset + 1); + expect(h.map(pid, 4, addr)).toBe(true); + const process = new Uint8Array(h.memories.get(pid)!.buffer); + process[sourcePtr] = 0x9a; + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, sourcePtr, 1, offset], 1, 0, + ); + const page = Math.floor(offset / 4096); + expect(backing.pages.get(page)[offset % 4096]).toBe(0x9a); + + const version = backing.version; + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, sourcePtr, 1, -1], 1, 0, + ); + expect(backing.sizeValid).toBe(false); + expect(backing.version).toBeGreaterThan(version); + }); + + it("rejects shared memfd mappings deliberately without affecting private mmap", () => { + const h = createFileHarness(); + (h.kw as any).getFdPathForSharedMapping.mockReturnValue({ + kind: "ok", + value: "memfd:php-cache", + }); + expect(h.mapResult(h.pids[0], 4, 0x1000)) + .toEqual({ kind: "error", errno: 95 }); + expect(h.open).not.toHaveBeenCalled(); + expect((h.kw as any).sharedMappings.size).toBe(0); + // The _handleSyscallInner preflight is gated on MAP_SHARED; MAP_PRIVATE + // still uses the existing fd-pread population path. + }); + + it("rejects a backend that cannot promise stable file identity", () => { + const h = createFileHarness(); + h.io.fileIdentity.mockReturnValue(null); + expect(h.mapResult(h.pids[0], 4, 0x1000)) + .toEqual({ kind: "error", errno: 95 }); + expect(h.open).not.toHaveBeenCalled(); + }); + + it("fails storage syscalls before the kernel when dirty-page flush fails", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + new Uint8Array(h.memories.get(pid)!.buffer)[addr + 9] = 0xcc; + h.io.write.mockReturnValueOnce(0); + + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4], + )).toBe(false); + // close does not need the guest fd for writeback: the stable handle owns + // the mapping lifetime and is flushed later by msync/munmap/exit. + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Close, [4], + )).toBe(true); + }); + + it("skips file-coherence hooks when no shared file backing exists", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const syncFile = vi.spyOn(h.kw as any, "syncFileSharedMappingsFromProcess"); + const flushFd = vi.spyOn(h.kw as any, "flushSharedBackingForFd"); + const invalidateFd = vi.spyOn(h.kw as any, "invalidateSharedMmapFdCache"); + + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, 0, 1, 0], + )).toBe(true); + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Open, [0, 0], 4, 0, + ); + + expect(syncFile).not.toHaveBeenCalled(); + expect(flushFd).not.toHaveBeenCalled(); + expect(invalidateFd).not.toHaveBeenCalled(); + }); + + it("reaps a retained zero-reference backing after writeback recovers", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const write = h.io.write.getMockImplementation()!; + expect(h.map(pid, 4, addr)).toBe(true); + new Uint8Array(h.memories.get(pid)!.buffer)[addr + 7] = 0xee; + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + { force: true }, + ); + h.io.write.mockReturnValue(0); + + (h.kw as any).cleanupSharedMappings(pid, addr, 4096); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + expect(backing.refCount).toBe(0); + expect(backing.dirtyPages.has(0)).toBe(true); + expect(h.close).not.toHaveBeenCalledWith(backing.handle); + + h.io.write.mockImplementation(write); + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, 0, 1, 0], + )).toBe(true); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + expect(h.close).toHaveBeenCalledWith(backing.handle); + }); + + it("releases mixed anonymous and file mappings through their own backings", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const fileAddr = 0x1000; + const anonymousAddr = 0x3000; + expect(h.map(pid, 4, fileAddr)).toBe(true); + const fileBacking = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + (h.kw as any).trackAnonymousSharedMapping( + h.channels.get(pid), + anonymousAddr, + [0, 4096, PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0], + ); + expect((h.kw as any).sharedMappings.get(pid).size).toBe(2); + expect((h.kw as any).anonymousSharedBackings.size).toBe(1); + + (h.kw as any).releaseAllSharedMemoryForProcess(pid, false); + + expect((h.kw as any).sharedMappings.has(pid)).toBe(false); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + expect((h.kw as any).anonymousSharedBackings.size).toBe(0); + expect(fileBacking.refCount).toBe(0); + expect(h.close).toHaveBeenCalledWith(fileBacking.handle); + }); +}); diff --git a/host/test/fork-from-dlopen-side-module-e2e.test.ts b/host/test/fork-from-dlopen-side-module-e2e.test.ts new file mode 100644 index 000000000..aae67a2da --- /dev/null +++ b/host/test/fork-from-dlopen-side-module-e2e.test.ts @@ -0,0 +1,194 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { NodePlatformIO } from "../src/platform/node"; +import { + FORK_CAP_DYLINK_MAIN, + FORK_CAP_SIDE_ENTRY, + readForkInstrumentCapabilities, +} from "../src/dylink"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, "../.."); +const sysroot = join(repoRoot, "sysroot"); +const glueDir = join(repoRoot, "libc", "glue"); +const clangDriver = process.env.CLANG ?? "clang"; +const instrument = join(repoRoot, "scripts", "run-wasm-fork-instrument.sh"); +const buildDir = join(tmpdir(), "kandelo-fork-from-side-module"); +const hasPrerequisites = + existsSync(join(sysroot, "lib", "libc.a")) + && ( + existsSync(join(repoRoot, "binaries", "kernel.wasm")) + || existsSync(join(repoRoot, "local-binaries", "kernel.wasm")) + ); + +if (process.env.KANDELO_REQUIRE_SIDE_MODULE_FORK_E2E === "1" && !hasPrerequisites) { + throw new Error( + "side-module fork e2e was required but sysroot/libc.a or kernel.wasm is missing", + ); +} + +function llvmTool(name: "clang" | "wasm-ld"): string { + if (name === "wasm-ld" && process.env.WASM_LD) return process.env.WASM_LD; + // Nix's native clang wrapper injects Darwin hardening flags that are invalid + // for wasm32. Ask the driver for its underlying LLVM tools so this fixture + // follows the same cross-target path as the repository build scripts. Keep + // discovery lazy so a deliberately skipped fixture needs no compiler. + return execFileSync(clangDriver, [`-print-prog-name=${name}`], { + encoding: "utf8", + }).trim() || name; +} + +function instrumentInPlace(wasmPath: string, entry?: string): void { + const output = `${wasmPath}.instrumented`; + const args = [wasmPath, "-o", output]; + if (entry) args.push("--entry", entry); + execFileSync(instrument, args, { stdio: "pipe" }); + renameSync(output, wasmPath); +} + +function buildSharedLibrary(source: string): string { + const sourcePath = join(buildDir, "libforkinside.c"); + const objectPath = join(buildDir, "libforkinside.o"); + const libraryPath = join(buildDir, "libforkinside.so"); + writeFileSync(sourcePath, source); + execFileSync(llvmTool("clang"), [ + "--target=wasm32-unknown-unknown", + "-fPIC", + "-O2", + "-matomics", + "-mbulk-memory", + "-c", + sourcePath, + "-o", + objectPath, + ], { stdio: "pipe" }); + execFileSync(llvmTool("wasm-ld"), [ + "--experimental-pic", + "--shared", + "--shared-memory", + "--export-all", + "--allow-undefined", + "-o", + libraryPath, + objectPath, + ], { stdio: "pipe" }); + instrumentInPlace(libraryPath, "env.fork"); + return libraryPath; +} + +function buildMainProgram(source: string): string { + const sourcePath = join(buildDir, "fork-from-side-main.c"); + const wasmPath = join(buildDir, "fork-from-side-main.wasm"); + writeFileSync(sourcePath, source); + execFileSync(llvmTool("clang"), [ + "--target=wasm32-unknown-unknown", + `--sysroot=${sysroot}`, + "-nostdlib", + "-O2", + "-matomics", + "-mbulk-memory", + "-fno-trapping-math", + sourcePath, + join(glueDir, "channel_syscall.c"), + join(glueDir, "compiler_rt.c"), + join(glueDir, "dlopen.c"), + join(sysroot, "lib", "crt1.o"), + join(sysroot, "lib", "libc.a"), + "-Wl,--entry=_start", + "-Wl,--export=_start", + "-Wl,--export=__heap_base", + "-Wl,--import-memory", + "-Wl,--shared-memory", + "-Wl,--max-memory=1073741824", + "-Wl,--allow-undefined", + "-Wl,--global-base=1114112", + "-Wl,--table-base=3", + "-Wl,--export-table", + "-Wl,--growable-table", + "-Wl,--export=__wasm_init_tls", + "-Wl,--export=__tls_base", + "-Wl,--export=__tls_size", + "-Wl,--export=__tls_align", + "-Wl,--export=__stack_pointer", + "-Wl,--export=__wasm_thread_init", + "-Wl,--export-all", + "-o", + wasmPath, + ], { stdio: "pipe" }); + instrumentInPlace(wasmPath); + return wasmPath; +} + +describe.skipIf(!hasPrerequisites)("fork from a dlopened side module", () => { + beforeAll(() => mkdirSync(buildDir, { recursive: true })); + + it("preserves the side frame and returns in both parent and child", async () => { + const libraryPath = buildSharedLibrary(` + extern int fork(void); + extern void exit(int); + int side_fork(void) { + volatile int preserved = 37; + int pid = fork(); + if (preserved != 37) exit(91); + if (pid == 0) exit(0); + return pid; + } + `); + const programPath = buildMainProgram(` + #include + #include + #include + typedef int (*side_fork_fn)(void); + int main(int argc, char **argv) { + void *lib = dlopen(argv[1], RTLD_NOW); + if (!lib) return 2; + side_fork_fn side_fork = (side_fork_fn)dlsym(lib, "side_fork"); + if (!side_fork) return 3; + for (int i = 0; i < 2; i++) { + int pid = side_fork(); + if (pid < 0) return 4; + int status = 0; + if (waitpid(pid, &status, 0) != pid) return 5; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return 6; + } + puts("side fork ok"); + return 0; + } + `); + + // Ensure the compiler actually produced a side module with fork state; + // a stale/inert fixture must never turn this into a false-positive run. + const libraryModule = new WebAssembly.Module( + new Uint8Array(readFileSync(libraryPath)) as unknown as BufferSource, + ); + expect(WebAssembly.Module.exports(libraryModule).map((entry) => entry.name)) + .toContain("wpk_fork_state"); + expect(readForkInstrumentCapabilities(libraryModule) & FORK_CAP_SIDE_ENTRY) + .toBe(FORK_CAP_SIDE_ENTRY); + const programModule = new WebAssembly.Module( + new Uint8Array(readFileSync(programPath)) as unknown as BufferSource, + ); + expect(readForkInstrumentCapabilities(programModule) & FORK_CAP_DYLINK_MAIN) + .toBe(FORK_CAP_DYLINK_MAIN); + + const result = await runCentralizedProgram({ + programPath, + argv: ["fork-from-side-main", libraryPath], + timeout: 30_000, + io: new NodePlatformIO(), + }); + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain("side fork ok"); + }, 30_000); +}); diff --git a/host/test/kernel-errno.test.ts b/host/test/kernel-errno.test.ts new file mode 100644 index 000000000..2b7175d62 --- /dev/null +++ b/host/test/kernel-errno.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { negErrno } from "../src/kernel"; + +describe("negErrno", () => { + it.each([ + ["EBADF", -9], + ["EMFILE", -24], + ["ENFILE", -23], + ["EROFS", -30], + ["ENOTSUP", -95], + ["EOPNOTSUPP", -95], + ])("maps a plain %s platform error", (name, expected) => { + expect(negErrno(new Error(`${name}: backend failure`))).toBe(expected); + }); + + it("preserves numeric backend and Node errno values", () => { + expect(negErrno({ code: -28 })).toBe(-28); + expect(negErrno({ code: 28 })).toBe(-28); + expect(negErrno({ errno: -2 })).toBe(-2); + }); + + it("uses EIO only for an unclassified error", () => { + expect(negErrno(new Error("opaque backend failure"))).toBe(-5); + }); +}); diff --git a/host/test/mmap-tracking.test.ts b/host/test/mmap-tracking.test.ts new file mode 100644 index 000000000..6ae2a2d9c --- /dev/null +++ b/host/test/mmap-tracking.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +describe("MAP_SHARED host interval tracking", () => { + it("splits a mapping around a partial munmap", () => { + const worker = createWorker(); + worker.sharedMappings.set(7, new Map([ + [0x10000, { fd: 4, fileOffset: 0x2000, len: 0x30000 }], + ])); + + worker.cleanupSharedMappings(7, 0x20000, 0x10000); + + expect(Array.from(worker.sharedMappings.get(7)!.entries())).toEqual([ + [0x10000, { fd: 4, fileOffset: 0x2000, len: 0x10000 }], + [0x30000, { fd: 4, fileOffset: 0x22000, len: 0x10000 }], + ]); + }); + + it("moves and resizes file-backed metadata with mremap", () => { + const worker = createWorker(); + worker.sharedMappings.set(9, new Map([ + [0x40000, { fd: 5, fileOffset: 0x6000, len: 0x10000 }], + ])); + + worker.remapSharedMapping(9, 0x40000, 0x80000, 0x28000); + + expect(worker.sharedMappings.get(9)!.has(0x40000)).toBe(false); + expect(worker.sharedMappings.get(9)!.get(0x80000)).toEqual({ + fd: 5, + fileOffset: 0x6000, + len: 0x28000, + }); + }); + + it("retains mapping-level writeback eligibility after mprotect", () => { + const worker = createWorker(); + worker.sharedMappings.set(9, new Map([ + [0x40000, { + fd: 5, + fileOffset: 0x1000, + len: 0x30000, + writable: false, + }], + ])); + + worker.updateSharedMappingProtection(9, 0x50000, 0x10000, true); + worker.updateSharedMappingProtection(9, 0x50000, 0x10000, false); + + expect(worker.sharedMappings.get(9)!.get(0x40000)).toEqual({ + fd: 5, + fileOffset: 0x1000, + len: 0x30000, + writable: true, + }); + }); +}); + +function createWorker(): any { + return Object.assign(Object.create(CentralizedKernelWorker.prototype), { + sharedMappings: new Map(), + }); +} diff --git a/host/test/multi-worker.test.ts b/host/test/multi-worker.test.ts index bf42205a4..967ecf6b4 100644 --- a/host/test/multi-worker.test.ts +++ b/host/test/multi-worker.test.ts @@ -23,6 +23,7 @@ import { CH_DATA, CH_ERRNO, CH_RETURN, + HOST_INTERCEPTED_SYSCALLS, } from "../src/generated/abi"; const MAX_PAGES = 1024; // 64 MiB: enough to prove initial < maximum. @@ -102,6 +103,153 @@ describe("CentralizedKernelWorker Process Management", () => { expect((kw as any).hostReaped.has(pid)).toBe(false); }); + it("retries fork allocation when the kernel still owns a zombie pid", async () => { + const parentPid = 77; + const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); + const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; + const kernelForkProcess = vi.fn((_parent: number, child: number) => + child === 100 ? -17 : 0, + ); + const completeChannel = vi.fn(); + const onFork = vi.fn(() => Promise.resolve([WASM_PAGE_SIZE])); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + callbacks: { onFork }, + nextChildPid: 100, + processes: new Map([[parentPid, { channels: [channel] }]]), + threadForkContexts: new Map(), + sharedMappings: new Map(), + tcpListenerTargets: new Map(), + epollInterests: new Map(), + completeChannel, + kernelInstance: { + exports: { + kernel_fork_process: kernelForkProcess, + kernel_clear_fork_child: vi.fn(() => 0), + kernel_reset_signal_mask: vi.fn(() => 0), + }, + }, + }) as CentralizedKernelWorker; + + (kw as any).handleFork(channel, [0]); + await Promise.resolve(); + + expect(kernelForkProcess).toHaveBeenNthCalledWith(1, parentPid, 100); + expect(kernelForkProcess).toHaveBeenNthCalledWith(2, parentPid, 101); + expect(onFork).toHaveBeenCalledWith(parentPid, 101, memory, undefined); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], + undefined, + 101, + 0, + ); + }); + + it("inherits child fd mirrors when the parent channel becomes stale during fork", async () => { + const parentPid = 77; + const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); + const oldChannel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; + const replacementChannel = { + pid: parentPid, + channelOffset: 2 * WASM_PAGE_SIZE, + memory, + }; + const completeChannel = vi.fn(); + let finishFork!: (offsets: number[]) => void; + const forkLaunch = new Promise((resolve) => { + finishFork = resolve; + }); + const close = vi.fn(); + const listener = { + server: { close }, + pid: parentPid, + port: 8080, + connections: new Set(), + }; + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + callbacks: { onFork: vi.fn(() => forkLaunch) }, + nextChildPid: 100, + processes: new Map([[parentPid, { channels: [replacementChannel] }]]), + threadForkContexts: new Map(), + sharedMappings: new Map(), + tcpListenerTargets: new Map([[8080, [{ pid: parentPid, fd: 4 }]]]), + tcpListenerRRIndex: new Map([[8080, 0]]), + tcpVirtualListenerKeys: new Map(), + tcpListeners: new Map([[`${parentPid}:4`, listener]]), + tcpConnections: new Map(), + shmMappings: new Map(), + io: { network: undefined }, + epollInterests: new Map([[`${parentPid}:6`, [ + { fd: 8, events: 1, data: 11n }, + ]]]), + completeChannel, + kernelInstance: { + exports: { + kernel_fork_process: vi.fn(() => 0), + kernel_clear_fork_child: vi.fn(() => 0), + kernel_reset_signal_mask: vi.fn(() => 0), + }, + }, + }) as CentralizedKernelWorker; + + (kw as any).handleFork(oldChannel, [0]); + expect((kw as any).tcpListenerTargets.get(8080)).toContainEqual({ pid: 100, fd: 4 }); + (kw as any).cleanupTcpListeners(parentPid); + expect(close).not.toHaveBeenCalled(); + expect((kw as any).tcpListeners.has("100:4")).toBe(true); + finishFork([WASM_PAGE_SIZE]); + await Promise.resolve(); + + expect((kw as any).tcpListenerTargets.get(8080)).toEqual([{ pid: 100, fd: 4 }]); + expect((kw as any).epollInterests.get("100:6")).toEqual([ + { fd: 8, events: 1, data: 11n }, + ]); + expect(completeChannel).not.toHaveBeenCalled(); + }); + + it("removes eager child registrations and mirrors when fork worker launch fails", async () => { + const parentPid = 77; + const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); + const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; + const completeChannel = vi.fn(); + const deactivateProcess = vi.fn(); + const removeProcess = vi.fn(() => 0); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + callbacks: { onFork: vi.fn(() => Promise.reject(new Error("launch failed"))) }, + nextChildPid: 100, + processes: new Map([[parentPid, { channels: [channel] }]]), + threadForkContexts: new Map(), + tcpListenerTargets: new Map([[8080, [{ pid: parentPid, fd: 4 }]]]), + epollInterests: new Map(), + completeChannel, + deactivateProcess, + kernelInstance: { + exports: { + kernel_fork_process: vi.fn(() => 0), + kernel_clear_fork_child: vi.fn(() => 0), + kernel_reset_signal_mask: vi.fn(() => 0), + kernel_remove_process: removeProcess, + }, + }, + }) as CentralizedKernelWorker; + + (kw as any).handleFork(channel, [0]); + await Promise.resolve(); + await Promise.resolve(); + + expect(deactivateProcess).toHaveBeenCalledWith(100); + expect(removeProcess).toHaveBeenCalledWith(100); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], + undefined, + -1, + 12, + ); + }); + it("completes pthread SYS_EXIT channels (clearing the exiting guest's atomic-wait waiter) even when the host terminates the worker", () => { // Regression guard for the reused-slot notify-steal deadlock. On thread // exit the kernel must flip the channel status word off CH_PENDING @@ -224,6 +372,7 @@ describe("CentralizedKernelWorker Process Management", () => { [pid, { memory, channels: [{ channelOffset: mainChannelOffset }, channel] }], ]), activeChannels: [channel], + pendingSleeps: new Map(), channelTids: new Map([[`${pid}:${threadChannelOffset}`, tid]]), threadForkContexts: new Map([ [`${pid}:${threadChannelOffset}`, { fnPtr: 1, argPtr: 2 }], @@ -271,6 +420,7 @@ describe("CentralizedKernelWorker Process Management", () => { resolveClone = resolve; }); }); + const channel = { pid, channelOffset: mainChannelOffset, memory }; const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { callbacks: { onClone }, @@ -283,7 +433,7 @@ describe("CentralizedKernelWorker Process Management", () => { scratchOffset: 0, currentHandlePid: 0, processes: new Map([ - [pid, { channels: [{ channelOffset: mainChannelOffset }] }], + [pid, { channels: [channel] }], ]), threadCtidPtrs, completeChannel: vi.fn(), @@ -300,7 +450,7 @@ describe("CentralizedKernelWorker Process Management", () => { }); (kw as any).handleClone( - { pid, channelOffset: mainChannelOffset, memory }, + channel, [0, stackPtr, 0, tlsPtr, ctidPtr, 0], ); @@ -311,6 +461,67 @@ describe("CentralizedKernelWorker Process Management", () => { expect((kw as any).completeChannel).toHaveBeenCalled(); }); + it("does not erase replacement clear-TID metadata from a stale clone completion", async () => { + const pid = 126; + const tid = 79; + const oldMemory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + const newMemory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + const channelOffset = WASM_PAGE_SIZE; + const oldChannel = { pid, channelOffset, memory: oldMemory }; + const newChannel = { pid, channelOffset, memory: newMemory }; + const processView = new DataView(oldMemory.buffer, channelOffset); + processView.setUint32(CH_DATA, 11, true); + processView.setUint32(CH_DATA + 4, 22, true); + const kernelMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); + const kernelView = new DataView(kernelMemory.buffer); + const threadCtidPtrs = new Map(); + let resolveClone!: (value: number) => void; + const onClone = vi.fn(() => new Promise((resolve) => { + resolveClone = resolve; + })); + const completeChannel = vi.fn(); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + callbacks: { onClone }, + kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, + kernelMemory, + scratchOffset: 0, + currentHandlePid: 0, + processes: new Map([[pid, { channels: [oldChannel] }]]), + threadCtidPtrs, + completeChannel, + bindKernelTidForChannel: vi.fn(), + kernelInstance: { + exports: { + kernel_handle_channel: vi.fn(() => { + kernelView.setBigInt64(CH_RETURN, BigInt(tid), true); + kernelView.setUint32(CH_ERRNO, 0, true); + return 0; + }), + }, + }, + }); + + (kw as any).handleClone( + oldChannel, + [0, 0x00800000, 0, 0x00900000, 0x00040000, 0], + ); + (kw as any).processes.set(pid, { channels: [newChannel] }); + threadCtidPtrs.set(`${pid}:${tid}`, 0x00050000); + resolveClone(tid); + await Promise.resolve(); + + expect(threadCtidPtrs.get(`${pid}:${tid}`)).toBe(0x00050000); + expect(completeChannel).not.toHaveBeenCalled(); + }); + it("does not lower compact process max_addr when adding dynamic pthread channels", () => { const setMaxAddr = vi.fn(() => 0); const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { diff --git a/host/test/platform-node.test.ts b/host/test/platform-node.test.ts index ed3b4fe56..ca16be8f2 100644 --- a/host/test/platform-node.test.ts +++ b/host/test/platform-node.test.ts @@ -3,8 +3,16 @@ * translation that bridges the kernel's POSIX namespace to Node `fs.*`. */ +import { + linkSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, it, expect } from "vitest"; -import { translateWindowsDrivePath } from "../src/platform/node"; +import { NodePlatformIO, translateWindowsDrivePath } from "../src/platform/node"; describe("translateWindowsDrivePath", () => { it("converts /C/foo → C:/foo", () => { @@ -45,3 +53,41 @@ describe("translateWindowsDrivePath", () => { expect(translateWindowsDrivePath("/")).toBeNull(); }); }); + +describe("NodePlatformIO file identity", () => { + it("preserves hard-link aliases and distinguishes another inode", () => { + const dir = mkdtempSync(join(tmpdir(), "kandelo-file-identity-")); + try { + const original = join(dir, "original"); + const alias = join(dir, "alias"); + const other = join(dir, "other"); + writeFileSync(original, "one"); + linkSync(original, alias); + writeFileSync(other, "two"); + + const io = new NodePlatformIO(); + const originalStat = io.stat(original); + const aliasStat = io.stat(alias); + const otherStat = io.stat(other); + const originalIdentity = io.fileIdentity( + original, + BigInt(originalStat.dev), + BigInt(originalStat.ino), + ); + + expect(io.fileIdentity( + alias, + BigInt(aliasStat.dev), + BigInt(aliasStat.ino), + )).toBe(originalIdentity); + expect(io.fileIdentity( + other, + BigInt(otherStat.dev), + BigInt(otherStat.ino), + )).not.toBe(originalIdentity); + expect(io.fileIdentity(original, 0n, 0n)).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/host/test/process-wait-lifecycle.test.ts b/host/test/process-wait-lifecycle.test.ts index 741634cba..e811243c7 100644 --- a/host/test/process-wait-lifecycle.test.ts +++ b/host/test/process-wait-lifecycle.test.ts @@ -3,6 +3,7 @@ import { ABI_SYSCALLS } from "../src/generated/abi"; import { CentralizedKernelWorker } from "../src/kernel-worker"; const SIGCHLD = 17; +const SIGTERM = 15; const WNOHANG = 1; describe("Rust-owned process wait lifecycle", () => { @@ -121,6 +122,64 @@ describe("Rust-owned process wait lifecycle", () => { expect(worker.sharedMappings.has(42)).toBe(false); }); + it("marks a host crash reaped before shared-state teardown can re-enter", () => { + const worker = createWorkerHarness({ + kernel_mark_process_signaled: vi.fn(() => 0), + }); + worker.hostReaped = new Set(); + worker.releaseAllSharedMemoryForProcess = vi.fn(() => { + expect(worker.hostReaped.has(42)).toBe(true); + }); + worker.notifyParentOfExitedProcess = vi.fn(); + + worker.notifyHostProcessCrashed(42, 11); + + expect(worker.releaseAllSharedMemoryForProcess).toHaveBeenCalledWith(42); + expect(worker.notifyParentOfExitedProcess).toHaveBeenCalledOnce(); + }); + + it("does not overwrite signal death discovered during clean-exit writeback", () => { + let exitSignal = 0; + const kernelHandle = vi.fn(); + const worker = createWorkerHarness({ + kernel_get_process_exit_signal: vi.fn(() => exitSignal), + kernel_handle_channel: kernelHandle, + }); + const channel = createChannel(42, createSharedMemory()); + worker.processes = new Map([[42, { channels: [channel] }]]); + worker.hostReaped = new Set(); + worker.releaseAllSharedMemoryForProcess = vi.fn(() => { + exitSignal = SIGTERM; + }); + worker.handleProcessTerminated = vi.fn(); + + worker.handleExit(channel, ABI_SYSCALLS.ExitGroup, [0]); + + expect(worker.handleProcessTerminated).toHaveBeenCalledWith(channel); + expect(kernelHandle).not.toHaveBeenCalled(); + }); + + it("uses the explicit termination signal instead of classifying high exit codes", () => { + const exitSignals = new Map([[42, 0], [43, 15]]); + const worker = createWorkerHarness({ + kernel_get_process_exit_signal: vi.fn((pid: number) => exitSignals.get(pid) ?? -1), + }); + const normalChannel = createChannel(42, createSharedMemory()); + const signaledChannel = createChannel(43, createSharedMemory()); + worker.processes = new Map([ + [42, { channels: [normalChannel] }], + [43, { channels: [signaledChannel] }], + ]); + worker.pendingSleeps = new Map(); + worker.hostReaped = new Set(); + worker.handleProcessTerminated = vi.fn(); + + worker.reapKilledProcessesAfterSyscall(); + + expect(worker.handleProcessTerminated).toHaveBeenCalledOnce(); + expect(worker.handleProcessTerminated).toHaveBeenCalledWith(signaledChannel); + }); + it("SA_NOCLDWAIT auto-reaps through Rust without SIGCHLD", () => { const reapExitedChild = vi.fn(() => 0); const worker = createWorkerHarness({ diff --git a/host/test/readiness-deadline.test.ts b/host/test/readiness-deadline.test.ts index 6653a3c9c..95a174a2e 100644 --- a/host/test/readiness-deadline.test.ts +++ b/host/test/readiness-deadline.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { ABI_SYSCALLS } from "../src/generated/abi"; +import { ABI_SYSCALLS, CH_SIG_BASE } from "../src/generated/abi"; import { CentralizedKernelWorker } from "../src/kernel-worker"; -function createSharedMemory(): WebAssembly.Memory { - return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); +function createSharedMemory(pages = 1): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: pages, maximum: pages, shared: true }); } afterEach(() => { @@ -82,7 +82,7 @@ describe("finite readiness deadlines", () => { const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { processes: new Map([[channel.pid, { channels: [channel] }]]), pendingPollRetries: new Map(), - pendingSelectRetries: new Map([[channel.channelOffset, entry]]), + pendingSelectRetries: new Map([[channel, entry]]), pendingPipeReaders: new Map(), pendingPipeWriters: new Map(), wakeScheduled: false, @@ -101,7 +101,99 @@ describe("finite readiness deadlines", () => { expect(earlyFallback).not.toHaveBeenCalled(); expect(worker.handlePselect6).toHaveBeenCalledOnce(); expect(worker.handlePselect6).toHaveBeenCalledWith(channel, entry.origArgs); - expect(worker.pendingSelectRetries.has(channel.channelOffset)).toBe(false); + expect(worker.pendingSelectRetries.has(channel)).toBe(false); expect(worker.wakeAllBlockedRetries).toHaveBeenCalledOnce(); }); }); + +describe("host-emulated epoll signal delivery", () => { + it("interrupts epoll with EINTR after copying a caught handler signal", () => { + const harness = createEpollSignalHarness(15, 0); + + harness.worker.handleEpollPwait( + harness.channel, + ABI_SYSCALLS.EpollPwait, + [7, 4096, 1, 1000, 0, 8], + ); + + expect(harness.dequeueSignal).toHaveBeenCalledWith(harness.channel.pid, CH_SIG_BASE); + expect( + new DataView(harness.processMemory.buffer).getUint32(CH_SIG_BASE, true), + ).toBe(15); + expect(harness.completeChannelRaw).toHaveBeenCalledWith(harness.channel, -4, 4); + expect(harness.relistenChannel).toHaveBeenCalledWith(harness.channel); + expect(harness.handleProcessTerminated).not.toHaveBeenCalled(); + }); + + it("reaps a default signal death without waking guest epoll code", () => { + const harness = createEpollSignalHarness(0, 11, false); + + harness.worker.handleEpollPwait( + harness.channel, + ABI_SYSCALLS.EpollPwait, + [7, 4096, 1, 1000, 0, 8], + ); + + expect(harness.handleProcessTerminated).toHaveBeenCalledWith(harness.channel); + expect(harness.completeChannelRaw).not.toHaveBeenCalled(); + expect(harness.relistenChannel).not.toHaveBeenCalled(); + expect(harness.worker.pendingPollRetries.size).toBe(0); + expect(harness.handleChannel).not.toHaveBeenCalled(); + }); +}); + +function createEpollSignalHarness( + handlerSignal: number, + exitSignal: number, + hasInterest = true, +) { + const kernelMemory = createSharedMemory(2); + const processMemory = createSharedMemory(2); + const channel: any = { + pid: 42, + channelOffset: 0, + memory: processMemory, + }; + const dequeueSignal = vi.fn((_pid: number, outPtr: number) => { + if (handlerSignal > 0) { + new DataView(kernelMemory.buffer).setUint32(outPtr, handlerSignal, true); + } + return handlerSignal; + }); + const completeChannelRaw = vi.fn(); + const relistenChannel = vi.fn(); + const handleProcessTerminated = vi.fn(); + const handleChannel = vi.fn(() => 0); + const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, + kernelInstance: { + exports: { + kernel_handle_channel: handleChannel, + kernel_dequeue_signal: dequeueSignal, + kernel_get_process_exit_signal: vi.fn(() => exitSignal), + }, + }, + kernelMemory, + scratchOffset: 0, + currentHandlePid: 0, + epollInterests: new Map([ + ["42:7", hasInterest ? [{ fd: 3, events: 0x001, data: 99n }] : []], + ]), + pendingPollRetries: new Map(), + pendingSleeps: new Map(), + bindKernelTidForChannel: vi.fn(), + completeChannelRaw, + relistenChannel, + handleProcessTerminated, + }); + return { + channel, + completeChannelRaw, + dequeueSignal, + handleChannel, + handleProcessTerminated, + processMemory, + relistenChannel, + worker, + }; +} diff --git a/host/test/shared-memory-coherence.test.ts b/host/test/shared-memory-coherence.test.ts new file mode 100644 index 000000000..a65eb22ca --- /dev/null +++ b/host/test/shared-memory-coherence.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it, vi } from "vitest"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +function sharedMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); +} + +function anonymousHarness() { + const parentPid = 41; + const peerPid = 42; + const childPid = 43; + const mapAddr = 0x2000; + const len = 256; + const key = "anon:test"; + const parentMemory = sharedMemory(); + const peerMemory = sharedMemory(); + const childMemory = sharedMemory(); + const backing = { + key, + bytes: new Uint8Array(len), + refCount: 2, + version: 0, + }; + const mapping = () => ({ + fd: -1, + fileOffset: 0, + len, + writable: true, + backingKey: key, + snapshot: new Uint8Array(len), + seenVersion: 0, + }); + const channel = (pid: number, memory: WebAssembly.Memory) => ({ + pid, + memory, + channelOffset: 0, + i32View: new Int32Array(memory.buffer, 0, 1), + consecutiveSyscalls: 0, + }); + const parentChannel = channel(parentPid, parentMemory); + const peerChannel = channel(peerPid, peerMemory); + const childChannel = channel(childPid, childMemory); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + anonymousSharedBackings: new Map([[key, backing]]), + sharedMappings: new Map([ + [parentPid, new Map([[mapAddr, mapping()]])], + [peerPid, new Map([[mapAddr, mapping()]])], + ]), + shmMappings: new Map(), + processes: new Map([ + [parentPid, { pid: parentPid, memory: parentMemory, channels: [parentChannel] }], + [peerPid, { pid: peerPid, memory: peerMemory, channels: [peerChannel] }], + [childPid, { pid: childPid, memory: childMemory, channels: [childChannel] }], + ]), + }) as CentralizedKernelWorker; + return { + backing, + childMemory, + childPid, + key, + kw, + len, + mapAddr, + parentMemory, + parentPid, + peerMemory, + peerPid, + }; +} + +describe("anonymous MAP_SHARED coherence", () => { + it("merges stale same-page publishers without losing disjoint peer writes", () => { + const h = anonymousHarness(); + const parent = new Uint8Array(h.parentMemory.buffer); + const peer = new Uint8Array(h.peerMemory.buffer); + + parent[h.mapAddr + 11] = 0xa1; + (h.kw as any).syncAnonymousSharedMappingsFromProcess( + (h.kw as any).processes.get(h.parentPid), + ); + + peer[h.mapAddr + 29] = 0xb2; + (h.kw as any).syncAnonymousSharedMappingsFromProcess( + (h.kw as any).processes.get(h.peerPid), + ); + + expect(h.backing.bytes[11]).toBe(0xa1); + expect(h.backing.bytes[29]).toBe(0xb2); + expect(peer[h.mapAddr + 11]).toBe(0xa1); + expect(peer[h.mapAddr + 29]).toBe(0xb2); + + (h.kw as any).syncAnonymousSharedMappingsFromProcess( + (h.kw as any).processes.get(h.parentPid), + ); + expect(parent[h.mapAddr + 29]).toBe(0xb2); + }); + + it("force-publishes a sole observer before fork and inherits one backing", () => { + const h = anonymousHarness(); + (h.kw as any).sharedMappings.delete(h.peerPid); + h.backing.refCount = 1; + new Uint8Array(h.parentMemory.buffer)[h.mapAddr + 7] = 0x7c; + + (h.kw as any).syncAnonymousSharedMappingsFromProcess( + (h.kw as any).processes.get(h.parentPid), + { force: true }, + ); + h.kw.inheritProcessSharedMappings(h.parentPid, h.childPid); + + expect(h.backing.bytes[7]).toBe(0x7c); + expect(new Uint8Array(h.childMemory.buffer)[h.mapAddr + 7]).toBe(0x7c); + expect(h.backing.refCount).toBe(2); + expect((h.kw as any).sharedMappings.get(h.childPid).size).toBe(1); + }); + + it("refreshes a sole parent after its child publishes and detaches", () => { + const h = anonymousHarness(); + (h.kw as any).sharedMappings.delete(h.peerPid); + h.backing.refCount = 1; + h.kw.inheritProcessSharedMappings(h.parentPid, h.childPid); + + const child = new Uint8Array(h.childMemory.buffer); + child[h.mapAddr + 17] = 0x6d; + (h.kw as any).syncAnonymousSharedMappingsFromProcess( + (h.kw as any).processes.get(h.childPid), + ); + expect(h.backing.bytes[17]).toBe(0x6d); + expect(h.backing.refCount).toBe(2); + + (h.kw as any).releaseAllSharedMemoryForProcess(h.childPid); + expect(h.backing.refCount).toBe(1); + + const parent = new Uint8Array(h.parentMemory.buffer); + expect(parent[h.mapAddr + 17]).toBe(0); + (h.kw as any).syncAnonymousSharedMappingsFromProcess( + (h.kw as any).processes.get(h.parentPid), + ); + expect(parent[h.mapAddr + 17]).toBe(0x6d); + }); + + it("publishes and releases backing references exactly once at teardown", () => { + const h = anonymousHarness(); + new Uint8Array(h.parentMemory.buffer)[h.mapAddr + 3] = 0x55; + + (h.kw as any).releaseAllSharedMemoryForProcess(h.parentPid); + expect(h.backing.bytes[3]).toBe(0x55); + expect(h.backing.refCount).toBe(1); + + (h.kw as any).releaseAllSharedMemoryForProcess(h.parentPid); + expect(h.backing.refCount).toBe(1); + + (h.kw as any).releaseAllSharedMemoryForProcess(h.peerPid); + expect((h.kw as any).anonymousSharedBackings.has(h.key)).toBe(false); + }); + + it("rejects a stale pre-exec memory generation at a coherence boundary", () => { + const h = anonymousHarness(); + const replacement = sharedMemory(); + (h.kw as any).processes.set(h.parentPid, { + pid: h.parentPid, + memory: replacement, + channels: [], + }); + new Uint8Array(h.parentMemory.buffer)[h.mapAddr + 1] = 0xff; + + (h.kw as any).synchronizeSharedMemoryForBoundary({ + pid: h.parentPid, + memory: h.parentMemory, + }); + + expect(h.backing.bytes[1]).toBe(0); + }); + + it("skips coherence scans when no process has shared mappings", () => { + const pid = 51; + const process = { pid, memory: sharedMemory() }; + const processes = new Map([[pid, process]]); + const getProcess = vi.spyOn(processes, "get"); + const syncAnonymous = vi.fn(); + const syncFile = vi.fn(); + const syncSysv = vi.fn(); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + processes, + sharedMappings: new Map(), + shmMappings: new Map(), + syncAnonymousSharedMappingsFromProcess: syncAnonymous, + syncFileSharedMappingsFromProcess: syncFile, + syncSysvShmMappingsFromProcess: syncSysv, + }) as CentralizedKernelWorker; + + (kw as any).synchronizeSharedMemoryForBoundary(process); + + expect(getProcess).toHaveBeenCalledWith(pid); + expect(syncAnonymous).not.toHaveBeenCalled(); + expect(syncFile).not.toHaveBeenCalled(); + expect(syncSysv).not.toHaveBeenCalled(); + }); + + it.each(["POSIX", "SysV"])( + "runs coherence scans while %s shared mappings exist", + (mappingKind) => { + const pid = 52; + const process = { pid, memory: sharedMemory() }; + const syncAnonymous = vi.fn(); + const syncFile = vi.fn(); + const syncSysv = vi.fn(); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + processes: new Map([[pid, process]]), + sharedMappings: mappingKind === "POSIX" + ? new Map([[pid, new Map([[0x1000, {}]])]]) + : new Map(), + shmMappings: mappingKind === "SysV" + ? new Map([[pid, new Map([[0x2000, {}]])]]) + : new Map(), + syncAnonymousSharedMappingsFromProcess: syncAnonymous, + syncFileSharedMappingsFromProcess: syncFile, + syncSysvShmMappingsFromProcess: syncSysv, + }) as CentralizedKernelWorker; + + (kw as any).synchronizeSharedMemoryForBoundary(process); + + expect(syncAnonymous).toHaveBeenCalledWith(process); + expect(syncFile).toHaveBeenCalledWith(process); + expect(syncSysv).toHaveBeenCalledWith(process); + }, + ); +}); + +function sysvHarness() { + const pids = [61, 62, 63]; + const mapAddr = 0x3000; + const size = 256; + const segId = 9; + const memories = new Map(pids.map((pid) => [pid, sharedMemory()])); + const kernelMemory = new WebAssembly.Memory({ initial: 2 }); + const segment = new Uint8Array(size); + const setCurrentPid = vi.fn(); + const shmat = vi.fn(() => size); + const shmdt = vi.fn(() => 0); + const readChunk = vi.fn((id: number, offset: number, outPtr: number, maxLen: number) => { + expect(id).toBe(segId); + const len = Math.min(maxLen, segment.length - offset); + new Uint8Array(kernelMemory.buffer).set(segment.subarray(offset, offset + len), outPtr); + return len; + }); + const writeChunk = vi.fn((id: number, offset: number, dataPtr: number, len: number) => { + expect(id).toBe(segId); + segment.set(new Uint8Array(kernelMemory.buffer, dataPtr, len), offset); + return len; + }); + const mapping = (readOnly = false) => ({ + segId, + size, + readOnly, + snapshot: new Uint8Array(size), + seenVersion: 0, + }); + const processes = new Map(pids.map((pid) => { + const memory = memories.get(pid)!; + return [pid, { pid, memory, channels: [{ pid, memory, channelOffset: 0 }] }]; + })); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + currentHandlePid: 0, + kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, + kernelMemory, + kernelInstance: { + exports: { + kernel_set_current_pid: setCurrentPid, + kernel_ipc_shmat: shmat, + kernel_ipc_shmdt: shmdt, + kernel_ipc_shm_read_chunk: readChunk, + kernel_ipc_shm_write_chunk: writeChunk, + }, + }, + scratchOffset: 0, + processes, + sharedMappings: new Map(), + anonymousSharedBackings: new Map(), + shmMappings: new Map([ + [pids[0], new Map([[mapAddr, mapping()]])], + [pids[1], new Map([[mapAddr, mapping()]])], + ]), + shmSegmentVersions: new Map([[segId, 0]]), + }) as CentralizedKernelWorker; + return { kw, mapAddr, memories, pids, segment, segId, shmat, shmdt, size }; +} + +describe("SysV SHM coherence and lifecycle", () => { + it("merges stale same-page publishers and refreshes the later publisher", () => { + const h = sysvHarness(); + const first = new Uint8Array(h.memories.get(h.pids[0])!.buffer); + const second = new Uint8Array(h.memories.get(h.pids[1])!.buffer); + first[h.mapAddr + 5] = 0x15; + (h.kw as any).syncSysvShmMappingsFromProcess( + (h.kw as any).processes.get(h.pids[0]), + ); + second[h.mapAddr + 19] = 0x29; + (h.kw as any).syncSysvShmMappingsFromProcess( + (h.kw as any).processes.get(h.pids[1]), + ); + + expect(h.segment[5]).toBe(0x15); + expect(h.segment[19]).toBe(0x29); + expect(second[h.mapAddr + 5]).toBe(0x15); + expect(second[h.mapAddr + 19]).toBe(0x29); + }); + + it("never publishes a SHM_RDONLY attachment but still refreshes it", () => { + const h = sysvHarness(); + const readonlyMap = (h.kw as any).shmMappings.get(h.pids[1]).get(h.mapAddr); + readonlyMap.readOnly = true; + const first = new Uint8Array(h.memories.get(h.pids[0])!.buffer); + const second = new Uint8Array(h.memories.get(h.pids[1])!.buffer); + second[h.mapAddr + 8] = 0xee; + first[h.mapAddr + 14] = 0x44; + + (h.kw as any).syncSysvShmMappingsFromProcess( + (h.kw as any).processes.get(h.pids[0]), + ); + (h.kw as any).syncSysvShmMappingsFromProcess( + (h.kw as any).processes.get(h.pids[1]), + ); + + expect(h.segment[8]).toBe(0); + expect(h.segment[14]).toBe(0x44); + expect(second[h.mapAddr + 8]).toBe(0); + expect(second[h.mapAddr + 14]).toBe(0x44); + }); + + it("increments inherited nattch and detaches the child exactly once", () => { + const h = sysvHarness(); + h.kw.inheritProcessSharedMappings(h.pids[0], h.pids[2]); + expect(h.shmat).toHaveBeenCalledWith(h.segId, h.mapAddr, 0); + expect((h.kw as any).shmMappings.get(h.pids[2]).size).toBe(1); + + (h.kw as any).releaseAllSharedMemoryForProcess(h.pids[2]); + expect(h.shmdt).toHaveBeenCalledTimes(1); + (h.kw as any).releaseAllSharedMemoryForProcess(h.pids[2]); + expect(h.shmdt).toHaveBeenCalledTimes(1); + }); + + it("rolls back attachments when inherited SysV setup fails", () => { + const h = sysvHarness(); + const secondAddr = h.mapAddr + 0x1000; + (h.kw as any).shmMappings.get(h.pids[0]).set(secondAddr, { + segId: h.segId, + size: h.size, + readOnly: false, + snapshot: new Uint8Array(h.size), + seenVersion: 0, + }); + h.shmat.mockImplementationOnce(() => h.size).mockImplementationOnce(() => -12); + + expect(() => h.kw.inheritProcessSharedMappings(h.pids[0], h.pids[2])).toThrow(); + expect(h.shmdt).toHaveBeenCalledTimes(1); + expect((h.kw as any).shmMappings.has(h.pids[2])).toBe(false); + }); + + it("rolls back kernel nattch when host mmap allocation fails", () => { + const h = sysvHarness(); + const complete = vi.fn(); + const relisten = vi.fn(); + Object.assign(h.kw as any, { + shmMappings: new Map(), + runSyntheticMemorySyscall: vi.fn(() => ({ retVal: -1, errVal: 12 })), + completeChannelRaw: complete, + relistenChannel: relisten, + }); + const memory = h.memories.get(h.pids[2])!; + const channel = { pid: h.pids[2], memory, channelOffset: 0 }; + + (h.kw as any).handleIpcShmat(channel, [h.segId, 0, 0]); + expect(h.shmdt).toHaveBeenCalledTimes(1); + expect(complete).toHaveBeenCalledWith(channel, -12, 12); + expect(relisten).toHaveBeenCalledWith(channel); + }); +}); diff --git a/host/test/signal-accept-livelock.test.ts b/host/test/signal-accept-livelock.test.ts index 98d85adc2..1102771a8 100644 --- a/host/test/signal-accept-livelock.test.ts +++ b/host/test/signal-accept-livelock.test.ts @@ -6,7 +6,7 @@ * `sendSignalToProcess` / `notifyPipeReadable` iterate `pendingPollRetries` * and, for each matching entry, delete it and synchronously `retrySyscall`. * A blocked `accept()` re-runs, returns EAGAIN, and re-registers under the - * SAME `channelOffset` key. A raw `for..of` over the live Map revisits the + * SAME channel key. A raw `for..of` over the live Map revisits the * re-inserted entry forever (JS Map iterators are not snapshots), spinning * the single kernel-worker thread and wedging the whole machine. * @@ -21,9 +21,10 @@ import { describe, expect, it, vi } from "vitest"; import { CentralizedKernelWorker } from "../src/kernel-worker"; const SIGCHLD = 17; +const SIGTERM = 15; function createSharedMemory(): WebAssembly.Memory { - return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + return new WebAssembly.Memory({ initial: 2, maximum: 2, shared: true }); } function createChannel(pid: number, channelOffset: number): any { @@ -39,12 +40,14 @@ function createWorkerHarness(): any { exports: { kernel_handle_channel: () => 0, kernel_set_current_tid: () => {}, - kernel_is_signal_blocked: () => 0, // deliverable — proceed to the wake loops + kernel_pick_signal_target_tid: (pid: number) => pid, + kernel_thread_has_deliverable: () => 1, }, }, kernelMemory: createSharedMemory(), scratchOffset: 128, processes: new Map(), + channelTids: new Map(), pendingSleeps: new Map(), pendingPollRetries: new Map(), pendingSelectRetries: new Map(), @@ -61,7 +64,7 @@ describe("signal delivery to a process blocked in accept()", () => { worker.processes.set(targetPid, { channels: [channel] }); - // The accept()'s parked-retry entry, keyed by channelOffset (matches + // The accept()'s parked-retry entry, keyed by exact channel (matches // handleBlockingRetry's registration for SYS_ACCEPT). const makeEntry = () => ({ timer: null, @@ -69,7 +72,7 @@ describe("signal delivery to a process blocked in accept()", () => { pipeIndices: [], acceptIndices: [7], }); - worker.pendingPollRetries.set(channelOffset, makeEntry()); + worker.pendingPollRetries.set(channel, makeEntry()); // Model accept() re-parking: every retry re-inserts the SAME key, exactly // as the real EAGAIN path does. Cap the re-insertions so a *regressed* @@ -79,7 +82,7 @@ describe("signal delivery to a process blocked in accept()", () => { worker.retrySyscall = vi.fn(() => { retryCount++; if (retryCount < 5000) { - worker.pendingPollRetries.set(channelOffset, makeEntry()); + worker.pendingPollRetries.set(channel, makeEntry()); } }); @@ -103,16 +106,209 @@ describe("signal delivery to a process blocked in accept()", () => { worker.scheduleWakeBlockedRetries = () => {}; const makeEntry = () => ({ timer: null, channel, pipeIndices: [pipeIdx], acceptIndices: [] }); - worker.pendingPollRetries.set(channelOffset, makeEntry()); + worker.pendingPollRetries.set(channel, makeEntry()); let retryCount = 0; worker.retrySyscall = vi.fn(() => { retryCount++; - if (retryCount < 5000) worker.pendingPollRetries.set(channelOffset, makeEntry()); + if (retryCount < 5000) worker.pendingPollRetries.set(channel, makeEntry()); }); worker.notifyPipeReadable(pipeIdx); expect(retryCount).toBe(1); }); + + it("interrupts only the sleeping thread selected for a shared signal", () => { + vi.useFakeTimers(); + try { + const worker = createWorkerHarness(); + const pid = 44; + const threadTid = 45; + const mainChannel = createChannel(pid, 0); + const threadChannel = createChannel(pid, 256); + const mainTimer = setTimeout(() => {}, 60_000); + const threadTimer = setTimeout(() => {}, 60_000); + const mainSleep = { + timer: mainTimer, + channel: mainChannel, + syscallNr: 1, + origArgs: [], + retVal: 0, + errVal: 0, + }; + const threadSleep = { + timer: threadTimer, + channel: threadChannel, + syscallNr: 1, + origArgs: [], + retVal: 0, + errVal: 0, + }; + worker.processes.set(pid, { channels: [mainChannel, threadChannel] }); + worker.channelTids.set(`${pid}:${threadChannel.channelOffset}`, threadTid); + worker.pendingSleeps.set(mainChannel, mainSleep); + worker.pendingSleeps.set(threadChannel, threadSleep); + worker.kernelInstance.exports.kernel_pick_signal_target_tid = vi.fn( + () => threadTid, + ); + // Model a caught SIGCHLD still pending for the selected pthread. + worker.completeSleepWithSignalCheck = vi.fn(); + + worker.sendSignalToProcess(pid, SIGCHLD); + + expect( + worker.kernelInstance.exports.kernel_pick_signal_target_tid, + ).toHaveBeenCalledWith(pid, SIGCHLD); + expect(worker.pendingSleeps.get(mainChannel)).toBe(mainSleep); + expect(worker.pendingSleeps.has(threadChannel)).toBe(false); + expect(worker.completeSleepWithSignalCheck).toHaveBeenCalledOnce(); + expect(worker.completeSleepWithSignalCheck).toHaveBeenCalledWith( + threadChannel, + threadSleep.syscallNr, + threadSleep.origArgs, + threadSleep.retVal, + threadSleep.errVal, + ); + } finally { + vi.useRealTimers(); + } + }); + + it("binds a sleeping pthread before dequeuing its pending signal", () => { + const worker = createWorkerHarness(); + const pid = 46; + const tid = 47; + const channel = createChannel(pid, 256); + const setCurrentTid = vi.fn(); + const dequeueSignal = vi.fn(() => 0); + worker.channelTids.set(`${pid}:${channel.channelOffset}`, tid); + worker.kernelInstance.exports.kernel_set_current_tid = setCurrentTid; + worker.kernelInstance.exports.kernel_dequeue_signal = dequeueSignal; + worker.completeChannel = vi.fn(); + + worker.completeSleepWithSignalCheck(channel, 1, [], 0, 0); + + expect(setCurrentTid).toHaveBeenCalledWith(tid); + expect(dequeueSignal).toHaveBeenCalledWith(pid, expect.any(Number)); + expect(setCurrentTid.mock.invocationCallOrder[0]).toBeLessThan( + dequeueSignal.mock.invocationCallOrder[0], + ); + }); + + it("does not rebind an ordinary synchronous signal dequeue", () => { + const worker = createWorkerHarness(); + const pid = 48; + const channel = createChannel(pid, 0); + const setCurrentTid = vi.fn(); + worker.kernelInstance.exports.kernel_set_current_tid = setCurrentTid; + worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => 0); + + worker.dequeueSignalForDelivery(channel); + + expect(setCurrentTid).not.toHaveBeenCalled(); + }); + + it("does not resume a sleeping pthread after dequeue terminates it", () => { + const worker = createWorkerHarness(); + const pid = 49; + const tid = 50; + const channel = createChannel(pid, 256); + let exited = false; + worker.channelTids.set(`${pid}:${channel.channelOffset}`, tid); + worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => { + exited = true; + return 0; + }); + worker.getProcessExitSignal = vi.fn(() => exited ? SIGTERM : -1); + worker.handleProcessTerminated = vi.fn(); + worker.completeChannel = vi.fn(); + + worker.completeSleepWithSignalCheck(channel, 1, [], 0, 0); + + expect(worker.handleProcessTerminated).toHaveBeenCalledWith(channel); + expect(worker.completeChannel).not.toHaveBeenCalled(); + }); + + it("leaves a sleep parked when the kernel consumed an ignored signal", () => { + vi.useFakeTimers(); + try { + const worker = createWorkerHarness(); + const pid = 51; + const channel = createChannel(pid, 0); + const timer = setTimeout(() => {}, 60_000); + const sleep = { + timer, + channel, + syscallNr: 1, + origArgs: [], + retVal: 0, + errVal: 0, + }; + worker.processes.set(pid, { channels: [channel] }); + worker.pendingSleeps.set(channel, sleep); + worker.kernelInstance.exports.kernel_thread_has_deliverable = vi.fn( + () => 0, + ); + worker.completeSleepWithSignalCheck = vi.fn(); + worker.retrySyscall = vi.fn(); + worker.handlePselect6 = vi.fn(); + const pollEntry = { timer: null, channel }; + const selectEntry = { + timer: setTimeout(() => {}, 60_000), + channel, + origArgs: [], + syscallNr: 0, + }; + worker.pendingPollRetries.set(channel, pollEntry); + worker.pendingSelectRetries.set(channel, selectEntry); + + worker.sendSignalToProcess(pid, SIGCHLD); + + expect(worker.pendingSleeps.get(channel)).toBe(sleep); + expect(worker.pendingPollRetries.get(channel)).toBe(pollEntry); + expect(worker.pendingSelectRetries.get(channel)).toBe(selectEntry); + expect(worker.completeSleepWithSignalCheck).not.toHaveBeenCalled(); + expect(worker.retrySyscall).not.toHaveBeenCalled(); + expect(worker.handlePselect6).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("reaps a default-terminated process without resuming its channel", () => { + const worker = createWorkerHarness(); + const pid = 52; + const pickSignalTarget = vi.fn(() => pid); + worker.kernelInstance.exports.kernel_pick_signal_target_tid = pickSignalTarget; + worker.reapKilledProcessesAfterSyscall = vi.fn(); + worker.getProcessExitSignal = vi.fn(() => SIGTERM); + + worker.sendSignalToProcess(pid, SIGTERM); + + expect(worker.reapKilledProcessesAfterSyscall).toHaveBeenCalledOnce(); + expect(pickSignalTarget).not.toHaveBeenCalled(); + }); + + it("does not wake blocked channels when queuing the signal traps", () => { + const worker = createWorkerHarness(); + const pid = 53; + const pickSignalTarget = vi.fn(() => pid); + const reaper = vi.fn(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + worker.kernelInstance.exports.kernel_handle_channel = () => { + throw new Error("synthetic kernel trap"); + }; + worker.kernelInstance.exports.kernel_pick_signal_target_tid = pickSignalTarget; + worker.reapKilledProcessesAfterSyscall = reaper; + + worker.sendSignalToProcess(pid, SIGTERM); + + expect(reaper).not.toHaveBeenCalled(); + expect(pickSignalTarget).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); }); diff --git a/host/test/thread-worker-disposition.test.ts b/host/test/thread-worker-disposition.test.ts index d667837f6..dc6515843 100644 --- a/host/test/thread-worker-disposition.test.ts +++ b/host/test/thread-worker-disposition.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { threadWorkerFailureDisposition } from "../src/thread-worker-disposition"; +import { + removeThreadWorkerRegistryEntry, + threadWorkerFailureDisposition, +} from "../src/thread-worker-disposition"; import { signalExitStatus, SIGILL, SIGSEGV } from "../src/trap-signals"; describe("pthread worker failure disposition", () => { @@ -26,4 +29,17 @@ describe("pthread worker failure disposition", () => { kind: "host-thread-failure", }); }); + + it("retires the per-process registry after its final worker is reclaimed", () => { + const first = { tid: 11 }; + const second = { tid: 12 }; + const registry = new Map([[42, [first, second]]]); + + expect(removeThreadWorkerRegistryEntry(registry, 42, first)).toBe(true); + expect(registry.get(42)).toEqual([second]); + + expect(removeThreadWorkerRegistryEntry(registry, 42, second)).toBe(true); + expect(registry.has(42)).toBe(false); + expect(removeThreadWorkerRegistryEntry(registry, 42, second)).toBe(false); + }); }); diff --git a/host/test/vfs.test.ts b/host/test/vfs.test.ts index 12288acde..c257b3d60 100644 --- a/host/test/vfs.test.ts +++ b/host/test/vfs.test.ts @@ -238,6 +238,52 @@ describe("VirtualPlatformIO mount resolution", () => { }); }); +describe("VirtualPlatformIO file identity", () => { + it("qualifies colliding inode numbers by backend", () => { + const root = createMockBackend(); + const first = createMockBackend(); + const second = createMockBackend(); + const vfs = new VirtualPlatformIO( + [ + { mountPoint: "/", backend: root }, + { mountPoint: "/first", backend: first }, + { mountPoint: "/second", backend: second }, + ], + new NodeTimeProvider(), + ); + + expect(vfs.fileIdentity("/first/file", 0n, 2n)).not.toBe( + vfs.fileIdentity("/second/file", 0n, 2n), + ); + }); + + it("uses one namespace when the same backend is mounted twice", () => { + const root = createMockBackend(); + const shared = createMockBackend(); + const vfs = new VirtualPlatformIO( + [ + { mountPoint: "/", backend: root }, + { mountPoint: "/one", backend: shared }, + { mountPoint: "/two", backend: shared }, + ], + new NodeTimeProvider(), + ); + + expect(vfs.fileIdentity("/one/alias", 0n, 7n)).toBe( + vfs.fileIdentity("/two/alias", 0n, 7n), + ); + }); + + it("rejects a backend that supplies no stable inode", () => { + const vfs = new VirtualPlatformIO( + [{ mountPoint: "/", backend: createMockBackend() }], + new NodeTimeProvider(), + ); + + expect(vfs.fileIdentity("/file", 0n, 0n)).toBeNull(); + }); +}); + // --------------------------------------------------------------------------- // 2. Handle mapping tests // --------------------------------------------------------------------------- diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 6c8b4916a..1958d81af 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -1839,6 +1839,7 @@ fn classify_compat_change(old: &Value, new: &Value) -> Result { classify_additive_array_by_name(key, old_value, new_value, &mut report)? } + "host_adapter" => classify_host_adapter(old_value, new_value, &mut report)?, "marshalled_structs" => { classify_additive_object_by_key(key, old_value, new_value, &mut report)? } @@ -1861,6 +1862,57 @@ fn additive_top_level_section(section: &str) -> bool { matches!(section, "host_adapter" | "syscall_arg_descriptors") } +fn classify_host_adapter( + old: &Value, + new: &Value, + report: &mut CompatReport, +) -> Result<(), String> { + let old_obj = old + .as_object() + .ok_or("old host_adapter section must be a JSON object")?; + let new_obj = new + .as_object() + .ok_or("new host_adapter section must be a JSON object")?; + + for key in old_obj.keys() { + let Some(new_value) = new_obj.get(key) else { + report + .breaking + .push(format!("removed host_adapter field {key:?}")); + continue; + }; + if key == "optional_kernel_exports" { + classify_additive_array( + "host_adapter.optional_kernel_exports", + &old_obj[key], + new_value, + report, + |entry| { + entry.as_str().map(ToOwned::to_owned).ok_or_else(|| { + format!( + "host_adapter.optional_kernel_exports entry must be a string: {entry}", + ) + }) + }, + )?; + } else if &old_obj[key] != new_value { + report + .breaking + .push(format!("changed host_adapter field {key:?}")); + } + } + + for key in new_obj.keys() { + if !old_obj.contains_key(key) { + report + .breaking + .push(format!("added host_adapter field {key:?}")); + } + } + + Ok(()) +} + fn classify_additive_object_by_key( section: &str, old: &Value, @@ -2146,6 +2198,37 @@ mod tests { ); } + #[test] + fn adding_optional_host_adapter_export_is_compatible() { + let old = base_snapshot(); + let mut new = old.clone(); + new["host_adapter"]["optional_kernel_exports"] = + json!(["kernel_get_process_exit_signal",]); + + let report = classify_compat_change(&old, &new).unwrap(); + assert!(report.breaking.is_empty(), "{report:?}"); + assert_eq!( + report.additive, + vec![ + "added host_adapter.optional_kernel_exports entry \"kernel_get_process_exit_signal\"", + ], + ); + } + + #[test] + fn changing_required_host_adapter_export_is_breaking() { + let old = base_snapshot(); + let mut new = old.clone(); + new["host_adapter"]["required_kernel_exports"] = + json!(["__abi_version", "kernel_new_requirement",]); + + let report = classify_compat_change(&old, &new).unwrap(); + assert_eq!( + report.breaking, + vec!["changed host_adapter field \"required_kernel_exports\""], + ); + } + #[test] fn changed_existing_export_is_breaking() { let old = base_snapshot();