//! Layer 4 — LocalDriver integration tests. //! //! Real OS processes, no actor layer. Tests LocalDriver in isolation. use std::collections::HashMap; use std::sync::{Arc, OnceLock}; use std::thread; use std::time::Duration; use swactor_process::*; fn automated_spec(cmd: &str, args: &[&str]) -> ProcessSpec { ProcessSpec { command: cmd.into(), args: args.iter().map(|s| s.to_string()).collect(), env: HashMap::new(), working_dir: None, mode: ProcessMode::Automated, initial_pty_size: None, kill_timeout: None, stdin_buffer_limit: None, } } /// Poll the driver until `pred` matches at least one collected event, or timeout. fn poll_until_match( driver: &mut LocalDriver, timeout: Duration, pred: impl Fn(&ProcessEvent) -> bool, ) -> Vec { let start = std::time::Instant::now(); let mut all_events = Vec::new(); loop { let events = driver.poll(); if events.is_empty() { if start.elapsed() >= timeout { break; } thread::sleep(Duration::from_millis(10)); } all_events.extend(events); if all_events.iter().any(&pred) { break; } } all_events } fn has_event(events: &[ProcessEvent], pred: impl Fn(&ProcessEvent) -> bool) -> bool { events.iter().any(pred) } #[test] fn echo_produces_started_output_and_exit_zero() { let queue = EventQueue::new(); let waker_slot = Arc::new(OnceLock::new()); let mut driver = LocalDriver::new(queue, waker_slot); let spec = automated_spec("echo", &["hello"]); driver.execute(ProcessAction::SpawnProcess { spec }); // Wait for Exited (which means Started + output + exit are all in) let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { matches!(e, ProcessEvent::Exited { .. }) }); assert!( has_event(&events, |e| matches!(e, ProcessEvent::Started)), "should have Started event, got: {:?}", events ); assert!( has_event(&events, |e| matches!( e, ProcessEvent::OutputReceived { is_stderr: false, .. } )), "should have stdout OutputReceived" ); // Check the output contains "hello" let output: Vec = events .iter() .filter_map(|e| match e { ProcessEvent::OutputReceived { data, is_stderr: false, } => Some(data.clone()), _ => None, }) .flatten() .collect(); let output_str = String::from_utf8_lossy(&output); assert!( output_str.contains("hello"), "output should contain 'hello', got: {:?}", output_str ); assert!( has_event(&events, |e| matches!( e, ProcessEvent::Exited { status: ExitStatus::Code(0) } )), "should have Exited(0)" ); } #[test] fn cat_stdin_echo_and_close() { let queue = EventQueue::new(); let waker_slot = Arc::new(OnceLock::new()); let mut driver = LocalDriver::new(queue, waker_slot); let spec = automated_spec("cat", &[]); driver.execute(ProcessAction::SpawnProcess { spec }); // Wait for Started let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { matches!(e, ProcessEvent::Started) }); assert!(has_event(&events, |e| matches!(e, ProcessEvent::Started))); // Write to stdin driver.execute(ProcessAction::WriteStdin { data: b"ping\n".to_vec(), }); // Wait until we see actual output (not just the StdinWritten ack) let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { matches!(e, ProcessEvent::OutputReceived { .. }) }); let output: Vec = events .iter() .filter_map(|e| match e { ProcessEvent::OutputReceived { data, .. } => Some(data.clone()), _ => None, }) .flatten() .collect(); let output_str = String::from_utf8_lossy(&output); assert!( output_str.contains("ping"), "cat should echo back 'ping', got: {:?}", output_str ); // Close stdin — cat should exit driver.execute(ProcessAction::CloseStdin); let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { matches!(e, ProcessEvent::Exited { .. }) }); assert!( has_event(&events, |e| matches!( e, ProcessEvent::Exited { status: ExitStatus::Code(0) } )), "cat should exit cleanly after stdin close, got: {:?}", events ); } #[test] fn signal_terminates_long_running_process() { let queue = EventQueue::new(); let waker_slot = Arc::new(OnceLock::new()); let mut driver = LocalDriver::new(queue, waker_slot); let spec = automated_spec("sleep", &["60"]); driver.execute(ProcessAction::SpawnProcess { spec }); // Wait for Started let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { matches!(e, ProcessEvent::Started) }); assert!(has_event(&events, |e| matches!(e, ProcessEvent::Started))); // Send SIGTERM driver.execute(ProcessAction::SendSignal { signal: Signal::Terminate, }); // Wait for Exited (may also see SignalSent ack first) let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { matches!(e, ProcessEvent::Exited { .. }) }); assert!( has_event(&events, |e| matches!( e, ProcessEvent::Exited { status: ExitStatus::Signal(_) } )), "sleep should exit with signal status after SIGTERM, got: {:?}", events ); } #[test] fn bad_command_produces_spawn_failed() { let queue = EventQueue::new(); let waker_slot = Arc::new(OnceLock::new()); let mut driver = LocalDriver::new(queue, waker_slot); let spec = automated_spec("/nonexistent/binary/that/does/not/exist", &[]); driver.execute(ProcessAction::SpawnProcess { spec }); let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { matches!(e, ProcessEvent::SpawnFailed { .. }) }); assert!( has_event(&events, |e| matches!(e, ProcessEvent::SpawnFailed { .. })), "nonexistent binary should produce SpawnFailed, got: {:?}", events ); } #[test] fn large_output_no_data_loss() { let queue = EventQueue::new(); let waker_slot = Arc::new(OnceLock::new()); let mut driver = LocalDriver::new(queue, waker_slot); // Generate a large amount of output: seq 1 10000 let spec = automated_spec("seq", &["1", "10000"]); driver.execute(ProcessAction::SpawnProcess { spec }); // Collect all events until exit let events = poll_until_match(&mut driver, Duration::from_secs(10), |e| { matches!(e, ProcessEvent::Exited { .. }) }); // Gather all output let output: Vec = events .iter() .filter_map(|e| match e { ProcessEvent::OutputReceived { data, .. } => Some(data.clone()), _ => None, }) .flatten() .collect(); let output_str = String::from_utf8_lossy(&output); // seq 1 10000 should end with "10000\n" assert!( output_str.contains("10000"), "large output should contain '10000'" ); // Check that it starts with "1\n" assert!( output_str.starts_with("1\n"), "large output should start with '1\\n'" ); assert!( has_event(&events, |e| matches!( e, ProcessEvent::Exited { status: ExitStatus::Code(0) } )), "seq should exit cleanly" ); } #[test] fn kill_timeout_escalates_to_sigkill() { let queue = EventQueue::new(); let waker_slot = Arc::new(OnceLock::new()); let mut driver = LocalDriver::new(queue, waker_slot); // Spawn a process that traps SIGTERM. Use exec to replace the shell so // SIGTERM goes directly to the perl process (avoids shell vs child races). let spec = automated_spec("perl", &["-e", "$SIG{TERM} = 'IGNORE'; sleep 300"]); driver.execute(ProcessAction::SpawnProcess { spec }); // Wait for Started let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { matches!(e, ProcessEvent::Started) }); assert!(has_event(&events, |e| matches!(e, ProcessEvent::Started))); // Give the process a moment to set up the trap thread::sleep(Duration::from_millis(100)); // Send SIGTERM (the process ignores it) driver.execute(ProcessAction::SendSignal { signal: Signal::Terminate, }); poll_until_match(&mut driver, Duration::from_secs(1), |e| { matches!(e, ProcessEvent::SignalSent) }); // Verify the process is still alive after a short wait (SIGTERM was ignored) thread::sleep(Duration::from_millis(200)); let events = driver.poll(); assert!( !has_event(&events, |e| matches!(e, ProcessEvent::Exited { .. })), "process should still be alive after SIGTERM (trap should ignore it)" ); // Schedule a short kill timeout driver.execute(ProcessAction::ScheduleKillTimeout { duration: Duration::from_millis(200), }); // Wait for KillTimeout event let events = poll_until_match(&mut driver, Duration::from_secs(3), |e| { matches!(e, ProcessEvent::KillTimeout) }); assert!( has_event(&events, |e| matches!(e, ProcessEvent::KillTimeout)), "should receive KillTimeout, got: {:?}", events ); // Now send SIGKILL driver.execute(ProcessAction::SendSignal { signal: Signal::Kill, }); // Wait for exit let events = poll_until_match(&mut driver, Duration::from_secs(5), |e| { matches!(e, ProcessEvent::Exited { .. }) }); assert!( has_event(&events, |e| matches!( e, ProcessEvent::Exited { status: ExitStatus::Signal(_) } )), "process should exit with signal after SIGKILL, got: {:?}", events ); }