//! T-actor: InferenceActor + process bridge component tests. //! //! Spawns the InferenceActor on a single swactor runtime with `echo_worker.py` //! as the child process. No networking, no GPU. use std::collections::HashMap; use std::time::{Duration, Instant}; use swactor::actor::ActorAddress; use swactor::runtime::{Inbox, Runtime, RuntimeConfig}; use single_gpu_inference::inference_actor::{InferenceActor, InferenceActorMsg, InferenceActorStatus}; use single_gpu_inference::messages::{InferenceRequest, InferenceResponse}; use swactor_process::{ExitStatus, ProcessMode, ProcessSpec}; // ── Helpers ─────────────────────────────────────────────────────────────── fn echo_worker_spec() -> ProcessSpec { ProcessSpec { command: "python3".into(), args: vec![format!("{}/echo_worker.py", env!("CARGO_MANIFEST_DIR"))], env: HashMap::new(), working_dir: None, mode: ProcessMode::Automated, initial_pty_size: None, kill_timeout: Some(Duration::from_secs(2)), stdin_buffer_limit: None, } } /// Tick the runtime in a polling loop until the inbox has a message or timeout. fn tick_until_recv( rt: &Runtime, inbox: &Inbox, timeout: Duration, ) -> Option { let start = Instant::now(); while start.elapsed() < timeout { rt.tick(); if let Some(msg) = inbox.try_recv() { return Some(msg); } std::thread::sleep(Duration::from_millis(5)); } None } /// Spin up an InferenceActor and wait until it reports WorkerReady. /// Returns (actor address, worker PID). fn spawn_and_wait_ready( rt: &Runtime, status_inbox: &Inbox, ) -> (ActorAddress, u32) { let sender = rt.create_sender(); let actor = InferenceActor::new(echo_worker_spec(), sender) .with_status_addr(*status_inbox.addr()); let addr = rt.spawn(actor).unwrap(); let timeout = Duration::from_secs(5); let mut got_started = false; let mut worker_pid = None; let start = Instant::now(); while start.elapsed() < timeout { if let Some(status) = tick_until_recv(rt, status_inbox, Duration::from_millis(100)) { match status { InferenceActorStatus::ProcessStarted => got_started = true, InferenceActorStatus::WorkerReady { pid } => { assert!(got_started, "WorkerReady should come after ProcessStarted"); worker_pid = pid; break; } other => panic!("unexpected status during startup: {:?}", other), } } } let pid = worker_pid.expect("worker should report PID within timeout"); (addr, pid) } fn is_process_alive(pid: u32) -> bool { std::fs::metadata(format!("/proc/{}", pid)).is_ok() } // ── Tests ───────────────────────────────────────────────────────────────── #[test] fn actor_spawns_process_and_receives_started() { let rt = Runtime::new(RuntimeConfig::default()); let status_inbox = rt.new_inbox::().unwrap(); let (_addr, pid) = spawn_and_wait_ready(&rt, &status_inbox); // The process should be alive assert!(is_process_alive(pid), "worker process should be running"); } #[test] fn inference_request_flows_through_process_and_reply_arrives() { let rt = Runtime::new(RuntimeConfig::default()); let status_inbox = rt.new_inbox::().unwrap(); let response_inbox = rt.new_inbox::().unwrap(); let (addr, _pid) = spawn_and_wait_ready(&rt, &status_inbox); // Send an inference request rt.send_to( addr, InferenceActorMsg::Request(InferenceRequest { prompt: "Hello, world!".into(), max_tokens: 8, temperature: 0.7, reply_to: *response_inbox.addr(), }), ) .unwrap(); let response = tick_until_recv(&rt, &response_inbox, Duration::from_secs(5)) .expect("should receive InferenceResponse"); assert!( response.text.contains("Hello, world!"), "echo worker should reflect the prompt, got: {:?}", response.text ); } #[test] fn worker_crash_is_handled_without_poisoning_runtime() { let rt = Runtime::new(RuntimeConfig::default()); let status_inbox = rt.new_inbox::().unwrap(); let response_inbox = rt.new_inbox::().unwrap(); let (addr, _pid) = spawn_and_wait_ready(&rt, &status_inbox); // Send a request whose prompt triggers an os._exit(1) in the worker rt.send_to( addr, InferenceActorMsg::Request(InferenceRequest { prompt: "__crash__".into(), max_tokens: 1, temperature: 0.0, reply_to: *response_inbox.addr(), }), ) .unwrap(); // The actor should report ProcessExited let status = tick_until_recv(&rt, &status_inbox, Duration::from_secs(5)) .expect("should receive ProcessExited status"); match status { InferenceActorStatus::ProcessExited { status } => { assert_ne!( status, ExitStatus::Code(0), "crashed worker should not exit 0" ); } other => panic!("expected ProcessExited, got: {:?}", other), } // Prove the runtime is still alive: spawn a trivial actor and interact with it #[derive(Clone)] struct Ping { reply_to: ActorAddress, } #[derive(Clone, Debug, PartialEq)] struct Pong; struct PongActor; impl swactor::actor::ActorInterface for PongActor { type Incoming = Ping; type Response = Pong; fn handle(&mut self, ctx: &swactor::runtime::Ctx, msg: Ping) { let _ = ctx.send(msg.reply_to, Pong); } } let pong_inbox = rt.new_inbox::().unwrap(); let pong_addr = rt.spawn(PongActor).unwrap(); rt.send_to(pong_addr, Ping { reply_to: *pong_inbox.addr() }).unwrap(); let pong = tick_until_recv(&rt, &pong_inbox, Duration::from_secs(2)); assert_eq!(pong, Some(Pong), "runtime should still be functional after worker crash"); } #[test] fn stopping_actor_kills_child_process() { let rt = Runtime::new(RuntimeConfig::default()); let status_inbox = rt.new_inbox::().unwrap(); let (addr, pid) = spawn_and_wait_ready(&rt, &status_inbox); assert!(is_process_alive(pid), "worker should be alive before stop"); // Stop the InferenceActor rt.stop_actor(addr).unwrap(); // Tick until the child process is gone let start = Instant::now(); let timeout = Duration::from_secs(5); while start.elapsed() < timeout { rt.tick(); std::thread::sleep(Duration::from_millis(10)); if !is_process_alive(pid) { break; } } assert!( !is_process_alive(pid), "worker process (pid {}) should be dead after actor stop", pid ); }