From 870f05bb48c75eab38fa19c394ba995043b59e86 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 24 Jul 2026 23:40:32 -0700 Subject: [PATCH] feat: add constrained child process execution --- PLAN.md | 2 + README.md | 14 +- crates/browser-runtime/src/lib.rs | 48 +++- crates/linux-abi/src/lib.rs | 13 + crates/linux-runtime/src/lib.rs | 263 +++++++++++++++++- docs/architecture.md | 4 +- guest-tests/spawn-exec/README.md | 13 + guest-tests/spawn-exec/build.sh | 57 ++++ guest-tests/spawn-exec/child.c | 53 ++++ guest-tests/spawn-exec/image-root/child | Bin 0 -> 1216 bytes guest-tests/spawn-exec/parent.c | 81 ++++++ guest-tests/spawn-exec/spawn-exec.aarch64.elf | Bin 0 -> 1392 bytes guest-tests/spawn-exec/spawn-exec.bnfs | Bin 0 -> 1288 bytes guest-tests/spawn-wait/README.md | 13 + guest-tests/spawn-wait/build.sh | 27 ++ guest-tests/spawn-wait/main.S | 85 ++++++ guest-tests/spawn-wait/spawn-wait.aarch64.elf | Bin 0 -> 1520 bytes web/index.html | 2 + web/src/probe.ts | 2 + web/tests/probe.spec.ts | 42 +++ 20 files changed, 698 insertions(+), 21 deletions(-) create mode 100644 guest-tests/spawn-exec/README.md create mode 100755 guest-tests/spawn-exec/build.sh create mode 100644 guest-tests/spawn-exec/child.c create mode 100644 guest-tests/spawn-exec/image-root/child create mode 100644 guest-tests/spawn-exec/parent.c create mode 100644 guest-tests/spawn-exec/spawn-exec.aarch64.elf create mode 100644 guest-tests/spawn-exec/spawn-exec.bnfs create mode 100644 guest-tests/spawn-wait/README.md create mode 100755 guest-tests/spawn-wait/build.sh create mode 100644 guest-tests/spawn-wait/main.S create mode 100644 guest-tests/spawn-wait/spawn-wait.aarch64.elf diff --git a/PLAN.md b/PLAN.md index 3d19954..4937b05 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1889,6 +1889,8 @@ Compiler source diagnostics now have a product UI path. Clang-format stderr reco The first pipe checkpoint adds AArch64 `pipe2` with process-owned descriptors and a fixed 64 KiB FIFO queue. The initial contract deliberately requires `O_NONBLOCK`: reads distinguish `EAGAIN` from EOF, writes are partial at remaining capacity and return `EPIPE` after the reader closes, `fstat` identifies FIFO descriptors, `lseek` returns `ESPIPE`, close-on-exec shares the ordinary descriptor flag path, and pipe ends count against the open-file limit. A checked-in assembly fixture round-trips bytes through the pipe and passes on the native host and in Chromium. Blocking pipe suspension/wakeup and descriptor sharing across concurrent child processes remain the next orchestration checkpoint; build cache policy and interactive-performance work also remain. +The first direct child-process checkpoint implements a deliberately constrained spawn/exec model rather than claiming `fork`. AArch64 `clone` accepts exactly `CLONE_VM | CLONE_VFORK | SIGCHLD`, one suspended parent, and an optional child stack; it rejects nested or unreaped children, TID/TLS modes, other flags, and inherited nonstandard descriptors. PID 2 can run directly or replace itself from the guest filesystem, after which exit restores the parent CPU, memory, interpreter, signals, identity, and configuration while retaining aggregate resource counters, output, trace history, and filesystem mutations. `getpid`, `getppid`, `gettid`, and `wait4` expose the child identity and encoded exit status. Native and Chromium fixtures cover both direct child exit and child `execve` followed by parent resumption. Parent/child descriptor sharing, blocking pipe wakeups, general clone modes, build cache policy, and interactive-performance work remain. + Do not begin the full web IDE before item 30 passes. --- diff --git a/README.md b/README.md index 39947ae..b07ccdd 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ The CLI writes this snapshot after execution stops even when a resource limit produces a diagnostic, so bounded compiler runs can retain their cache and other completed filesystem mutations for a later run. -The runnable instruction/syscall profile remains fixture-driven. The current static Rust program adds single-thread atomics and barriers, 128-bit vector moves/stores, byte popcount and reduction, multiplication/division, and signed shifts to the earlier libc instruction path. The runtime implements `openat`, `close`, `lseek`, file `read`/`write`, bounded nonblocking `pipe2`, polling, deterministic process/signal setup, static-ELF `execve`, anonymous memory management, terminal output, and exit. Invalid guest arguments return Linux errno values; instruction, syscall, committed-memory, ephemeral-filesystem, open-file, and combined-output limits are enforced before host side effects. +The runnable instruction/syscall profile remains fixture-driven. The current static Rust program adds single-thread atomics and barriers, 128-bit vector moves/stores, byte popcount and reduction, multiplication/division, and signed shifts to the earlier libc instruction path. The runtime implements `openat`, `close`, `lseek`, file `read`/`write`, bounded nonblocking `pipe2`, constrained vfork-style `clone`/`wait4`, PID queries, polling, deterministic process/signal setup, static-ELF `execve`, anonymous memory management, terminal output, and exit. Invalid guest arguments return Linux errno values; instruction, syscall, committed-memory, ephemeral-filesystem, open-file, and combined-output limits are enforced before host side effects. The native CPython checkpoint is reproducible without committing its large generated artifacts. `guest-tests/cpython/build.sh` creates a statically linked @@ -87,12 +87,12 @@ traceback behavior, and current limitations are summarized in [`docs/cpython-compatibility.md`](docs/cpython-compatibility.md). Phase 6 includes static process replacement, a packaged Clang/LLD/musl -toolchain, browser-edited C source, linked compiler diagnostics, and the first -pipe primitive. The checked-in `guest-tests/execve-launcher` replaces itself -with a second static ELF loaded from a project image, while -`guest-tests/pipe-roundtrip` exercises a fixed-capacity `O_NONBLOCK` pipe in -both native and Chromium hosts. Concurrent children and blocking pipe -scheduling remain the next process-orchestration milestone. +toolchain, browser-edited C source, linked compiler diagnostics, bounded pipes, +and a single-child spawn/exec model. `guest-tests/spawn-exec` suspends a parent, +runs a constrained vfork-style child that loads a project ELF, restores the +parent, and reaps the child's exact status. Native and Chromium hosts share the +same regressions. General fork/clone modes, inherited nonstandard descriptors, +concurrent children, and blocking pipe scheduling remain future milestones. The browser build generates its Memory64, JSPI, and P-code `.wasm` probes before starting Vite. Generated artifacts are not committed. Select **Uploaded AArch64 ELF** to run an external static executable with a chosen `argv[0]` and one argument per line; the executable is transferred directly to the runtime Worker. diff --git a/crates/browser-runtime/src/lib.rs b/crates/browser-runtime/src/lib.rs index 8e28604..f1d70a1 100644 --- a/crates/browser-runtime/src/lib.rs +++ b/crates/browser-runtime/src/lib.rs @@ -103,6 +103,12 @@ const VFS_LIFECYCLE_ELF: &[u8] = include_bytes!("../../../guest-tests/vfs-lifecycle/vfs-lifecycle.aarch64.elf"); const SYSTEM_SERVICES_ELF: &[u8] = include_bytes!("../../../guest-tests/system-services/system-services.aarch64.elf"); +const SPAWN_WAIT_ELF: &[u8] = + include_bytes!("../../../guest-tests/spawn-wait/spawn-wait.aarch64.elf"); +const SPAWN_EXEC_ELF: &[u8] = + include_bytes!("../../../guest-tests/spawn-exec/spawn-exec.aarch64.elf"); +#[cfg(test)] +const SPAWN_EXEC_IMAGE: &[u8] = include_bytes!("../../../guest-tests/spawn-exec/spawn-exec.bnfs"); const TERMINAL_INPUT_ELF: &[u8] = include_bytes!("../../../guest-tests/terminal-input/terminal-input.aarch64.elf"); const EXECVE_LAUNCHER_ELF: &[u8] = @@ -1206,6 +1212,8 @@ fn fixture(name: &str) -> Option<(&'static [u8], &'static [u8])> { "pipe-roundtrip" => Some((PIPE_ROUNDTRIP_ELF, b"/pipe-roundtrip")), "vfs-lifecycle" => Some((VFS_LIFECYCLE_ELF, b"/vfs-lifecycle")), "system-services" => Some((SYSTEM_SERVICES_ELF, b"/system-services")), + "spawn-wait" => Some((SPAWN_WAIT_ELF, b"/spawn-wait")), + "spawn-exec" => Some((SPAWN_EXEC_ELF, b"/spawn-exec")), "terminal-input" => Some((TERMINAL_INPUT_ELF, b"/terminal-input")), "execve-launcher" => Some((EXECVE_LAUNCHER_ELF, b"/execve-launcher")), _ => None, @@ -1282,7 +1290,7 @@ mod tests { use super::{ BrowserBlockExecutor, COMPILER_HELLO_ELF, Credentials, EXECVE_TOOLCHAIN_IMAGE, Interpreter, - ProcessConfig, ProcessParameters, execute_fixture, fixture, load_process, + ProcessConfig, ProcessParameters, SPAWN_EXEC_IMAGE, execute_fixture, fixture, load_process, program_environment, start_fixture, start_program, translate_fixture_entry_inner, }; @@ -1380,6 +1388,44 @@ mod tests { assert_eq!(result.dispatched_syscalls, 7); } + #[test] + fn executes_a_child_and_reaps_its_exit_status() { + let result = execute_fixture( + "spawn-wait", + DEFAULT_INSTRUCTIONS, + DEFAULT_SYSCALLS, + DEFAULT_OUTPUT, + DEFAULT_MEMORY, + DEFAULT_FILESYSTEM, + &[], + ); + + assert_eq!(result.outcome, "exited"); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "child\nparent\n"); + assert!(result.stderr.is_empty()); + assert_eq!(result.dispatched_syscalls, 9); + } + + #[test] + fn restores_the_parent_after_a_child_executes_from_the_filesystem() { + let result = execute_fixture( + "spawn-exec", + DEFAULT_INSTRUCTIONS, + DEFAULT_SYSCALLS, + DEFAULT_OUTPUT, + DEFAULT_MEMORY, + DEFAULT_FILESYSTEM, + SPAWN_EXEC_IMAGE, + ); + + assert_eq!(result.outcome, "exited"); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "spawned child\nparent resumed\n"); + assert!(result.stderr.is_empty()); + assert_eq!(result.dispatched_syscalls, 7); + } + #[test] fn replaces_a_browser_guest_from_the_filesystem() { let result = execute_fixture( diff --git a/crates/linux-abi/src/lib.rs b/crates/linux-abi/src/lib.rs index 2643fa1..95a31b7 100644 --- a/crates/linux-abi/src/lib.rs +++ b/crates/linux-abi/src/lib.rs @@ -32,12 +32,16 @@ pub enum Syscall { Sigaltstack = 132, RtSigaction = 134, RtSigprocmask = 135, + Getpid = 172, + Getppid = 173, Gettid = 178, Munmap = 215, + Clone = 220, Execve = 221, Mmap = 222, Mprotect = 226, Getrandom = 278, + Wait4 = 260, } impl Syscall { @@ -73,12 +77,16 @@ impl Syscall { 132 => Some(Self::Sigaltstack), 134 => Some(Self::RtSigaction), 135 => Some(Self::RtSigprocmask), + 172 => Some(Self::Getpid), + 173 => Some(Self::Getppid), 178 => Some(Self::Gettid), 215 => Some(Self::Munmap), + 220 => Some(Self::Clone), 221 => Some(Self::Execve), 222 => Some(Self::Mmap), 226 => Some(Self::Mprotect), 278 => Some(Self::Getrandom), + 260 => Some(Self::Wait4), _ => None, } } @@ -98,6 +106,7 @@ pub enum Errno { ArgumentListTooLong = 7, ExecutableFormat = 8, BadFileDescriptor = 9, + NoChild = 10, TryAgain = 11, OutOfMemory = 12, PermissionDenied = 13, @@ -158,12 +167,16 @@ mod tests { assert_eq!(Syscall::from_number(132), Some(Syscall::Sigaltstack)); assert_eq!(Syscall::from_number(134), Some(Syscall::RtSigaction)); assert_eq!(Syscall::from_number(135), Some(Syscall::RtSigprocmask)); + assert_eq!(Syscall::from_number(172), Some(Syscall::Getpid)); + assert_eq!(Syscall::from_number(173), Some(Syscall::Getppid)); assert_eq!(Syscall::from_number(178), Some(Syscall::Gettid)); assert_eq!(Syscall::from_number(215), Some(Syscall::Munmap)); + assert_eq!(Syscall::from_number(220), Some(Syscall::Clone)); assert_eq!(Syscall::from_number(221), Some(Syscall::Execve)); assert_eq!(Syscall::from_number(222), Some(Syscall::Mmap)); assert_eq!(Syscall::from_number(226), Some(Syscall::Mprotect)); assert_eq!(Syscall::from_number(278), Some(Syscall::Getrandom)); + assert_eq!(Syscall::from_number(260), Some(Syscall::Wait4)); assert_eq!(Syscall::from_number(55), None); } diff --git a/crates/linux-runtime/src/lib.rs b/crates/linux-runtime/src/lib.rs index 8941175..712a0de 100644 --- a/crates/linux-runtime/src/lib.rs +++ b/crates/linux-runtime/src/lib.rs @@ -20,7 +20,8 @@ use binarrow_runtime_core::{GuestAddress, ResourceLimit, ResourceLimits, Trap}; const STANDARD_INPUT: u64 = 0; const STANDARD_OUTPUT: u64 = 1; const STANDARD_ERROR: u64 = 2; -const MAIN_THREAD_ID: u64 = 1; +const INITIAL_PROCESS_ID: u64 = 1; +const CHILD_PROCESS_ID: u64 = 2; const FIRST_FILE_DESCRIPTOR: u32 = 3; const AT_FDCWD: u64 = (-100_i64).cast_unsigned(); const MAX_PATH_BYTES: usize = 4096; @@ -98,6 +99,11 @@ const CPU_AFFINITY_BYTES: u64 = 8; const INITIAL_CURRENT_DIRECTORY: &[u8] = b"/project"; const PIPE_CAPACITY_BYTES: usize = 64 * 1024; const SUPPORTED_PIPE_FLAGS: u64 = OPEN_CLOEXEC | OPEN_NONBLOCK; +const CLONE_VM: u64 = 0x100; +const CLONE_VFORK: u64 = 0x4000; +const SIGCHLD: u64 = 17; +const SUPPORTED_CLONE_FLAGS: u64 = CLONE_VM | CLONE_VFORK | SIGCHLD; +const RUSAGE_SIZE: usize = 144; #[derive(Clone, Copy, Debug, Default)] struct SignalAction { @@ -159,6 +165,30 @@ struct AnonymousPipe { writer_open: bool, } +struct SuspendedParent { + state: Aarch64State, + memory: AddressSpace, + interpreter: Interpreter, + limits: ResourceLimits, + clear_child_tid: Option, + signal_actions: [SignalAction; LINUX_SIGNAL_COUNT], + signal_stack: SignalStack, + signal_mask: u64, + next_mmap_address: GuestAddress, + descriptor_flags: BTreeMap, + current_directory: Vec, + executable_path: Vec, + config: ProcessConfig, + credentials: Credentials, + pending_input: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ExitedChild { + process_id: u64, + exit_code: u8, +} + struct DisabledBlockExecutor; impl BlockExecutor for DisabledBlockExecutor { @@ -273,7 +303,18 @@ impl fmt::Display for SyscallEvent { self.arguments[0] )?; } + Some(Syscall::Getpid) => write!(formatter, "getpid()")?, + Some(Syscall::Getppid) => write!(formatter, "getppid()")?, Some(Syscall::Gettid) => write!(formatter, "gettid()")?, + Some(Syscall::Clone) => write!( + formatter, + "clone(flags={:#x}, stack={:#x}, parent_tid={:#x}, tls={:#x}, child_tid={:#x})", + self.arguments[0], + self.arguments[1], + self.arguments[2], + self.arguments[3], + self.arguments[4], + )?, Some(Syscall::Execve) => write!( formatter, "execve(path={:#x}, argv={:#x}, envp={:#x})", @@ -307,6 +348,14 @@ impl fmt::Display for SyscallEvent { Some(syscall @ (Syscall::Munmap | Syscall::Mmap | Syscall::Mprotect)) => { format_memory_syscall(formatter, syscall, self.arguments)?; } + Some(Syscall::Wait4) => write!( + formatter, + "wait4(pid={}, status={:#x}, options={:#x}, rusage={:#x})", + self.arguments[0].cast_signed(), + self.arguments[1], + self.arguments[2], + self.arguments[3], + )?, None => write!(formatter, "syscall({})", self.number)?, } match self.outcome { @@ -496,6 +545,10 @@ pub struct Process { credentials: Credentials, trace: Vec, pending_input: Option, + current_process_id: u64, + parent_process_id: u64, + suspended_parent: Option, + exited_child: Option, } impl Process { @@ -531,6 +584,10 @@ impl Process { credentials: image.credentials, trace: Vec::new(), pending_input: None, + current_process_id: INITIAL_PROCESS_ID, + parent_process_id: 0, + suspended_parent: None, + exited_child: None, }) } @@ -756,6 +813,10 @@ impl Process { arguments, outcome: SyscallOutcome::Exited(exit_code), }); + if self.suspended_parent.is_some() { + self.finish_child(filesystem, exit_code); + return Ok(None); + } return Ok(Some(ExecutionEvent::Exited(ExecutionResult { exit_code, executed_instructions: self.executed_instructions, @@ -793,9 +854,13 @@ impl Process { Some(Syscall::Writev) => self.dispatch_writev(terminal, filesystem)?, Some(Syscall::SetTidAddress) => { self.clear_child_tid = Some(GuestAddress::new(self.register(0))); - self.set_return(MAIN_THREAD_ID); + self.set_return(self.current_process_id); + } + Some(Syscall::Getpid | Syscall::Gettid) => { + self.set_return(self.current_process_id); } - Some(Syscall::Gettid) => self.set_return(MAIN_THREAD_ID), + Some(Syscall::Getppid) => self.set_return(self.parent_process_id), + Some(Syscall::Clone) => self.dispatch_clone(), Some(Syscall::Execve) => self.dispatch_execve(filesystem, system), Some(Syscall::ClockGettime) => self.dispatch_clock_gettime(system), Some(Syscall::SchedGetaffinity) => self.dispatch_sched_getaffinity(), @@ -806,6 +871,7 @@ impl Process { Some(Syscall::Mmap) => self.dispatch_mmap(), Some(Syscall::Mprotect) => self.dispatch_mprotect(), Some(Syscall::Getrandom) => self.dispatch_getrandom(system), + Some(Syscall::Wait4) => self.dispatch_wait4(), None => self.set_return(Errno::NoSystemCall.return_value()), } self.trace.push(SyscallEvent { @@ -816,6 +882,117 @@ impl Process { Ok(None) } + fn dispatch_clone(&mut self) { + if self.register(0) != SUPPORTED_CLONE_FLAGS + || self.register(2) != 0 + || self.register(3) != 0 + || self.register(4) != 0 + || self.suspended_parent.is_some() + || self.exited_child.is_some() + || !self.file_descriptors.is_empty() + || !self.pipe_descriptors.is_empty() + { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + let child_stack = self.register(1); + let Ok(child_interpreter) = Interpreter::new() else { + self.set_return(Errno::OutOfMemory.return_value()); + return; + }; + let parent = SuspendedParent { + state: self.state.clone(), + memory: self.memory.clone(), + interpreter: core::mem::replace(&mut self.interpreter, child_interpreter), + limits: self.limits, + clear_child_tid: self.clear_child_tid, + signal_actions: self.signal_actions, + signal_stack: self.signal_stack, + signal_mask: self.signal_mask, + next_mmap_address: self.next_mmap_address, + descriptor_flags: self.descriptor_flags.clone(), + current_directory: self.current_directory.clone(), + executable_path: self.executable_path.clone(), + config: self.config, + credentials: self.credentials, + pending_input: self.pending_input, + }; + self.suspended_parent = Some(parent); + self.current_process_id = CHILD_PROCESS_ID; + self.parent_process_id = INITIAL_PROCESS_ID; + self.clear_child_tid = None; + self.pending_input = None; + if child_stack != 0 { + self.state.set_sp(GuestAddress::new(child_stack)); + } + self.set_return(0); + } + + fn finish_child(&mut self, filesystem: &mut F, exit_code: u8) { + self.close_all_descriptors(filesystem); + let parent = self + .suspended_parent + .take() + .expect("a child exit has a suspended parent"); + self.state = parent.state; + self.memory = parent.memory; + self.interpreter = parent.interpreter; + self.limits = parent.limits; + self.clear_child_tid = parent.clear_child_tid; + self.signal_actions = parent.signal_actions; + self.signal_stack = parent.signal_stack; + self.signal_mask = parent.signal_mask; + self.next_mmap_address = parent.next_mmap_address; + self.descriptor_flags = parent.descriptor_flags; + self.current_directory = parent.current_directory; + self.executable_path = parent.executable_path; + self.config = parent.config; + self.credentials = parent.credentials; + self.pending_input = parent.pending_input; + self.current_process_id = INITIAL_PROCESS_ID; + self.parent_process_id = 0; + self.exited_child = Some(ExitedChild { + process_id: CHILD_PROCESS_ID, + exit_code, + }); + self.set_return(CHILD_PROCESS_ID); + } + + fn dispatch_wait4(&mut self) { + let Some(child) = self.exited_child else { + self.set_return(Errno::NoChild.return_value()); + return; + }; + let requested_process = self.register(0).cast_signed(); + if !matches!(requested_process, -1) && requested_process != child.process_id.cast_signed() { + self.set_return(Errno::NoChild.return_value()); + return; + } + if self.register(2) != 0 { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + let status = GuestAddress::new(self.register(1)); + if status != GuestAddress::NULL { + let wait_status = u32::from(child.exit_code) << 8; + if self + .memory + .write(status, &wait_status.to_le_bytes()) + .is_err() + { + self.set_return(Errno::Fault.return_value()); + return; + } + } + let rusage = GuestAddress::new(self.register(3)); + if rusage != GuestAddress::NULL && self.memory.write(rusage, &[0; RUSAGE_SIZE]).is_err() { + self.set_return(Errno::Fault.return_value()); + return; + } + self.exited_child = None; + self.set_return(child.process_id); + } + fn dispatch_execve( &mut self, filesystem: &mut F, @@ -2029,6 +2206,24 @@ impl Process { Ok(()) } + fn close_all_descriptors(&mut self, filesystem: &mut F) { + let descriptors = self + .file_descriptors + .keys() + .chain(self.pipe_descriptors.keys()) + .copied() + .collect::>(); + for descriptor in descriptors { + let _ = self.close_descriptor(filesystem, descriptor); + } + self.file_descriptors.clear(); + self.pipe_descriptors.clear(); + self.pipes.clear(); + self.descriptor_paths.clear(); + self.descriptor_flags + .retain(|descriptor, _| *descriptor < 3); + } + fn file_handle(&self, file_descriptor: u64) -> Option { self.file_descriptors .get(&u32::try_from(file_descriptor).ok()?) @@ -2079,7 +2274,7 @@ impl Process { let pid = self.register(0); let size = self.register(1); let mask = GuestAddress::new(self.register(2)); - if !matches!(pid, 0 | MAIN_THREAD_ID) || size < CPU_AFFINITY_BYTES { + if (pid != 0 && pid != self.current_process_id) || size < CPU_AFFINITY_BYTES { self.set_return(Errno::InvalidArgument.return_value()); return; } @@ -2454,12 +2649,12 @@ mod tests { use binarrow_runtime_core::{GuestAddress, ResourceLimit, Trap}; use super::{ - AT_FDCWD, AnonymousPipe, DESCRIPTOR_CLOEXEC, ExecutionError, ExecutionEvent, - FCNTL_GET_DESCRIPTOR_FLAGS, IOCTL_CLEAR_CLOSE_ON_EXEC, IOCTL_SET_CLOSE_ON_EXEC, - MAIN_THREAD_ID, OPEN_CLOEXEC, OPEN_DIRECTORY, OPEN_NOCTTY, OPEN_NOFOLLOW, OPEN_NONBLOCK, - OPEN_PATH, PIPE_CAPACITY_BYTES, Process, STANDARD_OUTPUT, STAT_CHARACTER_MODE, - STAT_FIFO_MODE, STAT_FILE_SIZE_OFFSET, STAT_MODE_OFFSET, STAT_REGULAR_MODE, STAT_SIZE, - SyscallEvent, SyscallOutcome, + AT_FDCWD, AnonymousPipe, CHILD_PROCESS_ID, DESCRIPTOR_CLOEXEC, ExecutionError, + ExecutionEvent, FCNTL_GET_DESCRIPTOR_FLAGS, INITIAL_PROCESS_ID, IOCTL_CLEAR_CLOSE_ON_EXEC, + IOCTL_SET_CLOSE_ON_EXEC, OPEN_CLOEXEC, OPEN_DIRECTORY, OPEN_NOCTTY, OPEN_NOFOLLOW, + OPEN_NONBLOCK, OPEN_PATH, PIPE_CAPACITY_BYTES, Process, STANDARD_OUTPUT, + STAT_CHARACTER_MODE, STAT_FIFO_MODE, STAT_FILE_SIZE_OFFSET, STAT_MODE_OFFSET, + STAT_REGULAR_MODE, STAT_SIZE, SUPPORTED_CLONE_FLAGS, SyscallEvent, SyscallOutcome, }; const MESSAGE: &[u8] = b"hello, world\n"; @@ -2684,7 +2879,7 @@ mod tests { 0x101_0870, 0, ], - outcome: SyscallOutcome::Returned(MAIN_THREAD_ID), + outcome: SyscallOutcome::Returned(INITIAL_PROCESS_ID), }, SyscallEvent { number: 64, @@ -3454,6 +3649,52 @@ mod tests { assert_eq!(process.register(0), 0); } + #[test] + fn constrained_clone_rejects_descriptors_and_wait4_preserves_unreaped_status_on_fault() { + let image = load_hello(ProcessConfig::default(), 1, MESSAGE_ADDRESS); + let mut process = Process::new(image).unwrap(); + let mut filesystem = NullFileSystem; + process.file_descriptors.insert(3, 1); + process.state.set_x(0, SUPPORTED_CLONE_FLAGS).unwrap(); + for register in 1..=4 { + process.state.set_x(register, 0).unwrap(); + } + process.dispatch_clone(); + assert_eq!(process.register(0), Errno::InvalidArgument.return_value()); + + process.file_descriptors.clear(); + process.state.set_x(0, SUPPORTED_CLONE_FLAGS).unwrap(); + process.dispatch_clone(); + assert_eq!(process.register(0), 0); + assert_eq!(process.current_process_id, CHILD_PROCESS_ID); + process.finish_child(&mut filesystem, 7); + assert_eq!(process.register(0), CHILD_PROCESS_ID); + + process.state.set_x(0, CHILD_PROCESS_ID).unwrap(); + process.state.set_x(1, 1).unwrap(); + process.state.set_x(2, 0).unwrap(); + process.state.set_x(3, 0).unwrap(); + process.dispatch_wait4(); + assert_eq!(process.register(0), Errno::Fault.return_value()); + assert!(process.exited_child.is_some()); + + let status = process.state.sp().checked_sub(4).unwrap(); + process.state.set_x(0, CHILD_PROCESS_ID).unwrap(); + process.state.set_x(1, status.get()).unwrap(); + process.dispatch_wait4(); + assert_eq!(process.register(0), CHILD_PROCESS_ID); + let mut status_bytes = [0; 4]; + process + .memory + .read_exact(status, &mut status_bytes) + .unwrap(); + assert_eq!(u32::from_le_bytes(status_bytes), 7 << 8); + + process.state.set_x(0, CHILD_PROCESS_ID).unwrap(); + process.dispatch_wait4(); + assert_eq!(process.register(0), Errno::NoChild.return_value()); + } + #[test] fn ioctl_tracks_close_on_exec_and_rejects_other_requests_as_not_tty() { let image = load_hello(ProcessConfig::default(), 1, MESSAGE_ADDRESS); diff --git a/docs/architecture.md b/docs/architecture.md index c674f20..483c876 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,7 @@ Implements the Phase 2 ephemeral filesystem behind `binarrow-host-api`. It norma ### `binarrow-linux-runtime` -Consumes a loaded process image, owns its architectural execution state and descriptor table, and repeatedly runs the interpreter to structured supervisor-call stops. The dispatcher implements the process calls reached by the static Rust fixture plus `openat`, `close`, `lseek`, regular-file `read`/`write`, bounded nonblocking `pipe2`, and static-ELF `execve`. Anonymous pipes are process-owned rather than host filesystem objects: each has a fixed 64 KiB queue, reader/writer lifetime, FIFO `fstat` identity, close-on-exec flags, EOF and broken-pipe behavior, and deterministic `EAGAIN` at empty/full nonblocking boundaries. Blocking pipe creation is rejected until the multi-process scheduler can suspend and wake readers and writers correctly. `*at` calls resolve absolute paths independently of their directory descriptor and relative paths against `AT_FDCWD` or the retained guest path of an open directory descriptor; `O_PATH`, no-follow, no-controlling-terminal, directory, and close-on-exec flags cover toolchain traversal without exposing host paths. Successful process replacement reads a bounded pathname and argument/environment vectors from the old address space, loads the new executable through `HostFileSystem`, rebuilds the process image, closes `O_CLOEXEC` descriptors, and retains the current directory, credentials, signal mask, ordinary descriptors, resource counters, and trace history. Failed replacement leaves the old process image and descriptors intact. File contents remain behind `HostFileSystem`; guest descriptor allocation, guest-memory copying, errno mapping, and open-file limits remain Linux-runtime responsibilities. Unsupported calls return `ENOSYS`, invalid arguments return Linux errno values, and terminal output is bounded before bytes cross the host trait. Successful termination reports the guest exit code plus instruction, syscall, and output counters. Every completed dispatch also appends a project-owned trace event whose arguments are captured before return-register mutation; an explicit syscall budget bounds trace growth. +Consumes a loaded process image, owns its architectural execution state and descriptor table, and repeatedly runs the interpreter to structured supervisor-call stops. The dispatcher implements the process calls reached by the static Rust fixture plus `openat`, `close`, `lseek`, regular-file `read`/`write`, bounded nonblocking `pipe2`, constrained `clone`/`wait4`, PID queries, and static-ELF `execve`. Anonymous pipes are process-owned rather than host filesystem objects: each has a fixed 64 KiB queue, reader/writer lifetime, FIFO `fstat` identity, close-on-exec flags, EOF and broken-pipe behavior, and deterministic `EAGAIN` at empty/full nonblocking boundaries. Blocking pipe creation is rejected until the multi-process scheduler can suspend and wake readers and writers correctly. The first child-process mode accepts exactly `CLONE_VM | CLONE_VFORK | SIGCHLD`, supports an optional child stack, suspends one parent while PID 2 runs, permits the child to replace itself, restores the parent's CPU/memory/interpreter state at child exit, and exposes the encoded status through `wait4`. It rejects nested/unreaped children, TID/TLS features, other clone modes, and inherited nonstandard descriptors; this is a spawn/exec stepping stone, not general fork semantics. `*at` calls resolve absolute paths independently of their directory descriptor and relative paths against `AT_FDCWD` or the retained guest path of an open directory descriptor; `O_PATH`, no-follow, no-controlling-terminal, directory, and close-on-exec flags cover toolchain traversal without exposing host paths. Successful process replacement reads a bounded pathname and argument/environment vectors from the old address space, loads the new executable through `HostFileSystem`, rebuilds the process image, closes `O_CLOEXEC` descriptors, and retains the current directory, credentials, signal mask, ordinary descriptors, resource counters, and trace history. Failed replacement leaves the old process image and descriptors intact. File contents remain behind `HostFileSystem`; guest descriptor allocation, guest-memory copying, errno mapping, and open-file limits remain Linux-runtime responsibilities. Unsupported calls return `ENOSYS`, invalid arguments return Linux errno values, and terminal output is bounded before bytes cross the host trait. Successful termination reports the guest exit code plus instruction, syscall, and output counters. Every completed dispatch also appends a project-owned trace event whose arguments are captured before return-register mutation; an explicit syscall budget bounds trace growth. ### `binarrow-browser-runtime` @@ -104,7 +104,7 @@ Phase 4 is complete for the initial Tier-1 scope: bounded profiling, scalar basi Phase 5 is complete. A shared native/browser CPython regression independently installs the standard-library snapshot, a multi-file project image, and a checksum-pinned official `packaging` 26.2 wheel image. The CLI's repeatable image-overlay option mirrors the browser install operation, while the browser disables conflicting controls until each asynchronous image request is acknowledged and persists the result in OPFS. The Linux runtime implements the close-on-exec ioctl requests used by CPython's directory-opening path. Project and third-party filesystem imports, retained wheel metadata, deterministic output, guest-path tracebacks, exception text, and exit status are verified in both hosts. Guest-side package resolution, network indexes, and native wheels remain outside this phase; Phase 6 moves to in-browser C compilation. -Phase 6 is in progress. Static `execve` replacement provides bounded guest-vector ingestion and Linux-compatible close-on-exec handling; the packaged Clang/LLD/musl path now compiles browser-edited source, links it, and executes the resulting ELF. Compiler-format stderr records navigate to editor source positions. A bounded `O_NONBLOCK` `pipe2` checkpoint now moves bytes through the same process in native and Chromium hosts. Concurrent child processes, blocking pipe wakeups, cache policy, and interactive-performance work remain. +Phase 6 is in progress. Static `execve` replacement provides bounded guest-vector ingestion and Linux-compatible close-on-exec handling; the packaged Clang/LLD/musl path now compiles browser-edited source, links it, and executes the resulting ELF. Compiler-format stderr records navigate to editor source positions. Bounded `O_NONBLOCK` pipes move bytes through one process, and the constrained single-child scheduler now covers clone/exit/wait plus child `execve` with parent restoration in native and Chromium hosts. Descriptor sharing between parent and child, blocking wakeups, broader clone modes, cache policy, and interactive-performance work remain. An official static AArch64 Linux Zig 0.16.0 distribution serves as an ignored LLVM/LLD compatibility probe while the final Clang package is selected and pruned. It now completes its version path and advances `zig cc` through a bounded 20-million-instruction startup/compilation run using project-local caches. The trace-derived additions are floating-point width conversion, NEON `rev32`, directory-relative `*at` operations, path-only descriptors, initial guest environment entries, and precise missing-parent errno behavior. The probe bundle and caches remain under `.tmp`; they are evidence and test input, not a shipped replacement for Clang/LLD. diff --git a/guest-tests/spawn-exec/README.md b/guest-tests/spawn-exec/README.md new file mode 100644 index 0000000..7f9b978 --- /dev/null +++ b/guest-tests/spawn-exec/README.md @@ -0,0 +1,13 @@ +# Spawn/exec fixture + +The parent uses the runtime's constrained vfork-style `clone`, then the child +loads `/project/spawn/child` with `execve`. The child validates its argument, +prints `spawned child`, and exits 7. The restored parent reaps that exact status +with `wait4`, prints `parent resumed`, and exits zero. + +The filesystem image and both ELFs are deterministic checked-in fixtures. +Rebuild them with every cache and temporary file inside the repository: + +```sh +guest-tests/spawn-exec/build.sh +``` diff --git a/guest-tests/spawn-exec/build.sh b/guest-tests/spawn-exec/build.sh new file mode 100755 index 0000000..e852707 --- /dev/null +++ b/guest-tests/spawn-exec/build.sh @@ -0,0 +1,57 @@ +#!/bin/sh +set -eu + +fixture_directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +workspace_directory=$(CDPATH= cd -- "$fixture_directory/../.." && pwd) +cache_directory=$workspace_directory/.tmp/spawn-exec-zig-cache +cargo_home=$workspace_directory/.tmp/cargo-home +cargo_target=$workspace_directory/.tmp/cargo-target +rust_tmp=$workspace_directory/.tmp/rust-tmp +image_root=$fixture_directory/image-root +parent=$fixture_directory/spawn-exec.aarch64.elf +child=$image_root/child +image=$fixture_directory/spawn-exec.bnfs +zig_version=$(zig version) + +if [ "$zig_version" != "0.16.0" ]; then + echo "spawn-exec requires Zig 0.16.0; found $zig_version" >&2 + exit 1 +fi + +mkdir -p \ + "$cache_directory/local" \ + "$cache_directory/global" \ + "$cargo_home" \ + "$cargo_target" \ + "$rust_tmp" \ + "$image_root" +export TMPDIR=$rust_tmp + +for source_and_output in "parent.c:$parent" "child.c:$child"; do + source=${source_and_output%%:*} + output=${source_and_output#*:} + env \ + ZIG_LOCAL_CACHE_DIR="$cache_directory/local" \ + ZIG_GLOBAL_CACHE_DIR="$cache_directory/global" \ + zig cc \ + -target aarch64-linux-musl \ + -nostdlib \ + -static \ + -fno-stack-protector \ + -O1 \ + -g0 \ + -s \ + -Wl,--build-id=none \ + -Wl,-e,_start \ + "$fixture_directory/$source" \ + -o "$output" +done + +CARGO_HOME="$cargo_home" CARGO_TARGET_DIR="$cargo_target" CARGO_NET_OFFLINE=true \ + cargo build --manifest-path "$workspace_directory/Cargo.toml" --release -p binarrow-cli +"$cargo_target/release/binarrow" image pack \ + --guest-root /project/spawn \ + "$image_root" \ + "$image" + +chmod 0644 "$parent" "$child" "$image" diff --git a/guest-tests/spawn-exec/child.c b/guest-tests/spawn-exec/child.c new file mode 100644 index 0000000..69160e5 --- /dev/null +++ b/guest-tests/spawn-exec/child.c @@ -0,0 +1,53 @@ +enum { + SYS_WRITE = 64, + SYS_EXIT = 93, +}; + +static long syscall3(long number, long first, long second, long third) { + register long x0 __asm__("x0") = first; + register long x1 __asm__("x1") = second; + register long x2 __asm__("x2") = third; + register long x8 __asm__("x8") = number; + __asm__ volatile("svc #0" + : "+r"(x0) + : "r"(x1), "r"(x2), "r"(x8) + : "memory"); + return x0; +} + +__attribute__((noreturn)) static void exit_guest(long status) { + (void)syscall3(SYS_EXIT, status, 0, 0); + __builtin_unreachable(); +} + +__attribute__((noreturn)) void child_main(const unsigned long *stack) { + static const char expected_argument[] = "spawned"; + static const char message[] = "spawned child\n"; + unsigned long argc = stack[0]; + const char *const *argv = (const char *const *)&stack[1]; + const char *actual = argv[1]; + const char *expected = expected_argument; + + if (argc != 2 || argv[2] != 0) { + exit_guest(2); + } + while (*actual != 0 && *actual == *expected) { + ++actual; + ++expected; + } + if (*actual != *expected) { + exit_guest(3); + } + if (syscall3(SYS_WRITE, 1, (long)message, sizeof(message) - 1) != + sizeof(message) - 1) { + exit_guest(4); + } + exit_guest(7); +} + +__asm__( + ".global _start\n" + ".type _start, %function\n" + "_start:\n" + "mov x0, sp\n" + "b child_main\n"); diff --git a/guest-tests/spawn-exec/image-root/child b/guest-tests/spawn-exec/image-root/child new file mode 100644 index 0000000000000000000000000000000000000000..fa07b6a2262de421884437e0991c0c3a9382f21d GIT binary patch literal 1216 zcmb<-^>JfjWMqH=CWh?{Al?>6h@b;Zf*HnOU~pitU|?l%U|?rpV_;)oU|?YYi9zHY z7{J;YV6+6tFa`z&7|j7u237>p2jzk&s7kOD!xktHMnjDSOR_RBAjw1A33KlikVy;- z3@{p|FW_lO3dp_!sJR7aQ{Hhh)4MB_y3|BZg z9DZsrI#^~fGMH$wGE9_LV7|)v`@4WPql2Y9BX{;g28Iw1Q3nf6R)&cg|NomZGcbsN z)V%!pT|g78hBNyhBSVM+LqiZqJn`pufkTW9K^zAtlp+L=L9ZmWqJ%*&IX^cyHLrw0uQ;Q)q^Kk@2^5qRV^BPTuo2Xx z2xz*B00}ZMFo;9NVH8vt#%17SfTU0+s0b`Q!qO=ytw5!~6f9kWIZ*RKX2SG$K<)1U z2{JIi(-%xVEdRmyictGuX&jc1VEGP_k03hG?N^8DH-JdP$quN4tl%OL2Gm)Q%*VjM U0Mdt4v_KRwFfbeJfjWMqH=CWh?{Af60V&;cs3ff>qRU|?`yuwYp2jzk&s7kODgA5Z$hyhMRjRi}xG9biZ?u5|_P*Y$u zOkcp$k`$1A8=&$V7{Nv{z-SqWbqp{Xq!uI;__QPiK-~XOnjuwFmVM`ZR7v{6BI?*COR-Q1UWG^1i{pC za6rrgiOVoBOnE5GFcD-YOuj<_A`h~Qk%=MX1-rvec}AJSg^Ua#3JeWFU~{jqPJG0O zX{Njl!^edT3?UDgRB-OWaee37Fj94Rr&b1z~mV8it5(d5EjN+1_lEfr%yih`bq924o=?7#EES*Jw1Q{3@ zB0z#j7?xjQLYxec90k)4%RjJu1xlMRwICXnk3eh?7Dv;60h+!qK<)PdDMG^N_A5g5 z!}267-^0oaM81a@f^NS$RKEd48cy0k%Qq{y2!sK37NoEM=OG3Ls8WamC|L#-#HAmq Gi~#`UAH6I9 literal 0 HcmV?d00001 diff --git a/guest-tests/spawn-exec/spawn-exec.bnfs b/guest-tests/spawn-exec/spawn-exec.bnfs new file mode 100644 index 0000000000000000000000000000000000000000..fae3b1e85072f44b99744a525e463a1d0e8c19e9 GIT binary patch literal 1288 zcmZ?ra|>o-U|;}YMm`1v&@U*;&q_@$(JwAYEYD*SVPIf5zygs*mC{en$jnKpclB{& zVq}CU0Gqd+0mR$F2oZFE$uPqh3=9qo77VNm4h-xJYz%A+3=AOSVd@+hz}gvLv;@d7 z1_lNg%>gwABn;9A<$@@X5S-ou<-usEv0%el85kH~;xKo@=qn%-7#J8}G)!N>(~=aB zeFac+3mCyhF~De0IDxdnXpmYEKk#Wu3doNjHUSJZ8KMdt`l%@lP+9>T*jx+@jMB{3 zAUi;ceHa)SbRfzZ1fVoyRS*MXjU1yik33jR0u*)(j0|U>@*wH~Gs8qi1_luh28W;W zoD3fm85lx1cpQG7U|`tdz|au%kXe4!BWC$kE4Uki7#SF@aB?{O)L?Y5%wS|N(PCwo zD6hbLmGk#^0c}PHOL<1_?1u~tAs(U*7MiRK6Ept*H)UpE5CN%q`SZJgCRhz;_CZF5 z5Cw*YAdqwsjs(gH0U~&w4MfoX-C5a4rsTuKUMTxno@fj&a z2p)r8Noqw2gI;oeZfm7MsZ0|Nn#QxC@IFEcm!c1s7VpfbQJ*-WME(rhl;}} zs4$Gnz{vnfp`d&W5`?8kSULrz6_7X#!_p;;4>BKQCQN?^)cy{TAOizDeZkbj@*j+^ z2(=%U#$ovgmcJ1B2%-bses!pR1Bf)7?0`DR3N8X+K%E82d<+Z>Abm(h3q%nE1H&N# H`e6zIJsO!@ literal 0 HcmV?d00001 diff --git a/guest-tests/spawn-wait/README.md b/guest-tests/spawn-wait/README.md new file mode 100644 index 0000000..81b8485 --- /dev/null +++ b/guest-tests/spawn-wait/README.md @@ -0,0 +1,13 @@ +# Spawn/wait fixture + +This deterministic AArch64 Linux fixture uses the runtime's constrained +`clone(CLONE_VM | CLONE_VFORK | SIGCHLD)` mode. The child verifies PID 2 and +parent PID 1, writes `child`, and exits 7. The restored parent verifies PID 1, +reaps PID 2 with `wait4`, checks the encoded exit status, writes `parent`, and +exits zero. + +Rebuild it from the repository root while keeping the Zig cache local: + +```sh +TMPDIR="$PWD/.tmp" guest-tests/spawn-wait/build.sh +``` diff --git a/guest-tests/spawn-wait/build.sh b/guest-tests/spawn-wait/build.sh new file mode 100755 index 0000000..9da21e7 --- /dev/null +++ b/guest-tests/spawn-wait/build.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +fixture_directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cache_directory=${TMPDIR:-/tmp}/binarrow-spawn-wait-zig-cache +output=$fixture_directory/spawn-wait.aarch64.elf +zig_version=$(zig version) + +if [ "$zig_version" != "0.16.0" ]; then + echo "spawn-wait requires Zig 0.16.0; found $zig_version" >&2 + exit 1 +fi + +env \ + ZIG_LOCAL_CACHE_DIR="$cache_directory/local" \ + ZIG_GLOBAL_CACHE_DIR="$cache_directory/global" \ + zig cc \ + -target aarch64-linux-musl \ + -nostdlib \ + -static \ + -g0 \ + -Wl,-e,_start \ + -Wl,--build-id=none \ + "$fixture_directory/main.S" \ + -o "$output" + +chmod 0644 "$output" diff --git a/guest-tests/spawn-wait/main.S b/guest-tests/spawn-wait/main.S new file mode 100644 index 0000000..ab3e3f5 --- /dev/null +++ b/guest-tests/spawn-wait/main.S @@ -0,0 +1,85 @@ +.global _start +.type _start, %function + +_start: + movz x0, #0x4111 + mov x1, #0 + mov x2, #0 + mov x3, #0 + mov x4, #0 + mov x8, #220 + svc #0 + cbz x0, .Lchild + + mov x19, x0 + mov x8, #172 + svc #0 + cmp x0, #1 + b.ne .Lfailure + + mov x0, x19 + adr x1, child_status + mov x2, #0 + mov x3, #0 + mov x8, #260 + svc #0 + cmp x0, x19 + b.ne .Lfailure + adr x9, child_status + ldr w10, [x9] + cmp w10, #0x700 + b.ne .Lfailure + + mov x0, #1 + adr x1, parent_message + mov x2, #(parent_message_end - parent_message) + mov x8, #64 + svc #0 + cmp x0, #(parent_message_end - parent_message) + b.ne .Lfailure + + mov x0, #0 + mov x8, #93 + svc #0 + +.Lchild: + mov x8, #172 + svc #0 + cmp x0, #2 + b.ne .Lfailure + mov x8, #173 + svc #0 + cmp x0, #1 + b.ne .Lfailure + + mov x0, #1 + adr x1, child_message + mov x2, #(child_message_end - child_message) + mov x8, #64 + svc #0 + cmp x0, #(child_message_end - child_message) + b.ne .Lfailure + + mov x0, #7 + mov x8, #93 + svc #0 + +.Lfailure: + mov x0, #1 + mov x8, #93 + svc #0 + +.size _start, .-_start + +.section .rodata +child_message: + .ascii "child\n" +child_message_end: +parent_message: + .ascii "parent\n" +parent_message_end: + +.section .bss +.balign 4 +child_status: + .space 4 diff --git a/guest-tests/spawn-wait/spawn-wait.aarch64.elf b/guest-tests/spawn-wait/spawn-wait.aarch64.elf new file mode 100644 index 0000000000000000000000000000000000000000..44ade1f46dd419c65cf9fb525ba3be3a22569568 GIT binary patch literal 1520 zcmb<-^>JfjWMqH=CWh?{AYKL|M9={$@qro2U|?WyV6b3dWpH5NU|?flV_;xl0gFN8 z9T>pc8DO*o$S?*51{lo&Q3h57(g)>&D5y%X6hkVM2cx0Jf+blQ7#K33@-TP8=m#K^ z7#J8}G_pRBTRoT{3OtyYz$UMB3@-`M09BHlk(raiRghSe znpeWbz`&rO)NzTCq2Uq}L&GIzhK5Tl3=Nk$q#G_VGB8|eU}o6znVDf#hbV+E&%*Fg zk(D9j0kiNbM^+92n4S&=s62!4Yeg1@kW3a10WC&{o$-t^g$tP(LKGMpE+zi?9`J~v z;SvW2RILod$Ae4^Aq)%+msW5?#NhUDFnoN-$Plsu$=*aph&hbEz6%^eHb;WtVVFI81#~giy8Ei^K)}k^GX=>iYs$V5|bG8iZhB! ziXbcq&7e}j08Tpb#U+U)rNs=86cnGET3no%o{GkePt8k#Du*e6Xom8@@+v6|Dk%&g z^NLEqo}h>T1uh6fvk^R8BS3--3=F(zrbC6{qMQtn90|))uy}{%XL$HQbwJVql*7Ql zz|X+I04hUZ#Stuh!16V^dUU=B)P4hmkzf`~zYLg%AYkr?(XtE-3^2bVR537QKm|WQ z1=$%G7(jjnxfLWN0U{U}7*rV;7+`LP3W2EzsGvERhah0~!bJ3O=PkNonblocking pipe round trip + + diff --git a/web/src/probe.ts b/web/src/probe.ts index 2f38f46..a7a505b 100644 --- a/web/src/probe.ts +++ b/web/src/probe.ts @@ -25,6 +25,8 @@ export type FixtureName = | "pipe-roundtrip" | "vfs-lifecycle" | "system-services" + | "spawn-wait" + | "spawn-exec" | "terminal-input" | "execve-launcher"; diff --git a/web/tests/probe.spec.ts b/web/tests/probe.spec.ts index 9599b72..14969fa 100644 --- a/web/tests/probe.spec.ts +++ b/web/tests/probe.spec.ts @@ -199,6 +199,48 @@ test("round trips bytes through a bounded nonblocking pipe", async ({ page }) => ); }); +test("runs a constrained child and reaps its exit status", async ({ page }) => { + await page.getByLabel("Fixture").selectOption("spawn-wait"); + await page.getByRole("button", { name: "Start" }).click(); + + await expect(page.getByRole("status")).toHaveText("Guest exited"); + await expect(page.getByLabel("Guest terminal output")).toHaveText( + "child\nparent", + ); + await expect(page.locator("#exit-code")).toHaveText("0"); + await expect(page.locator("#syscall-count")).toHaveText("9"); + await expect(page.getByLabel("System call trace")).toContainText( + "clone(flags=0x4111", + ); + await expect(page.getByLabel("System call trace")).toContainText( + "wait4(pid=2", + ); +}); + +test("restores the parent after its child executes a project ELF", async ({ + page, +}) => { + await page.getByLabel("Snapshot or package image").setInputFiles( + path.resolve(process.cwd(), "../guest-tests/spawn-exec/spawn-exec.bnfs"), + ); + await page.getByRole("button", { name: "Replace project" }).click(); + await expect(page.getByRole("status")).toHaveText("Project snapshot imported"); + await page.getByLabel("Fixture").selectOption("spawn-exec"); + await page.getByRole("button", { name: "Start" }).click(); + + await expect(page.getByRole("status")).toHaveText("Guest exited"); + await expect(page.getByLabel("Guest terminal output")).toHaveText( + "spawned child\nparent resumed", + ); + await expect(page.locator("#exit-code")).toHaveText("0"); + await expect(page.getByLabel("System call trace")).toContainText( + "execve(path=", + ); + await expect(page.getByLabel("System call trace")).toContainText( + "wait4(pid=2", + ); +}); + test("persists project files in OPFS across a page reload", async ({ page }) => { await page .getByLabel("Fixture") -- 2.51.2