diff --git a/README.md b/README.md index 7cebe66c0c..b8424d6048 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ See [docs/architecture.md](docs/architecture.md) for the full architecture refer | Time | clock_gettime, gettimeofday, nanosleep, utimensat, timer_create/settime/gettime/delete | | Terminal | Full PTY support (/dev/ptmx + /dev/pts/N), line discipline, canonical/raw mode, 16 terminal ioctls | | Virtual devices | /dev/null, /dev/zero, /dev/urandom, /dev/full, /dev/fd/N, /dev/tty, /dev/ptmx, /dev/pts/* | -| Procfs | /proc/self, /proc/\/stat, status, cmdline, environ, maps, fd/\*, /proc/net/tcp, unix | +| Procfs (partial Linux compatibility) | /proc/stat, /proc/meminfo, /proc/self, /proc/\/{stat,status,statm,task,cmdline,environ,maps,fd/\*}, /proc/net/{tcp,unix}; virtual size is logical address-space size, while CPU, RSS, and system-memory zeroes mean accounting is unavailable | | IPC | SysV msg queues, semaphores, shared memory; POSIX mqueues | | Event/Notification | eventfd, timerfd, signalfd | | Poll/Select | poll, ppoll, pselect6, epoll (host-intercepted in browser) | diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index 5be7327463..aee600fca0 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -114,6 +114,15 @@ pub trait HostIO { let _ = handle; Ok(events) } + /// Query readiness for a host-delegated file descriptor. + /// + /// Native hosts that do not distinguish delegated descriptor readiness + /// retain the legacy ready behavior. `WasmHostIO` overrides this and uses + /// a tagged call over the existing `host_net_poll` ABI import. + fn host_fd_poll(&mut self, handle: i64, events: i16) -> Result { + let _ = handle; + Ok(events) + } fn host_net_close(&mut self, handle: i32) -> Result<(), Errno>; /// Notify the host that an AF_INET socket is now listening, so the host /// can open a real TCP server on the given port. @@ -390,6 +399,8 @@ pub struct ThreadInfo { pub stack_ptr: usize, pub tls_ptr: usize, pub tidptr: usize, // set_tid_address pointer + /// Linux thread name set by `prctl(PR_SET_NAME)`. + pub thread_name: [u8; 16], /// Per-thread signal state: directed-pending set + blocked mask + RT queue. /// Handlers remain process-wide and live on [`Process::signals`]. pub signals: PerThreadSignalState, @@ -403,6 +414,7 @@ impl ThreadInfo { stack_ptr, tls_ptr, tidptr: 0, + thread_name: [0u8; 16], signals: PerThreadSignalState::new(), } } @@ -822,6 +834,29 @@ impl Process { self.threads.iter_mut().find(|t| t.tid == tid) } + /// Return the Linux thread name storage for `tid`. + /// + /// The main thread's name remains on [`Process::thread_name`]. Worker + /// names live on their [`ThreadInfo`] records so a pthread rename cannot + /// change `/proc//stat` for the thread-group leader. + pub fn thread_name_for(&self, tid: u32) -> Option<&[u8; 16]> { + if self.is_main_thread(tid) { + Some(&self.thread_name) + } else { + self.get_thread(tid).map(|thread| &thread.thread_name) + } + } + + /// Mutable counterpart to [`Process::thread_name_for`]. + pub fn thread_name_for_mut(&mut self, tid: u32) -> Option<&mut [u8; 16]> { + if self.is_main_thread(tid) { + Some(&mut self.thread_name) + } else { + self.get_thread_mut(tid) + .map(|thread| &mut thread.thread_name) + } + } + /// True if `tid` names the process's main thread. The main thread's TID /// equals the process PID (Linux convention) and is not tracked in /// [`Process::threads`]; per-thread signal state for the main thread lives diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 6632161df0..6f9d3088af 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -851,6 +851,19 @@ impl ProcessTable { self.processes.keys().copied().collect() } + /// Collect PIDs that represent user-visible procfs processes. + /// + /// Running processes and unreaped Exited zombies remain visible. Limbo + /// records are resource-free process-group identity placeholders, not + /// processes, so exposing them as `/proc/` would invent state. + pub fn procfs_pids(&self) -> Vec { + self.processes + .iter() + .filter(|(_, process)| process.state != ProcessState::Limbo) + .map(|(&pid, _)| pid) + .collect() + } + /// Collect PIDs of all processes in a given process group. pub fn pids_in_group(&self, pgid: u32) -> Vec { self.processes @@ -957,6 +970,18 @@ impl ProcessTable { mod wait_tests { use super::*; + #[test] + fn procfs_pids_retain_zombies_but_exclude_limbo_placeholders() { + let mut table = ProcessTable::new(); + table.create_process(100).unwrap(); + table.create_process(101).unwrap(); + table.processes.get_mut(&100).unwrap().state = ProcessState::Exited; + table.processes.get_mut(&1).unwrap().state = ProcessState::Limbo; + + assert_eq!(table.procfs_pids(), vec![100, 101]); + assert_eq!(table.all_pids(), vec![1, 100, 101]); + } + #[test] fn spawn_pid_allocation_does_not_reuse_reaped_pid() { let mut table = ProcessTable::new(); diff --git a/crates/kernel/src/procfs.rs b/crates/kernel/src/procfs.rs index 84844e3d69..c1de1a8948 100644 --- a/crates/kernel/src/procfs.rs +++ b/crates/kernel/src/procfs.rs @@ -44,28 +44,33 @@ fn procfs_buf_handle(idx: usize) -> i64 { /// A parsed procfs path entry. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProcfsEntry { - Root, // /proc - Mounts, // /proc/mounts - SelfLink, // /proc/self (symlink → /proc/) - ThreadSelfLink, // /proc/thread-self (symlink) - PidDir(u32), // /proc/ - PidMounts(u32), // /proc//mounts - PidMountinfo(u32), // /proc//mountinfo - FdDir(u32), // /proc//fd - FdLink(u32, i32), // /proc//fd/ (symlink) - FdInfoDir(u32), // /proc//fdinfo - FdInfo(u32, i32), // /proc//fdinfo/ - Stat(u32), // /proc//stat - Status(u32), // /proc//status - Cmdline(u32), // /proc//cmdline - Environ(u32), // /proc//environ - Maps(u32), // /proc//maps - Cwd(u32), // /proc//cwd (symlink) - Exe(u32), // /proc//exe (symlink) - Root_(u32), // /proc//root (symlink) - NetDir, // /proc/net - NetTcp, // /proc/net/tcp - NetUnix, // /proc/net/unix + Root, // /proc + Mounts, // /proc/mounts + SystemStat, // /proc/stat + Meminfo, // /proc/meminfo + SelfLink, // /proc/self (symlink → /proc/) + ThreadSelfLink, // /proc/thread-self (symlink) + PidDir(u32), // /proc/ + PidMounts(u32), // /proc//mounts + PidMountinfo(u32), // /proc//mountinfo + FdDir(u32), // /proc//fd + FdLink(u32, i32), // /proc//fd/ (symlink) + FdInfoDir(u32), // /proc//fdinfo + FdInfo(u32, i32), // /proc//fdinfo/ + Stat(u32), // /proc//stat + Statm(u32), // /proc//statm + Status(u32), // /proc//status + Cmdline(u32), // /proc//cmdline + Environ(u32), // /proc//environ + Maps(u32), // /proc//maps + Cwd(u32), // /proc//cwd (symlink) + Exe(u32), // /proc//exe (symlink) + Root_(u32), // /proc//root (symlink) + TaskDir(u32), // /proc//task + TaskTidDir(u32, u32), // /proc//task/ + NetDir, // /proc/net + NetTcp, // /proc/net/tcp + NetUnix, // /proc/net/unix } impl ProcfsEntry { @@ -90,6 +95,8 @@ impl ProcfsEntry { | ProcfsEntry::PidDir(_) | ProcfsEntry::FdDir(_) | ProcfsEntry::FdInfoDir(_) + | ProcfsEntry::TaskDir(_) + | ProcfsEntry::TaskTidDir(_, _) | ProcfsEntry::NetDir ) } @@ -106,6 +113,26 @@ pub const MOUNTS_CONTENT: &[u8] = const MOUNTINFO_CONTENT: &[u8] = b"1 0 0:1 / / rw - kandelo-vfs kandelo-root rw\n2 1 0:2 / /proc rw,nosuid,nodev,noexec - proc proc rw,nosuid,nodev,noexec\n3 1 0:3 / /dev rw,nosuid - devfs devfs rw,nosuid\n"; +/// Aggregate CPU accounting is not available on Kandelo's current hosts. +/// +/// Linux procfs consumers interpret a present all-zero CPU line as no measured +/// CPU time. These zeroes deliberately mean "accounting unavailable" here; +/// they do not claim that the system is idle or that no work has run. +pub const SYSTEM_STAT_CONTENT: &[u8] = b"cpu 0 0 0 0 0 0 0 0 0 0\n"; + +/// Physical-memory and page-cache accounting is not available on Kandelo's +/// current hosts. +/// +/// Every exported counter is therefore zero. In this procfs implementation a +/// zero `MemTotal` explicitly means that system memory accounting is +/// unavailable, not that Kandelo has a zero-byte physical machine. +pub const MEMINFO_CONTENT: &[u8] = b"MemTotal: 0 kB\n\ +MemFree: 0 kB\n\ +MemAvailable: 0 kB\n\ +Buffers: 0 kB\n\ +Cached: 0 kB\n\ +SReclaimable: 0 kB\n"; + /// Extract the pid from a ProcfsEntry (0 for root/net entries). pub fn entry_pid(entry: &ProcfsEntry) -> u32 { entry_ids(entry).0 @@ -153,8 +180,11 @@ pub fn match_procfs(path: &[u8], current_pid: u32) -> Option { } let rest = &rest[1..]; // after "/proc/" - if rest == b"mounts" { - return Some(ProcfsEntry::Mounts); + match rest { + b"mounts" => return Some(ProcfsEntry::Mounts), + b"stat" => return Some(ProcfsEntry::SystemStat), + b"meminfo" => return Some(ProcfsEntry::Meminfo), + _ => {} } // /proc/self/... → resolve to current pid @@ -226,6 +256,7 @@ fn match_pid_subpath(pid: u32, remainder: &[u8]) -> Option { match rem { b"stat" => Some(ProcfsEntry::Stat(pid)), + b"statm" => Some(ProcfsEntry::Statm(pid)), b"status" => Some(ProcfsEntry::Status(pid)), b"cmdline" => Some(ProcfsEntry::Cmdline(pid)), b"environ" => Some(ProcfsEntry::Environ(pid)), @@ -237,6 +268,7 @@ fn match_pid_subpath(pid: u32, remainder: &[u8]) -> Option { b"root" => Some(ProcfsEntry::Root_(pid)), b"fd" => Some(ProcfsEntry::FdDir(pid)), b"fdinfo" => Some(ProcfsEntry::FdInfoDir(pid)), + b"task" => Some(ProcfsEntry::TaskDir(pid)), _ => { if rem.starts_with(b"fd/") { let fd_str = &rem[3..]; @@ -244,6 +276,9 @@ fn match_pid_subpath(pid: u32, remainder: &[u8]) -> Option { } else if rem.starts_with(b"fdinfo/") { let fd_str = &rem[7..]; parse_i32(fd_str).map(|fd| ProcfsEntry::FdInfo(pid, fd)) + } else if rem.starts_with(b"task/") { + let tid_str = &rem[5..]; + parse_u32(tid_str).map(|tid| ProcfsEntry::TaskTidDir(pid, tid)) } else if rem == b"net" || rem.starts_with(b"net/") { match_net_path(rem) } else { @@ -270,6 +305,41 @@ fn match_net_path(rest: &[u8]) -> Option { // ── Content generators ────────────────────────────────────────────────────── +/// Kandelo's logical process page size is the WebAssembly page size. +pub const LOGICAL_PAGE_SIZE: u64 = 65_536; + +/// Return the logical virtual bytes represented by kernel-owned process state. +/// +/// The prefix through the current program break represents the loaded program, +/// stack, and brk heap. Active guest mmap regions are unioned with that prefix +/// so a fixed mapping below the break is not counted twice. This is logical +/// address-space accounting only and does not claim physical residency. The +/// required main control prefix below brk is part of this logical linear-memory +/// prefix; separately reserved host ranges above brk are not counted unless +/// they are also represented by a guest mapping. +pub(crate) fn logical_virtual_bytes(proc: &Process) -> u64 { + let mut total = proc.memory.get_brk() as u64; + let mut covered_until = total; + + // MemoryManager keeps mappings sorted and non-overlapping. Retain the union + // calculation here so the answer also remains correct for a fixed mapping + // that overlaps the prefix through brk. + for mapping in proc.memory.mappings() { + let start = mapping.addr as u64; + let end = start.saturating_add(mapping.len as u64); + if end <= covered_until { + continue; + } + total = total.saturating_add(end - start.max(covered_until)); + covered_until = end; + } + total +} + +fn logical_virtual_pages(proc: &Process) -> u64 { + logical_virtual_bytes(proc).div_ceil(LOGICAL_PAGE_SIZE) +} + /// Generate /proc//stat content. pub fn generate_stat(proc: &Process) -> Vec { use alloc::format; @@ -281,17 +351,45 @@ pub fn generate_stat(proc: &Process) -> Vec { 'Z' }; - // Linux /proc/pid/stat format (simplified): - // pid (comm) state ppid pgrp session tty_nr tpgid flags - // minflt cminflt majflt cmajflt utime stime cutime cstime - // priority nice num_threads itrealvalue starttime vsize rss ... - let line = format!( - "{} ({}) {} {} {} {} 0 0 0 0 0 0 0 0 0 0 {} 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - proc.pid, name, state, proc.ppid, proc.pgid, proc.sid, proc.nice, - ); + // Linux /proc/pid/stat has 52 fields. Kandelo exposes authoritative + // identity, nice, thread count, and logical vsize. CPU time, scheduler + // priority, start time, and RSS remain zero because those values are not + // tracked; zero here is an explicit unavailable value, not invented usage. + let mut fields = Vec::with_capacity(52); + fields.push(format!("{}", proc.pid)); // 1 pid + fields.push(format!("({})", name)); // 2 comm + fields.push(format!("{}", state)); // 3 state + fields.push(format!("{}", proc.ppid)); // 4 ppid + fields.push(format!("{}", proc.pgid)); // 5 pgrp + fields.push(format!("{}", proc.sid)); // 6 session + for _ in 7..=18 { + fields.push("0".into()); + } + fields.push(format!("{}", proc.nice)); // 19 nice + fields.push(format!("{}", 1 + proc.threads.len())); // 20 num_threads + fields.push("0".into()); // 21 itrealvalue + fields.push("0".into()); // 22 starttime (unavailable) + fields.push(format!("{}", logical_virtual_bytes(proc))); // 23 vsize + fields.push("0".into()); // 24 rss (unavailable) + while fields.len() < 52 { + fields.push("0".into()); + } + let mut line = fields.join(" "); + line.push('\n'); line.into_bytes() } +/// Generate `/proc//statm` in Linux's seven-field page-count shape. +/// +/// Only field 1 (logical virtual pages) is backed by kernel state. Resident, +/// shared, text, library, data/stack, and dirty-page accounting are unavailable +/// on current hosts and are deliberately reported as zero. +pub fn generate_statm(proc: &Process) -> Vec { + use alloc::format; + + format!("{} 0 0 0 0 0 0\n", logical_virtual_pages(proc)).into_bytes() +} + /// Generate /proc//status content. pub fn generate_status(proc: &Process) -> Vec { use alloc::format; @@ -304,6 +402,7 @@ pub fn generate_status(proc: &Process) -> Vec { "Z (zombie)" }; + let logical_kib = logical_virtual_bytes(proc).div_ceil(1024); let content = format!( "Name:\t{}\n\ Umask:\t{:04o}\n\ @@ -316,7 +415,8 @@ pub fn generate_status(proc: &Process) -> Vec { Uid:\t{}\t{}\t{}\t{}\n\ Gid:\t{}\t{}\t{}\t{}\n\ FDSize:\t{}\n\ - VmSize:\t0 kB\n\ + VmSize:\t{} kB\n\ + VmRSS:\t0 kB\n\ Threads:\t{}\n\ SigPnd:\t{:016x}\n\ SigBlk:\t{:016x}\n", @@ -335,6 +435,7 @@ pub fn generate_status(proc: &Process) -> Vec { proc.egid, proc.egid, count_open_fds(&proc.fd_table), + logical_kib, 1 + proc.threads.len(), // main thread + spawned threads proc.signals.pending_mask(), proc.signals.blocked, @@ -419,18 +520,44 @@ fn procfs_ino(pid: u32, entry_type: u8) -> u64 { 0x50_00_0000u64 | ((pid as u64) << 8) | (entry_type as u64) } -/// Build a synthetic WasmStat for a procfs entry. -/// `content_size` is used for regular file st_size (pass 0 for dirs/symlinks). -pub fn procfs_stat(entry: &ProcfsEntry, content_size: u64, follow_symlinks: bool) -> WasmStat { +/// Task-directory inode that remains unique for a `(pid, tid)` pair. +fn procfs_task_ino(pid: u32, tid: u32) -> u64 { + 0x51_00_0000_0000_0000u64 | ((pid as u64) << 32) | tid as u64 +} + +fn entry_ino(entry: &ProcfsEntry) -> u64 { + match entry { + ProcfsEntry::TaskTidDir(pid, tid) => procfs_task_ino(*pid, *tid), + _ => { + let (pid, entry_type) = entry_ids(entry); + procfs_ino(pid, entry_type) + } + } +} + +/// Build procfs metadata with authoritative ownership for a PID-scoped entry. +/// Global procfs nodes remain owned by root regardless of the supplied owner. +fn procfs_stat_owned( + entry: &ProcfsEntry, + content_size: u64, + follow_symlinks: bool, + owner_uid: u32, + owner_gid: u32, +) -> WasmStat { + let (st_uid, st_gid) = if entry_pid(entry) == 0 { + (0, 0) + } else { + (owner_uid, owner_gid) + }; + if entry.is_symlink() && !follow_symlinks { - let (pid, etype) = entry_ids(entry); return WasmStat { st_dev: 0x50, - st_ino: procfs_ino(pid, etype), + st_ino: entry_ino(entry), st_mode: S_IFLNK | 0o777, st_nlink: 1, - st_uid: 0, - st_gid: 0, + st_uid, + st_gid, st_size: 0, st_atime_sec: 0, st_atime_nsec: 0, @@ -443,14 +570,13 @@ pub fn procfs_stat(entry: &ProcfsEntry, content_size: u64, follow_symlinks: bool } if entry.is_dir() { - let (pid, etype) = entry_ids(entry); return WasmStat { st_dev: 0x50, - st_ino: procfs_ino(pid, etype), + st_ino: entry_ino(entry), st_mode: S_IFDIR | 0o555, st_nlink: 2, - st_uid: 0, - st_gid: 0, + st_uid, + st_gid, st_size: 0, st_atime_sec: 0, st_atime_nsec: 0, @@ -463,14 +589,13 @@ pub fn procfs_stat(entry: &ProcfsEntry, content_size: u64, follow_symlinks: bool } // Regular file - let (pid, etype) = entry_ids(entry); WasmStat { st_dev: 0x50, - st_ino: procfs_ino(pid, etype), + st_ino: entry_ino(entry), st_mode: S_IFREG | 0o444, st_nlink: 1, - st_uid: 0, - st_gid: 0, + st_uid, + st_gid, st_size: content_size, st_atime_sec: 0, st_atime_nsec: 0, @@ -482,11 +607,48 @@ pub fn procfs_stat(entry: &ProcfsEntry, content_size: u64, follow_symlinks: bool } } +/// Validate a procfs entry and synthesize metadata using its target process's +/// effective credentials. Stat-family syscall call sites should use this +/// helper rather than treating every syntactically valid procfs path as root- +/// owned and existent. +pub fn procfs_stat_for_process( + caller: &Process, + entry: &ProcfsEntry, + content_size: u64, + follow_symlinks: bool, +) -> Result { + validate_entry(caller, entry)?; + let target_pid = entry_pid(entry); + let (uid, gid) = if target_pid == 0 { + (0, 0) + } else if target_pid == caller.pid { + (caller.euid, caller.egid) + } else { + #[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))] + { + crate::wasm_api::procfs_credentials_for_pid(target_pid).ok_or(Errno::ENOENT)? + } + #[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))] + { + return Err(Errno::ENOENT); + } + }; + Ok(procfs_stat_owned( + entry, + content_size, + follow_symlinks, + uid, + gid, + )) +} + /// Extract (pid, entry_type_id) for inode generation. fn entry_ids(entry: &ProcfsEntry) -> (u32, u8) { match entry { ProcfsEntry::Root => (0, 0), ProcfsEntry::Mounts => (0, 1), + ProcfsEntry::SystemStat => (0, 22), + ProcfsEntry::Meminfo => (0, 23), ProcfsEntry::SelfLink => (0, 2), ProcfsEntry::ThreadSelfLink => (0, 3), ProcfsEntry::PidDir(pid) => (*pid, 4), @@ -497,6 +659,7 @@ fn entry_ids(entry: &ProcfsEntry) -> (u32, u8) { ProcfsEntry::FdInfoDir(pid) => (*pid, 9), ProcfsEntry::FdInfo(pid, _) => (*pid, 10), ProcfsEntry::Stat(pid) => (*pid, 11), + ProcfsEntry::Statm(pid) => (*pid, 24), ProcfsEntry::Status(pid) => (*pid, 12), ProcfsEntry::Cmdline(pid) => (*pid, 13), ProcfsEntry::Environ(pid) => (*pid, 14), @@ -504,6 +667,8 @@ fn entry_ids(entry: &ProcfsEntry) -> (u32, u8) { ProcfsEntry::Cwd(pid) => (*pid, 16), ProcfsEntry::Exe(pid) => (*pid, 17), ProcfsEntry::Root_(pid) => (*pid, 18), + ProcfsEntry::TaskDir(pid) => (*pid, 25), + ProcfsEntry::TaskTidDir(pid, _) => (*pid, 26), ProcfsEntry::NetDir => (0, 19), ProcfsEntry::NetTcp => (0, 20), ProcfsEntry::NetUnix => (0, 21), @@ -557,11 +722,7 @@ pub fn procfs_open( } if entry.is_dir() { - // Validate that the target pid exists for pid-scoped directories - let target_pid = entry_pid(entry); - if target_pid != 0 { - validate_pid(proc, target_pid)?; - } + validate_entry(proc, entry)?; let ofd_idx = proc.ofd_table.create( FileType::Directory, status_flags, @@ -591,12 +752,17 @@ pub fn procfs_open( /// Generate content for a procfs regular file entry. fn generate_content(proc: &Process, entry: &ProcfsEntry) -> Result, Errno> { match entry { - ProcfsEntry::Stat(pid) | ProcfsEntry::Status(pid) | ProcfsEntry::Cmdline(pid) - | ProcfsEntry::Environ(pid) | ProcfsEntry::Maps(pid) => { + ProcfsEntry::Stat(pid) + | ProcfsEntry::Statm(pid) + | ProcfsEntry::Status(pid) + | ProcfsEntry::Cmdline(pid) + | ProcfsEntry::Environ(pid) + | ProcfsEntry::Maps(pid) => { validate_pid(proc, *pid)?; if *pid == proc.pid { match entry { ProcfsEntry::Stat(_) => Ok(generate_stat(proc)), + ProcfsEntry::Statm(_) => Ok(generate_statm(proc)), ProcfsEntry::Status(_) => Ok(generate_status(proc)), ProcfsEntry::Cmdline(_) => Ok(generate_cmdline(proc)), ProcfsEntry::Environ(_) => Ok(generate_environ(proc)), @@ -622,6 +788,8 @@ fn generate_content(proc: &Process, entry: &ProcfsEntry) -> Result, Errn } } ProcfsEntry::Mounts => Ok(MOUNTS_CONTENT.to_vec()), + ProcfsEntry::SystemStat => Ok(SYSTEM_STAT_CONTENT.to_vec()), + ProcfsEntry::Meminfo => Ok(MEMINFO_CONTENT.to_vec()), ProcfsEntry::PidMounts(pid) => { validate_pid(proc, *pid)?; Ok(MOUNTS_CONTENT.to_vec()) @@ -657,6 +825,38 @@ fn validate_pid(proc: &Process, pid: u32) -> Result<(), Errno> { Err(Errno::ENOENT) } +fn process_has_tid(proc: &Process, tid: u32) -> bool { + tid == proc.pid || proc.threads.iter().any(|thread| thread.tid == tid) +} + +/// Validate that a parsed procfs entry names authoritative process/thread +/// state. Global entries always validate; PID-scoped entries require a visible +/// (non-Limbo) process, and task TID directories additionally require that the +/// TID belongs to that process. +pub fn validate_entry(proc: &Process, entry: &ProcfsEntry) -> Result<(), Errno> { + let pid = entry_pid(entry); + if pid == 0 { + return Ok(()); + } + validate_pid(proc, pid)?; + + if let ProcfsEntry::TaskTidDir(_, tid) = entry { + if pid == proc.pid { + if process_has_tid(proc, *tid) { + return Ok(()); + } + } else { + #[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))] + if crate::wasm_api::procfs_tid_exists(pid, *tid) { + return Ok(()); + } + } + return Err(Errno::ENOENT); + } + + 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() { @@ -835,8 +1035,10 @@ fn dir_entries( match entry { ProcfsEntry::Root => { - // /proc: self (symlink), numeric PIDs (dirs), mounts (file), net (dir) + // /proc: global files, self links, numeric PIDs, and net. entries.push((b"mounts".to_vec(), DT_REG, procfs_ino(0, 1))); + entries.push((b"stat".to_vec(), DT_REG, procfs_ino(0, 22))); + entries.push((b"meminfo".to_vec(), DT_REG, procfs_ino(0, 23))); entries.push((b"self".to_vec(), DT_LNK, procfs_ino(0, 2))); entries.push((b"thread-self".to_vec(), DT_LNK, procfs_ino(0, 3))); for &pid in pids { @@ -850,6 +1052,7 @@ fn dir_entries( entries.push((b"fd".to_vec(), DT_DIR, procfs_ino(pid, 7))); entries.push((b"fdinfo".to_vec(), DT_DIR, procfs_ino(pid, 9))); entries.push((b"stat".to_vec(), DT_REG, procfs_ino(pid, 11))); + entries.push((b"statm".to_vec(), DT_REG, procfs_ino(pid, 24))); entries.push((b"status".to_vec(), DT_REG, procfs_ino(pid, 12))); entries.push((b"cmdline".to_vec(), DT_REG, procfs_ino(pid, 13))); entries.push((b"environ".to_vec(), DT_REG, procfs_ino(pid, 14))); @@ -859,6 +1062,7 @@ fn dir_entries( entries.push((b"cwd".to_vec(), DT_LNK, procfs_ino(pid, 16))); entries.push((b"exe".to_vec(), DT_LNK, procfs_ino(pid, 17))); entries.push((b"root".to_vec(), DT_LNK, procfs_ino(pid, 18))); + entries.push((b"task".to_vec(), DT_DIR, procfs_ino(pid, 25))); entries.push((b"net".to_vec(), DT_DIR, procfs_ino(pid, 19))); } ProcfsEntry::FdDir(pid) => { @@ -883,6 +1087,27 @@ fn dir_entries( } } } + ProcfsEntry::TaskDir(pid) => { + if pid != proc.pid { + return Err(Errno::ENOENT); + } + let main_name = format!("{}", pid).into_bytes(); + entries.push((main_name, DT_DIR, procfs_task_ino(pid, pid))); + for thread in &proc.threads { + if thread.tid == pid { + continue; + } + let name = format!("{}", thread.tid).into_bytes(); + entries.push((name, DT_DIR, procfs_task_ino(pid, thread.tid))); + } + } + ProcfsEntry::TaskTidDir(pid, tid) => { + if pid != proc.pid || !process_has_tid(proc, tid) { + return Err(Errno::ENOENT); + } + // A task directory is intentionally minimal today; `.` and `..` + // are emitted by procfs_getdents64 itself. + } ProcfsEntry::NetDir => { entries.push((b"tcp".to_vec(), DT_REG, procfs_ino(0, 20))); entries.push((b"unix".to_vec(), DT_REG, procfs_ino(0, 21))); @@ -936,12 +1161,20 @@ fn count_open_fds(fd_table: &crate::fd::FdTable) -> usize { #[cfg(test)] mod tests { use super::*; - use crate::process::Process; + use crate::process::{Process, ThreadInfo}; #[test] fn test_match_procfs_root() { assert_eq!(match_procfs(b"/proc", 1), Some(ProcfsEntry::Root)); assert_eq!(match_procfs(b"/proc/", 1), Some(ProcfsEntry::Root)); + assert_eq!( + match_procfs(b"/proc/stat", 1), + Some(ProcfsEntry::SystemStat) + ); + assert_eq!( + match_procfs(b"/proc/meminfo", 1), + Some(ProcfsEntry::Meminfo) + ); } #[test] @@ -951,6 +1184,14 @@ mod tests { match_procfs(b"/proc/self/stat", 42), Some(ProcfsEntry::Stat(42)) ); + assert_eq!( + match_procfs(b"/proc/self/statm", 42), + Some(ProcfsEntry::Statm(42)) + ); + assert_eq!( + match_procfs(b"/proc/self/task/43", 42), + Some(ProcfsEntry::TaskTidDir(42, 43)) + ); assert_eq!( match_procfs(b"/proc/self/mounts", 42), Some(ProcfsEntry::PidMounts(42)) @@ -972,6 +1213,10 @@ mod tests { match_procfs(b"/proc/42/stat", 1), Some(ProcfsEntry::Stat(42)) ); + assert_eq!( + match_procfs(b"/proc/42/statm", 1), + Some(ProcfsEntry::Statm(42)) + ); assert_eq!( match_procfs(b"/proc/42/status", 1), Some(ProcfsEntry::Status(42)) @@ -1014,6 +1259,14 @@ mod tests { match_procfs(b"/proc/42/fdinfo/7", 1), Some(ProcfsEntry::FdInfo(42, 7)) ); + assert_eq!( + match_procfs(b"/proc/42/task", 1), + Some(ProcfsEntry::TaskDir(42)) + ); + assert_eq!( + match_procfs(b"/proc/42/task/43", 1), + Some(ProcfsEntry::TaskTidDir(42, 43)) + ); } #[test] @@ -1042,11 +1295,32 @@ mod tests { proc.sid = 1; proc.nice = 5; proc.argv.push(b"test_program".to_vec()); + proc.add_thread(ThreadInfo::new(43, 0, 0, 0)); + let mapped = proc.memory.mmap_anonymous(0, 1, 3, 0); + assert_ne!(mapped, wasm_posix_shared::mmap::MAP_FAILED); let stat = generate_stat(&proc); let stat_str = core::str::from_utf8(&stat).unwrap(); - assert!(stat_str.starts_with("42 (test_program) R 1 42 1")); - assert!(stat_str.contains(" 5 ")); // nice value + let fields: Vec<&str> = stat_str.split_whitespace().collect(); + assert_eq!(fields.len(), 52); + assert_eq!(&fields[..6], &["42", "(test_program)", "R", "1", "42", "1"]); + assert_eq!(fields[13], "0"); // field 14: utime unavailable + assert_eq!(fields[14], "0"); // field 15: stime unavailable + assert_eq!(fields[18], "5"); // field 19: nice + assert_eq!(fields[19], "2"); // field 20: main + worker thread + assert_eq!(fields[21], "0"); // field 22: starttime unavailable + assert_eq!(fields[22], "16842752"); // field 23: 16 MiB brk prefix + 64 KiB mmap + assert_eq!(fields[23], "0"); // field 24: RSS unavailable + } + + #[test] + fn test_generate_statm_reports_only_logical_virtual_pages() { + let mut proc = Process::new(42); + let mapped = proc.memory.mmap_anonymous(0, 1, 3, 0); + assert_ne!(mapped, wasm_posix_shared::mmap::MAP_FAILED); + + let statm = generate_statm(&proc); + assert_eq!(core::str::from_utf8(&statm).unwrap(), "257 0 0 0 0 0 0\n"); } #[test] @@ -1054,12 +1328,32 @@ mod tests { let mut proc = Process::new(1); proc.argv.push(b"init".to_vec()); proc.umask = 0o022; + proc.add_thread(ThreadInfo::new(2, 0, 0, 0)); let status = generate_status(&proc); let status_str = core::str::from_utf8(&status).unwrap(); assert!(status_str.contains("Name:\tinit\n")); assert!(status_str.contains("Pid:\t1\n")); assert!(status_str.contains("Umask:\t0022\n")); + assert!(status_str.contains("VmSize:\t16384 kB\n")); + assert!(status_str.contains("VmRSS:\t0 kB\n")); + assert!(status_str.contains("Threads:\t2\n")); + } + + #[test] + fn test_global_accounting_files_use_documented_unavailable_zeroes() { + let proc = Process::new(1); + let system_stat = generate_content(&proc, &ProcfsEntry::SystemStat).unwrap(); + assert_eq!(system_stat, SYSTEM_STAT_CONTENT); + assert_eq!(system_stat, b"cpu 0 0 0 0 0 0 0 0 0 0\n"); + + let meminfo = generate_content(&proc, &ProcfsEntry::Meminfo).unwrap(); + assert_eq!(meminfo, MEMINFO_CONTENT); + let text = core::str::from_utf8(&meminfo).unwrap(); + assert!(text.contains("MemTotal: 0 kB\n")); + assert!(text.contains("MemFree: 0 kB\n")); + assert!(text.contains("MemAvailable: 0 kB\n")); + assert!(text.contains("Cached: 0 kB\n")); } #[test] @@ -1103,7 +1397,7 @@ mod tests { #[test] fn test_procfs_stat_dir() { let entry = ProcfsEntry::Root; - let st = procfs_stat(&entry, 0, true); + let st = procfs_stat_owned(&entry, 0, true, 0, 0); assert_eq!(st.st_mode, S_IFDIR | 0o555); assert_eq!(st.st_dev, 0x50); } @@ -1111,18 +1405,71 @@ mod tests { #[test] fn test_procfs_stat_symlink_nofollow() { let entry = ProcfsEntry::SelfLink; - let st = procfs_stat(&entry, 0, false); + let st = procfs_stat_owned(&entry, 0, false, 0, 0); assert_eq!(st.st_mode, S_IFLNK | 0o777); } #[test] fn test_procfs_stat_regular() { let entry = ProcfsEntry::Stat(1); - let st = procfs_stat(&entry, 100, true); + let st = procfs_stat_owned(&entry, 100, true, 0, 0); assert_eq!(st.st_mode, S_IFREG | 0o444); assert_eq!(st.st_size, 100); } + #[test] + fn test_procfs_stat_uses_target_effective_credentials() { + let mut proc = Process::new(42); + proc.euid = 1000; + proc.egid = 100; + + let st = procfs_stat_for_process(&proc, &ProcfsEntry::Stat(42), 100, true).unwrap(); + assert_eq!(st.st_uid, 1000); + assert_eq!(st.st_gid, 100); + + let global = procfs_stat_for_process(&proc, &ProcfsEntry::Meminfo, 100, true).unwrap(); + assert_eq!(global.st_uid, 0); + assert_eq!(global.st_gid, 0); + + assert!(matches!( + procfs_stat_for_process(&proc, &ProcfsEntry::Stat(99), 0, true), + Err(Errno::ENOENT) + )); + assert!(matches!( + procfs_stat_for_process(&proc, &ProcfsEntry::TaskTidDir(42, 99), 0, true), + Err(Errno::ENOENT) + )); + } + + #[test] + fn test_task_entries_and_tid_validation_use_process_threads() { + let mut proc = Process::new(42); + proc.add_thread(ThreadInfo::new(43, 0, 0, 0)); + proc.add_thread(ThreadInfo::new(44, 0, 0, 0)); + + let entries = dir_entries(&proc, b"/proc/42/task", &[42]).unwrap(); + let names: Vec<&[u8]> = entries.iter().map(|(name, _, _)| name.as_slice()).collect(); + assert_eq!( + names, + vec![b"42".as_slice(), b"43".as_slice(), b"44".as_slice()] + ); + assert!(validate_entry(&proc, &ProcfsEntry::TaskTidDir(42, 42)).is_ok()); + assert!(validate_entry(&proc, &ProcfsEntry::TaskTidDir(42, 43)).is_ok()); + assert_eq!( + validate_entry(&proc, &ProcfsEntry::TaskTidDir(42, 99)), + Err(Errno::ENOENT) + ); + assert!( + dir_entries(&proc, b"/proc/42/task/43", &[42]) + .unwrap() + .is_empty() + ); + assert_eq!( + dir_entries(&proc, b"/proc/42/task/99", &[42]), + Err(Errno::ENOENT) + ); + } + #[test] fn test_write_dirent64() { let mut buf = [0u8; 256]; @@ -1186,8 +1533,8 @@ mod tests { procfs_getdents64(&proc, b"/proc", &mut buf, 0, &pids).unwrap(); assert!(bytes > 0); assert!(exhausted); - // Should have: . , .. , mounts, self, thread-self, 1, net = 7 entries - assert_eq!(offset, 7); + // . , .. , mounts, stat, meminfo, self, thread-self, 1, net = 9 + assert_eq!(offset, 9); } #[test] @@ -1198,9 +1545,22 @@ mod tests { procfs_getdents64(&proc, b"/proc/1", &mut buf, 0, &[1]).unwrap(); assert!(bytes > 0); assert!(exhausted); - // . , .. , fd, fdinfo, stat, status, cmdline, environ, maps, - // mounts, mountinfo, cwd, exe, root, net = 15 - assert_eq!(offset, 15); + // . , .. , fd, fdinfo, stat, statm, status, cmdline, environ, maps, + // mounts, mountinfo, cwd, exe, root, task, net = 17 + assert_eq!(offset, 17); + } + + #[test] + fn test_procfs_getdents64_task_dir() { + let mut proc = Process::new(42); + proc.add_thread(ThreadInfo::new(43, 0, 0, 0)); + let mut buf = [0u8; 4096]; + let (bytes, offset, exhausted) = + procfs_getdents64(&proc, b"/proc/42/task", &mut buf, 0, &[42]).unwrap(); + assert!(bytes > 0); + assert!(exhausted); + // . , .. , 42, 43 + assert_eq!(offset, 4); } #[test] diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 175960c9aa..bc7e3db57a 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -2621,8 +2621,14 @@ pub fn sys_write( FileType::Pipe => { if host_handle >= 0 { // Host-delegated pipe (cross-process): use host_write - let n = host.host_write(host_handle, buf)?; - Ok(n) + match host.host_write(host_handle, buf) { + Ok(n) => Ok(n), + Err(Errno::EPIPE) => { + proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); + Err(Errno::EPIPE) + } + Err(err) => Err(err), + } } else { // Kernel pipe const PIPE_BUF: usize = 4096; @@ -3659,23 +3665,25 @@ pub fn sys_fstat(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result Resul }); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { - return Ok(crate::procfs::procfs_stat(&entry, 0, true)); + return crate::procfs::procfs_stat_for_process(proc, &entry, 0, true); } if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { return Ok(st); @@ -4106,7 +4114,7 @@ pub fn sys_lstat( }); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { - return Ok(crate::procfs::procfs_stat(&entry, 0, false)); + return crate::procfs::procfs_stat_for_process(proc, &entry, 0, false); } if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { return Ok(st); @@ -4302,9 +4310,13 @@ pub fn sys_access( { return Ok(()); } - if crate::procfs::match_procfs(&resolved, proc.pid).is_some() { - // Procfs entries are read-only: allow R_OK/F_OK/X_OK(dirs), deny W_OK - if amode & 0o2 != 0 { + if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + // Validate the parsed PID/TID before preserving procfs's existing + // read/execute access behavior. + crate::procfs::procfs_stat_for_process(proc, &entry, 0, true)?; + // Procfs is mounted read-only even for uid 0; mode-bit privilege + // bypass must not turn W_OK into a writable-filesystem claim. + if amode & W_OK != 0 { return Err(Errno::EACCES); } return Ok(()); @@ -4320,7 +4332,7 @@ pub fn sys_chdir(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Resu let resolved = crate::path::resolve_path(path, &proc.cwd); // Check virtual filesystems first (procfs, devfs), then fall through to host if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { - let st = crate::procfs::procfs_stat(&entry, 0, true); + let st = crate::procfs::procfs_stat_for_process(proc, &entry, 0, true)?; if st.st_mode & wasm_posix_shared::mode::S_IFMT != wasm_posix_shared::mode::S_IFDIR { return Err(Errno::ENOTDIR); } @@ -7813,12 +7825,20 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) } FileType::Pipe => { if ofd.host_handle >= 0 { - // Host-delegated pipe: report as ready (non-blocking) - if pollfd.events & POLLIN != 0 { - revents |= POLLIN; - } - if pollfd.events & POLLOUT != 0 { - revents |= POLLOUT; + // The host owns both captured stdin and SharedPipeBuffer + // state, so it is the authority on whether data, EOF, or + // write capacity is currently observable. In particular, + // an open-but-empty pipe is not readable. + match host.host_fd_poll(ofd.host_handle, pollfd.events) { + Ok(host_revents) => { + // POLLERR/POLLHUP/POLLNVAL are reported even when + // they were not requested; other bits must have + // appeared in the caller's requested event mask. + revents |= host_revents + & (pollfd.events | POLLERR | POLLHUP | POLLNVAL); + } + Err(Errno::EBADF) => revents |= POLLNVAL, + Err(_) => revents |= POLLERR, } } else { // Kernel pipe @@ -8247,7 +8267,7 @@ pub fn sys_fstatat( } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { let follow = flags & AT_SYMLINK_NOFOLLOW == 0; - return Ok(crate::procfs::procfs_stat(&entry, 0, follow)); + return crate::procfs::procfs_stat_for_process(proc, &entry, 0, follow); } if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { return Ok(st); @@ -8938,9 +8958,15 @@ pub fn sys_ioctl( } /// prctl — process control operations. -/// PR_SET_NAME (15) stores thread name, PR_GET_NAME (16) returns it. +/// PR_SET_NAME (15) stores the calling thread's name, PR_GET_NAME (16) +/// returns it. /// All other operations are no-ops returning success. -pub fn sys_prctl(proc: &mut Process, option: u32, _arg2: u32, buf: &mut [u8]) -> Result<(), Errno> { +pub fn sys_prctl( + proc: &mut Process, + tid: u32, + option: u32, + buf: &mut [u8], +) -> Result<(), Errno> { const PR_SET_NAME: u32 = 15; const PR_GET_NAME: u32 = 16; @@ -8953,15 +8979,17 @@ pub fn sys_prctl(proc: &mut Process, option: u32, _arg2: u32, buf: &mut [u8]) -> .position(|&b| b == 0) .unwrap_or(buf.len()) .min(15); - proc.thread_name = [0u8; 16]; - proc.thread_name[..name_len].copy_from_slice(&buf[..name_len]); + let thread_name = proc.thread_name_for_mut(tid).ok_or(Errno::ESRCH)?; + *thread_name = [0u8; 16]; + thread_name[..name_len].copy_from_slice(&buf[..name_len]); Ok(()) } PR_GET_NAME => { if buf.len() < 16 { return Err(Errno::EINVAL); } - buf[..16].copy_from_slice(&proc.thread_name); + let thread_name = proc.thread_name_for(tid).ok_or(Errno::ESRCH)?; + buf[..16].copy_from_slice(thread_name); Ok(()) } _ => Ok(()), // no-op for unrecognized operations @@ -9115,8 +9143,13 @@ pub fn sys_clone( // channel mailbox but the kernel stores masks by TID. let caller_tid = crate::process_table::current_tid(); let inherited_blocked = proc.blocked_for(caller_tid); + let inherited_thread_name = proc + .thread_name_for(caller_tid) + .copied() + .unwrap_or(proc.thread_name); let mut thread_info = ThreadInfo::new(tid, effective_ctid, stack_ptr, effective_tls); thread_info.signals.blocked = inherited_blocked; + thread_info.thread_name = inherited_thread_name; proc.add_thread(thread_info); let _ = flags & CLONE_PARENT_SETTID; @@ -11086,6 +11119,10 @@ mod tests { /// Override for `gl_submit`'s return value (0 = success, negative /// = errno). Defaults to 0. gl_submit_rc: i32, + /// Optional delegated-fd readiness response. None preserves the + /// legacy mock behavior of returning the requested event mask. + host_fd_revents: Option, + host_write_error: Option, } impl MockHostIO { @@ -11109,6 +11146,8 @@ mod tests { gl_unbind_calls: Vec::new(), gbm_bo_bind_rc: 0, gl_submit_rc: 0, + host_fd_revents: None, + host_write_error: None, } } @@ -11179,9 +11218,16 @@ mod tests { } fn host_write(&mut self, _handle: i64, buf: &[u8]) -> Result { + if let Some(err) = self.host_write_error { + return Err(err); + } Ok(buf.len()) } + fn host_fd_poll(&mut self, _handle: i64, events: i16) -> Result { + Ok(self.host_fd_revents.unwrap_or(events)) + } + fn host_seek(&mut self, _handle: i64, _offset: i64, _whence: u32) -> Result { Ok(0) } @@ -14555,18 +14601,47 @@ mod tests { let mut proc = Process::new(1); let mut buf = [0u8; 16]; buf[..5].copy_from_slice(b"hello"); - sys_prctl(&mut proc, 15, 0, &mut buf).unwrap(); // PR_SET_NAME + sys_prctl(&mut proc, 0, 15, &mut buf).unwrap(); // PR_SET_NAME let mut out = [0u8; 16]; - sys_prctl(&mut proc, 16, 0, &mut out).unwrap(); // PR_GET_NAME + sys_prctl(&mut proc, 0, 16, &mut out).unwrap(); // PR_GET_NAME assert_eq!(&out[..5], b"hello"); assert_eq!(out[5], 0); } + #[test] + fn test_prctl_worker_name_does_not_replace_process_name() { + use crate::process::ThreadInfo; + + let mut proc = Process::new(42); + proc.argv.push(b"lxpanel".to_vec()); + + let mut leader_name = [0u8; 16]; + leader_name[..7].copy_from_slice(b"lxpanel"); + sys_prctl(&mut proc, 0, 15, &mut leader_name).unwrap(); + + proc.add_thread(ThreadInfo::new(43, 0, 0, 0)); + let mut worker_name = [0u8; 16]; + worker_name[..13].copy_from_slice(b"menu-cache-io"); + sys_prctl(&mut proc, 43, 15, &mut worker_name).unwrap(); + + let mut leader_out = [0u8; 16]; + sys_prctl(&mut proc, 0, 16, &mut leader_out).unwrap(); + assert_eq!(&leader_out[..7], b"lxpanel"); + + let mut worker_out = [0u8; 16]; + sys_prctl(&mut proc, 43, 16, &mut worker_out).unwrap(); + assert_eq!(&worker_out[..13], b"menu-cache-io"); + + let stat = crate::procfs::generate_stat(&proc); + let stat = core::str::from_utf8(&stat).unwrap(); + assert!(stat.starts_with("42 (lxpanel) R ")); + } + #[test] fn test_prctl_unknown_is_noop() { let mut proc = Process::new(1); let mut buf = [0u8; 16]; - assert!(sys_prctl(&mut proc, 999, 0, &mut buf).is_ok()); + assert!(sys_prctl(&mut proc, 0, 999, &mut buf).is_ok()); } #[test] @@ -15562,6 +15637,135 @@ mod tests { assert_ne!(writefds[byte] & (1 << bit), 0); } + #[test] + fn test_select_host_pipe_uses_host_readiness_and_eof() { + use wasm_posix_shared::poll::{POLLHUP, POLLIN}; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let rfd = add_fallback_pipe_fd(&mut proc, 0, O_RDONLY); + let byte = rfd as usize / 8; + let bit = rfd as usize % 8; + + host.host_fd_revents = Some(0); + let mut readfds = [0u8; 128]; + readfds[byte] = 1 << bit; + assert_eq!( + sys_select( + &mut proc, + &mut host, + rfd + 1, + Some(&mut readfds), + None, + None, + 100, + ), + Err(Errno::EAGAIN), + ); + assert_eq!(readfds[byte] & (1 << bit), 0); + + for revents in [POLLIN, POLLHUP] { + host.host_fd_revents = Some(revents); + readfds[byte] = 1 << bit; + assert_eq!( + sys_select( + &mut proc, + &mut host, + rfd + 1, + Some(&mut readfds), + None, + None, + 100, + ), + Ok(1), + ); + assert_ne!(readfds[byte] & (1 << bit), 0); + } + } + + #[test] + fn test_pselect_zero_timeout_clears_sets_and_restores_mask() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.host_fd_revents = Some(0); + let rfd = add_fallback_pipe_fd(&mut proc, 0, O_RDONLY); + let byte = rfd as usize / 8; + let bit = rfd as usize % 8; + let mut readfds = [0u8; 128]; + readfds[byte] = 1 << bit; + let original_mask = crate::signal::sig_bit(2); + let temporary_mask = crate::signal::sig_bit(3); + proc.signals.blocked = original_mask; + + assert_eq!( + sys_pselect6( + &mut proc, + &mut host, + rfd + 1, + Some(&mut readfds), + None, + None, + 0, + Some(temporary_mask), + ), + Ok(0), + ); + assert_eq!(readfds[byte] & (1 << bit), 0); + assert_eq!(proc.signals.blocked, original_mask); + assert_eq!( + proc.sigsuspend_saved_mask_for(crate::process_table::current_tid()), + None, + ); + } + + #[test] + fn test_ppoll_zero_timeout_clears_revents_and_restores_mask() { + use wasm_posix_shared::poll::POLLIN; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.host_fd_revents = Some(0); + let rfd = add_fallback_pipe_fd(&mut proc, 0, O_RDONLY); + let mut fds = [WasmPollFd { + fd: rfd, + events: POLLIN, + revents: POLLIN, + }]; + let original_mask = crate::signal::sig_bit(2); + let temporary_mask = crate::signal::sig_bit(3); + proc.signals.blocked = original_mask; + + assert_eq!( + sys_ppoll( + &mut proc, + &mut host, + &mut fds, + 0, + Some(temporary_mask), + ), + Ok(0), + ); + assert_eq!(fds[0].revents, 0); + assert_eq!(proc.signals.blocked, original_mask); + assert_eq!( + proc.sigsuspend_saved_mask_for(crate::process_table::current_tid()), + None, + ); + } + + #[test] + fn test_host_pipe_epipe_raises_sigpipe() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.host_write_error = Some(Errno::EPIPE); + let wfd = add_fallback_pipe_fd(&mut proc, 1, O_WRONLY); + + assert_eq!(sys_write(&mut proc, &mut host, wfd, b"x"), Err(Errno::EPIPE)); + assert!(proc + .signals + .is_pending(wasm_posix_shared::signal::SIGPIPE)); + } + #[test] fn test_select_multiple_fds() { let mut proc = Process::new(1); @@ -17703,7 +17907,10 @@ mod tests { #[test] fn test_clone_thread_allocates_kernel_thread() { + let _guard = THREAD_IDENTITY_LOCK.lock().unwrap(); + set_test_current_tid(0); let mut proc = Process::new(1); + proc.thread_name[..6].copy_from_slice(b"leader"); let mut host = MockHostIO::new(); const CLONE_VM: u32 = 0x00000100; const CLONE_THREAD: u32 = 0x00010000; @@ -17711,7 +17918,9 @@ mod tests { let result = sys_clone(&mut proc, &mut host, 0, 0x8000, flags, 0, 0, 0, 0); let tid = result.expect("thread-style clone should allocate a tid"); assert!(tid > 0); - assert!(proc.get_thread(tid as u32).is_some()); + let thread = proc.get_thread(tid as u32).unwrap(); + assert_eq!(&thread.thread_name[..6], b"leader"); + set_test_current_tid(0); } #[test] diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 93f9c8ad94..dc5c37b7c1 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -715,6 +715,17 @@ impl HostIO for WasmHostIO { } } + fn host_fd_poll(&mut self, handle: i64, events: i16) -> Result { + if handle < 0 || handle > i32::MAX as i64 { + return Err(Errno::EBADF); + } + // Network handles occupy the nonnegative host_net_poll namespace. + // Encode delegated fds as their bitwise complement so fd 0 cannot be + // confused with network handle 0. No new Wasm import is needed. + let tagged_handle = !(handle as i32); + self.host_net_poll(tagged_handle, events) + } + fn host_net_close(&mut self, handle: i32) -> Result<(), Errno> { let result = unsafe { host_net_close(handle) }; i32_to_result(result) @@ -1055,10 +1066,31 @@ use crate::process_table::GLOBAL_PROCESS_TABLE as PROCESS_TABLE; // SAFETY: Only called while inside kernel_handle_channel, where syscall // dispatch is serialized by the host. -/// Get all active PIDs from the process table. +/// Get all user-visible procfs PIDs from the process table. pub(crate) fn procfs_all_pids() -> Vec { let table = unsafe { &*PROCESS_TABLE.0.get() }; - table.all_pids() + table.procfs_pids() +} + +/// Return effective credentials for a user-visible procfs process. +pub(crate) fn procfs_credentials_for_pid(pid: u32) -> Option<(u32, u32)> { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + let proc = table.get(pid)?; + if proc.state == crate::process::ProcessState::Limbo { + return None; + } + Some((proc.euid, proc.egid)) +} + +/// Return whether `tid` is the main thread or a registered worker thread of a +/// user-visible procfs process. +pub(crate) fn procfs_tid_exists(pid: u32, tid: u32) -> bool { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + let Some(proc) = table.get(pid) else { + return false; + }; + proc.state != crate::process::ProcessState::Limbo + && (tid == pid || proc.threads.iter().any(|thread| thread.tid == tid)) } /// Generate procfs content for a foreign process (cross-process access). @@ -1070,8 +1102,12 @@ pub(crate) fn procfs_generate_for_pid( ) -> Option> { let table = unsafe { &*PROCESS_TABLE.0.get() }; let proc = table.get(pid)?; + if proc.state == crate::process::ProcessState::Limbo { + return None; + } match entry { crate::procfs::ProcfsEntry::Stat(_) => Some(crate::procfs::generate_stat(proc)), + crate::procfs::ProcfsEntry::Statm(_) => Some(crate::procfs::generate_statm(proc)), crate::procfs::ProcfsEntry::Status(_) => Some(crate::procfs::generate_status(proc)), crate::procfs::ProcfsEntry::Cmdline(_) => Some(crate::procfs::generate_cmdline(proc)), crate::procfs::ProcfsEntry::Environ(_) => Some(crate::procfs::generate_environ(proc)), @@ -1089,6 +1125,9 @@ pub(crate) fn procfs_readlink_for_pid( ) -> Option { let table = unsafe { &*PROCESS_TABLE.0.get() }; let proc = table.get(pid)?; + if proc.state == crate::process::ProcessState::Limbo { + return None; + } crate::procfs::procfs_readlink(proc, entry, buf).ok() } @@ -1101,7 +1140,10 @@ pub(crate) fn procfs_getdents64_for_pid( ) -> Option<(usize, i64, bool)> { let table = unsafe { &*PROCESS_TABLE.0.get() }; let proc = table.get(pid)?; - let pids = table.all_pids(); + if proc.state == crate::process::ProcessState::Limbo { + return None; + } + let pids = table.procfs_pids(); crate::procfs::procfs_getdents64(proc, ofd_path, buf, offset, &pids).ok() } @@ -1918,7 +1960,7 @@ pub extern "C" fn kernel_get_fd_path(pid: u32, fd: i32, buf_ptr: *mut u8, buf_le /// u32 ppid /// u32 uid -- effective uid for ps-style USER display /// u32 gid -- effective gid -/// u64 vsize_bytes -- sum of mmap-region sizes +/// u64 vsize_bytes -- kernel-tracked logical virtual bytes /// u32 state -- 'R' (running) or 'Z' (zombie) as ASCII /// u32 comm_len /// u32 cmdline_len @@ -1974,7 +2016,7 @@ pub extern "C" fn kernel_enum_procs(out_ptr: *mut u8, out_len: u32) -> i32 { let cmdline = crate::procfs::generate_cmdline(proc); let comm = process_name_bytes(proc); let state: u32 = b'R' as u32; - let vsize: u64 = proc.memory.mappings().iter().map(|r| r.len as u64).sum(); + let vsize = crate::procfs::logical_virtual_bytes(proc); write_u32(buf, &mut off, proc.pid); write_u32(buf, &mut off, proc.ppid); @@ -7878,6 +7920,7 @@ pub extern "C" fn kernel_ioctl(fd: i32, request: u32, buf_ptr: *mut u8, buf_len: #[unsafe(no_mangle)] pub extern "C" fn kernel_prctl(option: u32, arg2: u32, _arg3: *mut u8, _arg4: u32) -> i32 { let (_gkl, proc) = unsafe { get_process() }; + let tid = crate::process_table::current_tid(); // For PR_SET_NAME (15) and PR_GET_NAME (16), arg2 is the pointer to // a 16-byte name buffer. The other prctl args are option-specific and // may be garbage for options that don't use them. @@ -7888,7 +7931,7 @@ pub extern "C" fn kernel_prctl(option: u32, arg2: u32, _arg3: *mut u8, _arg4: u3 } else { &mut [] }; - let result = match syscalls::sys_prctl(proc, option, arg2, buf) { + let result = match syscalls::sys_prctl(proc, tid, option, buf) { Ok(()) => 0, Err(e) => -(e as i32), }; diff --git a/docs/architecture.md b/docs/architecture.md index 75ef9aa0a3..c153b8cf94 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -202,6 +202,23 @@ Some syscalls (read from empty pipe, accept on socket, poll with timeout) cannot This mechanism is critical: the process worker blocks on `Atomics.wait` while the host manages async retry via `Atomics.waitAsync`. +Captured standard input is represented in the kernel as a pipe but its bytes +live in host state. Readiness for that host-delegated pipe therefore comes from +the same buffered/EOF state used by `host_read`: buffered bytes and finite EOF +are read-ready, while an open incremental-input stream with no bytes is not. +Reporting every host pipe as readable would make `select()` wake only for the +following `read()` to return `EAGAIN`, creating a retry loop instead of real +pipe semantics. + +Finite `poll()`/`ppoll()` and `select()`/`pselect6()` waits retain one deadline +from the first attempt. Targeted pipe wakeups, safety retries, and other host +retry cycles use the remaining duration; they do not start the caller's timeout +again. This keeps unrelated host activity from extending a finite wait +indefinitely. Except for a descriptor-free `select()` used only as a sleep, +expiry is finalized by one zero-timeout kernel pass. That pass clears readiness +outputs and restores any temporary `ppoll()`/`pselect6()` signal mask before +the host completes the channel. + ## Multi-Process Model ### fork() @@ -273,9 +290,11 @@ PATH-relative names. The implementation is regression-guarded by a per-process counter: `kernel_get_fork_count(pid)` returns the number of times that pid has -called `kernel_fork_process`. The vitest harness asserts this stays at -0 across a `posix_spawn` — any non-zero value means the path silently -fell back to fork. +called `kernel_fork_process`. The Rust `ProcessTable::spawn_child` regression +asserts this stays at 0 across a `posix_spawn`, while the Node end-to-end test +exercises the complete spawn/wait path and the host-parity test pins both +worker-entry `onSpawn` wires. A completed top-level host process is reaped, so +post-exit counter queries are intentionally not used as evidence. **Browser parity:** @@ -298,6 +317,23 @@ fell back to fork. remains as a fast-CI tripwire for someone removing one of the parallel wires. +### Host-owned top-level process lifecycle + +Processes launched directly by the Node or browser host, rather than by a guest +parent, are registered as children of the host-owned `ppid=0` namespace. Their +exit status is consumed by the host's spawn result/exit promise, so there is no +guest process that can call `waitpid()` for them. After the process and thread +workers have been torn down and their syscall channels deactivated, the host +asks Rust to reap the exited `(parent=0, child=pid)` entry. Rust verifies both +the parent relationship and exited state before removing it. + +This cleanup is intentionally narrower than hiding zombies or reaping every +process during host teardown. A process with a guest parent does not satisfy the +`ppid=0` check and remains an exited zombie until that parent consumes its +status through `wait()`/`waitpid()`. Procfs therefore continues to expose +unreaped guest-child zombies, while completed host-owned launches do not +accumulate entries after their host worker is gone. + ### clone() (threads) 1. User calls `clone(CLONE_VM | CLONE_THREAD, ...)` → kernel returns clone request @@ -309,6 +345,16 @@ fell back to fork. Threads share memory with the parent (CLONE_VM) but have their own channel, fork-save scratch page, and TLS/control page. +### Procfs process view and accounting boundaries + +Kandelo exposes a deliberately partial Linux-compatible procfs backed by the authoritative `ProcessTable`. `/proc` includes global `/proc/stat` and `/proc/meminfo` nodes and per-process nodes such as `/proc//stat`, `status`, `statm`, and `task`. PID-scoped nodes use the target process's effective uid and gid as their filesystem owner; global nodes remain root-owned. The visible PID set includes running processes and unreaped exited zombies. It excludes `ProcessState::Limbo` entries, which retain process-group identity after the user-visible process and its resources are gone. + +Virtual size is logical address-space accounting, not physical memory accounting. `logical_virtual_bytes` counts the contiguous address-space prefix through the current program break (including the loaded program, stack, and required main control pages), then adds the union of active guest `mmap` ranges without double-counting overlaps. Host-reserved ranges above the break are excluded unless they are also active guest mappings. The result is reported as bytes in `/proc//stat`, rounded-up kilobytes in `status`'s `VmSize`, and 64 KiB logical pages in the first field of `statm`. + +CPU time, physical residency, and system-memory/cache accounting are not implemented. Consequently, the CPU counters in `/proc/stat`, RSS-related process fields (including `VmRSS` and fields 2-7 of `statm`), and memory totals in `/proc/meminfo` are zero placeholders that explicitly mean **unavailable**, not measured zero usage. `/proc//task` enumerates the main PID and registered worker-thread TIDs, but this does not imply complete Linux procfs coverage. + +Processor-count queries are separate from procfs accounting. On the normal musl path, `sysconf(_SC_NPROCESSORS_CONF)` and `sysconf(_SC_NPROCESSORS_ONLN)` derive their result from `sched_getaffinity()`; Kandelo currently exposes only logical CPU 0, so both return 1. That logical topology does not supply CPU-usage accounting. + ## Memory Layout Each process has a WebAssembly linear memory (shared, up to 1GB by default). The host does not instantiate that memory at the maximum size. It creates the memory large enough for the wasm import minimum plus the main-thread control pages, then grows it after successful guest allocation syscalls or after dynamically reserving a pthread control slot. diff --git a/docs/browser-support.md b/docs/browser-support.md index 314b673586..8d79a9a0bb 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -107,7 +107,11 @@ connection in any nginx worker. The standalone nginx image runs with ### Terminal - PTY support with full line discipline -- Interactive stdin via `appendStdinData` for incremental input +- Omitted stdin on a non-PTY `boot`, `spawn`, or `spawnFromVfs` launch is a + finite empty input stream (immediate EOF); an explicit input buffer is finite + and ends in EOF as well +- Interactive launches deliberately kept open use `appendStdinData` for + incremental input; PTY launches remain attached to their terminal stream - xterm.js integration via `PtyTerminal` ### Framebuffer (`/dev/fb0`) diff --git a/docs/posix-status.md b/docs/posix-status.md index dbec136eef..72be060a5d 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -39,7 +39,7 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `open()` | Partial | Host-delegated. O_CREAT, O_EXCL, O_TRUNC, O_APPEND, O_NONBLOCK, O_CLOEXEC, O_DIRECTORY, O_NOFOLLOW flags handled. umask applied to mode on O_CREAT. Virtual device interception (`/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/full`, `/dev/fd/N`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`). | | `openat()` | Full | AT_FDCWD delegates to open(). Absolute paths handled. Real dirfd supported via stored OFD paths. | | `close()` | Partial | Ref-counted OFD cleanup. Host handle closed when last ref dropped. Releases all fcntl advisory locks on the file (POSIX-compliant). EINTR not yet handled. | -| `read()` | Partial | Host-delegated for files. Pipe/socket reads from kernel ring buffer with blocking when empty (EINTR on signal). Short reads permitted. O_NONBLOCK returns EAGAIN. | +| `read()` | Partial | Host-delegated for files. Pipe/socket reads from kernel ring buffer with blocking when empty (EINTR on signal). Captured host stdin returns its finite launch buffer followed by EOF; an explicitly open incremental-input stream blocks until `appendStdinData()` supplies bytes. Short reads permitted. O_NONBLOCK returns EAGAIN. | | `pread()` | Partial | Host-delegated via seek-read-restore. Not atomic (single-threaded safe only). Rejects pipes/sockets with ESPIPE. | | `write()` | Partial | Host-delegated for files. Pipe writes to kernel ring buffer with blocking when full (EINTR on signal). EPIPE + SIGPIPE on closed read end (POSIX-compliant). O_APPEND seeks to end before write. RLIMIT_FSIZE enforced (EFBIG + SIGXFSZ). | | `pwrite()` | Partial | Host-delegated via seek-write-restore. Not atomic (single-threaded safe only). Rejects pipes/sockets with ESPIPE. | @@ -101,10 +101,10 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve |----------|--------|-------| | `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`. | -| `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. | +| `waitpid()` | Full | Kernel-internal: blocks parent until child exits (WNOHANG supported). Reaps guest-child 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. A top-level host launch has `ppid=0`; its status is consumed by the host API and it is reaped only after host worker teardown. | +| `exit()` / `_exit()` | Full | Closes all fds and dir streams, releases all fcntl locks, sets ProcessState::Exited. SIGCHLD delivered to a guest parent. Guest-child zombie state is maintained until reaped by waitpid; the host separately reaps only exited direct children of `ppid=0` after their workers can issue no more syscalls. | | `getpid()` | Full | Returns pid from Process struct. | -| `getppid()` | Full | Returns ppid (0 for init process). | +| `getppid()` | Full | Returns ppid (0 for a top-level process launched directly by the host). | | `getuid()` / `geteuid()` | Full | Simulated; defaults to uid=0 (root). Configurable via setuid/seteuid. | | `getgid()` / `getegid()` | Full | Simulated; defaults to gid=0 (root). Configurable via setgid/setegid. | | `setuid()` / `seteuid()` | Full | POSIX semantics (no saved-set-uid tracked). As root: setuid sets both uid and euid; seteuid sets any euid. Non-root: setuid only to own uid; seteuid only to own ruid. Returns EPERM otherwise. | @@ -147,6 +147,21 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `getcontext()` / `setcontext()` / `makecontext()` / `swapcontext()` | Unsupported | Userspace stack-switching primitives, deprecated in POSIX.1-2008, not planned. See the "ucontext API unsupported" row under [Wasm-Inherent gaps](#wasm-inherent--gaps-that-cannot-be-fully-resolved-in-wasm) for rationale. | | `fork()` called from a C++/Ruby exception catch handler | Full | B1 stages 1+2 + Phase 6 catch-handler resume machinery (per-arm scratch space in the save buffer, multi-arm rewind dispatch, `$capture`-block emission, rewind-throw stub via `_wpk_fork_exnref_stash`) close fork-from-plain-catch under **modern wasm-EH lowering** (`try_table` / `catch_ref` / `throw_ref`). The fierce-wire mega-PR (PR #307) commit 9 + 2026-05-14 followup flipped the SDK + libcxx (revision 4) to modern EH explicitly (LLVM 21's `-wasm-use-legacy-eh` defaults to `true`, so removing the prior `=true` override silently kept legacy lowering — the explicit `=false` is required). Test coverage in `host/test/fork-instrument-coverage.test.ts`: C-02 fork-in-catch, C-03 multi-arm catch, C-04 throw-from-outside, C-05 modern EH single typed catch, C-06 modern multi-target `*_ref`, C-07 modern multi-arm plain, C-10 fork in both try body + handler, C-11 post-catch fork (SpiderMonkey-spike test (b)), S-08 throw-from-outside + fork-in-catch — all 9 PASS. Combined with C-01 (fork-in-try-body), the catch-handler coverage is comprehensive for both legacy-EH-pattern and modern-EH-pattern C++. Funcref/externref catch operands (A4) remain on the not-yet-supported list — see [docs/fork-instrumentation.md §Not guaranteed](fork-instrumentation.md#not-guaranteed-unsupported-patterns). | +### Procfs (Linux compatibility, not POSIX) + +Procfs is a deliberately partial Linux-compatibility surface, not a POSIX API. Existing identity, environment, mapping, fd, and network nodes remain available; the accounting-related surface has these limits: + +| Surface | Status | Notes | +|---------|--------|-------| +| `/proc/stat` | Partial | Exposes a Linux-shaped aggregate CPU line. All CPU counters are zero because CPU-time accounting is unavailable; they do not mean measured zero work. | +| `/proc/meminfo` | Partial | Exposes the expected memory/cache headings with zero values. Zero means physical system-memory accounting is unavailable, not that the machine has zero bytes. | +| `/proc//stat` and `status` | Partial | Identity, nice value, thread count, and logical virtual size are authoritative. CPU-time and RSS fields remain zero because those measurements are unavailable. | +| `/proc//statm` | Partial | Field 1 reports logical virtual size in 64 KiB pages. Resident, shared, text, library, data/stack, and dirty fields (2-7) are zero placeholders for unavailable accounting. | +| `/proc//task` | Partial | Enumerates the main PID and registered worker-thread TIDs. This is the implemented task view, not a claim of complete Linux per-thread procfs semantics. | +| PID visibility and ownership | Partial | Running processes and unreaped zombies are visible. Resource-free `Limbo` process-group placeholders are excluded. Every PID-scoped node is owned by the target process's effective uid and gid; global nodes are root-owned. | + +The virtual-size value is logical address-space size: the prefix through the current program break plus the non-overlapping union of active guest mappings. It is not resident or physical memory usage. + ## Signals | Function | Status | Notes | @@ -237,10 +252,10 @@ shortcuts. | `sendto()` / `recvfrom()` | Partial | AF_INET UDP loopback and local virtual-network send/receive, connected and unconnected sendto, source address reporting, and connected receive filtering are implemented. External raw UDP routes return ENETUNREACH and are narrowly xfailed in the Sortix UDP suite. | | `setsockopt()` / `getsockopt()` | Partial | SOL_SOCKET: SO_TYPE, SO_DOMAIN, SO_ERROR, SO_ACCEPTCONN, SO_RCVBUF, SO_SNDBUF readable; SO_REUSEADDR affects UDP bind conflicts; SO_KEEPALIVE, SO_LINGER, SO_RCVTIMEO, SO_SNDTIMEO, SO_BROADCAST accepted/stored. IPPROTO_TCP: TCP_NODELAY stored. | | `shutdown()` | Partial | SHUT_RD, SHUT_WR, SHUT_RDWR for stream sockets and UDP readiness/error behavior. UDP write shutdown returns EPIPE on datagram send; read shutdown is EOF-like for recv/poll. | -| `select()` | Partial | Wrapper around poll(). Converts fd_set bitmasks to pollfd array. Timeout supported via polling loop. | -| `poll()` | Partial | Checks readiness for regular files, pipes, and sockets. UDP poll reports queued datagrams, connected-peer filtering, EOF-like read shutdown, write-shutdown hangup, and pending socket errors. Timeout supported via polling loop with 1ms sleep intervals. Returns EINTR on pending signals. | -| `ppoll()` | Full | Wraps poll() with atomic signal mask swap: save → set → poll → restore. Timespec converted to timeout_ms in glue layer. | -| `pselect6()` | Full | Wraps select() with atomic signal mask swap. Sigmask extracted from pselect6-style {sigset_t*, size_t} struct in glue layer. | +| `select()` | Partial | Wrapper around poll(). Converts fd_set bitmasks to pollfd array. Host-delegated captured-input pipes are read-ready only when bytes or finite EOF are available. A finite timeout keeps one deadline across host retries. | +| `poll()` | Partial | Checks readiness for regular files, pipes, and sockets. Host-delegated captured-input readiness mirrors its buffered/EOF state; an open empty incremental-input stream is not readable. UDP poll reports queued datagrams, connected-peer filtering, EOF-like read shutdown, write-shutdown hangup, and pending socket errors. A finite timeout keeps one deadline across the host polling/retry loop and finishes with a zero-timeout kernel pass that clears `revents`. Returns EINTR on pending signals. | +| `ppoll()` | Full | Wraps poll() with atomic signal mask swap: save → set → poll → restore. Timespec converted to timeout_ms in glue layer, with the resulting deadline preserved across host retries. Timeout expiry finishes in the kernel so `revents` is copied back and the temporary mask is restored. | +| `pselect6()` | Full | Wraps select() with atomic signal mask swap. Sigmask extracted from pselect6-style {sigset_t*, size_t} struct in glue layer. Finite waits preserve their original deadline across host retries. | | `epoll_create1()` | Full | Creates epoll instance with per-process interest list. EPOLL_CLOEXEC flag supported. | | `epoll_ctl()` | Full | EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL. Stores interest set with events + data. | | `epoll_pwait()` | Full | Builds pollfd from interest set, delegates to poll, maps results back to epoll_event structs. Optional signal mask swap. | @@ -362,7 +377,7 @@ All virtual devices return synthetic `stat()` with `S_IFCHR | 0666`, determinist | Function | Status | Notes | |----------|--------|-------| | `uname()` | Full | Returns sysname="wasm-posix", nodename="localhost", release="1.0.0", version="kandelo", machine="wasm32". 5 x 65-byte null-terminated strings. | -| `sysconf()` | Partial | Handles _SC_CHILD_MAX, _SC_CLK_TCK=100, _SC_PAGE_SIZE=65536, _SC_OPEN_MAX=1024, _SC_NPROCESSORS_ONLN=1, _SC_NPROCESSORS_CONF=1, _SC_MONOTONIC_CLOCK=1, _SC_THREAD_SAFE_FUNCTIONS=1, plus 100+ POSIX.1-2024 constants via musl overlay. Unknown names return EINVAL. | +| `sysconf()` | Partial | Handles _SC_CHILD_MAX, _SC_CLK_TCK=100, _SC_PAGE_SIZE=65536, _SC_OPEN_MAX=1024, _SC_MONOTONIC_CLOCK=1, _SC_THREAD_SAFE_FUNCTIONS=1, plus 100+ POSIX.1-2024 constants via the musl overlay. For the normal `_SC_NPROCESSORS_CONF` and `_SC_NPROCESSORS_ONLN` queries, musl counts `sched_getaffinity()` bits; Kandelo exposes logical CPU 0 only, so both return 1. Unknown names return EINVAL. | | `umask()` | Full | Set file creation mask, returns previous mask. Default 0o022. Applied in open() and mkdir(). Masked to 0o777. | | `getrlimit()` | Full | Returns (soft, hard) resource limits. Defaults: NOFILE=(1024,4096), STACK=(8MB,infinity), others infinity. | | `setrlimit()` | Partial | Sets resource limits. Validates soft <= hard. RLIMIT_NOFILE enforced via FdTable max_fds sync. RLIMIT_FSIZE enforced in write()/ftruncate() (EFBIG + SIGXFSZ). | diff --git a/examples/procfs_accounting_test.c b/examples/procfs_accounting_test.c new file mode 100644 index 0000000000..f1a5f94512 --- /dev/null +++ b/examples/procfs_accounting_test.c @@ -0,0 +1,634 @@ +/* + * End-to-end guest regression for Kandelo's intentionally limited Linux + * procfs resource-accounting surface. + * + * The values checked here are platform contracts, not host-machine metrics: + * Kandelo exposes the process's logical Wasm address-space size, while CPU, + * residency, and machine-wide memory accounting remain unavailable and are + * reported as zero. Keeping the assertions in a guest program exercises the + * SDK/libc, syscall channel, kernel procfs implementation, and directory/stat + * marshalling together. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int failures; +extern char **environ; + +static void fail_check(const char *check, const char *detail) { + fprintf(stderr, "FAIL %s: %s\n", check, detail); + failures++; +} + +static int read_text_file(const char *path, char *buf, size_t capacity) { + FILE *file; + size_t used; + + if (capacity < 2) { + fail_check(path, "test buffer is too small"); + return -1; + } + + file = fopen(path, "r"); + if (file == NULL) { + char detail[96]; + snprintf(detail, sizeof(detail), "fopen failed with errno=%d", errno); + fail_check(path, detail); + return -1; + } + + used = fread(buf, 1, capacity - 1, file); + if (ferror(file)) { + char detail[96]; + snprintf(detail, sizeof(detail), "fread failed with errno=%d", errno); + fail_check(path, detail); + fclose(file); + return -1; + } + if (used == capacity - 1 && fgetc(file) != EOF) { + fail_check(path, "content exceeds the test buffer"); + fclose(file); + return -1; + } + if (fclose(file) != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "fclose failed with errno=%d", errno); + fail_check(path, detail); + return -1; + } + + buf[used] = '\0'; + return 0; +} + +static int owner_matches_effective_ids(const char *path, struct stat *out) { + struct stat st; + + if (stat(path, &st) != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "stat failed with errno=%d", errno); + fail_check(path, detail); + return 0; + } + if (st.st_uid != geteuid() || st.st_gid != getegid()) { + char detail[160]; + snprintf(detail, sizeof(detail), + "owner=%lu:%lu effective=%lu:%lu", + (unsigned long)st.st_uid, (unsigned long)st.st_gid, + (unsigned long)geteuid(), (unsigned long)getegid()); + fail_check(path, detail); + return 0; + } + if (out != NULL) { + *out = st; + } + return 1; +} + +static int drop_to_demo_user(void) { + if (geteuid() != 0 || getegid() != 0) { + fail_check("identity", "fixture did not start as root"); + return -1; + } + /* Drop the group first; after setuid the process must not retain a path + * back to privileged group credentials. */ + if (setgid(1000) != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "setgid(1000) failed with errno=%d", errno); + fail_check("identity", detail); + return -1; + } + if (setuid(1000) != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "setuid(1000) failed with errno=%d", errno); + fail_check("identity", detail); + return -1; + } + if (geteuid() != 1000 || getegid() != 1000) { + char detail[128]; + snprintf(detail, sizeof(detail), "effective identity is %lu:%lu", + (unsigned long)geteuid(), (unsigned long)getegid()); + fail_check("identity", detail); + return -1; + } + puts("IDENTITY euid=1000 egid=1000"); + return 0; +} + +static void check_proc_enumeration(void) { + DIR *dir; + struct dirent *entry; + char pid_name[32]; + int saw_stat = 0; + int saw_meminfo = 0; + int saw_pid = 0; + + snprintf(pid_name, sizeof(pid_name), "%ld", (long)getpid()); + dir = opendir("/proc"); + if (dir == NULL) { + char detail[96]; + snprintf(detail, sizeof(detail), "opendir failed with errno=%d", errno); + fail_check("/proc enumeration", detail); + return; + } + + errno = 0; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, "stat") == 0) saw_stat = 1; + if (strcmp(entry->d_name, "meminfo") == 0) saw_meminfo = 1; + if (strcmp(entry->d_name, pid_name) == 0) saw_pid = 1; + } + if (errno != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "readdir failed with errno=%d", errno); + fail_check("/proc enumeration", detail); + } + if (closedir(dir) != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "closedir failed with errno=%d", errno); + fail_check("/proc enumeration", detail); + } + + if (!saw_stat) fail_check("/proc enumeration", "missing stat"); + if (!saw_meminfo) fail_check("/proc enumeration", "missing meminfo"); + if (!saw_pid) fail_check("/proc enumeration", "missing current PID directory"); + if (saw_stat && saw_meminfo && saw_pid) { + printf("PROC enumeration stat=1 meminfo=1 self_pid=1\n"); + } +} + +static int read_and_check_statm(const char *path, unsigned long long *size_pages) { + char text[512]; + char extra[2]; + unsigned long long fields[7]; + int parsed; + int unsupported_zero = 1; + int i; + + if (read_text_file(path, text, sizeof(text)) != 0) return 0; + parsed = sscanf(text, "%llu %llu %llu %llu %llu %llu %llu %1s", + &fields[0], &fields[1], &fields[2], &fields[3], + &fields[4], &fields[5], &fields[6], extra); + if (parsed != 7) { + char detail[96]; + snprintf(detail, sizeof(detail), "expected 7 fields, parsed %d", parsed); + fail_check(path, detail); + return 0; + } + if (fields[0] == 0) { + fail_check(path, "logical size field is zero"); + } + for (i = 1; i < 7; i++) { + if (fields[i] != 0) unsupported_zero = 0; + } + if (!unsupported_zero) { + fail_check(path, "unsupported fields are not all zero"); + } + if (fields[0] == 0 || !unsupported_zero) return 0; + if (size_pages != NULL) { + *size_pages = fields[0]; + } + return 1; +} + +static void check_statm(void) { + unsigned long long size_pages; + + if (read_and_check_statm("/proc/self/statm", &size_pages)) { + printf("STATM size_pages=%llu unsupported_fields_zero=1\n", size_pages); + } +} + +static void check_task_directory(void) { + DIR *dir; + struct dirent *entry; + struct stat st; + char main_tid[32]; + int saw_main = 0; + int owner_ok; + + snprintf(main_tid, sizeof(main_tid), "%ld", (long)getpid()); + dir = opendir("/proc/self/task"); + if (dir == NULL) { + char detail[96]; + snprintf(detail, sizeof(detail), "opendir failed with errno=%d", errno); + fail_check("/proc/self/task", detail); + return; + } + + errno = 0; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, main_tid) == 0) saw_main = 1; + } + if (errno != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "readdir failed with errno=%d", errno); + fail_check("/proc/self/task", detail); + } + if (closedir(dir) != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "closedir failed with errno=%d", errno); + fail_check("/proc/self/task", detail); + } + if (!saw_main) { + fail_check("/proc/self/task", "main TID is absent"); + } + + owner_ok = owner_matches_effective_ids("/proc/self/task", &st); + if (saw_main && owner_ok) { + printf("TASK main_tid=%s owner=%lu:%lu\n", main_tid, + (unsigned long)st.st_uid, (unsigned long)st.st_gid); + } +} + +static void check_process_stat(void) { + char text[4096]; + char *cursor; + char *comm_end; + char *end; + long long nice_value = 0; + long long vsize = 0; + long long rss = 0; + int field; + int expected_nice; + int parsed_through_rss = 1; + int owner_ok; + struct stat st; + + /* A non-zero value catches field-shift and hard-coded-zero mistakes. */ + if (setpriority(PRIO_PROCESS, 0, 7) != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "setpriority failed with errno=%d", errno); + fail_check("nice accounting", detail); + return; + } + errno = 0; + expected_nice = getpriority(PRIO_PROCESS, 0); + if (expected_nice == -1 && errno != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "getpriority failed with errno=%d", errno); + fail_check("nice accounting", detail); + return; + } + if (expected_nice != 7) { + char detail[96]; + snprintf(detail, sizeof(detail), "expected nice=7, got %d", expected_nice); + fail_check("nice accounting", detail); + return; + } + + if (read_text_file("/proc/self/stat", text, sizeof(text)) != 0) return; + comm_end = strrchr(text, ')'); + if (comm_end == NULL) { + fail_check("/proc/self/stat", "missing closing comm parenthesis"); + return; + } + + cursor = comm_end + 1; + for (field = 3; field <= 24; field++) { + while (*cursor == ' ' || *cursor == '\t') cursor++; + if (*cursor == '\0' || *cursor == '\n') { + parsed_through_rss = 0; + break; + } + if (field == 3) { + /* State is the only non-numeric field after comm. */ + while (*cursor != '\0' && *cursor != '\n' && + *cursor != ' ' && *cursor != '\t') { + cursor++; + } + continue; + } + + errno = 0; + end = NULL; + { + long long value = strtoll(cursor, &end, 10); + if (end == cursor || errno == ERANGE) { + parsed_through_rss = 0; + break; + } + if (field == 19) nice_value = value; + if (field == 23) vsize = value; + if (field == 24) rss = value; + } + cursor = end; + } + + if (!parsed_through_rss) { + fail_check("/proc/self/stat", "could not parse through field 24"); + return; + } + if (nice_value != expected_nice) { + char detail[96]; + snprintf(detail, sizeof(detail), "field19=%lld expected=%d", + nice_value, expected_nice); + fail_check("/proc/self/stat", detail); + } + if (vsize <= 0) { + fail_check("/proc/self/stat", "field23 vsize is not positive"); + } + if (rss != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "field24 rss=%lld expected=0", rss); + fail_check("/proc/self/stat", detail); + } + + owner_ok = owner_matches_effective_ids("/proc/self/stat", &st); + if (nice_value == expected_nice && vsize > 0 && rss == 0 && owner_ok) { + printf("STAT nice=%lld vsize_bytes=%lld rss_pages=0 owner=%lu:%lu\n", + nice_value, vsize, (unsigned long)st.st_uid, + (unsigned long)st.st_gid); + } +} + +static void check_cpu_stat(void) { + char text[2048]; + char *save = NULL; + char *token; + int fields = 0; + int all_zero = 1; + + if (read_text_file("/proc/stat", text, sizeof(text)) != 0) return; + token = strtok_r(text, " \t\r\n", &save); + if (token == NULL || strcmp(token, "cpu") != 0) { + fail_check("/proc/stat", "first record is not aggregate cpu"); + return; + } + + while ((token = strtok_r(NULL, " \t\r\n", &save)) != NULL) { + char *end = NULL; + unsigned long long value; + + errno = 0; + value = strtoull(token, &end, 10); + if (end == token || *end != '\0' || errno == ERANGE) { + /* A later non-numeric record marks the end of the first line. */ + break; + } + fields++; + if (value != 0) all_zero = 0; + } + + if (fields < 4) { + fail_check("/proc/stat", "aggregate cpu record has fewer than 4 fields"); + } + if (!all_zero) { + fail_check("/proc/stat", "aggregate cpu fields are not all zero"); + } + if (fields >= 4 && all_zero) { + printf("CPU aggregate_fields=%d all_zero=1\n", fields); + } +} + +static void check_meminfo(void) { + static const char *required[] = { + "MemTotal", "MemFree", "Cached", "SReclaimable", "Buffers" + }; + char text[2048]; + char *save = NULL; + char *line; + int seen[sizeof(required) / sizeof(required[0])] = {0}; + int all_zero = 1; + size_t i; + + if (read_text_file("/proc/meminfo", text, sizeof(text)) != 0) return; + for (line = strtok_r(text, "\n", &save); line != NULL; + line = strtok_r(NULL, "\n", &save)) { + char key[64]; + unsigned long long value; + + if (sscanf(line, " %63[^:]: %llu", key, &value) != 2) continue; + for (i = 0; i < sizeof(required) / sizeof(required[0]); i++) { + if (strcmp(key, required[i]) != 0) continue; + seen[i] = 1; + if (value != 0) { + char detail[128]; + all_zero = 0; + snprintf(detail, sizeof(detail), "%s=%llu expected=0", key, value); + fail_check("/proc/meminfo", detail); + } + } + } + + for (i = 0; i < sizeof(required) / sizeof(required[0]); i++) { + if (!seen[i]) { + char detail[96]; + snprintf(detail, sizeof(detail), "missing %s", required[i]); + fail_check("/proc/meminfo", detail); + } + } + if (seen[0] && seen[1] && seen[2] && seen[3] && seen[4] && all_zero) { + printf("MEMINFO required_fields=5 all_zero=1\n"); + } +} + +static void check_processor_count(void) { + long online = sysconf(_SC_NPROCESSORS_ONLN); + long configured = sysconf(_SC_NPROCESSORS_CONF); + + if (online != 1) { + char detail[96]; + snprintf(detail, sizeof(detail), "online=%ld expected=1 errno=%d", online, errno); + fail_check("sysconf processors", detail); + } + if (configured != 1) { + char detail[96]; + snprintf(detail, sizeof(detail), "configured=%ld expected=1 errno=%d", + configured, errno); + fail_check("sysconf processors", detail); + } + if (online == 1 && configured == 1) { + printf("NPROCESSORS online=1 configured=1\n"); + } +} + +static int blocked_child_main(const char *fd_text) { + char *end = NULL; + long fd_long; + char byte; + ssize_t count; + + errno = 0; + fd_long = strtol(fd_text, &end, 10); + if (end == fd_text || *end != '\0' || errno == ERANGE || + fd_long < 0 || fd_long > 1023) { + fprintf(stderr, "blocked child: invalid fd %s\n", fd_text); + return 2; + } + if (geteuid() != 1000 || getegid() != 1000) { + fprintf(stderr, "blocked child: identity=%lu:%lu expected=1000:1000\n", + (unsigned long)geteuid(), (unsigned long)getegid()); + return 3; + } + + do { + count = read((int)fd_long, &byte, 1); + } while (count < 0 && errno == EINTR); + if (count != 1) { + fprintf(stderr, "blocked child: read returned %ld errno=%d\n", + (long)count, errno); + return 4; + } + return 0; +} + +static int spawn_blocked_child(pid_t *child_pid, int *release_fd) { + static const char *child_path = "/usr/bin/procfs-accounting-test"; + int pipe_fds[2]; + char read_fd_text[32]; + char *child_argv[4]; + int rc; + + if (pipe(pipe_fds) != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "pipe failed with errno=%d", errno); + fail_check("foreign procfs", detail); + return -1; + } + snprintf(read_fd_text, sizeof(read_fd_text), "%d", pipe_fds[0]); + child_argv[0] = (char *)"procfs_accounting_test"; + child_argv[1] = (char *)"--blocked-child"; + child_argv[2] = read_fd_text; + child_argv[3] = NULL; + + rc = posix_spawn(child_pid, child_path, NULL, NULL, child_argv, environ); + if (rc != 0) { + char detail[128]; + snprintf(detail, sizeof(detail), "posix_spawn failed with rc=%d", rc); + fail_check("foreign procfs", detail); + close(pipe_fds[0]); + close(pipe_fds[1]); + return -1; + } + + close(pipe_fds[0]); + *release_fd = pipe_fds[1]; + return 0; +} + +static void check_foreign_process(void) { + pid_t child_pid; + int release_fd; + char stat_path[64]; + char statm_path[64]; + char task_path[64]; + char child_name[32]; + char stat_text[4096]; + struct stat stat_st; + struct stat task_st; + DIR *task_dir = NULL; + struct dirent *entry; + unsigned long long statm_pages = 0; + int stat_owner_ok; + int task_owner_ok; + int stat_pid_ok = 0; + int main_tid_seen = 0; + int status; + char release = 'x'; + + if (spawn_blocked_child(&child_pid, &release_fd) != 0) return; + + snprintf(child_name, sizeof(child_name), "%ld", (long)child_pid); + snprintf(stat_path, sizeof(stat_path), "/proc/%ld/stat", (long)child_pid); + snprintf(statm_path, sizeof(statm_path), "/proc/%ld/statm", (long)child_pid); + snprintf(task_path, sizeof(task_path), "/proc/%ld/task", (long)child_pid); + + stat_owner_ok = owner_matches_effective_ids(stat_path, &stat_st); + task_owner_ok = owner_matches_effective_ids(task_path, &task_st); + + if (read_text_file(stat_path, stat_text, sizeof(stat_text)) == 0) { + char *end = NULL; + long reported_pid; + + errno = 0; + reported_pid = strtol(stat_text, &end, 10); + if (end == stat_text || errno == ERANGE || reported_pid != (long)child_pid) { + fail_check("foreign /proc//stat", "field1 does not match child PID"); + } else { + stat_pid_ok = 1; + } + } + (void)read_and_check_statm(statm_path, &statm_pages); + + task_dir = opendir(task_path); + if (task_dir == NULL) { + char detail[96]; + snprintf(detail, sizeof(detail), "opendir failed with errno=%d", errno); + fail_check("foreign /proc//task", detail); + } else { + errno = 0; + while ((entry = readdir(task_dir)) != NULL) { + if (strcmp(entry->d_name, child_name) == 0) main_tid_seen = 1; + } + if (errno != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "readdir failed with errno=%d", errno); + fail_check("foreign /proc//task", detail); + } + if (closedir(task_dir) != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "closedir failed with errno=%d", errno); + fail_check("foreign /proc//task", detail); + } + if (!main_tid_seen) { + fail_check("foreign /proc//task", "child main TID is absent"); + } + } + + if (stat_owner_ok && task_owner_ok && stat_pid_ok && + statm_pages > 0 && main_tid_seen) { + printf("FOREIGN pid=%ld owner=%lu:%lu statm_pages=%llu main_tid=1\n", + (long)child_pid, (unsigned long)stat_st.st_uid, + (unsigned long)stat_st.st_gid, statm_pages); + } + + if (write(release_fd, &release, 1) != 1) { + char detail[96]; + snprintf(detail, sizeof(detail), "release write failed with errno=%d", errno); + fail_check("foreign procfs", detail); + } + close(release_fd); + if (waitpid(child_pid, &status, 0) != child_pid) { + char detail[96]; + snprintf(detail, sizeof(detail), "waitpid failed with errno=%d", errno); + fail_check("foreign procfs", detail); + } else if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + char detail[96]; + snprintf(detail, sizeof(detail), "child status=0x%x", status); + fail_check("foreign procfs", detail); + } +} + +int main(int argc, char **argv) { + if (argc == 3 && strcmp(argv[1], "--blocked-child") == 0) { + return blocked_child_main(argv[2]); + } + if (drop_to_demo_user() != 0) return 1; + + check_proc_enumeration(); + check_statm(); + check_task_directory(); + check_process_stat(); + check_cpu_stat(); + check_meminfo(); + check_processor_count(); + check_foreign_process(); + + if (failures != 0) { + fprintf(stderr, "FAIL procfs_accounting_test failures=%d\n", failures); + return 1; + } + puts("PASS procfs_accounting_test"); + return 0; +} diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 8fb880e993..d13fa65e36 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -443,12 +443,15 @@ export class BrowserKernel { uid?: number; gid?: number; pty?: boolean; + /** Finite stdin buffer. If omitted for a non-PTY spawn, stdin is + * immediately at EOF. */ stdin?: Uint8Array; ptyCols?: number; ptyRows?: number; }, ): Promise<{ pid: number; exit: Promise }> { const requestId = this.nextRequestId++; + const stdin = options?.stdin ?? (!options?.pty ? new Uint8Array() : undefined); const pid = await this.request(requestId, { type: "spawn", requestId, @@ -461,7 +464,7 @@ export class BrowserKernel { pty: options?.pty, ptyCols: options?.ptyCols, ptyRows: options?.ptyRows, - stdin: options?.stdin, + stdin, maxPages: this.maxPages, }) as number; diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 3f6a42ddc9..cba8f186e3 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -88,6 +88,7 @@ import { SIGSEGV, } from "./trap-signals"; import { threadWorkerFailureDisposition } from "./thread-worker-disposition"; +import { reapHostOwnedExitedProcess } from "./host-owned-process-reap"; import type { CentralizedWorkerInitMessage, CentralizedThreadInitMessage, @@ -1412,6 +1413,7 @@ async function finishProcessExit( // exit promises, and no further guest syscalls can arrive on this // channel once the worker is gone. kernelWorker.deactivateProcess(pid); + reapHostOwnedExitedProcess(kernelInstance, pid); processes.delete(pid); threadModuleCache.delete(pid); diff --git a/host/src/host-owned-process-reap.ts b/host/src/host-owned-process-reap.ts new file mode 100644 index 0000000000..9298b4b8ea --- /dev/null +++ b/host/src/host-owned-process-reap.ts @@ -0,0 +1,23 @@ +type ReapExitedChild = (parentPid: number, childPid: number) => number; + +/** + * Reap an exited process only when it is a direct child of the host-owned + * ppid=0 namespace. + * + * Rust owns both the parent relationship and the exited-state check. A guest + * child therefore returns ECHILD here and remains available to its parent via + * wait/waitpid. + */ +export function reapHostOwnedExitedProcess( + kernelInstance: WebAssembly.Instance | null, + pid: number, +): boolean { + if (!kernelInstance) return false; + + const reapExitedChild = kernelInstance.exports.kernel_reap_exited_child; + if (typeof reapExitedChild !== "function") { + throw new Error("kernel_reap_exited_child export is unavailable"); + } + + return (reapExitedChild as ReapExitedChild)(0, pid) === 0; +} diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 9a182a4971..15bc9d74e2 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -319,7 +319,7 @@ export interface ProcessSnapshot { /** Effective user/group IDs for ps-style USER display. */ uid: number; gid: number; - /** Sum of mmap-region sizes for this process, in bytes. */ + /** Kernel-tracked logical virtual address-space size, in bytes; not RSS. */ vsizeBytes: number; /** Current WebAssembly.Memory buffer size for this process, in bytes. */ memoryBytes?: number; @@ -447,6 +447,12 @@ interface ChannelInfo { * retry/sleep/fork/exec path. Prevents the poller from re-entering a * channel that is already in flight. Only used when usePolling=true. */ handling?: boolean; + /** Absolute Date.now() deadline retained for the lifetime of one blocked + * poll/ppoll call. -1 means the call has an infinite timeout. */ + pollDeadline?: number; + /** Host-only timeout substituted on the final kernel pass. The guest's + * original poll/ppoll arguments remain untouched. */ + pollTimeoutOverride?: number; } /** Info about a registered process. */ @@ -928,6 +934,9 @@ export class CentralizedKernelWorker { } return chunk; }, + onStdinPoll: (events: number): number => { + return this.stdinPollEvents(this.currentHandlePid, events); + }, onAlarm: (seconds: number): number => { const pid = this.currentHandlePid; if (pid === 0) return 0; @@ -1244,6 +1253,20 @@ export class CentralizedKernelWorker { this.stdinFinite.add(pid); // EOF after data is consumed } + /** Compute captured-stdin readiness from the same per-process state as read. */ + private stdinPollEvents(pid: number, events: number): number { + const POLLIN = 0x0001; + const POLLHUP = 0x0010; + const buf = this.stdinBuffers.get(pid); + const remaining = buf ? buf.data.length - buf.offset : 0; + let revents = 0; + if ((events & POLLIN) !== 0 && remaining > 0) revents |= POLLIN; + // A finite input source has no future writers. Buffered bytes and HUP can + // coexist; once the bytes drain, HUP alone makes read/select observe EOF. + if (this.stdinFinite.has(pid)) revents |= POLLHUP; + return revents; + } + /** * Set stdout/stderr capture callbacks on the underlying kernel instance. * Must be called after construction but works at any time. @@ -2387,6 +2410,13 @@ export class CentralizedKernelWorker { } } + if ( + (syscallNr === SYS_POLL || syscallNr === SYS_PPOLL) + && channel.pollTimeoutOverride !== undefined + ) { + adjustedArgs[2] = channel.pollTimeoutOverride; + } + // Write adjusted args to kernel scratch kernelView.setUint32(CH_SYSCALL, syscallNr, true); for (let i = 0; i < CH_ARGS_COUNT; i++) { @@ -2649,6 +2679,10 @@ export class CentralizedKernelWorker { retVal: number, errVal: number, ): void { + if (syscallNr === SYS_POLL || syscallNr === SYS_PPOLL) { + channel.pollDeadline = undefined; + channel.pollTimeoutOverride = undefined; + } const processView = new DataView(channel.memory.buffer, channel.channelOffset); // Copy output data from kernel scratch back to process memory @@ -2975,6 +3009,8 @@ export class CentralizedKernelWorker { * Used for thread exit where we need to unblock the worker. */ private completeChannelRaw(channel: ChannelInfo, retVal: number, errVal: number): void { + channel.pollDeadline = undefined; + channel.pollTimeoutOverride = undefined; // Clear handling flag (channel is done — poller can pick it up for next syscall) channel.handling = false; @@ -3397,9 +3433,9 @@ export class CentralizedKernelWorker { // Re-dispatch to the right handler — SYS_SELECT and SYS_PSELECT6 have // different time-struct shapes (timeval vs timespec). if (entry.syscallNr === SYS_SELECT) { - this.handleSelect(entry.channel, entry.origArgs); + this.handleSelect(entry.channel, entry.origArgs, entry.deadline); } else { - this.handlePselect6(entry.channel, entry.origArgs); + this.handlePselect6(entry.channel, entry.origArgs, entry.deadline); } } @@ -3691,6 +3727,13 @@ export class CentralizedKernelWorker { } } + /** Finish an expired poll/ppoll through the kernel so it clears revents + * and restores any temporary ppoll signal mask before host completion. */ + private finalizePollTimeout(channel: ChannelInfo): void { + channel.pollTimeoutOverride = 0; + this.retrySyscall(channel); + } + private handleBlockingRetry( channel: ChannelInfo, syscallNr: number, @@ -3754,7 +3797,20 @@ export class CentralizedKernelWorker { } } if (timeoutMs === 0) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], 0, 0); + this.finalizePollTimeout(channel); + return; + } + + // Keep one absolute deadline for the entire blocked syscall. Safety + // timers and readiness wakes re-enter handleSyscall with the original + // timeout argument still in the channel; deriving a fresh deadline on + // each entry would make a quiet finite poll wait forever. + const now = Date.now(); + const deadline = channel.pollDeadline + ?? (timeoutMs > 0 ? now + timeoutMs : -1); + channel.pollDeadline = deadline; + if (deadline > 0 && now >= deadline) { + this.finalizePollTimeout(channel); return; } @@ -3762,36 +3818,35 @@ export class CentralizedKernelWorker { const { pipeIndices, acceptIndices } = this.resolvePollReadinessIndices(channel.pid, origArgs); - // For finite timeout, track the deadline so we return 0 (timeout) when it - // expires instead of retrying forever. The nfds=0 case (pure sleep) is - // optimized to skip retries entirely — just wait for the deadline. + // The nfds=0 case (pure sleep) is optimized to skip safety retries and + // wait for the remaining time to the same absolute deadline. const nfds = origArgs[1]; // poll(fds, nfds, ...) / ppoll(fds, nfds, ...) if (timeoutMs > 0 && nfds === 0) { // Pure sleep: no fds to poll, just wait for timeout + const remainingMs = Math.max(1, deadline - Date.now()); const timer = setTimeout(() => { this.pendingPollRetries.delete(channel.channelOffset); if (this.processes.has(channel.pid)) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], 0, 0); + this.finalizePollTimeout(channel); } - }, timeoutMs); + }, remainingMs); this.pendingPollRetries.set(channel.channelOffset, { timer, channel, pipeIndices, acceptIndices, needsSignalSafeWake, - deadline: Date.now() + timeoutMs, + deadline, }); return; } - const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : -1; const retryFn = () => { this.pendingPollRetries.delete(channel.channelOffset); if (!this.processes.has(channel.pid)) return; // Check deadline for finite timeout if (deadline > 0 && Date.now() >= deadline) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], 0, 0); + this.finalizePollTimeout(channel); return; } this.retrySyscall(channel); @@ -4222,7 +4277,11 @@ export class CentralizedKernelWorker { * own code is `select(0, NULL, NULL, NULL, &tv)` (mysys/my_sleep.c) — the * pure-sleep case, fast-path'd to a setTimeout. */ - private handleSelect(channel: ChannelInfo, origArgs: number[]): void { + private handleSelect( + channel: ChannelInfo, + origArgs: number[], + existingDeadline?: number, + ): void { const FD_SET_SIZE = 128; const nfds = origArgs[0]; const readPtr = origArgs[1]; @@ -4246,6 +4305,15 @@ export class CentralizedKernelWorker { if (timeoutMs < 0) timeoutMs = 0; } + const deadline = existingDeadline + ?? (timeoutMs > 0 ? Date.now() + timeoutMs : -1); + const deadlineExpired = existingDeadline !== undefined + && deadline > 0 + && Date.now() >= deadline; + const remainingTimeoutMs = deadline > 0 + ? Math.max(deadline - Date.now(), 0) + : timeoutMs; + // Pure-sleep fast path: select(0, NULL, NULL, NULL, &tv) is `my_sleep`. // The kernel can't tell us anything new — there are no fds to poll — // so we just wait the timeout and return 0. Tracked in @@ -4253,24 +4321,24 @@ export class CentralizedKernelWorker { // (handleKill -> scheduleWakeBlockedRetries -> wakeAllBlockedRetries // already iterates pendingSelectRetries entries). if (nfds === 0 && readPtr === 0 && writePtr === 0 && exceptPtr === 0) { - if (timeoutMs === 0) { + if (timeoutMs === 0 || deadlineExpired) { this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); return; } - const finite = timeoutMs > 0; + const finite = deadline > 0; const timer = finite ? setTimeout(() => { this.pendingSelectRetries.delete(channel.channelOffset); if (this.processes.has(channel.pid)) { this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); } - }, timeoutMs) + }, remainingTimeoutMs) : (null as any); this.pendingSelectRetries.set(channel.channelOffset, { timer, channel, origArgs, - deadline: finite ? Date.now() + timeoutMs : -1, + deadline, needsSignalSafeWake: false, syscallNr: SYS_SELECT, }); @@ -4307,7 +4375,7 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(readPtr !== 0 ? dataStart : 0), true); kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(writePtr !== 0 ? dataStart + FD_SET_SIZE : 0), true); kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(exceptPtr !== 0 ? dataStart + 2 * FD_SET_SIZE : 0), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(timeoutMs), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(remainingTimeoutMs), true); const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; @@ -4350,17 +4418,12 @@ export class CentralizedKernelWorker { this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); return; } - const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : -1; const retryFn = () => { this.pendingSelectRetries.delete(channel.channelOffset); if (!this.processes.has(channel.pid)) return; - if (deadline > 0 && Date.now() >= deadline) { - this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); - return; - } - this.handleSelect(channel, origArgs); + this.handleSelect(channel, origArgs, deadline); }; - const finite = timeoutMs > 0; + const finite = deadline > 0; const remainingMs = finite ? Math.max(deadline - Date.now(), 1) : 50; const timer = setTimeout(retryFn, Math.min(remainingMs, 50)); this.pendingSelectRetries.set(channel.channelOffset, { @@ -4373,7 +4436,11 @@ export class CentralizedKernelWorker { this.completeChannel(channel, SYS_SELECT, origArgs, undefined, retVal, errVal); } - private handlePselect6(channel: ChannelInfo, origArgs: number[]): void { + private handlePselect6( + channel: ChannelInfo, + origArgs: number[], + existingDeadline?: number, + ): void { const FD_SET_SIZE = 128; const processMem = new Uint8Array(channel.memory.buffer); const kernelMem = this.getKernelMem(); @@ -4413,6 +4480,12 @@ export class CentralizedKernelWorker { timeoutMs = sec * 1000 + Math.floor(nsec / 1000000); } + const deadline = existingDeadline + ?? (timeoutMs > 0 ? Date.now() + timeoutMs : -1); + const remainingTimeoutMs = deadline > 0 + ? Math.max(deadline - Date.now(), 0) + : timeoutMs; + // Decode sigmask: pselect6 arg6 → pointer to {sigset_t *mask, size_t size} // On wasm32: {u32 mask_ptr, u32 size} = 8 bytes // On wasm64: {u64 mask_ptr, u64 size} = 16 bytes @@ -4449,7 +4522,7 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(readPtr !== 0 ? dataStart : 0), true); kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(writePtr !== 0 ? dataStart + FD_SET_SIZE : 0), true); kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(exceptPtr !== 0 ? dataStart + 2 * FD_SET_SIZE : 0), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(timeoutMs), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(remainingTimeoutMs), true); kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(kernelMaskPtr), true); const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as @@ -4497,7 +4570,6 @@ export class CentralizedKernelWorker { return; } - const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : -1; // pselect6 with a non-null sigmask pointer has the same late-signal // race as ppoll. See scheduleWakeBlockedRetriesDeferred. const needsSignalSafeWake = maskDataPtr !== 0; @@ -4510,9 +4582,9 @@ export class CentralizedKernelWorker { const timer = setTimeout(() => { this.pendingSelectRetries.delete(channel.channelOffset); if (this.processes.has(channel.pid)) { - this.completeChannel(channel, SYS_PSELECT6, origArgs, undefined, 0, 0); + this.handlePselect6(channel, origArgs, deadline); } - }, timeoutMs); + }, Math.max(deadline - Date.now(), 0)); this.pendingSelectRetries.set(channel.channelOffset, { timer, channel, origArgs, deadline, needsSignalSafeWake, syscallNr: SYS_PSELECT6, }); @@ -4531,13 +4603,10 @@ export class CentralizedKernelWorker { const retryFn = () => { this.pendingSelectRetries.delete(channel.channelOffset); if (!this.processes.has(channel.pid)) return; - if (deadline > 0 && Date.now() >= deadline) { - this.completeChannel(channel, SYS_PSELECT6, origArgs, undefined, 0, 0); - return; - } - this.handlePselect6(channel, origArgs); + this.handlePselect6(channel, origArgs, deadline); }; - const timer = setImmediate(retryFn); + const remainingMs = deadline > 0 ? Math.max(deadline - Date.now(), 1) : 50; + const timer = setTimeout(retryFn, Math.min(remainingMs, 50)); this.pendingSelectRetries.set(channel.channelOffset, { timer, channel, origArgs, deadline, needsSignalSafeWake, syscallNr: SYS_PSELECT6, }); @@ -7110,9 +7179,9 @@ export class CentralizedKernelWorker { this.pendingSelectRetries.delete(key); if (!this.processes.has(targetPid)) continue; if (selectEntry.syscallNr === SYS_SELECT) { - this.handleSelect(selectEntry.channel, selectEntry.origArgs); + this.handleSelect(selectEntry.channel, selectEntry.origArgs, selectEntry.deadline); } else { - this.handlePselect6(selectEntry.channel, selectEntry.origArgs); + this.handlePselect6(selectEntry.channel, selectEntry.origArgs, selectEntry.deadline); } } } diff --git a/host/src/kernel.ts b/host/src/kernel.ts index 19fb9f319f..f8335adf51 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -171,8 +171,10 @@ export interface KernelCallbacks { onUdpUnbind?: (handle: number) => number; onStdout?: (data: Uint8Array) => void; onStderr?: (data: Uint8Array) => void; - /** Read up to maxLen bytes from stdin. Return a Uint8Array with available data, or empty/null for EOF. */ + /** Read up to maxLen bytes from stdin. Empty means not ready; null means EOF. */ onStdin?: (maxLen: number) => Uint8Array | null; + /** Return poll(2) revents for the current process's captured stdin. */ + onStdinPoll?: (events: number) => number; /** * Resolve the wasm `Memory` for `pid`. The GL bridge reads cmdbuf bytes * directly out of the process's Memory SAB on `host_gl_submit` and @@ -1108,9 +1110,13 @@ export class WasmPosixKernel { // Check shared pipe registry const readEntry = this.sharedPipes.get(h); if (readEntry) { + if (readEntry.end !== "read") return -9; // -EBADF const mem = this.getMemoryBuffer(); const dst = new Uint8Array(mem.buffer, bufPtr, bufLen); - return readEntry.pipe.read(dst); + const n = readEntry.pipe.read(dst); + if (n > 0 || bufLen === 0) return n; + // Empty with a live writer is a blocking condition, not EOF. + return readEntry.pipe.isWriteOpen() ? -11 : 0; } // stdin @@ -1152,7 +1158,18 @@ export class WasmPosixKernel { // Check shared pipe registry const writeEntry = this.sharedPipes.get(h); if (writeEntry) { - return writeEntry.pipe.write(data); + const PIPE_BUF = 4096; + if (writeEntry.end !== "write") return -9; // -EBADF + if (!writeEntry.pipe.isReadOpen()) return -32; // -EPIPE + const free = writeEntry.pipe.capacity() - writeEntry.pipe.available(); + // POSIX requires writes up to PIPE_BUF to be atomic. Do not let the + // ring's partial-write primitive expose a short write for that range. + if (bufLen <= PIPE_BUF && free < bufLen) return -11; // -EAGAIN + const n = writeEntry.pipe.write(data); + if (n > 0 || bufLen === 0) return n; + // A full pipe blocks while its reader remains open. Re-check the read + // end after the write attempt so a concurrent close reports EPIPE. + return writeEntry.pipe.isReadOpen() ? -11 : -32; } // stdout / stderr — callback → process → console fallback chain @@ -2380,6 +2397,11 @@ export class WasmPosixKernel { } private hostNetPoll(handle: number, events: number): number { + // The Rust Wasm adapter tags delegated file descriptors as bitwise- + // complemented (negative) handles. Real network handles remain + // nonnegative, including network handle 0. + if (handle < 0) return this.hostFdPoll(~handle, events); + const POLLIN = 0x0001; const POLLOUT = 0x0004; if (!this.io.network) return -107; // -ENOTCONN @@ -2394,6 +2416,46 @@ export class WasmPosixKernel { } } + private hostFdPoll(handle: number, events: number): number { + const POLLIN = 0x0001; + const POLLOUT = 0x0004; + const POLLERR = 0x0008; + const POLLHUP = 0x0010; + const POLLNVAL = 0x0020; + + const pipeEntry = this.sharedPipes.get(handle); + if (pipeEntry) { + if (pipeEntry.end === "read") { + let revents = 0; + if ((events & POLLIN) !== 0 && pipeEntry.pipe.available() > 0) { + revents |= POLLIN; + } + // EOF is readable and poll reports HUP even when it was not requested. + if (!pipeEntry.pipe.isWriteOpen()) revents |= POLLHUP; + return revents; + } + + if (!pipeEntry.pipe.isReadOpen()) return POLLERR; + return (events & POLLOUT) !== 0 + && pipeEntry.pipe.available() < pipeEntry.pipe.capacity() + ? POLLOUT + : 0; + } + + // Captured stdio is represented as host-delegated pipes in the kernel. + // Stdin readiness is per-process, so the kernel worker supplies it while + // currentHandlePid identifies the process whose syscall is in flight. + if (handle === 0) { + if (!this.callbacks.onStdinPoll) return POLLHUP; + return this.callbacks.onStdinPoll(events) + & (POLLIN | POLLOUT | POLLERR | POLLHUP | POLLNVAL); + } + if (handle === 1 || handle === 2) { + return events & POLLOUT; + } + return -9; // -EBADF: the kernel fd outlived its host-owned resource + } + private hostNetClose(handle: number): number { if (!this.io.network) return 0; try { diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 42d00b517b..558d8945b7 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -51,6 +51,7 @@ import { SIGSEGV, } from "./trap-signals"; import { threadWorkerFailureDisposition } from "./thread-worker-disposition"; +import { reapHostOwnedExitedProcess } from "./host-owned-process-reap"; import { computeProcessMemoryLayout, createProcessMemory, @@ -224,6 +225,7 @@ async function finalizeProcessWorker( ): Promise { const cur = processes.get(pid); if (cur && cur.worker === worker) { + let deactivated = false; // 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). @@ -232,12 +234,18 @@ async function finalizeProcessWorker( // 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 */ } + try { + kernelWorker.deactivateProcess(pid); + deactivated = true; + } catch { /* best-effort */ } processes.delete(pid); threadModuleCache.delete(pid); ptyByPid.delete(pid); await terminateThreadWorkers(pid); await terminateTrackedWorker(worker); + if (deactivated) { + reapHostOwnedExitedProcess(kernelWorker.getKernelInstance(), pid); + } } reportProcessExit(pid, exitStatus); } @@ -1178,6 +1186,7 @@ async function finishProcessExit(pid: number, exitStatus: number): Promise // Deactivate process (zombie until reaped or destroy) after worker // termination so no further guest syscalls can arrive on its channel. kernelWorker.deactivateProcess(pid); + reapHostOwnedExitedProcess(kernelWorker.getKernelInstance(), pid); processes.delete(pid); threadModuleCache.delete(pid); diff --git a/host/test/browser-kernel.test.ts b/host/test/browser-kernel.test.ts index 178cfe1c25..98258f5b58 100644 --- a/host/test/browser-kernel.test.ts +++ b/host/test/browser-kernel.test.ts @@ -147,6 +147,70 @@ describe("BrowserKernel", () => { expect(await exit).toBe(7); }); + describe("spawnFromVfs stdin", () => { + async function bootedKernel() { + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ kernelOwnedFs: true }); + const bootPromise = kernel.boot({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + argv: ["/init"], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const worker = MockWorker.instances[0]!; + worker.simulateMessage({ type: "ready" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const initSpawn = worker.lastMessage("spawn"); + worker.simulateMessage({ + type: "response", + requestId: initSpawn.requestId, + result: 100, + }); + await bootPromise; + return { kernel, worker }; + } + + async function spawnAndCapture( + options?: Parameters< + Awaited>["kernel"]["spawnFromVfs"] + >[2], + ) { + const { kernel, worker } = await bootedKernel(); + const processPromise = kernel.spawnFromVfs("/usr/local/bin/tool", ["tool"], options); + await new Promise((resolve) => setTimeout(resolve, 0)); + const spawn = worker.lastMessage("spawn"); + worker.simulateMessage({ + type: "response", + requestId: spawn.requestId, + result: 101, + }); + await processPromise; + return spawn; + } + + it("sends immediate EOF when non-PTY stdin is omitted", async () => { + const spawn = await spawnAndCapture(); + + expect(spawn.stdin).toBeInstanceOf(Uint8Array); + expect(spawn.stdin).toHaveLength(0); + }); + + it("preserves an explicit finite stdin buffer", async () => { + const stdin = new Uint8Array([1, 2, 3]); + const spawn = await spawnAndCapture({ stdin }); + + expect(spawn.stdin).toEqual(stdin); + }); + + it("leaves omitted PTY stdin open", async () => { + const spawn = await spawnAndCapture({ pty: true }); + + expect(spawn.pty).toBe(true); + expect(spawn.stdin).toBeUndefined(); + }); + }); + it("readFileFromVfs round-trips a path to the worker and back", async () => { const BrowserKernel = await loadBrowserKernel(); const kernel = new BrowserKernel({ kernelOwnedFs: true }); diff --git a/host/test/centralized-spawn.test.ts b/host/test/centralized-spawn.test.ts index ee078c5b92..50c3161697 100644 --- a/host/test/centralized-spawn.test.ts +++ b/host/test/centralized-spawn.test.ts @@ -1,10 +1,11 @@ /** - * Non-forking posix_spawn — basic flow + fork-counter regression guardrail. + * Non-forking posix_spawn — Node host end-to-end flow. * - * The guardrail is the load-bearing assertion: SYS_SPAWN must NOT bump - * the parent's `fork_count`. If it does, the spawn path is silently - * falling back to `kernel_fork_process` (which does bump the counter) - * and the whole "non-forking" claim of this PR is wrong. + * The authoritative fork-counter invariant is covered while the parent is + * live by the Rust `spawn_child_basic_inherits_cwd_and_returns_pid` test. + * Querying that counter after this helper resolves is invalid: top-level + * host processes are reaped when their exit status is consumed. The source + * parity test separately pins both Node and browser `onSpawn` wiring. * * Companion smoke C program: `examples/spawn-smoke.c`. */ @@ -25,7 +26,7 @@ const spawnCoverageWasm = join(repoRoot, "examples", "spawn-coverage.wasm"); const spawnPauseWasm = join(repoRoot, "examples", "spawn-pause.wasm"); describe("non-forking posix_spawn", () => { - it("runs spawn-smoke and the parent's fork_count stays 0", async () => { + it("runs spawn-smoke through the Node host", async () => { // Spawn a child program that lives in examples/ — keeps the test free // of the binaries-cache fetch. spawn-smoke takes the child path as // argv[1] and just exec-equivalents it via posix_spawn + waitpid. @@ -37,7 +38,6 @@ describe("non-forking posix_spawn", () => { ]), useDefaultRootfs: false, timeout: 30_000, - captureForkCount: true, }); expect(result.exitCode).toBe(0); @@ -45,9 +45,6 @@ describe("non-forking posix_spawn", () => { expect(result.stdout).toContain("OK"); // The spawn child is hello.wasm, which prints its greeting. expect(result.stdout).toContain("Hello from musl"); - // GUARDRAIL: spawn must not increment the parent's fork counter. - // A non-zero value here means SYS_SPAWN silently fell back to fork. - expect(result.forkCount).toBe(0n); }); it("covers spawnp / file actions / SETPGROUP", async () => { @@ -64,7 +61,6 @@ describe("non-forking posix_spawn", () => { ]), useDefaultRootfs: false, timeout: 60_000, - captureForkCount: true, }); expect(result.exitCode, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0); @@ -72,8 +68,6 @@ describe("non-forking posix_spawn", () => { expect(result.stdout, `missing 'OK ${subtest}' in stdout`).toContain(`OK ${subtest}`); } expect(result.stdout).toContain("ALL OK"); - // GUARDRAIL: three posix_spawn calls and zero fork bumps. - expect(result.forkCount).toBe(0n); }); it("reports ENOEXEC for non-Wasm spawn targets before launching a worker", async () => { diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index 5aefc85573..448904617e 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -130,12 +130,6 @@ export interface RunProgramOptions { /** Callback invoked after the process starts. * Use this to call appendStdinData() for interactive stdin testing. */ onStarted?: (kernelProxy: KernelStdinProxy, pid: number) => void | Promise; - /** If `true`, the helper queries `kernel_get_fork_count(pid)` after the - * program exits and surfaces the value on `RunProgramResult.forkCount`. - * Used by the non-forking-spawn regression tests. Worker-thread mode - * only (NodeKernelHost.getForkCount); main-thread mode falls back to - * reading from the kernel instance directly. */ - captureForkCount?: boolean; /** Use the canonical rootfs image in worker-thread mode. Defaults to true. */ useDefaultRootfs?: boolean; } @@ -146,10 +140,6 @@ export interface RunProgramResult { stderr: string; /** Raw stdout bytes (for binary output like compressed data) */ stdoutBytes: Uint8Array; - /** Per-process fork counter for the spawned process, captured immediately - * before the kernel is destroyed. Only populated when - * `captureForkCount: true` is set on the run options. */ - forkCount?: bigint; } /** @@ -219,11 +209,7 @@ async function runInWorkerThread(options: RunProgramOptions): Promise { - capturedPid = pid; if (!options.onStarted) return; const proxy: KernelStdinProxy = { appendStdinData(stdinPid: number, data: Uint8Array) { @@ -250,12 +236,8 @@ async function runInWorkerThread(options: RunProgramOptions): Promise {}); @@ -269,7 +251,7 @@ async function runInWorkerThread(options: RunProgramOptions): Promise(), + stdinFinite: new Set(), + }); +} + +function createKernelHarness(options: { + onStdinPoll?: (events: number) => number; + networkPoll?: (handle: number, events: number) => number; +} = {}): any { + const networkPoll = options.networkPoll ?? vi.fn(() => 0); + return Object.assign(Object.create(WasmPosixKernel.prototype), { + callbacks: { onStdinPoll: options.onStdinPoll }, + io: { network: { poll: networkPoll } }, + memory: new WebAssembly.Memory({ initial: 1 }), + sharedPipes: new Map(), + }); +} + +describe("host-delegated pipe readiness", () => { + it("derives open-empty, buffered, and EOF stdin readiness per process", () => { + const worker = createStdinHarness(); + const pid = 7; + + expect(worker.stdinPollEvents(pid, POLLIN)).toBe(0); + + worker.stdinBuffers.set(pid, { + data: new Uint8Array([1, 2, 3]), + offset: 1, + }); + expect(worker.stdinPollEvents(pid, POLLIN)).toBe(POLLIN); + + worker.stdinFinite.add(pid); + expect(worker.stdinPollEvents(pid, POLLIN)).toBe(POLLIN | POLLHUP); + + worker.stdinBuffers.delete(pid); + expect(worker.stdinPollEvents(pid, POLLIN)).toBe(POLLHUP); + }); + + it("keeps ordinary network handle 0 separate from tagged stdin fd 0", () => { + const onStdinPoll = vi.fn(() => POLLHUP); + const networkPoll = vi.fn(() => POLLIN); + const kernel = createKernelHarness({ onStdinPoll, networkPoll }); + + expect(kernel.hostNetPoll(0, POLLIN)).toBe(POLLIN); + expect(networkPoll).toHaveBeenCalledWith(0, POLLIN); + expect(onStdinPoll).not.toHaveBeenCalled(); + + // WasmHostIO encodes delegated fd N as ~N on the existing poll import. + expect(kernel.hostNetPoll(~0, POLLIN)).toBe(POLLHUP); + expect(onStdinPoll).toHaveBeenCalledWith(POLLIN); + expect(networkPoll).toHaveBeenCalledTimes(1); + }); + + it("reports SharedPipeBuffer data, EOF, capacity, and peer closure", () => { + const kernel = createKernelHarness(); + const pipe = SharedPipeBuffer.create(4); + kernel.sharedPipes.set(20, { pipe, end: "read" }); + + expect(kernel.hostFdPoll(20, POLLIN)).toBe(0); + pipe.write(new Uint8Array([1, 2, 3, 4])); + expect(kernel.hostFdPoll(20, POLLIN)).toBe(POLLIN); + pipe.closeWrite(); + expect(kernel.hostFdPoll(20, POLLIN)).toBe(POLLIN | POLLHUP); + + kernel.sharedPipes.set(21, { pipe, end: "write" }); + expect(kernel.hostFdPoll(21, POLLOUT)).toBe(0); + const drained = new Uint8Array(1); + pipe.read(drained); + expect(kernel.hostFdPoll(21, POLLOUT)).toBe(POLLOUT); + pipe.closeRead(); + expect(kernel.hostFdPoll(21, POLLOUT)).toBe(POLLERR); + }); + + it("returns EAGAIN rather than EOF for an open empty shared pipe", () => { + const kernel = createKernelHarness(); + const pipe = SharedPipeBuffer.create(4); + kernel.sharedPipes.set(20, { pipe, end: "read" }); + + expect(kernel.hostRead(20n, 0, 4)).toBe(-11); + pipe.write(new Uint8Array([9])); + expect(kernel.hostRead(20n, 0, 4)).toBe(1); + pipe.closeWrite(); + expect(kernel.hostRead(20n, 0, 4)).toBe(0); + }); + + it("returns EAGAIN for a full shared pipe and EPIPE after reader closure", () => { + const kernel = createKernelHarness(); + const pipe = SharedPipeBuffer.create(2); + kernel.sharedPipes.set(21, { pipe, end: "write" }); + + pipe.write(new Uint8Array([1, 2])); + expect(kernel.hostWrite(21n, 0, 1)).toBe(-11); + const drained = new Uint8Array(1); + pipe.read(drained); + expect(kernel.hostWrite(21n, 0, 1)).toBe(1); + pipe.closeRead(); + expect(kernel.hostWrite(21n, 0, 1)).toBe(-32); + }); + + it("does not partially write data at or below PIPE_BUF", () => { + const kernel = createKernelHarness(); + const pipe = SharedPipeBuffer.create(4); + kernel.sharedPipes.set(21, { pipe, end: "write" }); + pipe.write(new Uint8Array([1, 2, 3])); + + expect(kernel.hostWrite(21n, 0, 2)).toBe(-11); + expect(pipe.available()).toBe(3); + }); +}); diff --git a/host/test/host-owned-process-reap.test.ts b/host/test/host-owned-process-reap.test.ts new file mode 100644 index 0000000000..4cc0fab60d --- /dev/null +++ b/host/test/host-owned-process-reap.test.ts @@ -0,0 +1,72 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; +import { reapHostOwnedExitedProcess } from "../src/host-owned-process-reap"; +import { NodeKernelHost } from "../src/node-kernel-host"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const helloWasm = join(__dirname, "../../examples/hello.wasm"); + +function kernelInstanceWithReaper( + reaper: (parentPid: number, childPid: number) => number, +) { + return { exports: { kernel_reap_exited_child: reaper } } as unknown as WebAssembly.Instance; +} + +function loadProgramBytes(path: string): ArrayBuffer { + const bytes = readFileSync(path); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} + +describe("host-owned exited-process reaping", () => { + it("asks Rust to reap only a ppid=0 child", () => { + const reapExitedChild = vi.fn(() => 0); + + expect( + reapHostOwnedExitedProcess(kernelInstanceWithReaper(reapExitedChild), 42), + ).toBe(true); + expect(reapExitedChild).toHaveBeenCalledOnce(); + expect(reapExitedChild).toHaveBeenCalledWith(0, 42); + }); + + it("leaves guest-owned children for wait/waitpid when Rust returns ECHILD", () => { + const reapExitedChild = vi.fn(() => -10); + + expect( + reapHostOwnedExitedProcess(kernelInstanceWithReaper(reapExitedChild), 42), + ).toBe(false); + expect(reapExitedChild).toHaveBeenCalledWith(0, 42); + }); + + it.skipIf(!existsSync(helloWasm))( + "removes a completed top-level Node process from the authoritative process table", + async () => { + const host = new NodeKernelHost(); + let pid: number | undefined; + + await host.init(); + try { + const status = await host.spawn(loadProgramBytes(helloWasm), ["hello"], { + onStarted(startedPid) { + pid = startedPid; + }, + }); + + expect(status).toBe(0); + expect(pid).toBeDefined(); + // enumProcs() intentionally filters Exited entries, so it cannot + // distinguish a retained zombie from a reaped process. Proc maps stay + // addressable while the Rust Process entry exists and become null only + // after the ppid=0 child has actually been reaped. + await expect.poll( + async () => host.readProcMaps(pid!), + { timeout: 5_000, interval: 10 }, + ).toBeNull(); + } finally { + await host.destroy(); + } + }, + 10_000, + ); +}); diff --git a/host/test/poll-deadline.test.ts b/host/test/poll-deadline.test.ts new file mode 100644 index 0000000000..b82dba87c2 --- /dev/null +++ b/host/test/poll-deadline.test.ts @@ -0,0 +1,309 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ABI_SYSCALLS, + CH_ARGS, + CH_ARG_SIZE, + CH_ERRNO, + CH_RETURN, + CH_SYSCALL, +} from "../src/generated/abi"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +const PID = 100; +const EAGAIN = 11; + +function makeChannel(): any { + return { + pid: PID, + memory: new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }), + channelOffset: 0, + i32View: new Int32Array(new SharedArrayBuffer(4)), + consecutiveSyscalls: 0, + }; +} + +function makeWorker(pipeIndices: number[] = [7]): any { + return Object.assign(Object.create(CentralizedKernelWorker.prototype), { + processes: new Map([[PID, {}]]), + pendingPollRetries: new Map(), + pendingSelectRetries: new Map(), + pendingPipeReaders: new Map(), + pendingPipeWriters: new Map(), + resolvePollReadinessIndices: vi.fn(() => ({ + pipeIndices, + acceptIndices: [], + })), + kernelMemory: new WebAssembly.Memory({ initial: 1 }), + scratchOffset: 1024, + cachedKernelMem: null, + cachedKernelBuffer: null, + clearSocketTimeout: vi.fn(), + drainAllPtyOutputs: vi.fn(), + flushTcpSendPipes: vi.fn(), + drainAndProcessWakeupEvents: vi.fn(), + relistenChannel: vi.fn(), + }); +} + +function pollArgs(_channel: any, timeoutMs: number): number[] { + return [256, 1, timeoutMs, 0, 0, 0]; +} + +function ppollArgs(channel: any, timeoutMs: number): number[] { + const timeoutPtr = 512; + const view = new DataView(channel.memory.buffer); + view.setBigInt64(timeoutPtr, BigInt(Math.floor(timeoutMs / 1000)), true); + view.setBigInt64(timeoutPtr + 8, BigInt(timeoutMs % 1000) * 1_000_000n, true); + return [256, 1, timeoutPtr, 0, 8, 0]; +} + +function completeFinalPollRetry( + worker: any, + channel: any, + syscallNr: number, + args: number[], +): void { + if (channel.pollTimeoutOverride !== 0) { + worker.handleBlockingRetry(channel, syscallNr, args); + return; + } + worker.completeChannel(channel, syscallNr, args, undefined, 0, 0); +} + +describe("CentralizedKernelWorker finite poll deadlines", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([ + ["poll", ABI_SYSCALLS.Poll, pollArgs], + ["ppoll", ABI_SYSCALLS.Ppoll, ppollArgs], + ] as const)("times out %s at its original deadline across safety retries", async ( + _name, + syscallNr, + makeArgs, + ) => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const worker = makeWorker(); + const channel = makeChannel(); + const args = makeArgs(channel, 25) as number[]; + const complete = vi.spyOn(worker, "completeChannel"); + worker.retrySyscall = vi.fn((retryChannel: any) => { + completeFinalPollRetry(worker, retryChannel, syscallNr, args); + }); + + worker.handleBlockingRetry(channel, syscallNr, args); + + await vi.advanceTimersByTimeAsync(24); + expect(complete).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + expect(complete).toHaveBeenCalledTimes(1); + expect(channel.pollDeadline).toBeUndefined(); + expect(channel.pollTimeoutOverride).toBeUndefined(); + }); + + it("does not extend an nfds=0 deadline across broad wakes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(2_000); + const worker = makeWorker([]); + const channel = makeChannel(); + const args = [0, 0, 30, 0, 0, 0]; + const complete = vi.spyOn(worker, "completeChannel"); + worker.retrySyscall = vi.fn((retryChannel: any) => { + completeFinalPollRetry(worker, retryChannel, ABI_SYSCALLS.Poll, args); + }); + + worker.handleBlockingRetry(channel, ABI_SYSCALLS.Poll, args); + await vi.advanceTimersByTimeAsync(5); + worker.wakeAllBlockedRetries(); + await vi.advanceTimersByTimeAsync(7); + worker.wakeAllBlockedRetries(); + await vi.advanceTimersByTimeAsync(17); + expect(complete).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + expect(complete).toHaveBeenCalledTimes(1); + expect(channel.pollDeadline).toBeUndefined(); + expect(channel.pollTimeoutOverride).toBeUndefined(); + }); + + it("cancels the timeout when targeted readiness arrives before the deadline", async () => { + vi.useFakeTimers(); + vi.setSystemTime(3_000); + const worker = makeWorker([7]); + const channel = makeChannel(); + const args = pollArgs(channel, 40); + const complete = vi.spyOn(worker, "completeChannel"); + worker.retrySyscall = vi.fn((retryChannel: any) => { + expect(retryChannel.pollDeadline).toBe(3_040); + worker.completeChannel( + retryChannel, + ABI_SYSCALLS.Poll, + args, + undefined, + 1, + 0, + ); + }); + + worker.handleBlockingRetry(channel, ABI_SYSCALLS.Poll, args); + await vi.advanceTimersByTimeAsync(7); + worker.wakeBlockedPoll(PID, 7); + + expect(complete).toHaveBeenCalledTimes(1); + expect(channel.pollDeadline).toBeUndefined(); + expect(channel.pollTimeoutOverride).toBeUndefined(); + expect(worker.pendingPollRetries.size).toBe(0); + + await vi.advanceTimersByTimeAsync(100); + expect(complete).toHaveBeenCalledTimes(1); + }); + + it("finalizes ppoll in the kernel with timeout zero and copies cleared revents", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + + const processMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + const kernelMemory = new WebAssembly.Memory({ initial: 2 }); + const channelOffset = 65_536; + const channel: any = { + pid: PID, + memory: processMemory, + channelOffset, + i32View: new Int32Array(processMemory.buffer, channelOffset), + consecutiveSyscalls: 0, + }; + const originalMask = 0x20n; + const temporaryMask = 0x400n; + let activeMask = originalMask; + let savedMask: bigint | null = null; + const timeoutArgs: number[] = []; + + const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + kernel: { toKernelPtr: (value: number | bigint) => value }, + kernelMemory, + scratchOffset: 0, + cachedKernelMem: null, + cachedKernelBuffer: null, + currentHandlePid: 0, + channelTids: new Map(), + processes: new Map([[PID, { + pid: PID, + ptrWidth: 4, + channels: [channel], + explicitMaxAddr: true, + }]]), + pendingPollRetries: new Map(), + pendingSelectRetries: new Map(), + pendingPipeReaders: new Map(), + pendingPipeWriters: new Map(), + pendingCancels: new Set(), + pendingSleeps: new Map(), + sharedMappings: new Map(), + syscallRing: new Map(), + syscallTraceEnabled: false, + syscallTraceRing: [], + config: {}, + usePolling: false, + schedulingDeferredChannels: new Set(), + schedulingDeferredRelistens: new Set(), + resolvePollReadinessIndices: vi.fn(() => ({ + pipeIndices: [], + acceptIndices: [], + })), + clearSocketTimeout: vi.fn(), + drainAllPtyOutputs: vi.fn(), + flushTcpSendPipes: vi.fn(), + drainAndProcessWakeupEvents: vi.fn(), + relistenChannel: vi.fn(), + }); + + worker.kernelInstance = { + exports: { + kernel_set_current_tid: () => {}, + kernel_handle_channel: () => { + const view = new DataView(kernelMemory.buffer); + const timeoutMs = Number( + view.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + ); + timeoutArgs.push(timeoutMs); + + const hasMask = Number( + view.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), + ); + if (hasMask !== 0 && savedMask === null) { + const lo = BigInt(Number( + view.getBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, true), + ) >>> 0); + const hi = BigInt(Number( + view.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), + ) >>> 0); + savedMask = activeMask; + activeMask = lo | (hi << 32n); + } + + const fdsPtr = Number(view.getBigInt64(CH_ARGS, true)); + new DataView(kernelMemory.buffer).setInt16(fdsPtr + 6, 0, true); + if (timeoutMs === 0) { + if (savedMask !== null) { + activeMask = savedMask; + savedMask = null; + } + view.setBigInt64(CH_RETURN, 0n, true); + view.setUint32(CH_ERRNO, 0, true); + } else { + view.setBigInt64(CH_RETURN, -1n, true); + view.setUint32(CH_ERRNO, EAGAIN, true); + } + return 0; + }, + }, + }; + + const pollfdPtr = 256; + const timespecPtr = 512; + const maskPtr = 544; + const processView = new DataView(processMemory.buffer); + processView.setInt32(pollfdPtr, 0, true); + processView.setInt16(pollfdPtr + 4, 1, true); + processView.setInt16(pollfdPtr + 6, 0x20, true); + processView.setBigInt64(timespecPtr, 0n, true); + processView.setBigInt64(timespecPtr + 8, 25_000_000n, true); + processView.setBigUint64(maskPtr, temporaryMask, true); + const originalTimespec = new Uint8Array( + processMemory.buffer, + timespecPtr, + 16, + ).slice(); + + const channelView = new DataView(processMemory.buffer, channelOffset); + channelView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Ppoll, true); + const args = [pollfdPtr, 1, timespecPtr, maskPtr, 8, 0]; + for (let i = 0; i < args.length; i++) { + channelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(args[i]), true); + } + + worker.handleSyscall(channel); + expect(channel.pollDeadline).toBe(25); + expect(activeMask).toBe(temporaryMask); + + await vi.advanceTimersByTimeAsync(5); + worker.wakeAllBlockedRetries(); + expect(channel.pollDeadline).toBe(25); + await vi.advanceTimersByTimeAsync(20); + + expect(timeoutArgs).toEqual([25, 25, 0]); + expect(processView.getInt16(pollfdPtr + 6, true)).toBe(0); + expect(new Uint8Array(processMemory.buffer, timespecPtr, 16)).toEqual(originalTimespec); + expect(activeMask).toBe(originalMask); + expect(savedMask).toBeNull(); + expect(channel.pollDeadline).toBeUndefined(); + expect(channel.pollTimeoutOverride).toBeUndefined(); + }); +}); diff --git a/host/test/procfs-accounting.test.ts b/host/test/procfs-accounting.test.ts new file mode 100644 index 0000000000..5a039f42f3 --- /dev/null +++ b/host/test/procfs-accounting.test.ts @@ -0,0 +1,48 @@ +/** + * Runs a real libc guest through the centralized Node host and verifies the + * Linux-compatible procfs resource-accounting surface consumed by lxtask. + * The C fixture owns the field-level parsing so this test covers guest-visible + * directory, stat, stdio, and sysconf behavior rather than Rust helpers alone. + */ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, "../.."); +const fixture = join(repoRoot, "examples/procfs_accounting_test.wasm"); + +describe("procfs resource accounting guest contract", () => { + it("reports logical sizes while unsupported resource metrics stay zero", async () => { + expect(existsSync(fixture), `missing global-setup output: ${fixture}`).toBe(true); + + const result = await runCentralizedProgram({ + programPath: fixture, + argv: ["procfs_accounting_test"], + timeout: 15_000, + useDefaultRootfs: false, + execPrograms: new Map([ + ["/usr/bin/procfs-accounting-test", fixture], + ]), + }); + + expect(result.exitCode, `stdout=${result.stdout}\nstderr=${result.stderr}`).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("IDENTITY euid=1000 egid=1000"); + expect(result.stdout).toContain("PROC enumeration stat=1 meminfo=1 self_pid=1"); + expect(result.stdout).toMatch(/STATM size_pages=[1-9]\d* unsupported_fields_zero=1/); + expect(result.stdout).toMatch(/TASK main_tid=\d+ owner=1000:1000/); + expect(result.stdout).toMatch( + /STAT nice=7 vsize_bytes=[1-9]\d* rss_pages=0 owner=1000:1000/, + ); + expect(result.stdout).toMatch(/CPU aggregate_fields=\d+ all_zero=1/); + expect(result.stdout).toContain("MEMINFO required_fields=5 all_zero=1"); + expect(result.stdout).toContain("NPROCESSORS online=1 configured=1"); + expect(result.stdout).toMatch( + /FOREIGN pid=\d+ owner=1000:1000 statm_pages=[1-9]\d* main_tid=1/, + ); + expect(result.stdout).toContain("PASS procfs_accounting_test"); + }); +}); diff --git a/host/test/select-deadline.test.ts b/host/test/select-deadline.test.ts new file mode 100644 index 0000000000..4c7a120ba2 --- /dev/null +++ b/host/test/select-deadline.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ABI_SYSCALLS, + CH_ARGS, + CH_ARG_SIZE, + CH_ERRNO, + CH_RETURN, +} from "../src/generated/abi"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +const EAGAIN = 11; + +function createMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); +} + +function createChannel(pid: number, channelOffset: number): any { + return { pid, channelOffset, memory: createMemory() }; +} + +function createWorkerHarness( + pid: number, + kernelHandlesSelectWait = false, + onKernelCall?: (view: DataView, memory: WebAssembly.Memory) => void, +): any { + const kernelMemory = createMemory(); + const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + kernel: { toKernelPtr: (value: number | bigint) => value }, + kernelMemory, + scratchOffset: 0, + cachedKernelMem: null, + cachedKernelBuffer: null, + currentHandlePid: 0, + channelTids: new Map(), + processes: new Map([[pid, { pid, ptrWidth: 4 }]]), + pendingSleeps: new Map(), + pendingPollRetries: new Map(), + pendingSelectRetries: new Map(), + pendingPipeReaders: new Map(), + pendingPipeWriters: new Map(), + completeChannel: vi.fn(), + }); + worker.kernelInstance = { + exports: { + kernel_set_current_tid: () => {}, + kernel_is_signal_blocked: () => 0, + kernel_handle_channel: () => { + const view = new DataView(kernelMemory.buffer); + onKernelCall?.(view, kernelMemory); + if (!kernelHandlesSelectWait) return 0; + const timeoutMs = Number( + view.getBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, true), + ); + if (timeoutMs > 0) { + view.setBigInt64(CH_RETURN, -1n, true); + view.setUint32(CH_ERRNO, EAGAIN, true); + return 0; + } + + // Model the kernel's final nonblocking select pass: no descriptors + // are ready, so each supplied fd_set is cleared and the call returns 0. + const mem = new Uint8Array(kernelMemory.buffer); + for (let arg = 1; arg <= 3; arg++) { + const ptr = Number( + view.getBigInt64(CH_ARGS + arg * CH_ARG_SIZE, true), + ); + if (ptr !== 0) mem.fill(0, ptr, ptr + 128); + } + view.setBigInt64(CH_RETURN, 0n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }, + }, + }; + return worker; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("select deadline preservation", () => { + it("finalizes a finite select with timeout zero and clears fd_sets", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(0)); + const pid = 7; + const timeoutArgs: number[] = []; + const worker = createWorkerHarness(pid, true, (view) => { + timeoutArgs.push(Number(view.getBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, true))); + }); + const channel = createChannel(pid, 1024); + const timevalPtr = 128; + const readPtr = 256; + const timeval = new DataView(channel.memory.buffer, timevalPtr); + timeval.setInt32(0, 0, true); + timeval.setInt32(4, 100_000, true); + new Uint8Array(channel.memory.buffer)[readPtr] = 1; + const args = [1, readPtr, 0, 0, timevalPtr]; + + worker.handleSelect(channel, args); + expect(worker.pendingSelectRetries.get(channel.channelOffset).deadline).toBe(100); + + vi.advanceTimersByTime(40); + worker.wakeAllBlockedRetries(); + expect(worker.pendingSelectRetries.get(channel.channelOffset).deadline).toBe(100); + + vi.advanceTimersByTime(59); + expect(worker.completeChannel).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(worker.completeChannel).toHaveBeenCalledTimes(1); + expect(new Uint8Array(channel.memory.buffer)[readPtr]).toBe(0); + expect(timeoutArgs).toEqual([100, 60, 10, 0]); + }); + + it("finalizes pselect6 with timeout zero, clears fd_sets, and restores its mask", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(0)); + const pid = 8; + const originalMask = 0x20n; + const temporaryMask = 0x400n; + let activeMask = originalMask; + let savedMask: bigint | null = null; + const seenMasks: bigint[] = []; + const timeoutArgs: number[] = []; + const worker = createWorkerHarness(pid, true, (view, kernelMemory) => { + const timeoutMs = Number( + view.getBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, true), + ); + timeoutArgs.push(timeoutMs); + const maskPtr = Number( + view.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), + ); + if (maskPtr !== 0) { + const mask = new DataView(kernelMemory.buffer).getBigUint64(maskPtr, true); + seenMasks.push(mask); + if (savedMask === null) { + savedMask = activeMask; + activeMask = mask; + } + } + if (timeoutMs === 0 && savedMask !== null) { + activeMask = savedMask; + savedMask = null; + } + }); + const channel = createChannel(pid, 2048); + const timespecPtr = 128; + const readPtr = 256; + const maskDataPtr = 400; + const maskPtr = 416; + const timespec = new DataView(channel.memory.buffer, timespecPtr); + timespec.setBigInt64(0, 0n, true); + timespec.setBigInt64(8, 100_000_000n, true); + const processView = new DataView(channel.memory.buffer); + processView.setUint8(readPtr, 1); + processView.setUint32(maskDataPtr, maskPtr, true); + processView.setUint32(maskDataPtr + 4, 8, true); + processView.setBigUint64(maskPtr, temporaryMask, true); + const args = [1, readPtr, 0, 0, timespecPtr, maskDataPtr]; + + worker.handlePselect6(channel, args); + expect(worker.pendingSelectRetries.get(channel.channelOffset).deadline).toBe(100); + expect(activeMask).toBe(temporaryMask); + + vi.advanceTimersByTime(40); + worker.wakeAllBlockedRetries(); + expect(worker.pendingSelectRetries.get(channel.channelOffset).deadline).toBe(100); + + vi.advanceTimersByTime(59); + expect(worker.completeChannel).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(worker.completeChannel).toHaveBeenCalledTimes(1); + expect(processView.getUint8(readPtr)).toBe(0); + expect(activeMask).toBe(originalMask); + expect(savedMask).toBeNull(); + expect(seenMasks).toEqual([ + temporaryMask, + temporaryMask, + temporaryMask, + temporaryMask, + ]); + expect(timeoutArgs).toEqual([100, 60, 10, 0]); + }); + + it("carries select and pselect6 deadlines through signal wakes", () => { + const pid = 9; + const worker = createWorkerHarness(pid); + const selectChannel = createChannel(pid, 1024); + const pselectChannel = createChannel(pid, 2048); + const selectArgs = [0, 0, 0, 0, 0]; + const pselectArgs = [0, 0, 0, 0, 0, 0]; + worker.pendingSelectRetries.set(selectChannel.channelOffset, { + timer: null, + channel: selectChannel, + origArgs: selectArgs, + deadline: 123, + syscallNr: ABI_SYSCALLS.Select, + }); + worker.pendingSelectRetries.set(pselectChannel.channelOffset, { + timer: null, + channel: pselectChannel, + origArgs: pselectArgs, + deadline: 456, + syscallNr: ABI_SYSCALLS.Pselect6, + }); + worker.handleSelect = vi.fn(); + worker.handlePselect6 = vi.fn(); + + worker.sendSignalToProcess(pid, 17); + + expect(worker.handleSelect).toHaveBeenCalledWith(selectChannel, selectArgs, 123); + expect(worker.handlePselect6).toHaveBeenCalledWith(pselectChannel, pselectArgs, 456); + }); +});