diff --git a/crates/browser-runtime/src/lib.rs b/crates/browser-runtime/src/lib.rs index 65dbbec..96f551c 100644 --- a/crates/browser-runtime/src/lib.rs +++ b/crates/browser-runtime/src/lib.rs @@ -2,8 +2,10 @@ use core::convert::Infallible; -use binarrow_host_api::{DeterministicSystem, HostTerminal, HostTime, TerminalStream}; -use binarrow_linux_runtime::{ExecutionError, Process}; +use binarrow_host_api::{ + DeterministicSystem, HostInput, HostTerminal, HostTime, TerminalInputRead, TerminalStream, +}; +use binarrow_linux_runtime::{ExecutionError, ExecutionEvent, Process}; use binarrow_loader::{Credentials, ProcessConfig, ProcessParameters, load_process}; use binarrow_memory_fs::MemoryFileSystem; use binarrow_runtime_core::{MemoryAccess, ResourceLimit, Trap}; @@ -47,6 +49,8 @@ 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 TERMINAL_INPUT_ELF: &[u8] = + include_bytes!("../../../guest-tests/terminal-input/terminal-input.aarch64.elf"); /// Browser-safe result returned after a guest exits or stops diagnostically. #[wasm_bindgen] @@ -62,6 +66,7 @@ pub struct BrowserExecution { dispatched_syscalls: u64, output_bytes: u64, filesystem_snapshot: Vec, + input_max_bytes: u64, } #[wasm_bindgen] @@ -131,6 +136,12 @@ impl BrowserExecution { pub fn filesystem_snapshot(&self) -> Vec { self.filesystem_snapshot.clone() } + + #[wasm_bindgen(getter)] + #[must_use] + pub fn input_max_bytes(&self) -> u64 { + self.input_max_bytes + } } /// Run one embedded deterministic fixture with browser-supplied resource limits. @@ -156,6 +167,223 @@ pub fn run_fixture( ) } +/// Stateful browser guest that can suspend and resume blocking terminal input. +#[wasm_bindgen] +pub struct BrowserGuestSession { + process: Option, + filesystem: MemoryFileSystem, + terminal: CapturedTerminal, + system: DeterministicSystem, + startup_failure: Option, + finished: bool, +} + +#[wasm_bindgen] +impl BrowserGuestSession { + /// Continue execution with optional terminal bytes or an end-of-file marker. + #[must_use] + pub fn resume(&mut self, input: &[u8], end_of_file: bool) -> BrowserExecution { + if let Some(failure) = self.startup_failure.take() { + self.finished = true; + return failure; + } + if self.finished { + return BrowserExecution::diagnostic( + "session.finished", + "guest session has already finished".to_owned(), + ); + } + let mut host_input = SessionInput { + bytes: input, + consumed: false, + end_of_file, + }; + let Some(process) = self.process.as_mut() else { + self.finished = true; + return BrowserExecution::diagnostic( + "session.invalid_state", + "guest session has no runnable process".to_owned(), + ); + }; + let execution = process.run_until_event( + &mut self.terminal, + &mut self.filesystem, + &mut self.system, + &mut host_input, + ); + let (outcome, diagnostic_code, diagnostic_message, exit_code, input_max_bytes) = + match execution { + Ok(ExecutionEvent::Exited(result)) => { + self.finished = true; + ( + "exited", + String::new(), + String::new(), + i32::from(result.exit_code), + 0, + ) + } + Ok(ExecutionEvent::Input(request)) => ( + "waiting-input", + String::new(), + String::new(), + -1, + request.max_bytes, + ), + Err(error) => { + self.finished = true; + let (code, message) = execution_diagnostic(&error); + ("diagnostic", code.to_owned(), message, -1, 0) + } + }; + observed_execution( + process, + &self.terminal, + &self.filesystem, + ObservedState { + outcome, + diagnostic_code, + diagnostic_message, + exit_code, + input_max_bytes, + }, + ) + } +} + +/// Create a stateful fixture session using browser-supplied resource limits. +#[wasm_bindgen] +#[must_use] +pub fn start_fixture( + fixture_name: &str, + instruction_budget: u64, + syscall_budget: u64, + max_output_bytes: u64, + max_memory_bytes: u64, + max_filesystem_bytes: u64, + filesystem_snapshot: &[u8], +) -> BrowserGuestSession { + let mut config = ProcessConfig::default(); + config.limits.instruction_budget = instruction_budget; + config.limits.syscall_budget = syscall_budget; + config.limits.max_output_bytes = max_output_bytes; + config.limits.max_memory_bytes = max_memory_bytes; + config.limits.max_filesystem_bytes = max_filesystem_bytes; + let filesystem = if filesystem_snapshot.is_empty() { + Ok(MemoryFileSystem::new(max_filesystem_bytes)) + } else { + MemoryFileSystem::from_snapshot(max_filesystem_bytes, filesystem_snapshot) + }; + let process = fixture(fixture_name) + .ok_or_else(|| { + BrowserExecution::diagnostic( + "request.unknown_fixture", + format!("unknown embedded fixture {fixture_name:?}"), + ) + }) + .and_then(|(elf, argv0)| { + load_process( + elf, + &ProcessParameters { + argv: vec![argv0.to_vec()], + envp: Vec::new(), + random_bytes: [0x42; 16], + credentials: Credentials::default(), + }, + config, + ) + .map_err(|error| BrowserExecution::diagnostic("loader.failed", error.to_string())) + }) + .and_then(|image| { + Process::new(image).map_err(|error| { + BrowserExecution::diagnostic("cpu.initialization_failed", error.to_string()) + }) + }); + let (process, filesystem, startup_failure) = match (process, filesystem) { + (Ok(process), Ok(filesystem)) => (Some(process), filesystem, None), + (Err(failure), Ok(filesystem)) => (None, filesystem, Some(failure)), + (_, Err(error)) => ( + None, + MemoryFileSystem::new(max_filesystem_bytes), + Some(BrowserExecution::diagnostic( + "filesystem.invalid_snapshot", + error.to_string(), + )), + ), + }; + BrowserGuestSession { + process, + filesystem, + terminal: CapturedTerminal::default(), + system: host_system(), + startup_failure, + finished: false, + } +} + +struct SessionInput<'a> { + bytes: &'a [u8], + consumed: bool, + end_of_file: bool, +} + +struct ObservedState { + outcome: &'static str, + diagnostic_code: String, + diagnostic_message: String, + exit_code: i32, + input_max_bytes: u64, +} + +fn observed_execution( + process: &Process, + terminal: &CapturedTerminal, + filesystem: &MemoryFileSystem, + state: ObservedState, +) -> BrowserExecution { + let filesystem_snapshot = match filesystem.export_snapshot() { + Ok(snapshot) => snapshot, + Err(error) => { + return BrowserExecution::diagnostic("filesystem.snapshot_failed", error.to_string()); + } + }; + BrowserExecution { + outcome: state.outcome.to_owned(), + diagnostic_code: state.diagnostic_code, + diagnostic_message: state.diagnostic_message, + stdout: String::from_utf8_lossy(&terminal.stdout).into_owned(), + stderr: String::from_utf8_lossy(&terminal.stderr).into_owned(), + trace: process + .trace() + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + exit_code: state.exit_code, + executed_instructions: process.executed_instructions(), + dispatched_syscalls: process.dispatched_syscalls(), + output_bytes: process.output_bytes(), + filesystem_snapshot, + input_max_bytes: state.input_max_bytes, + } +} + +impl HostInput for SessionInput<'_> { + fn read(&mut self, destination: &mut [u8]) -> TerminalInputRead { + if !self.consumed && !self.bytes.is_empty() { + self.consumed = true; + let count = destination.len().min(self.bytes.len()); + destination[..count].copy_from_slice(&self.bytes[..count]); + return TerminalInputRead::Available(count); + } + if self.end_of_file { + TerminalInputRead::Available(0) + } else { + TerminalInputRead::WouldBlock + } + } +} + fn execute_fixture( fixture_name: &str, instruction_budget: u64, @@ -253,6 +481,7 @@ fn execute_fixture( ); } }, + input_max_bytes: 0, } } @@ -295,6 +524,7 @@ impl BrowserExecution { dispatched_syscalls: 0, output_bytes: 0, filesystem_snapshot: Vec::new(), + input_max_bytes: 0, } } } @@ -314,6 +544,7 @@ fn fixture(name: &str) -> Option<(&'static [u8], &'static [u8])> { } "vfs-lifecycle" => Some((VFS_LIFECYCLE_ELF, b"/vfs-lifecycle")), "system-services" => Some((SYSTEM_SERVICES_ELF, b"/system-services")), + "terminal-input" => Some((TERMINAL_INPUT_ELF, b"/terminal-input")), _ => None, } } @@ -383,7 +614,7 @@ impl HostTerminal for CapturedTerminal { #[cfg(test)] mod tests { - use super::execute_fixture; + use super::{execute_fixture, start_fixture}; const DEFAULT_INSTRUCTIONS: u64 = 10_000_000; const DEFAULT_SYSCALLS: u64 = 100_000; @@ -527,4 +758,30 @@ mod tests { assert!(result.trace.contains("clock_gettime(clock=0")); assert!(result.trace.contains("getrandom(buffer=")); } + + #[test] + fn stateful_browser_session_resumes_terminal_input() { + let mut session = start_fixture( + "terminal-input", + DEFAULT_INSTRUCTIONS, + DEFAULT_SYSCALLS, + DEFAULT_OUTPUT, + DEFAULT_MEMORY, + DEFAULT_FILESYSTEM, + &[], + ); + + let waiting = session.resume(&[], false); + assert_eq!(waiting.outcome, "waiting-input"); + assert_eq!(waiting.stdout, "input> "); + assert_eq!(waiting.input_max_bytes, 64); + assert_eq!(waiting.dispatched_syscalls, 2); + + let completed = session.resume(b"browser input\n", false); + assert_eq!(completed.outcome, "exited"); + assert_eq!(completed.exit_code, 0); + assert_eq!(completed.stdout, "input> echo: browser input\n"); + assert_eq!(completed.dispatched_syscalls, 5); + assert!(completed.trace.contains("read(fd=0")); + } } diff --git a/crates/host-api/src/lib.rs b/crates/host-api/src/lib.rs index 6b468f2..6c51ee7 100644 --- a/crates/host-api/src/lib.rs +++ b/crates/host-api/src/lib.rs @@ -96,6 +96,28 @@ pub trait HostTerminal { fn write(&mut self, stream: TerminalStream, bytes: &[u8]) -> Result<(), Self::Error>; } +/// Result of attempting to read bytes from an interactive terminal. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TerminalInputRead { + Available(usize), + WouldBlock, +} + +/// Input service that may cooperatively suspend a guest terminal read. +pub trait HostInput { + fn read(&mut self, destination: &mut [u8]) -> TerminalInputRead; +} + +/// End-of-file input used for non-interactive execution APIs. +#[derive(Default)] +pub struct ClosedInput; + +impl HostInput for ClosedInput { + fn read(&mut self, _destination: &mut [u8]) -> TerminalInputRead { + TerminalInputRead::Available(0) + } +} + /// Access requested for an opened host-backed file. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FileAccess { diff --git a/crates/linux-runtime/src/lib.rs b/crates/linux-runtime/src/lib.rs index 9492303..2f9b328 100644 --- a/crates/linux-runtime/src/lib.rs +++ b/crates/linux-runtime/src/lib.rs @@ -6,13 +6,15 @@ use std::collections::BTreeMap; use binarrow_aarch64::{Aarch64State, Interpreter, InterpreterInitializationError}; use binarrow_guest_memory::{AddressSpace, Permissions, RegionKind}; use binarrow_host_api::{ - DeterministicSystem, FileAccess, FileOpenFlags, FileOpenOptions, FileSeekFrom, FileSystemError, - FileType, HostClock, HostFileSystem, HostSystem, HostTerminal, NullFileSystem, TerminalStream, + ClosedInput, DeterministicSystem, FileAccess, FileOpenFlags, FileOpenOptions, FileSeekFrom, + FileSystemError, FileType, HostClock, HostFileSystem, HostInput, HostSystem, HostTerminal, + NullFileSystem, TerminalInputRead, TerminalStream, }; use binarrow_linux_abi::{Errno, Syscall}; use binarrow_loader::ProcessImage; 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; @@ -83,6 +85,26 @@ pub struct ExecutionResult { pub output_bytes: u64, } +/// Terminal input requested by a cooperatively suspended guest process. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct InputRequest { + pub max_bytes: u64, +} + +/// A process either exits or suspends while waiting for terminal input. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExecutionEvent { + Exited(ExecutionResult), + Input(InputRequest), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PendingInput { + address: GuestAddress, + count: u64, + arguments: [u64; 6], +} + /// Observable result of one completed Linux syscall dispatch. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SyscallOutcome { @@ -304,6 +326,7 @@ pub struct Process { next_mmap_address: GuestAddress, file_descriptors: BTreeMap, trace: Vec, + pending_input: Option, } impl Process { @@ -329,6 +352,7 @@ impl Process { next_mmap_address: GuestAddress::new(MMAP_ARENA_START), file_descriptors: BTreeMap::new(), trace: Vec::new(), + pending_input: None, }) } @@ -407,6 +431,32 @@ impl Process { filesystem: &mut F, system: &mut S, ) -> Result> { + match self.run_until_event(terminal, filesystem, system, &mut ClosedInput)? { + ExecutionEvent::Exited(result) => Ok(result), + ExecutionEvent::Input(_) => { + unreachable!("closed non-interactive input cannot block") + } + } + } + + /// Run until exit, failure, or a blocking terminal-input request. + /// + /// Calling this method again with available input resumes the pending + /// `read` without re-executing or recounting its supervisor call. + /// + /// # Errors + /// + /// Returns the same structured execution failures as [`Self::run`]. + pub fn run_until_event( + &mut self, + terminal: &mut T, + filesystem: &mut F, + system: &mut S, + input: &mut I, + ) -> Result> { + if let Some(request) = self.resume_pending_input(input) { + return Ok(ExecutionEvent::Input(request)); + } loop { let remaining = self .limits @@ -443,21 +493,22 @@ impl Process { let number = stop.supervisor_call.syscall_number; let arguments = self.syscall_arguments(); if let Some(result) = - self.dispatch_syscall(terminal, filesystem, system, number, arguments)? + self.dispatch_syscall(terminal, filesystem, system, input, number, arguments)? { return Ok(result); } } } - fn dispatch_syscall( + fn dispatch_syscall( &mut self, terminal: &mut T, filesystem: &mut F, system: &mut S, + input: &mut I, number: u64, arguments: [u64; 6], - ) -> Result, ExecutionError> { + ) -> Result, ExecutionError> { match Syscall::from_number(number) { Some(Syscall::Exit | Syscall::ExitGroup) => { let exit_code = self.register(0).to_le_bytes()[0]; @@ -467,12 +518,12 @@ impl Process { arguments, outcome: SyscallOutcome::Exited(exit_code), }); - return Ok(Some(ExecutionResult { + return Ok(Some(ExecutionEvent::Exited(ExecutionResult { exit_code, executed_instructions: self.executed_instructions, dispatched_syscalls: self.dispatched_syscalls, output_bytes: self.output_bytes, - })); + }))); } Some(Syscall::Mkdirat) => self.dispatch_mkdirat(filesystem), Some(Syscall::Unlinkat) => self.dispatch_unlinkat(filesystem), @@ -481,7 +532,15 @@ impl Process { Some(Syscall::Close) => self.dispatch_close(filesystem), Some(Syscall::Getdents64) => self.dispatch_getdents64(filesystem), Some(Syscall::Lseek) => self.dispatch_lseek(filesystem), - Some(Syscall::Read) => self.dispatch_read(filesystem), + Some(Syscall::Read) => { + if self.register(0) == STANDARD_INPUT { + if let Some(request) = self.dispatch_terminal_read(input, arguments) { + return Ok(Some(ExecutionEvent::Input(request))); + } + } else { + self.dispatch_read(filesystem); + } + } Some(Syscall::Ppoll) => self.set_return(0), Some(Syscall::Write) => self.dispatch_write(terminal, filesystem)?, Some(Syscall::SetTidAddress) => { @@ -804,6 +863,76 @@ impl Process { } } + fn dispatch_terminal_read( + &mut self, + input: &mut I, + arguments: [u64; 6], + ) -> Option { + let count = self.register(2); + let Ok(host_count) = usize::try_from(count) else { + self.set_return(Errno::InvalidArgument.return_value()); + return None; + }; + if count > self.limits.max_memory_bytes { + self.set_return(Errno::InvalidArgument.return_value()); + return None; + } + if host_count == 0 { + self.set_return(0); + return None; + } + let pending = PendingInput { + address: GuestAddress::new(self.register(1)), + count, + arguments, + }; + let mut bytes = vec![0; host_count]; + match input.read(&mut bytes) { + TerminalInputRead::WouldBlock => { + self.pending_input = Some(pending); + Some(InputRequest { max_bytes: count }) + } + TerminalInputRead::Available(read) => { + self.finish_terminal_read(pending, &bytes[..read.min(host_count)]); + None + } + } + } + + fn resume_pending_input(&mut self, input: &mut I) -> Option { + let pending = self.pending_input.take()?; + let host_count = + usize::try_from(pending.count).expect("validated pending terminal count fits the host"); + let mut bytes = vec![0; host_count]; + match input.read(&mut bytes) { + TerminalInputRead::WouldBlock => { + self.pending_input = Some(pending); + Some(InputRequest { + max_bytes: pending.count, + }) + } + TerminalInputRead::Available(read) => { + let returned = self.finish_terminal_read(pending, &bytes[..read.min(host_count)]); + self.trace.push(SyscallEvent { + number: Syscall::Read.number(), + arguments: pending.arguments, + outcome: SyscallOutcome::Returned(returned), + }); + None + } + } + } + + fn finish_terminal_read(&mut self, pending: PendingInput, bytes: &[u8]) -> u64 { + let returned = if self.memory.write(pending.address, bytes).is_ok() { + bytes.len() as u64 + } else { + Errno::Fault.return_value() + }; + self.set_return(returned); + returned + } + fn dispatch_read(&mut self, filesystem: &mut F) { let Some(handle) = self.file_handle(self.register(0)) else { self.set_return(Errno::BadFileDescriptor.return_value()); @@ -1255,13 +1384,18 @@ const fn filesystem_error_return(error: FileSystemError) -> u64 { mod tests { use core::convert::Infallible; - use binarrow_host_api::{HostTerminal, NullFileSystem, TerminalStream}; + use binarrow_host_api::{ + DeterministicSystem, HostInput, HostTerminal, NullFileSystem, TerminalInputRead, + TerminalStream, + }; use binarrow_linux_abi::Errno; use binarrow_loader::{Credentials, ProcessConfig, ProcessParameters, load_process}; use binarrow_memory_fs::MemoryFileSystem; use binarrow_runtime_core::{GuestAddress, ResourceLimit, Trap}; - use super::{ExecutionError, MAIN_THREAD_ID, Process, SyscallEvent, SyscallOutcome}; + use super::{ + ExecutionError, ExecutionEvent, MAIN_THREAD_ID, Process, SyscallEvent, SyscallOutcome, + }; const MESSAGE: &[u8] = b"hello, world\n"; const MESSAGE_LENGTH: u16 = 13; @@ -1281,6 +1415,8 @@ mod tests { include_bytes!("../../../guest-tests/infinite-loop/infinite-loop.aarch64.elf"); const FILE_ROUNDTRIP_ELF: &[u8] = include_bytes!("../../../guest-tests/file-roundtrip/file-roundtrip.aarch64.elf"); + const TERMINAL_INPUT_ELF: &[u8] = + include_bytes!("../../../guest-tests/terminal-input/terminal-input.aarch64.elf"); const FILE_ROUNDTRIP_MESSAGE: &[u8] = b"filesystem hello\n"; const RUST_MESSAGE: &[u8] = b"rust hello\n"; @@ -1302,6 +1438,19 @@ mod tests { } } + struct TestInput(Option>); + + impl HostInput for TestInput { + fn read(&mut self, destination: &mut [u8]) -> TerminalInputRead { + let Some(bytes) = self.0.take() else { + return TerminalInputRead::WouldBlock; + }; + let count = destination.len().min(bytes.len()); + destination[..count].copy_from_slice(&bytes[..count]); + TerminalInputRead::Available(count) + } + } + #[test] fn static_elf_writes_hello_world_and_exits() { let image = load_hello(ProcessConfig::default(), 1, MESSAGE_ADDRESS); @@ -1582,6 +1731,64 @@ mod tests { ); } + #[test] + fn blocking_terminal_read_suspends_and_resumes_without_reexecuting_the_syscall() { + let image = load_process( + TERMINAL_INPUT_ELF, + &ProcessParameters { + argv: vec![b"/terminal-input".to_vec()], + envp: Vec::new(), + random_bytes: [0x42; 16], + credentials: Credentials::default(), + }, + ProcessConfig::default(), + ) + .unwrap(); + let mut process = Process::new(image).unwrap(); + let mut terminal = RecordingTerminal::default(); + let mut filesystem = NullFileSystem; + let mut system = DeterministicSystem::default(); + + let suspended = process + .run_until_event( + &mut terminal, + &mut filesystem, + &mut system, + &mut TestInput(None), + ) + .unwrap(); + assert_eq!( + suspended, + ExecutionEvent::Input(super::InputRequest { max_bytes: 64 }) + ); + assert_eq!(terminal.standard_output, b"input> "); + assert_eq!(process.dispatched_syscalls(), 2); + assert_eq!(process.trace().len(), 1); + + let completed = process + .run_until_event( + &mut terminal, + &mut filesystem, + &mut system, + &mut TestInput(Some(b"hello\n".to_vec())), + ) + .unwrap(); + let ExecutionEvent::Exited(result) = completed else { + panic!("guest should exit after input is supplied"); + }; + assert_eq!(result.exit_code, 0); + assert_eq!(terminal.standard_output, b"input> echo: hello\n"); + assert_eq!(process.dispatched_syscalls(), 5); + assert_eq!( + process + .trace() + .iter() + .map(|event| event.number) + .collect::>(), + [64, 63, 64, 64, 93] + ); + } + #[test] fn syscall_limit_bounds_dispatch_and_trace_growth() { let mut config = ProcessConfig::default(); diff --git a/guest-tests/terminal-input/README.md b/guest-tests/terminal-input/README.md new file mode 100644 index 0000000..e9a67f4 --- /dev/null +++ b/guest-tests/terminal-input/README.md @@ -0,0 +1,10 @@ +# Blocking terminal input AArch64 fixture + +This freestanding static AArch64 Linux program prints a prompt, blocks on +standard input, then echoes the bytes supplied after the process resumes. + +Build the deterministic fixture with Zig 0.16.0: + +```sh +guest-tests/terminal-input/build.sh +``` diff --git a/guest-tests/terminal-input/build.sh b/guest-tests/terminal-input/build.sh new file mode 100755 index 0000000..991cfcd --- /dev/null +++ b/guest-tests/terminal-input/build.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +fixture_directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cache_directory=${TMPDIR:-/tmp}/binarrow-zig-cache +output=$fixture_directory/terminal-input.aarch64.elf +zig_version=$(zig version) + +if [ "$zig_version" != "0.16.0" ]; then + echo "terminal-input 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 \ + -fno-stack-protector \ + -O1 \ + -g0 \ + -s \ + -Wl,--build-id=none \ + -Wl,-e,_start \ + "$fixture_directory/main.c" \ + -o "$output" + +chmod 0644 "$output" diff --git a/guest-tests/terminal-input/main.c b/guest-tests/terminal-input/main.c new file mode 100644 index 0000000..6ea9e98 --- /dev/null +++ b/guest-tests/terminal-input/main.c @@ -0,0 +1,41 @@ +enum { + SYS_READ = 63, + 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 _start(void) { + static const char prompt[] = "input> "; + static const char prefix[] = "echo: "; + char input[64]; + + if (syscall3(SYS_WRITE, 1, (long)prompt, sizeof(prompt) - 1) != sizeof(prompt) - 1) { + exit_guest(1); + } + long count = syscall3(SYS_READ, 0, (long)input, sizeof(input)); + if (count <= 0) { + exit_guest(2); + } + if (syscall3(SYS_WRITE, 1, (long)prefix, sizeof(prefix) - 1) != sizeof(prefix) - 1 || + syscall3(SYS_WRITE, 1, (long)input, count) != count) { + exit_guest(3); + } + exit_guest(0); +} diff --git a/guest-tests/terminal-input/terminal-input.aarch64.elf b/guest-tests/terminal-input/terminal-input.aarch64.elf new file mode 100644 index 0000000000000000000000000000000000000000..48f68c2aa6ddabfdbbfb7232f35d849d16b0ad56 GIT binary patch literal 1200 zcmb<-^>JfjWMqH=CWh?{Al@2Ah@b;h;sX3zlSMU_g?GxD)2y3m}si z7#LtQOkcp$k`$1A8BlXG7{Nv{z-SSuJ{S#B3la)^T9N|tBZy4^LrsRL%FHV$EwNKz zNKMYjw^CqWV3cOI2AK^~vFnr`?VF-E2%&-cizw!V735pG@6AvNlmtgql$ixr=k_YL3#LT%0ZZ-qw>qJJF z+48F%G0U%7!3{ARq8?Q&fuSMjAv3C&0~Rrl{M^)}qSSJQyVugYgxi`eA7rmTzGB43TdjI$-$#!ed}i khw3+g2|(!@sE4eed>93F76Xh1>0^dTBGKCj=!Yo<0NW9X6aWAK literal 0 HcmV?d00001 diff --git a/web/index.html b/web/index.html index efaab55..43489cb 100644 --- a/web/index.html +++ b/web/index.html @@ -34,6 +34,7 @@ + @@ -49,6 +50,11 @@ + +
+ + +

         
diff --git a/web/src/main.ts b/web/src/main.ts index cfb28ca..b80fd30 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -36,10 +36,13 @@ const memoryLimit = requiredElement("#memory-limit"); const filesystemLimit = requiredElement("#filesystem-limit"); const startButton = requiredElement("#start"); const stopButton = requiredElement("#stop"); +const terminalInput = requiredElement("#terminal-input"); +const sendInputButton = requiredElement("#send-input"); let worker: Worker; let workerReady = false; let running = false; +let waitingInput = false; let runId = 0; let preserveDiagnosticStatus = false; @@ -47,6 +50,8 @@ function setControls(): void { startButton.disabled = !workerReady || running; stopButton.disabled = !running; fixture.disabled = running; + terminalInput.disabled = !waitingInput; + sendInputButton.disabled = !waitingInput; } function renderFeatures(results: FeatureResult[]): void { @@ -93,7 +98,10 @@ function renderExecution(execution: ExecutionReport): void { outputCount.textContent = String(execution.outputBytes); renderDiagnostic(execution.diagnostic); - if (execution.outcome === "exited") { + if (execution.outcome === "waiting-input") { + status.textContent = `Waiting for terminal input (up to ${execution.inputMaxBytes} bytes)`; + status.dataset.state = "running"; + } else if (execution.outcome === "exited") { status.textContent = "Guest exited"; status.dataset.state = "ready"; } else { @@ -129,6 +137,7 @@ function createWorker(): void { worker.addEventListener("message", (event: MessageEvent) => { if (event.data.kind === "error") { running = false; + waitingInput = false; status.textContent = `Worker failed: ${event.data.message}`; status.dataset.state = "error"; setControls(); @@ -147,12 +156,17 @@ function createWorker(): void { if (event.data.runId !== runId) { return; } - running = false; + waitingInput = event.data.execution.outcome === "waiting-input"; + running = waitingInput; renderExecution(event.data.execution); setControls(); + if (waitingInput) { + terminalInput.focus(); + } }); worker.addEventListener("error", (event) => { running = false; + waitingInput = false; status.textContent = `Worker failed: ${event.message}`; status.dataset.state = "error"; setControls(); @@ -164,6 +178,7 @@ startButton.addEventListener("click", () => { const limits = readLimits(); preserveDiagnosticStatus = false; running = true; + waitingInput = false; runId += 1; clearExecution(); status.textContent = "Guest running…"; @@ -188,6 +203,7 @@ stopButton.addEventListener("click", () => { } worker.terminate(); running = false; + waitingInput = false; preserveDiagnosticStatus = true; status.textContent = "Execution stopped by user"; status.dataset.state = "error"; @@ -199,4 +215,30 @@ stopButton.addEventListener("click", () => { createWorker(); }); +function sendTerminalInput(): void { + if (!waitingInput) { + return; + } + waitingInput = false; + status.textContent = "Guest running…"; + status.dataset.state = "running"; + setControls(); + const command: WorkerCommand = { + kind: "input", + runId, + input: `${terminalInput.value}\n`, + endOfFile: false, + }; + terminalInput.value = ""; + worker.postMessage(command); +} + +sendInputButton.addEventListener("click", sendTerminalInput); +terminalInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + sendTerminalInput(); + } +}); + createWorker(); diff --git a/web/src/probe.ts b/web/src/probe.ts index 24347ec..225c01e 100644 --- a/web/src/probe.ts +++ b/web/src/probe.ts @@ -21,7 +21,8 @@ export type FixtureName = | "project-persistence-write" | "project-persistence-read" | "vfs-lifecycle" - | "system-services"; + | "system-services" + | "terminal-input"; export interface FeatureResult { name: FeatureName; @@ -43,7 +44,7 @@ export interface ExecutionDiagnostic { } export interface ExecutionReport { - outcome: "exited" | "diagnostic"; + outcome: "exited" | "diagnostic" | "waiting-input"; diagnostic?: ExecutionDiagnostic; stdout: string; stderr: string; @@ -52,14 +53,22 @@ export interface ExecutionReport { executedInstructions: bigint; dispatchedSyscalls: bigint; outputBytes: bigint; + inputMaxBytes: bigint; } -export type WorkerCommand = { - kind: "run"; - runId: number; - fixture: FixtureName; - limits: ExecutionLimits; -}; +export type WorkerCommand = + | { + kind: "run"; + runId: number; + fixture: FixtureName; + limits: ExecutionLimits; + } + | { + kind: "input"; + runId: number; + input: string; + endOfFile: boolean; + }; export type WorkerMessage = | { kind: "ready"; results: FeatureResult[] } diff --git a/web/src/probe.worker.ts b/web/src/probe.worker.ts index 09408c5..42d4b2e 100644 --- a/web/src/probe.worker.ts +++ b/web/src/probe.worker.ts @@ -7,13 +7,15 @@ import { typedFunctionReferences, } from "wasm-feature-detect"; import initBrowserRuntime, { - run_fixture, + start_fixture, } from "./generated/binarrow_browser_runtime.js"; +import type { BrowserGuestSession } from "./generated/binarrow_browser_runtime.js"; import type { ExecutionReport, FeatureName, FeatureResult, + FixtureName, WorkerCommand, WorkerMessage, } from "./probe"; @@ -174,9 +176,62 @@ async function initializeRuntime(): Promise { }); } -async function execute(command: WorkerCommand): Promise { +function executionReport( + result: ReturnType, +): ExecutionReport { + const diagnosticCode = result.diagnostic_code; + return { + outcome: result.outcome as ExecutionReport["outcome"], + ...(diagnosticCode + ? { + diagnostic: { + code: diagnosticCode, + message: result.diagnostic_message, + }, + } + : {}), + stdout: result.stdout, + stderr: result.stderr, + trace: result.trace, + exitCode: result.exit_code < 0 ? null : result.exit_code, + executedInstructions: result.executed_instructions, + dispatchedSyscalls: result.dispatched_syscalls, + outputBytes: result.output_bytes, + inputMaxBytes: result.input_max_bytes, + }; +} + +let activeSession: BrowserGuestSession | undefined; +let activeRunId = 0; +let activeFixture: FixtureName = "compiler-c"; + +async function continueSession( + input: Uint8Array, + endOfFile: boolean, +): Promise { + if (!activeSession) { + throw new Error("no guest session is waiting for input"); + } + const result = activeSession.resume(input, endOfFile); + try { + await saveFilesystemSnapshot(result.filesystem_snapshot); + const report = executionReport(result); + if (report.outcome !== "waiting-input") { + activeSession.free(); + activeSession = undefined; + } + return report; + } finally { + result.free(); + } +} + +async function execute( + command: Extract, +): Promise { const filesystemSnapshot = await loadFilesystemSnapshot(); - const result = run_fixture( + activeSession?.free(); + activeSession = start_fixture( command.fixture, command.limits.instructionBudget, command.limits.syscallBudget, @@ -185,30 +240,9 @@ async function execute(command: WorkerCommand): Promise { command.limits.maxFilesystemBytes, filesystemSnapshot, ); - try { - await saveFilesystemSnapshot(result.filesystem_snapshot); - const diagnosticCode = result.diagnostic_code; - return { - outcome: result.outcome as ExecutionReport["outcome"], - ...(diagnosticCode - ? { - diagnostic: { - code: diagnosticCode, - message: result.diagnostic_message, - }, - } - : {}), - stdout: result.stdout, - stderr: result.stderr, - trace: result.trace, - exitCode: result.exit_code < 0 ? null : result.exit_code, - executedInstructions: result.executed_instructions, - dispatchedSyscalls: result.dispatched_syscalls, - outputBytes: result.output_bytes, - }; - } finally { - result.free(); - } + activeRunId = command.runId; + activeFixture = command.fixture; + return continueSession(new Uint8Array(), false); } const ready = Promise.all([createReport(), initializeRuntime()]); @@ -227,17 +261,24 @@ ready }); self.addEventListener("message", (event: MessageEvent) => { - if (event.data.kind !== "run") { - return; - } const command = event.data; void ready .then(async () => { + if (command.kind === "input" && command.runId !== activeRunId) { + return; + } + const execution = + command.kind === "run" + ? await execute(command) + : await continueSession( + new TextEncoder().encode(command.input), + command.endOfFile, + ); const message: WorkerMessage = { kind: "result", runId: command.runId, - fixture: command.fixture, - execution: await execute(command), + fixture: command.kind === "run" ? command.fixture : activeFixture, + execution, }; self.postMessage(message); }) diff --git a/web/src/styles.css b/web/src/styles.css index 2e8b485..539b1de 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -97,6 +97,16 @@ h2 { gap: 0.75rem; } +.terminal-actions { + display: flex; + gap: 0.75rem; +} + +.terminal-actions input { + min-width: 0; + flex: 1; +} + #controls button { min-width: 7rem; cursor: pointer; diff --git a/web/tests/probe.spec.ts b/web/tests/probe.spec.ts index 89ac90d..e447845 100644 --- a/web/tests/probe.spec.ts +++ b/web/tests/probe.spec.ts @@ -148,6 +148,26 @@ test("provides clocks, randomness, and minimal dev and proc mounts", async ({ ); }); +test("suspends and resumes a blocking terminal read", async ({ page }) => { + await page.getByLabel("Fixture").selectOption("terminal-input"); + await page.getByRole("button", { name: "Start" }).click(); + + await expect(page.getByRole("status")).toHaveText( + "Waiting for terminal input (up to 64 bytes)", + ); + await expect(page.getByLabel("Guest terminal output")).toHaveText("input>"); + await expect(page.locator("#syscall-count")).toHaveText("2"); + await page.getByLabel("Terminal input").fill("hello browser"); + await page.getByRole("button", { name: "Send line" }).click(); + + await expect(page.getByRole("status")).toHaveText("Guest exited"); + await expect(page.getByLabel("Guest terminal output")).toHaveText( + "input> echo: hello browser", + ); + await expect(page.locator("#syscall-count")).toHaveText("5"); + await expect(page.getByLabel("System call trace")).toContainText("read(fd=0"); +}); + test("terminates and restarts a Worker running an infinite guest", async ({ page }) => { await page.getByLabel("Fixture").selectOption("infinite-loop"); await page