215 lines
7.1 KiB
Rust
215 lines
7.1 KiB
Rust
//! 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<M: swactor::actor::Message>(
|
|
rt: &Runtime,
|
|
inbox: &Inbox<M>,
|
|
timeout: Duration,
|
|
) -> Option<M> {
|
|
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<InferenceActorStatus>,
|
|
) -> (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::<InferenceActorStatus>().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::<InferenceActorStatus>().unwrap();
|
|
let response_inbox = rt.new_inbox::<InferenceResponse>().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::<InferenceActorStatus>().unwrap();
|
|
let response_inbox = rt.new_inbox::<InferenceResponse>().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::<Pong>().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::<InferenceActorStatus>().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
|
|
);
|
|
}
|