1use crate::context::RepoContext;
4use crate::error::{DaemonError, Result};
5use crate::models::{DaemonConfig, ExecutionMode, TestNode};
6use crate::storage::DaemonStorage;
7use nng::options::{Options, RecvTimeout};
8use nng::{Message, Protocol, Socket};
9use rmp_serde::Deserializer;
10use rpytest_core::protocol::{ErrorCode, Request, Response, PROTOCOL_VERSION};
11use serde::Deserialize;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use parking_lot::Mutex;
15use std::sync::{
16 atomic::{AtomicBool, Ordering},
17 Arc,
18};
19use std::thread;
20use std::time::Duration;
21use tokio::sync::{mpsc, oneshot};
22use tracing::{debug, error, info};
23use uuid::Uuid;
24
25type ContextMap = Mutex<HashMap<String, Arc<tokio::sync::Mutex<RepoContext>>>>;
28
29struct RequestJob {
30 message: Vec<u8>,
31 responder: oneshot::Sender<Vec<u8>>,
32}
33
34#[derive(Clone)]
36pub struct DaemonServer {
37 socket_url: String,
39 storage: DaemonStorage,
41 contexts: Arc<ContextMap>,
43 #[allow(dead_code)]
45 config: DaemonConfig,
46}
47
48impl DaemonServer {
49 pub fn new(socket_path: PathBuf, storage_path: PathBuf) -> Result<Self> {
51 let storage = DaemonStorage::open(&storage_path)?;
52
53 let socket_url = format!("ipc://{}", socket_path.display());
55
56 Ok(DaemonServer {
57 socket_url,
58 storage,
59 contexts: Arc::new(Mutex::new(HashMap::new())),
60 config: DaemonConfig::default(),
61 })
62 }
63
64 pub fn run(&mut self) -> Result<()> {
66 let socket = Socket::new(Protocol::Rep0)?;
68
69 if self.socket_url.starts_with("ipc://") {
71 let path = &self.socket_url[4..]; let path = PathBuf::from(path);
73 if path.exists() {
74 std::fs::remove_file(&path)?;
75 }
76 }
77
78 socket.listen(&self.socket_url)?;
80 socket.set_opt::<RecvTimeout>(Some(Duration::from_millis(100)))?;
81
82 info!("Daemon listening on {}", self.socket_url);
83
84 let running = Arc::new(AtomicBool::new(true));
86 let running_clone = running.clone();
87 let running_for_ctrlc = running.clone();
88
89 let storage = self.storage.clone();
90 let runtime_contexts = Arc::clone(&self.contexts);
91 let (request_tx, mut request_rx) = mpsc::unbounded_channel::<RequestJob>();
92
93 let runtime = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
95
96 thread::spawn(move || {
98 runtime.block_on(async move {
99 while let Some(job) = request_rx.recv().await {
100 let response_bytes =
101 match Self::process_message(job.message, &storage, &runtime_contexts).await
102 {
103 Ok(buf) => buf,
104 Err(e) => {
105 error!("Processing error: {}", e);
106 let fallback = Response::Error {
107 code: ErrorCode::InternalError,
108 message: format!("Daemon error: {}", e),
109 };
110 rpytest_ipc::framing::encode(&fallback).unwrap_or_default()
111 }
112 };
113
114 if job.responder.send(response_bytes).is_err() {
115 debug!("Socket loop dropped before response send");
116 }
117 }
118 });
119 });
120
121 let socket_running = running_clone.clone();
123 let socket_tx = request_tx.clone();
124 thread::spawn(move || {
125 let socket = socket;
126 while socket_running.load(Ordering::SeqCst) {
127 match socket.recv() {
128 Ok(msg) => {
129 let msg_bytes = msg.as_slice().to_vec();
130 let (resp_tx, resp_rx) = oneshot::channel();
131 if socket_tx
132 .send(RequestJob {
133 message: msg_bytes,
134 responder: resp_tx,
135 })
136 .is_err()
137 {
138 error!("Runtime channel closed, stopping socket loop");
139 break;
140 }
141
142 match resp_rx.blocking_recv() {
143 Ok(response_buf) => {
144 if response_buf.is_empty() {
145 continue;
146 }
147 if let Err((_, e)) =
148 socket.send(Message::from(response_buf.as_slice()))
149 {
150 error!("Failed to send response: {}", e);
151 break;
152 }
153 }
154 Err(_) => {
155 error!("Failed to receive daemon response");
156 break;
157 }
158 }
159 }
160 Err(nng::Error::TimedOut) | Err(nng::Error::TryAgain) => continue,
161 Err(e) => {
162 error!("Receive error: {}", e);
163 thread::sleep(Duration::from_millis(100));
164 }
165 }
166 }
167 });
168
169 if let Err(e) = ctrlc::set_handler(move || {
171 running_for_ctrlc.store(false, Ordering::SeqCst);
172 }) {
173 error!("Failed to set Ctrl+C handler: {}", e);
174 }
175
176 while running.load(Ordering::SeqCst) {
178 thread::sleep(Duration::from_millis(100));
179 }
180
181 drop(request_tx);
182 info!("Daemon shutting down");
183 Ok(())
184 }
185
186 async fn process_message(
188 msg_bytes: Vec<u8>,
189 storage: &DaemonStorage,
190 contexts: &Arc<ContextMap>,
191 ) -> Result<Vec<u8>> {
192 if msg_bytes.len() < 4 {
194 return Err(DaemonError::Other(
195 "Message too short for length prefix".to_string(),
196 ));
197 }
198
199 let len =
200 u32::from_le_bytes([msg_bytes[0], msg_bytes[1], msg_bytes[2], msg_bytes[3]]) as usize;
201
202 if msg_bytes.len() < 4 + len {
203 return Err(DaemonError::Other(format!(
204 "Message incomplete: expected {} bytes, got {}",
205 4 + len,
206 msg_bytes.len()
207 )));
208 }
209
210 let payload = &msg_bytes[4..4 + len];
211
212 let mut deserializer = Deserializer::new(payload);
214 let request: Request = Deserialize::deserialize(&mut deserializer)?;
215
216 let response = Self::process_request(request, storage, contexts).await;
218
219 let response_buf = rpytest_ipc::framing::encode(&response)?;
221
222 Ok(response_buf)
223 }
224
225 async fn process_request(
227 request: Request,
228 storage: &DaemonStorage,
229 contexts: &Arc<ContextMap>,
230 ) -> Response {
231 match request {
232 Request::InitContext {
233 protocol_version,
234 repo_path,
235 python_path,
236 execution_mode,
237 } => {
238 if protocol_version != PROTOCOL_VERSION {
240 return Response::Error {
241 code: ErrorCode::VersionMismatch,
242 message: format!(
243 "Protocol version mismatch: CLI={}, Daemon={}",
244 protocol_version, PROTOCOL_VERSION
245 ),
246 };
247 }
248
249 let mode = execution_mode
251 .as_deref()
252 .and_then(|s| s.parse::<ExecutionMode>().ok())
253 .unwrap_or(ExecutionMode::Auto);
254
255 let context_key = format!(
258 "{}:{}:{}",
259 repo_path,
260 python_path.as_deref().unwrap_or("auto"),
261 mode
262 );
263 use sha2::{Sha256, Digest};
264 let mut hasher = Sha256::new();
265 hasher.update(context_key.as_bytes());
266 let context_id = format!("ctx-{}", hex::encode(&hasher.finalize()[..8]));
267
268 {
270 let contexts_lock = contexts.lock();
271 if let Some(existing_context) = contexts_lock.get(&context_id) {
272 let context = existing_context.lock().await;
273 let inventory_hash = context.inventory_hash.clone();
274 let execution_mode = context.execution_mode;
275 info!(
276 "Reusing existing context {} with {} execution mode",
277 context_id,
278 execution_mode
279 );
280 return Response::ContextReady {
281 protocol_version: PROTOCOL_VERSION,
282 context_id,
283 inventory_hash,
284 };
285 }
286 }
287
288 let context = match RepoContext::new(
290 &context_id,
291 Path::new(&repo_path),
292 python_path.map(std::path::PathBuf::from),
293 Some(storage.clone()),
294 mode,
295 ).await {
296 Ok(ctx) => ctx,
297 Err(e) => {
298 return Response::Error {
299 code: ErrorCode::InternalError,
300 message: format!("Failed to create context: {}", e),
301 };
302 }
303 };
304 let inventory_hash = context.inventory_hash.clone();
305 let execution_mode = context.execution_mode;
306
307 let mut contexts = contexts.lock();
309 contexts.insert(context_id.clone(), Arc::new(tokio::sync::Mutex::new(context)));
310
311 info!(
312 "Created new context {} with {} execution mode",
313 context_id,
314 execution_mode
315 );
316
317 Response::ContextReady {
318 protocol_version: PROTOCOL_VERSION,
319 context_id,
320 inventory_hash,
321 }
322 }
323
324 Request::Collect { context_id, force } => {
325 let context_arc = {
327 let contexts = contexts.lock();
328 contexts.get(&context_id).cloned()
329 };
330
331 if let Some(context_arc) = context_arc {
332 let mut context = context_arc.lock().await;
333 match context.collect(force) {
334 Ok((count, duration_ms)) => Response::CollectionComplete {
335 node_count: count,
336 duration_ms,
337 },
338 Err(e) => Response::Error {
339 code: ErrorCode::CollectionFailed,
340 message: format!("Collection failed: {}", e),
341 },
342 }
343 } else {
344 Response::Error {
345 code: ErrorCode::ContextNotFound,
346 message: format!("Context not found: {}", context_id),
347 }
348 }
349 }
350
351 Request::Run {
352 context_id,
353 node_ids,
354 workers,
355 maxfail,
356 } => {
357 let context_arc = {
360 let contexts = contexts.lock();
361 contexts.get(&context_id).cloned()
362 };
363
364 if let Some(context_arc) = context_arc {
365 let mut context = context_arc.lock().await;
366 let run_result = context.run_tests(&node_ids, workers, maxfail).await;
367
368 match run_result {
369 Ok(summary) => Response::RunComplete {
370 total: summary.total,
371 passed: summary.passed,
372 failed: summary.failed,
373 skipped: summary.skipped,
374 errors: summary.errors,
375 duration_ms: summary.duration_ms,
376 },
377 Err(e) => Response::Error {
378 code: ErrorCode::InternalError,
379 message: format!("Run failed: {}", e),
380 },
381 }
382 } else {
383 Response::Error {
384 code: ErrorCode::ContextNotFound,
385 message: format!("Context not found: {}", context_id),
386 }
387 }
388 }
389
390 Request::List {
391 context_id,
392 keyword,
393 marker,
394 } => {
395 let context_arc = {
396 let contexts = contexts.lock();
397 contexts.get(&context_id).cloned()
398 };
399
400 if let Some(context_arc) = context_arc {
401 let context = context_arc.lock().await;
402 let filtered: Vec<TestNode> = if let Some(kw) = keyword {
403 context.filter_by_keyword(&kw)
404 } else if let Some(mk) = marker {
405 context.filter_by_marker(&mk)
406 } else {
407 context.get_inventory()
408 };
409
410 Response::TestList {
411 node_ids: filtered.into_iter().map(|n| n.node_id).collect(),
412 }
413 } else {
414 Response::Error {
415 code: ErrorCode::ContextNotFound,
416 message: format!("Context not found: {}", context_id),
417 }
418 }
419 }
420
421 Request::GetInventory { context_id } => {
422 let context_arc = {
423 let contexts = contexts.lock();
424 contexts.get(&context_id).cloned()
425 };
426
427 if let Some(context_arc) = context_arc {
428 let context = context_arc.lock().await;
429 let nodes: Vec<TestNode> = context.get_inventory();
430 Response::InventoryData {
431 hash: context.inventory_hash.clone(),
432 collected_at: context.last_collection_time as u64,
433 nodes: nodes.into_iter().map(|n| n.into()).collect(),
434 }
435 } else {
436 Response::Error {
437 code: ErrorCode::ContextNotFound,
438 message: format!("Context not found: {}", context_id),
439 }
440 }
441 }
442
443 Request::Ping => Response::Pong,
444
445 Request::Shutdown { context_id } => {
446 let mut contexts = contexts.lock();
447 if let Some(id) = context_id {
448 contexts.remove(&id);
449 } else {
450 contexts.clear();
451 }
452 Response::ShutdownAck
453 }
454
455 Request::GetWorkerStatus { context_id: _ } => Response::WorkerStatus {
456 active_workers: 0,
457 idle_workers: 0,
458 tests_executed: 0,
459 avg_test_duration_ms: 0,
460 },
461
462 Request::ConfigureWorkers {
463 context_id: _,
464 num_workers: _,
465 } => Response::WorkerConfigAck { num_workers: 0 },
466
467 Request::RunStream {
468 context_id: _,
469 node_ids: _,
470 workers: _,
471 maxfail: _,
472 } => Response::Error {
473 code: ErrorCode::InvalidRequest,
474 message: "Streaming runs not yet implemented".to_string(),
475 },
476
477 Request::GetRunProgress {
478 context_id: _,
479 run_id: _,
480 } => Response::Error {
481 code: ErrorCode::InvalidRequest,
482 message: "Streaming runs not yet implemented".to_string(),
483 },
484
485 Request::GetFlakinessReport { context_id } => {
486 let context_arc = {
487 let contexts = contexts.lock();
488 contexts.get(&context_id).cloned()
489 };
490
491 if let Some(context_arc) = context_arc {
492 let context = context_arc.lock().await;
493 let _report = context.get_flakiness_report();
494 Response::FlakinessReport {
495 flaky_tests: Vec::new(),
496 unstable_tests: Vec::new(),
497 stable_count: 0,
498 total_tracked: 0,
499 }
500 } else {
501 Response::Error {
502 code: ErrorCode::ContextNotFound,
503 message: format!("Context not found: {}", context_id),
504 }
505 }
506 }
507
508 _ => Response::Error {
509 code: ErrorCode::InvalidRequest,
510 message: "Request type not implemented".to_string(),
511 },
512 }
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use std::fs;
520 use tempfile::TempDir;
521
522 #[test]
523 fn test_init_context() {
524 let dir = TempDir::new().unwrap();
525
526 let test_file = dir.path().join("test_example.py");
528 fs::write(&test_file, "def test_simple():\n assert True\n").unwrap();
529
530 let socket_path = dir.path().join("test.sock");
531 let storage_path = dir.path().join("storage");
532
533 let server = DaemonServer::new(socket_path, storage_path).unwrap();
534 assert!(server.contexts.lock().is_empty());
535 }
536
537 #[test]
538 fn test_run_request_completes() {
539 let dir = TempDir::new().unwrap();
540 let repo_root = dir.path().join("repo");
541 let tests_dir = repo_root.join("tests");
542 std::fs::create_dir_all(&tests_dir).unwrap();
543 fs::write(
544 tests_dir.join("test_sample.py"),
545 "def test_ok():\n assert True\n",
546 )
547 .unwrap();
548
549 std::env::set_var("RPYTEST_FAKE_PYTEST", "1");
550
551 let storage_path = repo_root.join("storage");
552 let storage = DaemonStorage::open(&storage_path).unwrap();
553 let contexts = Arc::new(Mutex::new(HashMap::new()));
554
555 let rt = tokio::runtime::Runtime::new().unwrap();
556 rt.block_on(async {
557 let init_response = DaemonServer::process_request(
558 Request::InitContext {
559 protocol_version: PROTOCOL_VERSION,
560 repo_path: repo_root.to_string_lossy().to_string(),
561 python_path: None,
562 execution_mode: None,
563 },
564 &storage,
565 &contexts,
566 )
567 .await;
568
569 let context_id = match init_response {
570 Response::ContextReady { context_id, .. } => context_id,
571 other => panic!("Unexpected init response: {:?}", other),
572 };
573
574 let collect_response = DaemonServer::process_request(
575 Request::Collect {
576 context_id: context_id.clone(),
577 force: true,
578 },
579 &storage,
580 &contexts,
581 )
582 .await;
583
584 match collect_response {
585 Response::CollectionComplete { node_count, .. } => assert!(node_count > 0),
586 other => panic!("Unexpected collect response: {:?}", other),
587 }
588
589 let inventory_response = DaemonServer::process_request(
590 Request::GetInventory {
591 context_id: context_id.clone(),
592 },
593 &storage,
594 &contexts,
595 )
596 .await;
597
598 let nodes = match inventory_response {
599 Response::InventoryData { nodes, .. } => nodes,
600 other => panic!("Unexpected inventory response: {:?}", other),
601 };
602 assert!(!nodes.is_empty());
603
604 let node_ids: Vec<String> = nodes.into_iter().map(|node| node.node_id).collect();
605
606 let run_response = DaemonServer::process_request(
607 Request::Run {
608 context_id: context_id.clone(),
609 node_ids: node_ids.clone(),
610 workers: Some(1),
611 maxfail: None,
612 },
613 &storage,
614 &contexts,
615 )
616 .await;
617
618 match run_response {
619 Response::RunComplete {
620 total,
621 failed,
622 errors,
623 ..
624 } => {
625 assert_eq!(total, node_ids.len());
626 assert_eq!(failed, 0);
627 assert_eq!(errors, 0);
628 }
629 other => panic!("Unexpected run response: {:?}", other),
630 }
631 });
632
633 std::env::remove_var("RPYTEST_FAKE_PYTEST");
634 }
635}