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 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
18const 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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum RecycleReason {
187 MaxRunsReached,
189 Unhealthy,
191 ExplicitShutdown,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum WorkerState {
207 Spawning,
209 Ready,
211 Busy { started_at: Instant },
213 Recycling { reason: RecycleReason },
215 Dead { exit_code: Option<i32> },
217}
218
219impl WorkerState {
220 pub fn is_ready(&self) -> bool {
222 matches!(self, WorkerState::Ready)
223 }
224
225 pub fn is_busy(&self) -> bool {
227 matches!(self, WorkerState::Busy { .. })
228 }
229
230 pub fn is_alive(&self) -> bool {
232 matches!(
233 self,
234 WorkerState::Spawning | WorkerState::Ready | WorkerState::Busy { .. }
235 )
236 }
237
238 pub fn is_terminal(&self) -> bool {
240 matches!(
241 self,
242 WorkerState::Recycling { .. } | WorkerState::Dead { .. }
243 )
244 }
245
246 pub fn transition_to(self, next: WorkerState) -> Result<WorkerState> {
249 let valid = match (&self, &next) {
250 (WorkerState::Spawning, WorkerState::Ready) => true,
252 (WorkerState::Spawning, WorkerState::Dead { .. }) => true,
253 (WorkerState::Ready, WorkerState::Busy { .. }) => true,
255 (WorkerState::Ready, WorkerState::Recycling { .. }) => true,
256 (WorkerState::Ready, WorkerState::Dead { .. }) => true,
257 (WorkerState::Busy { .. }, WorkerState::Ready) => true,
259 (WorkerState::Busy { .. }, WorkerState::Recycling { .. }) => true,
260 (WorkerState::Busy { .. }, WorkerState::Dead { .. }) => true,
261 (WorkerState::Recycling { .. }, WorkerState::Dead { .. }) => true,
263 (WorkerState::Dead { .. }, _) => false,
265 _ => 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
280pub 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 const DEFAULT_MAX_RUNS: usize = 100;
304
305 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 worker.wait_ready().await?;
350 worker.state = WorkerState::Ready;
351 info!("Worker {} ready", id);
352
353 Ok(worker)
354 }
355
356 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 pub async fn run_tests(&mut self, tests: Vec<String>) -> Result<Vec<TestResult>> {
386 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 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 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 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 let mut line = String::new();
448 tokio::time::timeout(
449 std::time::Duration::from_secs(300), 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 pub fn needs_recycle(&self) -> bool {
480 self.run_count >= self.max_runs
481 }
482
483 pub fn is_alive(&mut self) -> bool {
487 if matches!(self.state, WorkerState::Dead { .. }) {
489 return false;
490 }
491
492 match self.child.try_wait() {
493 Ok(None) => true, Ok(Some(status)) => {
495 let exit_code = status.code();
497 self.state = WorkerState::Dead { exit_code };
498 false
499 }
500 Err(_) => {
501 self.state = WorkerState::Dead { exit_code: None };
503 false
504 }
505 }
506 }
507
508 pub fn state(&self) -> &WorkerState {
510 &self.state
511 }
512
513 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 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
537
538 let _ = self.child.kill().await;
540
541 self.state = WorkerState::Dead { exit_code: Some(0) };
542
543 Ok(())
544 }
545}
546
547#[derive(Debug)]
549pub struct WorkerPool {
550 available: Mutex<Vec<Worker>>,
552 python_path: PathBuf,
554 working_dir: PathBuf,
556 size: usize,
558}
559
560impl WorkerPool {
561 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 pool.ensure_workers().await?;
578
579 Ok(pool)
580 }
581
582 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 }
595 }
596 }
597 }
598
599 Ok(())
600 }
601
602 async fn acquire(&self) -> Option<Worker> {
607 let mut available = self.available.lock().await;
608
609 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 continue;
619 }
620 if worker.state().is_ready() {
621 return Some(worker);
622 }
623 warn!(
625 "Worker {} in unexpected state {:?}, dropping",
626 worker.id,
627 worker.state()
628 );
629 }
630
631 None
632 }
633
634 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 pub async fn execute_batch(&self, tests: Vec<String>) -> Vec<TestResult> {
657 if tests.is_empty() {
658 return Vec::new();
659 }
660
661 let worker = match self.acquire().await {
663 Some(w) => w,
664 None => {
665 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 self.release(worker).await;
707
708 let _ = self.ensure_workers().await;
710
711 results
712 }
713
714 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 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 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 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 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 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 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 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 assert_eq!(*worker.state(), WorkerState::Ready);
810
811 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 assert!(WorkerState::Spawning
822 .transition_to(WorkerState::Ready)
823 .is_ok());
824 assert!(WorkerState::Spawning
826 .transition_to(WorkerState::Dead { exit_code: Some(0) })
827 .is_ok());
828
829 let ready = WorkerState::Ready;
830 assert!(ready
832 .clone()
833 .transition_to(WorkerState::Busy {
834 started_at: Instant::now(),
835 })
836 .is_ok());
837 assert!(ready
839 .clone()
840 .transition_to(WorkerState::Recycling {
841 reason: RecycleReason::MaxRunsReached,
842 })
843 .is_ok());
844 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 assert!(busy.clone().transition_to(WorkerState::Ready).is_ok());
855 assert!(busy
857 .clone()
858 .transition_to(WorkerState::Recycling {
859 reason: RecycleReason::Unhealthy,
860 })
861 .is_ok());
862 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 assert!(recycling
872 .clone()
873 .transition_to(WorkerState::Dead { exit_code: Some(0) })
874 .is_ok());
875
876 let dead = WorkerState::Dead { exit_code: Some(0) };
878 assert!(dead.transition_to(WorkerState::Ready).is_err());
880
881 let ready2 = WorkerState::Ready;
882 assert!(ready2.clone().transition_to(WorkerState::Spawning).is_err());
884
885 let recycling2 = WorkerState::Recycling {
886 reason: RecycleReason::MaxRunsReached,
887 };
888 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 let _ = worker.shutdown().await;
939
940 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}