1use 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
17const 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#[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#[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#[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
183pub 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 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) .stdin(Stdio::piped())
211 .stdout(Stdio::piped())
212 .stderr(Stdio::null()) .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 worker.wait_ready().await?;
235 info!("Worker {} ready", id);
236
237 Ok(worker)
238 }
239
240 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 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 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 let mut line = String::new();
281 tokio::time::timeout(
282 std::time::Duration::from_secs(300), 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 pub fn needs_recycle(&self) -> bool {
313 self.run_count >= 100
314 }
315
316 pub fn is_alive(&mut self) -> bool {
318 match self.child.try_wait() {
319 Ok(None) => true, Ok(Some(_)) => false, Err(_) => false, }
323 }
324
325 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 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
335
336 let _ = self.child.kill().await;
338
339 Ok(())
340 }
341}
342
343#[derive(Debug)]
345pub struct WorkerPool {
346 available: Mutex<Vec<Worker>>,
348 python_path: PathBuf,
350 working_dir: PathBuf,
352 size: usize,
354}
355
356impl WorkerPool {
357 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 pool.ensure_workers().await?;
370
371 Ok(pool)
372 }
373
374 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 }
387 }
388 }
389 }
390
391 Ok(())
392 }
393
394 async fn acquire(&self) -> Option<Worker> {
396 let mut available = self.available.lock().await;
397
398 while let Some(mut worker) = available.pop() {
400 if worker.is_alive() && !worker.needs_recycle() {
401 return Some(worker);
402 }
403 debug!("Worker {} needs recycling", worker.id);
405 }
406
407 None
408 }
409
410 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 }
418
419 pub async fn execute_batch(&self, tests: Vec<String>) -> Vec<TestResult> {
421 if tests.is_empty() {
422 return Vec::new();
423 }
424
425 let worker = match self.acquire().await {
427 Some(w) => w,
428 None => {
429 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 self.release(worker).await;
471
472 let _ = self.ensure_workers().await;
474
475 results
476 }
477
478 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 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 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 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 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 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 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 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}