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 tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
13use tokio::process::{Child, Command};
14use tokio::sync::Mutex;
15use tracing::{debug, info, warn};
16
17/// Python worker script that runs as a persistent process.
18/// Communicates via JSON over stdin/stdout.
19const WORKER_SCRIPT: &str = r#"
20import sys
21import json
22import io
23import os
24
25# Save original stdout for JSON protocol communication
26_original_stdout = sys.stdout
27
28class WorkerPlugin:
29    """Pytest plugin that collects results for the worker."""
30
31    def __init__(self):
32        self.results = []
33
34    def pytest_runtest_logreport(self, report):
35        if report.when == 'call':
36            self.results.append({
37                'nodeid': report.nodeid,
38                'outcome': report.outcome,
39                'duration': report.duration,
40                'message': getattr(report, 'longreprtext', None)
41            })
42        elif report.when == 'setup' and report.outcome == 'skipped':
43            self.results.append({
44                'nodeid': report.nodeid,
45                'outcome': 'skipped',
46                'duration': report.duration,
47                'message': getattr(report, 'longreprtext', None)
48            })
49        elif report.when in ('setup', 'teardown') and report.outcome == 'failed':
50            self.results.append({
51                'nodeid': report.nodeid,
52                'outcome': 'error',
53                'duration': report.duration,
54                'message': getattr(report, 'longreprtext', None)
55            })
56
57def send_response(data):
58    """Send JSON response to the daemon."""
59    _original_stdout.write(json.dumps(data) + '\n')
60    _original_stdout.flush()
61
62# Signal ready
63send_response({'status': 'ready'})
64
65run_count = 0
66max_runs = 100  # Recycle worker after N runs to prevent memory leaks
67
68# Import pytest here to avoid startup time in the loop
69import pytest
70
71while run_count < max_runs:
72    try:
73        line = sys.stdin.readline()
74        if not line:
75            break
76
77        request = json.loads(line)
78        cmd = request.get('command')
79
80        if cmd == 'run':
81            # Clear test modules to ensure fresh imports
82            to_remove = [k for k in sys.modules.keys()
83                        if 'test_' in k or 'conftest' in k]
84            for k in to_remove:
85                del sys.modules[k]
86
87            # Redirect stdout/stderr during pytest run to avoid interfering with JSON protocol
88            captured_stdout = io.StringIO()
89            captured_stderr = io.StringIO()
90            sys.stdout = captured_stdout
91            sys.stderr = captured_stderr
92
93            try:
94                plugin = WorkerPlugin()
95                exit_code = pytest.main(
96                    request['tests'] + ['-q', '--tb=short', '-p', 'no:cacheprovider'],
97                    plugins=[plugin]
98                )
99            finally:
100                # Restore stdout/stderr
101                sys.stdout = _original_stdout
102                sys.stderr = sys.__stderr__
103
104            send_response({
105                'status': 'done',
106                'exit_code': exit_code,
107                'results': plugin.results
108            })
109
110            run_count += 1
111
112        elif cmd == 'ping':
113            send_response({'status': 'pong'})
114
115        elif cmd == 'shutdown':
116            break
117
118    except Exception as e:
119        # Ensure stdout is restored even on exception
120        sys.stdout = _original_stdout
121        send_response({'status': 'error', 'message': str(e)})
122
123# Signal shutdown
124send_response({'status': 'shutdown'})
125"#;
126
127/// Request sent to a worker.
128#[derive(Debug, Serialize)]
129#[serde(tag = "command")]
130enum WorkerRequest {
131    #[serde(rename = "run")]
132    Run { tests: Vec<String> },
133    #[serde(rename = "ping")]
134    Ping,
135    #[serde(rename = "shutdown")]
136    Shutdown,
137}
138
139/// Response from a worker.
140#[derive(Debug, Deserialize)]
141struct WorkerResponse {
142    status: String,
143    #[serde(default)]
144    exit_code: i32,
145    #[serde(default)]
146    results: Vec<WorkerResult>,
147    #[serde(default)]
148    message: Option<String>,
149}
150
151/// Individual test result from a worker.
152#[derive(Debug, Deserialize)]
153struct WorkerResult {
154    nodeid: String,
155    outcome: String,
156    duration: f64,
157    message: Option<String>,
158}
159
160impl WorkerResult {
161    fn into_test_result(self) -> TestResult {
162        let outcome = match self.outcome.as_str() {
163            "passed" => TestOutcome::Passed,
164            "failed" => TestOutcome::Failed,
165            "skipped" => TestOutcome::Skipped,
166            "error" => TestOutcome::Error,
167            "xfail" => TestOutcome::Xfail,
168            "xpass" => TestOutcome::Xpass,
169            _ => TestOutcome::Error,
170        };
171
172        TestResult {
173            node_id: self.nodeid,
174            outcome,
175            duration_ms: (self.duration * 1000.0) as u64,
176            message: self.message,
177            stdout: None,
178            stderr: None,
179        }
180    }
181}
182
183/// A persistent Python worker process.
184pub struct Worker {
185    child: Child,
186    stdin: tokio::process::ChildStdin,
187    stdout: BufReader<tokio::process::ChildStdout>,
188    id: usize,
189    run_count: usize,
190}
191
192impl std::fmt::Debug for Worker {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        f.debug_struct("Worker")
195            .field("id", &self.id)
196            .field("run_count", &self.run_count)
197            .finish_non_exhaustive()
198    }
199}
200
201impl Worker {
202    /// Spawn a new worker process.
203    pub async fn spawn(python_path: &PathBuf, id: usize, working_dir: &PathBuf) -> Result<Self> {
204        debug!("Spawning worker {} with python: {} in dir: {}", id, python_path.display(), working_dir.display());
205
206        let mut child = Command::new(python_path)
207            .arg("-c")
208            .arg(WORKER_SCRIPT)
209            .current_dir(working_dir) // Run from repo root so test paths resolve correctly
210            .stdin(Stdio::piped())
211            .stdout(Stdio::piped())
212            .stderr(Stdio::null()) // Ignore stderr to avoid blocking
213            .kill_on_drop(true)
214            .spawn()
215            .map_err(|e| DaemonError::Other(format!("Failed to spawn worker {}: {}", id, e)))?;
216
217        let stdin = child.stdin.take().ok_or_else(|| {
218            DaemonError::Other(format!("Worker {} has no stdin", id))
219        })?;
220
221        let stdout = child.stdout.take().ok_or_else(|| {
222            DaemonError::Other(format!("Worker {} has no stdout", id))
223        })?;
224
225        let mut worker = Self {
226            child,
227            stdin,
228            stdout: BufReader::new(stdout),
229            id,
230            run_count: 0,
231        };
232
233        // Wait for ready signal
234        worker.wait_ready().await?;
235        info!("Worker {} ready", id);
236
237        Ok(worker)
238    }
239
240    /// Wait for the worker to signal ready.
241    async fn wait_ready(&mut self) -> Result<()> {
242        let mut line = String::new();
243        tokio::time::timeout(
244            std::time::Duration::from_secs(30),
245            self.stdout.read_line(&mut line),
246        )
247        .await
248        .map_err(|_| DaemonError::Other(format!("Worker {} timed out during startup", self.id)))?
249        .map_err(|e| DaemonError::Other(format!("Worker {} read error: {}", self.id, e)))?;
250
251        let response: WorkerResponse = serde_json::from_str(&line)
252            .map_err(|e| DaemonError::Other(format!("Worker {} invalid response: {}", self.id, e)))?;
253
254        if response.status != "ready" {
255            return Err(DaemonError::Other(format!(
256                "Worker {} sent unexpected status: {}",
257                self.id, response.status
258            )));
259        }
260
261        Ok(())
262    }
263
264    /// Run tests and return results.
265    pub async fn run_tests(&mut self, tests: Vec<String>) -> Result<Vec<TestResult>> {
266        let request = WorkerRequest::Run { tests };
267        let request_json = serde_json::to_string(&request)
268            .map_err(|e| DaemonError::Other(format!("Failed to serialize request: {}", e)))?;
269
270        // Send request
271        self.stdin
272            .write_all(format!("{}\n", request_json).as_bytes())
273            .await
274            .map_err(|e| DaemonError::Other(format!("Worker {} write error: {}", self.id, e)))?;
275        self.stdin.flush().await.map_err(|e| {
276            DaemonError::Other(format!("Worker {} flush error: {}", self.id, e))
277        })?;
278
279        // Read response (with timeout)
280        let mut line = String::new();
281        tokio::time::timeout(
282            std::time::Duration::from_secs(300), // 5 minute timeout for tests
283            self.stdout.read_line(&mut line),
284        )
285        .await
286        .map_err(|_| DaemonError::Other(format!("Worker {} timed out during test run", self.id)))?
287        .map_err(|e| DaemonError::Other(format!("Worker {} read error: {}", self.id, e)))?;
288
289        let response: WorkerResponse = serde_json::from_str(&line)
290            .map_err(|e| DaemonError::Other(format!("Worker {} invalid response: {} (line: {})", self.id, e, line)))?;
291
292        if response.status == "error" {
293            return Err(DaemonError::Other(format!(
294                "Worker {} error: {}",
295                self.id,
296                response.message.unwrap_or_default()
297            )));
298        }
299
300        self.run_count += 1;
301        debug!(
302            "Worker {} completed run {} with {} results",
303            self.id,
304            self.run_count,
305            response.results.len()
306        );
307
308        Ok(response.results.into_iter().map(|r| r.into_test_result()).collect())
309    }
310
311    /// Check if the worker needs recycling (too many runs).
312    pub fn needs_recycle(&self) -> bool {
313        self.run_count >= 100
314    }
315
316    /// Check if the worker is still alive.
317    pub fn is_alive(&mut self) -> bool {
318        match self.child.try_wait() {
319            Ok(None) => true,  // Still running
320            Ok(Some(_)) => false,  // Exited
321            Err(_) => false,  // Error checking
322        }
323    }
324
325    /// Gracefully shutdown the worker.
326    pub async fn shutdown(&mut self) -> Result<()> {
327        let request = WorkerRequest::Shutdown;
328        let request_json = serde_json::to_string(&request).unwrap_or_default();
329
330        let _ = self.stdin.write_all(format!("{}\n", request_json).as_bytes()).await;
331        let _ = self.stdin.flush().await;
332
333        // Give it a moment to shutdown gracefully
334        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
335
336        // Force kill if still running
337        let _ = self.child.kill().await;
338
339        Ok(())
340    }
341}
342
343/// Pool of warm Python workers.
344#[derive(Debug)]
345pub struct WorkerPool {
346    /// Available workers ready to accept work
347    available: Mutex<Vec<Worker>>,
348    /// Path to Python interpreter
349    python_path: PathBuf,
350    /// Working directory (repo root) for workers
351    working_dir: PathBuf,
352    /// Target pool size
353    size: usize,
354}
355
356impl WorkerPool {
357    /// Create a new worker pool with the specified size.
358    pub async fn new(size: usize, python_path: PathBuf, working_dir: PathBuf) -> Result<Arc<Self>> {
359        info!("Creating worker pool with {} workers in {}", size, working_dir.display());
360
361        let pool = Arc::new(Self {
362            available: Mutex::new(Vec::with_capacity(size)),
363            python_path,
364            working_dir,
365            size,
366        });
367
368        // Spawn initial workers
369        pool.ensure_workers().await?;
370
371        Ok(pool)
372    }
373
374    /// Ensure the pool has enough workers.
375    async fn ensure_workers(&self) -> Result<()> {
376        let mut available = self.available.lock().await;
377        let current = available.len();
378
379        if current < self.size {
380            for i in current..self.size {
381                match Worker::spawn(&self.python_path, i, &self.working_dir).await {
382                    Ok(worker) => available.push(worker),
383                    Err(e) => {
384                        warn!("Failed to spawn worker {}: {}", i, e);
385                        // Continue with fewer workers
386                    }
387                }
388            }
389        }
390
391        Ok(())
392    }
393
394    /// Get an available worker from the pool.
395    async fn acquire(&self) -> Option<Worker> {
396        let mut available = self.available.lock().await;
397
398        // Find a healthy worker
399        while let Some(mut worker) = available.pop() {
400            if worker.is_alive() && !worker.needs_recycle() {
401                return Some(worker);
402            }
403            // Worker is dead or needs recycling, let it drop
404            debug!("Worker {} needs recycling", worker.id);
405        }
406
407        None
408    }
409
410    /// Return a worker to the pool.
411    async fn release(&self, worker: Worker) {
412        let mut available = self.available.lock().await;
413        if available.len() < self.size {
414            available.push(worker);
415        }
416        // Otherwise let it drop (pool is full)
417    }
418
419    /// Execute a batch of tests on an available worker.
420    pub async fn execute_batch(&self, tests: Vec<String>) -> Vec<TestResult> {
421        if tests.is_empty() {
422            return Vec::new();
423        }
424
425        // Try to get a worker
426        let worker = match self.acquire().await {
427            Some(w) => w,
428            None => {
429                // No workers available, try to spawn one
430                match Worker::spawn(&self.python_path, 999, &self.working_dir).await {
431                    Ok(w) => w,
432                    Err(e) => {
433                        warn!("Failed to spawn worker: {}", e);
434                        return tests
435                            .into_iter()
436                            .map(|id| TestResult {
437                                node_id: id,
438                                outcome: TestOutcome::Error,
439                                duration_ms: 0,
440                                message: Some(format!("Worker pool exhausted: {}", e)),
441                                stdout: None,
442                                stderr: None,
443                            })
444                            .collect();
445                    }
446                }
447            }
448        };
449
450        let mut worker = worker;
451        let results = match worker.run_tests(tests.clone()).await {
452            Ok(r) => r,
453            Err(e) => {
454                warn!("Worker error: {}", e);
455                tests
456                    .into_iter()
457                    .map(|id| TestResult {
458                        node_id: id,
459                        outcome: TestOutcome::Error,
460                        duration_ms: 0,
461                        message: Some(format!("Worker error: {}", e)),
462                        stdout: None,
463                        stderr: None,
464                    })
465                    .collect()
466            }
467        };
468
469        // Return worker to pool
470        self.release(worker).await;
471
472        // Ensure we have workers for next request
473        let _ = self.ensure_workers().await;
474
475        results
476    }
477
478    /// Execute multiple batches in parallel.
479    pub async fn execute_parallel(self: &Arc<Self>, batches: Vec<Vec<String>>) -> Vec<TestResult> {
480        if batches.is_empty() {
481            return Vec::new();
482        }
483
484        // Spawn a task for each batch
485        let handles: Vec<_> = batches
486            .into_iter()
487            .map(|batch| {
488                let pool = Arc::clone(self);
489                tokio::spawn(async move { pool.execute_batch(batch).await })
490            })
491            .collect();
492
493        // Collect results
494        let mut all_results = Vec::new();
495        for handle in handles {
496            match handle.await {
497                Ok(results) => all_results.extend(results),
498                Err(e) => warn!("Task join error: {}", e),
499            }
500        }
501
502        all_results
503    }
504
505    /// Shutdown all workers in the pool.
506    pub async fn shutdown(&self) {
507        let mut available = self.available.lock().await;
508        for mut worker in available.drain(..) {
509            let _ = worker.shutdown().await;
510        }
511    }
512
513    /// Get the current number of available workers.
514    pub async fn available_count(&self) -> usize {
515        self.available.lock().await.len()
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use std::env;
523
524    fn get_python_path() -> PathBuf {
525        // Try common Python paths
526        for path in &["python3", "python", ".venv/bin/python"] {
527            if let Ok(p) = which::which(path) {
528                return p;
529            }
530        }
531        PathBuf::from("python3")
532    }
533
534    #[tokio::test]
535    async fn test_worker_spawn() {
536        let python_path = get_python_path();
537        let working_dir = std::env::current_dir().unwrap();
538        let worker = Worker::spawn(&python_path, 0, &working_dir).await;
539
540        // This test may fail if Python or pytest is not available
541        if worker.is_err() {
542            eprintln!("Skipping test: {:?}", worker.err());
543            return;
544        }
545
546        let mut worker = worker.unwrap();
547        assert!(worker.is_alive());
548
549        // Cleanup
550        let _ = worker.shutdown().await;
551    }
552
553    #[tokio::test]
554    async fn test_worker_request_serialization() {
555        let request = WorkerRequest::Run {
556            tests: vec!["test_a.py::test_1".to_string()],
557        };
558        let json = serde_json::to_string(&request).unwrap();
559        assert!(json.contains("\"command\":\"run\""));
560        assert!(json.contains("test_a.py::test_1"));
561    }
562}