use std::collections::HashMap; use std::time::Duration; use swactor::actor::ActorAddress; use swactor_process::*; fn automated_spec() -> ProcessSpec { ProcessSpec { command: "echo".into(), args: vec!["hello".into()], env: HashMap::new(), working_dir: None, mode: ProcessMode::Automated, initial_pty_size: None, kill_timeout: None, stdin_buffer_limit: None, } } fn spec_with_kill_timeout(timeout: Duration) -> ProcessSpec { ProcessSpec { kill_timeout: Some(timeout), ..automated_spec() } } fn spec_with_stdin_limit(limit: usize) -> ProcessSpec { ProcessSpec { stdin_buffer_limit: Some(limit), ..automated_spec() } } fn interactive_spec() -> ProcessSpec { ProcessSpec { command: "/bin/bash".into(), args: vec![], env: HashMap::new(), working_dir: None, mode: ProcessMode::Interactive, initial_pty_size: Some(PtySize { cols: 80, rows: 24 }), kill_timeout: None, stdin_buffer_limit: None, } } fn addr(n: u8) -> ActorAddress { let mut bytes = [0u8; 32]; bytes[0] = n; ActorAddress(bytes) } /// Verify that a SelfTerminate is present and is the last action. fn assert_self_terminate_is_last(actions: &[ProcessAction]) { assert!( matches!(actions.last(), Some(ProcessAction::SelfTerminate)), "SelfTerminate must be the last action, got: {actions:?}" ); } // ────────────────────────────────────────────── // 1. Happy path — automated process // ────────────────────────────────────────────── #[test] fn automated_process_runs_produces_output_and_exits_cleanly() { let (mut session, init) = ProcessSession::new(automated_spec()); assert_eq!(session.state(), ProcessState::Starting); assert!(matches!(&init[0], ProcessAction::SpawnProcess { .. })); // Process starts let actions = session.apply(ProcessEvent::Started); assert_eq!(session.state(), ProcessState::Running); assert!(matches!(&actions[0], ProcessAction::NotifyStarted { .. })); // Some output arrives let actions = session.apply(ProcessEvent::OutputReceived { data: b"hello\n".to_vec(), is_stderr: false, }); assert!(matches!( &actions[0], ProcessAction::NotifyOutput { stream: OutputStream::Stdout, .. } )); // More output on stderr let actions = session.apply(ProcessEvent::OutputReceived { data: b"warn\n".to_vec(), is_stderr: true, }); assert!(matches!( &actions[0], ProcessAction::NotifyOutput { stream: OutputStream::Stderr, .. } )); // Process exits let actions = session.apply(ProcessEvent::Exited { status: ExitStatus::Code(0), }); assert_eq!(session.state(), ProcessState::Exited); assert_eq!(session.exit_status(), Some(ExitStatus::Code(0))); assert_self_terminate_is_last(&actions); } // ────────────────────────────────────────────── // 2. Interactive process with subscriber lifecycle // ────────────────────────────────────────────── #[test] fn interactive_session_manages_subscribers_correctly() { let (mut session, _) = ProcessSession::new(interactive_spec()); // Add two subscribers before start session.apply(ProcessEvent::Subscribe { address: addr(1) }); session.apply(ProcessEvent::Subscribe { address: addr(2) }); assert_eq!(session.subscriber_count(), 2); // Duplicate add is a no-op session.apply(ProcessEvent::Subscribe { address: addr(1) }); assert_eq!(session.subscriber_count(), 2); // Start — both subscribers notified let actions = session.apply(ProcessEvent::Started); match &actions[0] { ProcessAction::NotifyStarted { subscribers } => { assert_eq!(subscribers.len(), 2); } other => panic!("expected NotifyStarted, got {other:?}"), } // Remove one subscriber session.apply(ProcessEvent::Unsubscribe { address: addr(1) }); assert_eq!(session.subscriber_count(), 1); // Output only goes to remaining subscriber let actions = session.apply(ProcessEvent::OutputReceived { data: b"data".to_vec(), is_stderr: false, }); match &actions[0] { ProcessAction::NotifyOutput { subscribers, .. } => { assert_eq!(subscribers, &vec![addr(2)]); } other => panic!("expected NotifyOutput, got {other:?}"), } // Exit let actions = session.apply(ProcessEvent::Exited { status: ExitStatus::Code(0), }); match &actions[0] { ProcessAction::NotifyExited { subscribers, .. } => { assert_eq!(subscribers, &vec![addr(2)]); } other => panic!("expected NotifyExited, got {other:?}"), } assert_self_terminate_is_last(&actions); } // ────────────────────────────────────────────── // 3. Spawn failure // ────────────────────────────────────────────── #[test] fn spawn_failure_notifies_and_self_terminates() { let (mut session, _) = ProcessSession::new(automated_spec()); session.apply(ProcessEvent::Subscribe { address: addr(1) }); let actions = session.apply(ProcessEvent::SpawnFailed { reason: "command not found".into(), }); assert_eq!(session.state(), ProcessState::Exited); assert!(matches!( &actions[0], ProcessAction::NotifyError { error: ProcessError::SpawnFailed { .. }, .. } )); assert_self_terminate_is_last(&actions); } // ────────────────────────────────────────────── // 4. Connection loss mid-run // ────────────────────────────────────────────── #[test] fn connection_loss_during_running_transitions_to_exited() { let (mut session, _) = ProcessSession::new(automated_spec()); session.apply(ProcessEvent::Started); let actions = session.apply(ProcessEvent::ConnectionLost { reason: "pipe broken".into(), }); assert_eq!(session.state(), ProcessState::Exited); assert_eq!(session.exit_status(), Some(ExitStatus::Unknown)); assert!(matches!( &actions[0], ProcessAction::NotifyError { error: ProcessError::ConnectionLost { .. }, .. } )); assert_self_terminate_is_last(&actions); } // ────────────────────────────────────────────── // 5. Close requested before start // ────────────────────────────────────────────── #[test] fn close_before_start_sends_signal_on_belated_start() { let (mut session, _) = ProcessSession::new(automated_spec()); // Close requested while still Starting let actions = session.apply(ProcessEvent::CloseRequested); assert!(actions.is_empty()); assert_eq!(session.state(), ProcessState::Starting); // Process starts belatedly — should immediately get SIGTERM let actions = session.apply(ProcessEvent::Started); assert_eq!(session.state(), ProcessState::Stopping); assert!(matches!(&actions[0], ProcessAction::NotifyStarted { .. })); assert!(matches!( &actions[1], ProcessAction::SendSignal { signal: Signal::Terminate } )); } // ────────────────────────────────────────────── // 6. Invalid operations produce errors, not panics // ────────────────────────────────────────────── #[test] fn invalid_event_in_starting_produces_error() { let (mut session, _) = ProcessSession::new(automated_spec()); let actions = session.apply(ProcessEvent::WriteStdin { data: b"hi".to_vec(), }); assert!(matches!( &actions[0], ProcessAction::NotifyError { error: ProcessError::InvalidState { attempted: "WriteStdin", current_state: "Starting" }, .. } )); // State unchanged assert_eq!(session.state(), ProcessState::Starting); } #[test] fn invalid_event_in_exited_produces_error() { let (mut session, _) = ProcessSession::new(automated_spec()); session.apply(ProcessEvent::SpawnFailed { reason: "no".into(), }); assert_eq!(session.state(), ProcessState::Exited); let actions = session.apply(ProcessEvent::WriteStdin { data: b"hi".to_vec(), }); assert!(matches!( &actions[0], ProcessAction::NotifyError { error: ProcessError::InvalidState { attempted: "WriteStdin", current_state: "Exited" }, .. } )); } // ────────────────────────────────────────────── // 7. Stdin closed then write → error // ────────────────────────────────────────────── #[test] fn write_after_stdin_closed_produces_error() { let (mut session, _) = ProcessSession::new(automated_spec()); session.apply(ProcessEvent::Started); let actions = session.apply(ProcessEvent::CloseStdin); assert!(matches!(&actions[0], ProcessAction::CloseStdin)); assert!(session.stdin_closed()); // Duplicate close is a no-op let actions = session.apply(ProcessEvent::CloseStdin); assert!(actions.is_empty()); // Write after close → error let actions = session.apply(ProcessEvent::WriteStdin { data: b"too late".to_vec(), }); assert!(matches!( &actions[0], ProcessAction::NotifyError { error: ProcessError::InvalidState { .. }, .. } )); } // ────────────────────────────────────────────── // 8. MockDriver round-trip (driver + session tick loop) // ────────────────────────────────────────────── #[test] fn mock_driver_round_trip() { let (mut session, init_actions) = ProcessSession::new(automated_spec()); let mut driver = MockDriver::new(); // Execute initial actions (SpawnProcess) for action in init_actions { driver.execute(action); } assert!(matches!( &driver.executed_actions()[0], ProcessAction::SpawnProcess { .. } )); // Simulate: driver produces Started driver.inject(ProcessEvent::Started); // Tick loop: poll → apply → execute let events = driver.poll(); for event in events { let actions = session.apply(event); for action in actions { driver.execute(action); } } assert_eq!(session.state(), ProcessState::Running); // Simulate output and exit driver.inject(ProcessEvent::OutputReceived { data: b"done".to_vec(), is_stderr: false, }); driver.inject(ProcessEvent::Exited { status: ExitStatus::Code(0), }); let events = driver.poll(); for event in events { let actions = session.apply(event); for action in actions { driver.execute(action); } } assert_eq!(session.state(), ProcessState::Exited); // Verify the driver saw the expected sequence let all_actions = driver.take_executed_actions(); assert!(matches!(&all_actions[0], ProcessAction::SpawnProcess { .. })); assert!(matches!(&all_actions[1], ProcessAction::NotifyStarted { .. })); assert!(matches!(&all_actions[2], ProcessAction::NotifyOutput { .. })); assert!(matches!(&all_actions[3], ProcessAction::NotifyExited { .. })); assert!(matches!(&all_actions[4], ProcessAction::SelfTerminate)); } // ────────────────────────────────────────────── // 9. Signal escalation in Stopping // ────────────────────────────────────────────── #[test] fn signal_escalation_allowed_in_stopping() { let (mut session, _) = ProcessSession::new(automated_spec()); session.apply(ProcessEvent::Started); session.apply(ProcessEvent::CloseRequested); assert_eq!(session.state(), ProcessState::Stopping); // Escalate to Kill let actions = session.apply(ProcessEvent::SendSignal { signal: Signal::Kill, }); assert!(matches!( &actions[0], ProcessAction::SendSignal { signal: Signal::Kill } )); // Can still receive output while stopping let actions = session.apply(ProcessEvent::OutputReceived { data: b"final".to_vec(), is_stderr: false, }); assert!(matches!(&actions[0], ProcessAction::NotifyOutput { .. })); // Finally exits let actions = session.apply(ProcessEvent::Exited { status: ExitStatus::Signal(9), }); assert_eq!(session.exit_status(), Some(ExitStatus::Signal(9))); assert_self_terminate_is_last(&actions); } // ────────────────────────────────────────────── // 10. Late acks in Exited silently consumed // ────────────────────────────────────────────── #[test] fn late_acks_in_exited_are_silently_consumed() { let (mut session, _) = ProcessSession::new(automated_spec()); session.apply(ProcessEvent::Started); session.apply(ProcessEvent::Exited { status: ExitStatus::Code(0), }); assert_eq!(session.state(), ProcessState::Exited); // Acks should produce no actions, no errors assert!(session.apply(ProcessEvent::StdinWritten { byte_count: 10 }).is_empty()); assert!(session.apply(ProcessEvent::SignalSent).is_empty()); assert!(session.apply(ProcessEvent::PtyResized).is_empty()); // Subscribe/Unsubscribe also still works in Exited assert!(session.apply(ProcessEvent::Subscribe { address: addr(1) }).is_empty()); assert_eq!(session.subscriber_count(), 1); assert!(session.apply(ProcessEvent::Unsubscribe { address: addr(1) }).is_empty()); assert_eq!(session.subscriber_count(), 0); } // ────────────────────────────────────────────── // Flow control tracking // ────────────────────────────────────────────── #[test] fn flow_control_tracks_pending_stdin_bytes() { let (mut session, _) = ProcessSession::new(automated_spec()); session.apply(ProcessEvent::Started); session.apply(ProcessEvent::WriteStdin { data: vec![0u8; 100], }); assert_eq!(session.flow_control().pending_stdin_bytes, 100); session.apply(ProcessEvent::WriteStdin { data: vec![0u8; 50], }); assert_eq!(session.flow_control().pending_stdin_bytes, 150); session.apply(ProcessEvent::StdinWritten { byte_count: 80 }); assert_eq!(session.flow_control().pending_stdin_bytes, 70); // Ack more than pending → saturates at 0 session.apply(ProcessEvent::StdinWritten { byte_count: 200 }); assert_eq!(session.flow_control().pending_stdin_bytes, 0); } // ────────────────────────────────────────────── // CloseStdin in Stopping // ────────────────────────────────────────────── #[test] fn close_stdin_allowed_in_stopping() { let (mut session, _) = ProcessSession::new(automated_spec()); session.apply(ProcessEvent::Started); session.apply(ProcessEvent::CloseRequested); assert_eq!(session.state(), ProcessState::Stopping); let actions = session.apply(ProcessEvent::CloseStdin); assert!(matches!(&actions[0], ProcessAction::CloseStdin)); assert!(session.stdin_closed()); } // ────────────────────────────────────────────── // Connection loss in Stopping // ────────────────────────────────────────────── #[test] fn connection_loss_in_stopping_transitions_to_exited() { let (mut session, _) = ProcessSession::new(automated_spec()); session.apply(ProcessEvent::Started); session.apply(ProcessEvent::CloseRequested); assert_eq!(session.state(), ProcessState::Stopping); let actions = session.apply(ProcessEvent::ConnectionLost { reason: "gone".into(), }); assert_eq!(session.state(), ProcessState::Exited); assert_self_terminate_is_last(&actions); } // ────────────────────────────────────────────── // Redundant CloseRequested in Stopping is no-op // ────────────────────────────────────────────── #[test] fn duplicate_close_requested_in_stopping_is_noop() { let (mut session, _) = ProcessSession::new(automated_spec()); session.apply(ProcessEvent::Started); session.apply(ProcessEvent::CloseRequested); assert_eq!(session.state(), ProcessState::Stopping); let actions = session.apply(ProcessEvent::CloseRequested); assert!(actions.is_empty()); assert_eq!(session.state(), ProcessState::Stopping); } // ────────────────────────────────────────────── // Kill timeout — A1–A6 // ────────────────────────────────────────────── #[test] fn close_requested_with_kill_timeout_schedules_timer() { let (mut session, _) = ProcessSession::new(spec_with_kill_timeout(Duration::from_secs(5))); session.apply(ProcessEvent::Started); let actions = session.apply(ProcessEvent::CloseRequested); assert_eq!(session.state(), ProcessState::Stopping); assert!(matches!( &actions[0], ProcessAction::SendSignal { signal: Signal::Terminate } )); assert!(matches!( &actions[1], ProcessAction::ScheduleKillTimeout { duration } if *duration == Duration::from_secs(5) )); } #[test] fn close_before_start_with_kill_timeout_schedules_timer_on_belated_start() { let (mut session, _) = ProcessSession::new(spec_with_kill_timeout(Duration::from_secs(3))); session.apply(ProcessEvent::CloseRequested); let actions = session.apply(ProcessEvent::Started); assert_eq!(session.state(), ProcessState::Stopping); assert!(matches!(&actions[0], ProcessAction::NotifyStarted { .. })); assert!(matches!( &actions[1], ProcessAction::SendSignal { signal: Signal::Terminate } )); assert!(matches!( &actions[2], ProcessAction::ScheduleKillTimeout { duration } if *duration == Duration::from_secs(3) )); } #[test] fn kill_timeout_in_stopping_sends_sigkill() { let (mut session, _) = ProcessSession::new(spec_with_kill_timeout(Duration::from_secs(5))); session.apply(ProcessEvent::Started); session.apply(ProcessEvent::CloseRequested); assert_eq!(session.state(), ProcessState::Stopping); let actions = session.apply(ProcessEvent::KillTimeout); assert!(matches!( &actions[0], ProcessAction::SendSignal { signal: Signal::Kill } )); assert_eq!(session.state(), ProcessState::Stopping); } #[test] fn kill_timeout_silently_consumed_outside_stopping() { // Starting let (mut session, _) = ProcessSession::new(automated_spec()); assert!(session.apply(ProcessEvent::KillTimeout).is_empty()); assert_eq!(session.state(), ProcessState::Starting); // Running session.apply(ProcessEvent::Started); assert!(session.apply(ProcessEvent::KillTimeout).is_empty()); assert_eq!(session.state(), ProcessState::Running); // Exited session.apply(ProcessEvent::Exited { status: ExitStatus::Code(0) }); assert!(session.apply(ProcessEvent::KillTimeout).is_empty()); assert_eq!(session.state(), ProcessState::Exited); } #[test] fn close_requested_without_kill_timeout_no_schedule_action() { let (mut session, _) = ProcessSession::new(automated_spec()); session.apply(ProcessEvent::Started); let actions = session.apply(ProcessEvent::CloseRequested); assert_eq!(actions.len(), 1); assert!(matches!( &actions[0], ProcessAction::SendSignal { signal: Signal::Terminate } )); } #[test] fn kill_timeout_full_escalation_to_sigkill_then_exit() { let (mut session, _) = ProcessSession::new(spec_with_kill_timeout(Duration::from_secs(1))); session.apply(ProcessEvent::Started); // CloseRequested → SIGTERM + schedule let actions = session.apply(ProcessEvent::CloseRequested); assert_eq!(session.state(), ProcessState::Stopping); assert!(matches!(&actions[0], ProcessAction::SendSignal { signal: Signal::Terminate })); assert!(matches!(&actions[1], ProcessAction::ScheduleKillTimeout { .. })); // KillTimeout fires → SIGKILL let actions = session.apply(ProcessEvent::KillTimeout); assert!(matches!(&actions[0], ProcessAction::SendSignal { signal: Signal::Kill })); // Process finally exits via signal 9 let actions = session.apply(ProcessEvent::Exited { status: ExitStatus::Signal(9) }); assert_eq!(session.state(), ProcessState::Exited); assert_eq!(session.exit_status(), Some(ExitStatus::Signal(9))); assert_self_terminate_is_last(&actions); } // ────────────────────────────────────────────── // Backpressure — B1–B5 // ────────────────────────────────────────────── #[test] fn backpressure_buffers_when_over_limit() { let (mut session, _) = ProcessSession::new(spec_with_stdin_limit(100)); session.apply(ProcessEvent::Started); // First write (50 bytes) — under limit, passes through let actions = session.apply(ProcessEvent::WriteStdin { data: vec![1u8; 50] }); assert_eq!(actions.len(), 1); assert!(matches!(&actions[0], ProcessAction::WriteStdin { .. })); assert_eq!(session.flow_control().pending_stdin_bytes, 50); // Second write (60 bytes) — still under limit (50 < 100), passes through let actions = session.apply(ProcessEvent::WriteStdin { data: vec![2u8; 60] }); assert_eq!(actions.len(), 1); assert_eq!(session.flow_control().pending_stdin_bytes, 110); // Third write (30 bytes) — now at 110 >= 100, buffered let actions = session.apply(ProcessEvent::WriteStdin { data: vec![3u8; 30] }); assert!(actions.is_empty()); assert_eq!(session.stdin_buffer_bytes(), 30); // pending_stdin_bytes unchanged (buffered data not counted as pending) assert_eq!(session.flow_control().pending_stdin_bytes, 110); } #[test] fn stdin_written_ack_drains_buffer() { let (mut session, _) = ProcessSession::new(spec_with_stdin_limit(100)); session.apply(ProcessEvent::Started); // Fill up: 100 bytes pending session.apply(ProcessEvent::WriteStdin { data: vec![1u8; 100] }); assert_eq!(session.flow_control().pending_stdin_bytes, 100); // Buffer two chunks session.apply(ProcessEvent::WriteStdin { data: vec![2u8; 40] }); session.apply(ProcessEvent::WriteStdin { data: vec![3u8; 30] }); assert_eq!(session.stdin_buffer_bytes(), 70); // Ack 80 bytes → pending drops to 20, buffer should drain in FIFO order let actions = session.apply(ProcessEvent::StdinWritten { byte_count: 80 }); // pending was 100, now 20. Drain first chunk (40 bytes) → pending = 60. // 60 < 100, drain second chunk (30 bytes) → pending = 90. // 90 < 100, buffer empty. assert_eq!(actions.len(), 2); assert!(matches!(&actions[0], ProcessAction::WriteStdin { data } if data.len() == 40)); assert!(matches!(&actions[1], ProcessAction::WriteStdin { data } if data.len() == 30)); assert_eq!(session.flow_control().pending_stdin_bytes, 90); assert_eq!(session.stdin_buffer_bytes(), 0); } #[test] fn close_requested_clears_stdin_buffer() { let (mut session, _) = ProcessSession::new(spec_with_stdin_limit(50)); session.apply(ProcessEvent::Started); session.apply(ProcessEvent::WriteStdin { data: vec![1u8; 60] }); session.apply(ProcessEvent::WriteStdin { data: vec![2u8; 30] }); assert_eq!(session.stdin_buffer_bytes(), 30); session.apply(ProcessEvent::CloseRequested); assert_eq!(session.stdin_buffer_bytes(), 0); } #[test] fn no_backpressure_when_limit_is_none() { let (mut session, _) = ProcessSession::new(automated_spec()); session.apply(ProcessEvent::Started); // All writes pass through regardless of pending bytes for _ in 0..10 { let actions = session.apply(ProcessEvent::WriteStdin { data: vec![0u8; 1000] }); assert_eq!(actions.len(), 1); assert!(matches!(&actions[0], ProcessAction::WriteStdin { .. })); } assert_eq!(session.flow_control().pending_stdin_bytes, 10_000); assert_eq!(session.stdin_buffer_bytes(), 0); } #[test] fn exit_clears_stdin_buffer() { let (mut session, _) = ProcessSession::new(spec_with_stdin_limit(50)); session.apply(ProcessEvent::Started); session.apply(ProcessEvent::WriteStdin { data: vec![1u8; 60] }); session.apply(ProcessEvent::WriteStdin { data: vec![2u8; 30] }); assert_eq!(session.stdin_buffer_bytes(), 30); session.apply(ProcessEvent::Exited { status: ExitStatus::Code(0) }); assert_eq!(session.stdin_buffer_bytes(), 0); }