Skip to main content

rpytest_daemon/
pool.rs

1//! Worker pool for persistent Python test execution.
2//!
3//! This module provides a pool of warm Python worker processes that stay alive
4//! between test runs, eliminating subprocess spawn overhead (200-300ms per batch).
5
6use crate::error::{DaemonError, Result};
7use crate::models::{TestOutcome, TestResult};
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10use std::process::Stdio;
11use std::sync::Arc;
12use std::time::Instant;
13use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
14use tokio::process::{Child, Command};
15use tokio::sync::Mutex;
16use tracing::{debug, error, info, warn};
17
18/// Python worker script that runs as a persistent process.
19/// Communicates via JSON over stdin/stdout.
20const WORKER_SCRIPT: &str = r#"
21import sys
22import json
23import io
24import os
25
26# Save original stdout for JSON protocol communication
27_original_stdout = sys.stdout
28
29class WorkerPlugin:
30    """Pytest plugin that collects results for the worker."""
31
32    def __init__(self):
33        self.results = []
34
35    def pytest_runtest_logreport(self, report):
36        if report.when == 'call':
37            self.results.append({
38                'nodeid': report.nodeid,
39                'outcome': report.outcome,
40                'duration': report.duration,
41                'message': getattr(report, 'longreprtext', None)
42            })
43        elif report.when == 'setup' and report.outcome == 'skipped':
44            self.results.append({
45                'nodeid': report.nodeid,
46                'outcome': 'skipped',
47                'duration': report.duration,
48                'message': getattr(report, 'longreprtext', None)
49            })
50        elif report.when in ('setup', 'teardown') and report.outcome == 'failed':
51            self.results.append({
52                'nodeid': report.nodeid,
53                'outcome': 'error',
54                'duration': report.duration,
55                'message': getattr(report, 'longreprtext', None)
56            })
57
58def send_response(data):
59    """Send JSON response to the daemon."""
60    _original_stdout.write(json.dumps(data) + '\n')
61    _original_stdout.flush()
62
63# Signal ready
64send_response({'status': 'ready'})
65
66run_count = 0
67max_runs = 100  # Recycle worker after N runs to prevent memory leaks
68
69# Import pytest here to avoid startup time in the loop
70import pytest
71
72while run_count < max_runs:
73    try:
74        line = sys.stdin.readline()
75        if not line:
76            break
77
78        request = json.loads(line)
79        cmd = request.get('command')
80
81        if cmd == 'run':
82            # Clear test modules to ensure fresh imports
83            to_remove = [k for k in sys.modules.keys()
84                        if 'test_' in k or 'conftest' in k]
85            for k in to_remove:
86                del sys.modules[k]
87
88            # Redirect stdout/stderr during pytest run to avoid interfering with JSON protocol
89            captured_stdout = io.StringIO()
90            captured_stderr = io.StringIO()
91            sys.stdout = captured_stdout
92            sys.stderr = captured_stderr
93
94            try:
95                plugin = WorkerPlugin()
96                exit_code = pytest.main(
97                    request['tests'] + ['-q', '--tb=short', '-p', 'no:cacheprovider'],
98                    plugins=[plugin]
99                )
100            finally:
101                # Restore stdout/stderr
102                sys.stdout = _original_stdout
103                sys.stderr = sys.__stderr__
104
105            send_response({
106                'status': 'done',
107                'exit_code': exit_code,
108                'results': plugin.results
109            })
110
111            run_count += 1
112
113        elif cmd == 'ping':
114            send_response({'status': 'pong'})
115
116        elif cmd == 'shutdown':
117            break
118
119    except Exception as e:
120        # Ensure stdout is restored even on exception
121        sys.stdout = _original_stdout
122        send_response({'status': 'error', 'message': str(e)})
123
124# Signal shutdown
125send_response({'status': 'shutdown'})
126"#;
127
128/// Request sent to a worker.
129#[derive(Debug, Serialize)]
130#[serde(tag = "command")]
131enum WorkerRequest {
132    #[serde(rename = "run")]
133    Run { tests: Vec<String> },
134    #[serde(rename = "ping")]
135    Ping,
136    #[serde(rename = "shutdown")]
137    Shutdown,
138}
139
140/// Response from a worker.
141#[derive(Debug, Deserialize)]
142struct WorkerResponse {
143    status: String,
144    #[serde(default)]
145    exit_code: i32,
146    #[serde(default)]
147    results: Vec<WorkerResult>,
148    #[serde(default)]
149    message: Option<String>,
150}
151
152/// Individual test result from a worker.
153#[derive(Debug, Deserialize)]
154struct WorkerResult {
155    nodeid: String,
156    outcome: String,
157    duration: f64,
158    message: Option<String>,
159}
160
161impl WorkerResult {
162    fn into_test_result(self) -> TestResult {
163        let outcome = match self.outcome.as_str() {
164            "passed" => TestOutcome::Passed,
165            "failed" => TestOutcome::Failed,
166            "skipped" => TestOutcome::Skipped,
167            "error" => TestOutcome::Error,
168            "xfail" => TestOutcome::Xfail,
169            "xpass" => TestOutcome::Xpass,
170            _ => TestOutcome::Error,
171        };
172
173        TestResult {
174            node_id: self.nodeid,
175            outcome,
176            duration_ms: (self.duration * 1000.0) as u64,
177            message: self.message,
178            stdout: None,
179            stderr: None,
180        }
181    }
182}
183
184/// Reason why a worker is being recycled.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum RecycleReason {
187    /// Worker reached maximum run count.
188    MaxRunsReached,
189    /// Worker process is no longer healthy.
190    Unhealthy,
191    /// Worker was explicitly shut down.
192    ExplicitShutdown,
193}
194
195/// Lifecycle states for a worker process.
196///
197/// Transitions:
198/// - Spawning -> Ready (successful startup handshake)
199/// - Ready -> Busy (test execution started)
200/// - Busy -> Ready (test execution completed successfully)
201/// - Busy -> Recycling (max runs reached or error)
202/// - Ready -> Recycling (explicit shutdown or max runs)
203/// - Recycling -> Dead (process exited)
204/// - Spawning -> Dead (startup failed)
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum WorkerState {
207    /// Worker process is starting up.
208    Spawning,
209    /// Worker is idle and ready to accept work.
210    Ready,
211    /// Worker is currently executing tests.
212    Busy { started_at: Instant },
213    /// Worker is being recycled and should not accept new work.
214    Recycling { reason: RecycleReason },
215    /// Worker process has exited.
216    Dead { exit_code: Option<i32> },
217}
218
219impl WorkerState {
220    /// Returns true if the worker can accept test execution.
221    pub fn is_ready(&self) -> bool {
222        matches!(self, WorkerState::Ready)
223    }
224
225    /// Returns true if the worker is actively running tests.
226    pub fn is_busy(&self) -> bool {
227        matches!(self, WorkerState::Busy { .. })
228    }
229
230    /// Returns true if the worker process is still alive (Spawning, Ready, or Busy).
231    pub fn is_alive(&self) -> bool {
232        matches!(
233            self,
234            WorkerState::Spawning | WorkerState::Ready | WorkerState::Busy { .. }
235        )
236    }
237
238    /// Returns true if the worker has been recycled or is dead.
239    pub fn is_terminal(&self) -> bool {
240        matches!(
241            self,
242            WorkerState::Recycling { .. } | WorkerState::Dead { .. }
243        )
244    }
245
246    /// Attempt to transition to a new state.
247    /// Returns the new state on success, or an error if the transition is invalid.
248    pub fn transition_to(self, next: WorkerState) -> Result<WorkerState> {
249        let valid = match (&self, &next) {
250            // Spawning can become Ready or Dead
251            (WorkerState::Spawning, WorkerState::Ready) => true,
252            (WorkerState::Spawning, WorkerState::Dead { .. }) => true,
253            // Ready can become Busy, Recycling, or Dead
254            (WorkerState::Ready, WorkerState::Busy { .. }) => true,
255            (WorkerState::Ready, WorkerState::Recycling { .. }) => true,
256            (WorkerState::Ready, WorkerState::Dead { .. }) => true,
257            // Busy can become Ready, Recycling, or Dead
258            (WorkerState::Busy { .. }, WorkerState::Ready) => true,
259            (WorkerState::Busy { .. }, WorkerState::Recycling { .. }) => true,
260            (WorkerState::Busy { .. }, WorkerState::Dead { .. }) => true,
261            // Recycling can only become Dead
262            (WorkerState::Recycling { .. }, WorkerState::Dead { .. }) => true,
263            // Dead is terminal - no further transitions
264            (WorkerState::Dead { .. }, _) => false,
265            // Any other transition is invalid
266            _ => false,
267        };
268
269        if valid {
270            Ok(next)
271        } else {
272            Err(DaemonError::Other(format!(
273                "Invalid worker state transition: {:?} -> {:?}",
274                self, next
275            )))
276        }
277    }
278}
279
280/// A persistent Python worker process with explicit state machine lifecycle.
281pub struct Worker {
282    child: Child,
283    stdin: tokio::process::ChildStdin,
284    stdout: BufReader<tokio::process::ChildStdout>,
285    id: usize,
286    state: WorkerState,
287    run_count: usize,
288    max_runs: usize,
289}
290
291impl std::fmt::Debug for Worker {
292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        f.debug_struct("Worker")
294            .field("id", &self.id)
295            .field("state", &self.state)
296            .field("run_count", &self.run_count)
297            .finish_non_exhaustive()
298    }
299}
300
301impl Worker {
302    /// Maximum number of test runs before a worker is recycled.
303    const DEFAULT_MAX_RUNS: usize = 100;
304
305    /// Spawn a new worker process.
306    ///
307    /// The worker is created in `Spawning` state and transitions to `Ready`
308    /// after successfully receiving the ready signal from the worker script.
309    pub async fn spawn(python_path: &PathBuf, id: usize, working_dir: &PathBuf) -> Result<Self> {
310        debug!(
311            "Spawning worker {} with python: {} in dir: {}",
312            id,
313            python_path.display(),
314            working_dir.display()
315        );
316
317        let mut child = Command::new(python_path)
318            .arg("-c")
319            .arg(WORKER_SCRIPT)
320            .current_dir(working_dir)
321            .stdin(Stdio::piped())
322            .stdout(Stdio::piped())
323            .stderr(Stdio::null())
324            .kill_on_drop(true)
325            .spawn()
326            .map_err(|e| DaemonError::Other(format!("Failed to spawn worker {}: {}", id, e)))?;
327
328        let stdin = child
329            .stdin
330            .take()
331            .ok_or_else(|| DaemonError::Other(format!("Worker {} has no stdin", id)))?;
332
333        let stdout = child
334            .stdout
335            .take()
336            .ok_or_else(|| DaemonError::Other(format!("Worker {} has no stdout", id)))?;
337
338        let mut worker = Self {
339            child,
340            stdin,
341            stdout: BufReader::new(stdout),
342            id,
343            state: WorkerState::Spawning,
344            run_count: 0,
345            max_runs: Self::DEFAULT_MAX_RUNS,
346        };
347
348        // Wait for ready signal - transitions Spawning -> Ready
349        worker.wait_ready().await?;
350        worker.state = WorkerState::Ready;
351        info!("Worker {} ready", id);
352
353        Ok(worker)
354    }
355
356    /// Wait for the worker to signal ready.
357    async fn wait_ready(&mut self) -> Result<()> {
358        let mut line = String::new();
359        tokio::time::timeout(
360            std::time::Duration::from_secs(30),
361            self.stdout.read_line(&mut line),
362        )
363        .await
364        .map_err(|_| DaemonError::Other(format!("Worker {} timed out during startup", self.id)))?
365        .map_err(|e| DaemonError::Other(format!("Worker {} read error: {}", self.id, e)))?;
366
367        let response: WorkerResponse = serde_json::from_str(&line).map_err(|e| {
368            DaemonError::Other(format!("Worker {} invalid response: {}", self.id, e))
369        })?;
370
371        if response.status != "ready" {
372            return Err(DaemonError::Other(format!(
373                "Worker {} sent unexpected status: {}",
374                self.id, response.status
375            )));
376        }
377
378        Ok(())
379    }
380
381    /// Run tests and return results.
382    ///
383    /// Requires the worker to be in `Ready` state. Transitions to `Busy` during
384    /// execution, then back to `Ready` on success or `Recycling` on error.
385    pub async fn run_tests(&mut self, tests: Vec<String>) -> Result<Vec<TestResult>> {
386        // Validate state transition: Ready -> Busy
387        if !self.state.is_ready() {
388            return Err(DaemonError::Other(format!(
389                "Worker {} cannot run tests in state {:?}",
390                self.id, self.state
391            )));
392        }
393
394        self.state = WorkerState::Busy {
395            started_at: Instant::now(),
396        };
397
398        let result = self.execute_tests(tests).await;
399
400        // Transition back based on result and run count
401        match result {
402            Ok(ref results) => {
403                self.run_count += 1;
404                debug!(
405                    "Worker {} completed run {} with {} results",
406                    self.id,
407                    self.run_count,
408                    results.len()
409                );
410
411                if self.needs_recycle() {
412                    self.state = WorkerState::Recycling {
413                        reason: RecycleReason::MaxRunsReached,
414                    };
415                } else {
416                    self.state = WorkerState::Ready;
417                }
418            }
419            Err(ref e) => {
420                warn!("Worker {} error during test execution: {}", self.id, e);
421                self.state = WorkerState::Recycling {
422                    reason: RecycleReason::Unhealthy,
423                };
424            }
425        }
426
427        result
428    }
429
430    /// Internal test execution without state management.
431    async fn execute_tests(&mut self, tests: Vec<String>) -> Result<Vec<TestResult>> {
432        let request = WorkerRequest::Run { tests };
433        let request_json = serde_json::to_string(&request)
434            .map_err(|e| DaemonError::Other(format!("Failed to serialize request: {}", e)))?;
435
436        // Send request
437        self.stdin
438            .write_all(format!("{}\n", request_json).as_bytes())
439            .await
440            .map_err(|e| DaemonError::Other(format!("Worker {} write error: {}", self.id, e)))?;
441        self.stdin
442            .flush()
443            .await
444            .map_err(|e| DaemonError::Other(format!("Worker {} flush error: {}", self.id, e)))?;
445
446        // Read response (with timeout)
447        let mut line = String::new();
448        tokio::time::timeout(
449            std::time::Duration::from_secs(300), // 5 minute timeout for tests
450            self.stdout.read_line(&mut line),
451        )
452        .await
453        .map_err(|_| DaemonError::Other(format!("Worker {} timed out during test run", self.id)))?
454        .map_err(|e| DaemonError::Other(format!("Worker {} read error: {}", self.id, e)))?;
455
456        let response: WorkerResponse = serde_json::from_str(&line).map_err(|e| {
457            DaemonError::Other(format!(
458                "Worker {} invalid response: {} (line: {})",
459                self.id, e, line
460            ))
461        })?;
462
463        if response.status == "error" {
464            return Err(DaemonError::Other(format!(
465                "Worker {} error: {}",
466                self.id,
467                response.message.unwrap_or_default()
468            )));
469        }
470
471        Ok(response
472            .results
473            .into_iter()
474            .map(|r| r.into_test_result())
475            .collect())
476    }
477
478    /// Check if the worker needs recycling (too many runs).
479    pub fn needs_recycle(&self) -> bool {
480        self.run_count >= self.max_runs
481    }
482
483    /// Check if the worker is still alive.
484    ///
485    /// Also updates state to Dead if the process has exited.
486    pub fn is_alive(&mut self) -> bool {
487        // If state already indicates dead, skip process check
488        if matches!(self.state, WorkerState::Dead { .. }) {
489            return false;
490        }
491
492        match self.child.try_wait() {
493            Ok(None) => true, // Still running
494            Ok(Some(status)) => {
495                // Process exited - update state
496                let exit_code = status.code();
497                self.state = WorkerState::Dead { exit_code };
498                false
499            }
500            Err(_) => {
501                // Error checking - assume dead
502                self.state = WorkerState::Dead { exit_code: None };
503                false
504            }
505        }
506    }
507
508    /// Get the current state of the worker.
509    pub fn state(&self) -> &WorkerState {
510        &self.state
511    }
512
513    /// Gracefully shutdown the worker.
514    ///
515    /// Transitions to `Recycling` then `Dead`. Returns an error if the worker
516    /// is already in a terminal state.
517    pub async fn shutdown(&mut self) -> Result<()> {
518        if self.state.is_terminal() {
519            return Ok(());
520        }
521
522        self.state = WorkerState::Recycling {
523            reason: RecycleReason::ExplicitShutdown,
524        };
525
526        let request = WorkerRequest::Shutdown;
527        let request_json = serde_json::to_string(&request).unwrap_or_default();
528
529        let _ = self
530            .stdin
531            .write_all(format!("{}\n", request_json).as_bytes())
532            .await;
533        let _ = self.stdin.flush().await;
534
535        // Give it a moment to shutdown gracefully
536        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
537
538        // Force kill if still running
539        let _ = self.child.kill().await;
540
541        self.state = WorkerState::Dead { exit_code: Some(0) };
542
543        Ok(())
544    }
545}
546
547/// Pool of warm Python workers.
548#[derive(Debug)]
549pub struct WorkerPool {
550    /// Available workers ready to accept work
551    available: Mutex<Vec<Worker>>,
552    /// Path to Python interpreter
553    python_path: PathBuf,
554    /// Working directory (repo root) for workers
555    working_dir: PathBuf,
556    /// Target pool size
557    size: usize,
558}
559
560impl WorkerPool {
561    /// Create a new worker pool with the specified size.
562    pub async fn new(size: usize, python_path: PathBuf, working_dir: PathBuf) -> Result<Arc<Self>> {
563        info!(
564            "Creating worker pool with {} workers in {}",
565            size,
566            working_dir.display()
567        );
568
569        let pool = Arc::new(Self {
570            available: Mutex::new(Vec::with_capacity(size)),
571            python_path,
572            working_dir,
573            size,
574        });
575
576        // Spawn initial workers
577        pool.ensure_workers().await?;
578
579        Ok(pool)
580    }
581
582    /// Ensure the pool has enough workers.
583    async fn ensure_workers(&self) -> Result<()> {
584        let mut available = self.available.lock().await;
585        let current = available.len();
586
587        if current < self.size {
588            for i in current..self.size {
589                match Worker::spawn(&self.python_path, i, &self.working_dir).await {
590                    Ok(worker) => available.push(worker),
591                    Err(e) => {
592                        warn!("Failed to spawn worker {}: {}", i, e);
593                        // Continue with fewer workers
594                    }
595                }
596            }
597        }
598
599        Ok(())
600    }
601
602    /// Get an available worker from the pool.
603    ///
604    /// Only returns workers in `Ready` state. Workers that are dead or need
605    /// recycling are dropped.
606    async fn acquire(&self) -> Option<Worker> {
607        let mut available = self.available.lock().await;
608
609        // Find a healthy worker in Ready state
610        while let Some(mut worker) = available.pop() {
611            if !worker.is_alive() {
612                debug!("Worker {} is dead, dropping", worker.id);
613                continue;
614            }
615            if worker.needs_recycle() {
616                debug!("Worker {} needs recycling", worker.id);
617                // Let it drop (state is already Recycling)
618                continue;
619            }
620            if worker.state().is_ready() {
621                return Some(worker);
622            }
623            // Worker is in an unexpected state, drop it
624            warn!(
625                "Worker {} in unexpected state {:?}, dropping",
626                worker.id,
627                worker.state()
628            );
629        }
630
631        None
632    }
633
634    /// Return a worker to the pool.
635    ///
636    /// Only accepts workers in `Ready` state. Workers in other states are dropped.
637    async fn release(&self, worker: Worker) {
638        if !worker.state().is_ready() {
639            debug!(
640                "Not returning worker {} to pool (state: {:?})",
641                worker.id,
642                worker.state()
643            );
644            return;
645        }
646
647        let mut available = self.available.lock().await;
648        if available.len() < self.size {
649            available.push(worker);
650        } else {
651            debug!("Pool is full, dropping worker {}", worker.id);
652        }
653    }
654
655    /// Execute a batch of tests on an available worker.
656    pub async fn execute_batch(&self, tests: Vec<String>) -> Vec<TestResult> {
657        if tests.is_empty() {
658            return Vec::new();
659        }
660
661        // Try to get a worker
662        let worker = match self.acquire().await {
663            Some(w) => w,
664            None => {
665                // No workers available, try to spawn one
666                match Worker::spawn(&self.python_path, 999, &self.working_dir).await {
667                    Ok(w) => w,
668                    Err(e) => {
669                        warn!("Failed to spawn worker: {}", e);
670                        return tests
671                            .into_iter()
672                            .map(|id| TestResult {
673                                node_id: id,
674                                outcome: TestOutcome::Error,
675                                duration_ms: 0,
676                                message: Some(format!("Worker pool exhausted: {}", e)),
677                                stdout: None,
678                                stderr: None,
679                            })
680                            .collect();
681                    }
682                }
683            }
684        };
685
686        let mut worker = worker;
687        let results = match worker.run_tests(tests.clone()).await {
688            Ok(r) => r,
689            Err(e) => {
690                warn!("Worker error: {}", e);
691                tests
692                    .into_iter()
693                    .map(|id| TestResult {
694                        node_id: id,
695                        outcome: TestOutcome::Error,
696                        duration_ms: 0,
697                        message: Some(format!("Worker error: {}", e)),
698                        stdout: None,
699                        stderr: None,
700                    })
701                    .collect()
702            }
703        };
704
705        // Return worker to pool (only if still in Ready state)
706        self.release(worker).await;
707
708        // Ensure we have workers for next request
709        let _ = self.ensure_workers().await;
710
711        results
712    }
713
714    /// Execute multiple batches in parallel.
715    pub async fn execute_parallel(self: &Arc<Self>, batches: Vec<Vec<String>>) -> Vec<TestResult> {
716        if batches.is_empty() {
717            return Vec::new();
718        }
719
720        // Spawn a task for each batch
721        let handles: Vec<_> = batches
722            .into_iter()
723            .map(|batch| {
724                let pool = Arc::clone(self);
725                tokio::spawn(async move { pool.execute_batch(batch).await })
726            })
727            .collect();
728
729        // Collect results
730        let mut all_results = Vec::new();
731        for handle in handles {
732            match handle.await {
733                Ok(results) => all_results.extend(results),
734                Err(e) => warn!("Task join error: {}", e),
735            }
736        }
737
738        all_results
739    }
740
741    /// Shutdown all workers in the pool gracefully.
742    pub async fn shutdown(&self) {
743        let mut available = self.available.lock().await;
744        for worker in available.drain(..) {
745            let mut worker = worker;
746            if let Err(e) = worker.shutdown().await {
747                warn!("Error shutting down worker {}: {}", worker.id, e);
748            }
749        }
750    }
751
752    /// Get the current number of available workers.
753    pub async fn available_count(&self) -> usize {
754        self.available.lock().await.len()
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use std::env;
762
763    fn get_python_path() -> PathBuf {
764        // Try common Python paths
765        for path in &["python3", "python", ".venv/bin/python"] {
766            if let Ok(p) = which::which(path) {
767                return p;
768            }
769        }
770        PathBuf::from("python3")
771    }
772
773    #[tokio::test]
774    async fn test_worker_spawn() {
775        let python_path = get_python_path();
776        let working_dir = std::env::current_dir().unwrap();
777        let worker = Worker::spawn(&python_path, 0, &working_dir).await;
778
779        // This test may fail if Python or pytest is not available
780        if worker.is_err() {
781            eprintln!("Skipping test: {:?}", worker.err());
782            return;
783        }
784
785        let mut worker = worker.unwrap();
786        assert!(worker.is_alive());
787        assert!(worker.state().is_ready());
788        assert_eq!(*worker.state(), WorkerState::Ready);
789
790        // Cleanup
791        let _ = worker.shutdown().await;
792        assert!(worker.state().is_terminal());
793    }
794
795    #[tokio::test]
796    async fn test_worker_state_transitions() {
797        let python_path = get_python_path();
798        let working_dir = std::env::current_dir().unwrap();
799        let worker = Worker::spawn(&python_path, 0, &working_dir).await;
800
801        if worker.is_err() {
802            eprintln!("Skipping test: {:?}", worker.err());
803            return;
804        }
805
806        let mut worker = worker.unwrap();
807
808        // Initial state after spawn
809        assert_eq!(*worker.state(), WorkerState::Ready);
810
811        // Shutdown should transition to terminal state
812        let _ = worker.shutdown().await;
813        assert!(worker.state().is_terminal());
814        assert!(matches!(*worker.state(), WorkerState::Dead { .. }));
815    }
816
817    #[test]
818    fn test_worker_state_machine_transitions() {
819        // Test valid transitions using fresh values to avoid move issues
820        // Spawning -> Ready (valid)
821        assert!(WorkerState::Spawning
822            .transition_to(WorkerState::Ready)
823            .is_ok());
824        // Spawning -> Dead (valid)
825        assert!(WorkerState::Spawning
826            .transition_to(WorkerState::Dead { exit_code: Some(0) })
827            .is_ok());
828
829        let ready = WorkerState::Ready;
830        // Ready -> Busy (valid)
831        assert!(ready
832            .clone()
833            .transition_to(WorkerState::Busy {
834                started_at: Instant::now(),
835            })
836            .is_ok());
837        // Ready -> Recycling (valid)
838        assert!(ready
839            .clone()
840            .transition_to(WorkerState::Recycling {
841                reason: RecycleReason::MaxRunsReached,
842            })
843            .is_ok());
844        // Ready -> Dead (valid)
845        assert!(ready
846            .clone()
847            .transition_to(WorkerState::Dead { exit_code: Some(0) })
848            .is_ok());
849
850        let busy = WorkerState::Busy {
851            started_at: Instant::now(),
852        };
853        // Busy -> Ready (valid)
854        assert!(busy.clone().transition_to(WorkerState::Ready).is_ok());
855        // Busy -> Recycling (valid)
856        assert!(busy
857            .clone()
858            .transition_to(WorkerState::Recycling {
859                reason: RecycleReason::Unhealthy,
860            })
861            .is_ok());
862        // Busy -> Dead (valid)
863        assert!(busy
864            .transition_to(WorkerState::Dead { exit_code: Some(1) })
865            .is_ok());
866
867        let recycling = WorkerState::Recycling {
868            reason: RecycleReason::MaxRunsReached,
869        };
870        // Recycling -> Dead (valid)
871        assert!(recycling
872            .clone()
873            .transition_to(WorkerState::Dead { exit_code: Some(0) })
874            .is_ok());
875
876        // Invalid transitions
877        let dead = WorkerState::Dead { exit_code: Some(0) };
878        // Dead -> anything (invalid)
879        assert!(dead.transition_to(WorkerState::Ready).is_err());
880
881        let ready2 = WorkerState::Ready;
882        // Ready -> Spawning (invalid)
883        assert!(ready2.clone().transition_to(WorkerState::Spawning).is_err());
884
885        let recycling2 = WorkerState::Recycling {
886            reason: RecycleReason::MaxRunsReached,
887        };
888        // Recycling -> Ready (invalid)
889        assert!(recycling2.transition_to(WorkerState::Ready).is_err());
890    }
891
892    #[test]
893    fn test_worker_state_helpers() {
894        let ready = WorkerState::Ready;
895        let busy = WorkerState::Busy {
896            started_at: Instant::now(),
897        };
898        let recycling = WorkerState::Recycling {
899            reason: RecycleReason::MaxRunsReached,
900        };
901        let dead = WorkerState::Dead { exit_code: Some(0) };
902
903        assert!(ready.is_ready());
904        assert!(!ready.is_busy());
905        assert!(ready.is_alive());
906        assert!(!ready.is_terminal());
907
908        assert!(!busy.is_ready());
909        assert!(busy.is_busy());
910        assert!(busy.is_alive());
911        assert!(!busy.is_terminal());
912
913        assert!(!recycling.is_ready());
914        assert!(!recycling.is_busy());
915        assert!(!recycling.is_alive());
916        assert!(recycling.is_terminal());
917
918        assert!(!dead.is_ready());
919        assert!(!dead.is_busy());
920        assert!(!dead.is_alive());
921        assert!(dead.is_terminal());
922    }
923
924    #[tokio::test]
925    async fn test_worker_run_tests_requires_ready() {
926        let python_path = get_python_path();
927        let working_dir = std::env::current_dir().unwrap();
928        let worker = Worker::spawn(&python_path, 0, &working_dir).await;
929
930        if worker.is_err() {
931            eprintln!("Skipping test: {:?}", worker.err());
932            return;
933        }
934
935        let mut worker = worker.unwrap();
936
937        // Shutdown the worker first
938        let _ = worker.shutdown().await;
939
940        // Attempting to run tests on a dead worker should fail
941        let result = worker
942            .run_tests(vec!["nonexistent::test".to_string()])
943            .await;
944        assert!(result.is_err());
945        assert!(worker.state().is_terminal());
946    }
947
948    #[test]
949    fn test_worker_request_serialization() {
950        let request = WorkerRequest::Run {
951            tests: vec!["test_a.py::test_1".to_string()],
952        };
953        let json = serde_json::to_string(&request).unwrap();
954        assert!(json.contains("\"command\":\"run\""));
955        assert!(json.contains("test_a.py::test_1"));
956    }
957}