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 parking_lot::Mutex;
10use rmp_serde::Deserializer;
11use rpytest_core::protocol::{ErrorCode, Request, Response, PROTOCOL_VERSION};
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
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::{Digest, Sha256};
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, execution_mode
278 );
279 return Response::ContextReady {
280 protocol_version: PROTOCOL_VERSION,
281 context_id,
282 inventory_hash,
283 };
284 }
285 }
286
287 let context = match RepoContext::new(
289 &context_id,
290 Path::new(&repo_path),
291 python_path.map(std::path::PathBuf::from),
292 Some(storage.clone()),
293 mode,
294 )
295 .await
296 {
297 Ok(ctx) => ctx,
298 Err(e) => {
299 return Response::Error {
300 code: ErrorCode::InternalError,
301 message: format!("Failed to create context: {}", e),
302 };
303 }
304 };
305 let inventory_hash = context.inventory_hash.clone();
306 let execution_mode = context.execution_mode();
307
308 let mut contexts = contexts.lock();
310 contexts.insert(
311 context_id.clone(),
312 Arc::new(tokio::sync::Mutex::new(context)),
313 );
314
315 info!(
316 "Created new context {} with {} execution mode",
317 context_id, execution_mode
318 );
319
320 Response::ContextReady {
321 protocol_version: PROTOCOL_VERSION,
322 context_id,
323 inventory_hash,
324 }
325 }
326
327 Request::Collect { context_id, force } => {
328 let context_arc = {
330 let contexts = contexts.lock();
331 contexts.get(&context_id).cloned()
332 };
333
334 if let Some(context_arc) = context_arc {
335 let mut context = context_arc.lock().await;
336 match context.collect(force) {
337 Ok((count, duration_ms)) => Response::CollectionComplete {
338 node_count: count,
339 duration_ms,
340 },
341 Err(e) => Response::Error {
342 code: ErrorCode::CollectionFailed,
343 message: format!("Collection failed: {}", e),
344 },
345 }
346 } else {
347 Response::Error {
348 code: ErrorCode::ContextNotFound,
349 message: format!("Context not found: {}", context_id),
350 }
351 }
352 }
353
354 Request::Run {
355 context_id,
356 node_ids,
357 workers,
358 maxfail,
359 } => {
360 let context_arc = {
363 let contexts = contexts.lock();
364 contexts.get(&context_id).cloned()
365 };
366
367 if let Some(context_arc) = context_arc {
368 let mut context = context_arc.lock().await;
369 let run_result = context.run_tests(&node_ids, workers, maxfail).await;
370
371 match run_result {
372 Ok(summary) => Response::RunComplete {
373 total: summary.total,
374 passed: summary.passed,
375 failed: summary.failed,
376 skipped: summary.skipped,
377 errors: summary.errors,
378 duration_ms: summary.duration_ms,
379 },
380 Err(e) => Response::Error {
381 code: ErrorCode::InternalError,
382 message: format!("Run failed: {}", e),
383 },
384 }
385 } else {
386 Response::Error {
387 code: ErrorCode::ContextNotFound,
388 message: format!("Context not found: {}", context_id),
389 }
390 }
391 }
392
393 Request::List {
394 context_id,
395 keyword,
396 marker,
397 } => {
398 let context_arc = {
399 let contexts = contexts.lock();
400 contexts.get(&context_id).cloned()
401 };
402
403 if let Some(context_arc) = context_arc {
404 let context = context_arc.lock().await;
405 let filtered: Vec<TestNode> = if let Some(kw) = keyword {
406 context.filter_by_keyword(&kw)
407 } else if let Some(mk) = marker {
408 context.filter_by_marker(&mk)
409 } else {
410 context.get_inventory()
411 };
412
413 Response::TestList {
414 node_ids: filtered.into_iter().map(|n| n.node_id).collect(),
415 }
416 } else {
417 Response::Error {
418 code: ErrorCode::ContextNotFound,
419 message: format!("Context not found: {}", context_id),
420 }
421 }
422 }
423
424 Request::GetInventory { context_id } => {
425 let context_arc = {
426 let contexts = contexts.lock();
427 contexts.get(&context_id).cloned()
428 };
429
430 if let Some(context_arc) = context_arc {
431 let context = context_arc.lock().await;
432 let nodes: Vec<TestNode> = context.get_inventory();
433 Response::InventoryData {
434 hash: context.inventory_hash.clone(),
435 collected_at: context.last_collection_time as u64,
436 nodes: nodes.into_iter().map(|n| n.into()).collect(),
437 }
438 } else {
439 Response::Error {
440 code: ErrorCode::ContextNotFound,
441 message: format!("Context not found: {}", context_id),
442 }
443 }
444 }
445
446 Request::Ping => Response::Pong,
447
448 Request::Shutdown { context_id } => {
449 let mut contexts = contexts.lock();
450 if let Some(id) = context_id {
451 contexts.remove(&id);
452 } else {
453 contexts.clear();
454 }
455 Response::ShutdownAck
456 }
457
458 Request::GetWorkerStatus { context_id: _ } => Response::WorkerStatus {
459 active_workers: 0,
460 idle_workers: 0,
461 tests_executed: 0,
462 avg_test_duration_ms: 0,
463 },
464
465 Request::ConfigureWorkers {
466 context_id: _,
467 num_workers: _,
468 } => Response::WorkerConfigAck { num_workers: 0 },
469
470 Request::RunStream {
471 context_id: _,
472 node_ids: _,
473 workers: _,
474 maxfail: _,
475 } => Response::Error {
476 code: ErrorCode::InvalidRequest,
477 message: "Streaming runs not yet implemented".to_string(),
478 },
479
480 Request::GetRunProgress {
481 context_id: _,
482 run_id: _,
483 } => Response::Error {
484 code: ErrorCode::InvalidRequest,
485 message: "Streaming runs not yet implemented".to_string(),
486 },
487
488 Request::GetFlakinessReport { context_id } => {
489 let context_arc = {
490 let contexts = contexts.lock();
491 contexts.get(&context_id).cloned()
492 };
493
494 if let Some(context_arc) = context_arc {
495 let context = context_arc.lock().await;
496 let _report = context.get_flakiness_report();
497 Response::FlakinessReport {
498 flaky_tests: Vec::new(),
499 unstable_tests: Vec::new(),
500 stable_count: 0,
501 total_tracked: 0,
502 }
503 } else {
504 Response::Error {
505 code: ErrorCode::ContextNotFound,
506 message: format!("Context not found: {}", context_id),
507 }
508 }
509 }
510
511 _ => Response::Error {
512 code: ErrorCode::InvalidRequest,
513 message: "Request type not implemented".to_string(),
514 },
515 }
516 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use std::fs;
523 use tempfile::TempDir;
524
525 #[test]
526 fn test_init_context() {
527 let dir = TempDir::new().unwrap();
528
529 let test_file = dir.path().join("test_example.py");
531 fs::write(&test_file, "def test_simple():\n assert True\n").unwrap();
532
533 let socket_path = dir.path().join("test.sock");
534 let storage_path = dir.path().join("storage");
535
536 let server = DaemonServer::new(socket_path, storage_path).unwrap();
537 assert!(server.contexts.lock().is_empty());
538 }
539
540 #[test]
541 fn test_run_request_completes() {
542 let dir = TempDir::new().unwrap();
543 let repo_root = dir.path().join("repo");
544 let tests_dir = repo_root.join("tests");
545 std::fs::create_dir_all(&tests_dir).unwrap();
546 fs::write(
547 tests_dir.join("test_sample.py"),
548 "def test_ok():\n assert True\n",
549 )
550 .unwrap();
551
552 std::env::set_var("RPYTEST_FAKE_PYTEST", "1");
553
554 let storage_path = repo_root.join("storage");
555 let storage = DaemonStorage::open(&storage_path).unwrap();
556 let contexts = Arc::new(Mutex::new(HashMap::new()));
557
558 let rt = tokio::runtime::Runtime::new().unwrap();
559 rt.block_on(async {
560 let init_response = DaemonServer::process_request(
561 Request::InitContext {
562 protocol_version: PROTOCOL_VERSION,
563 repo_path: repo_root.to_string_lossy().to_string(),
564 python_path: None,
565 execution_mode: Some("subprocess".to_string()),
566 },
567 &storage,
568 &contexts,
569 )
570 .await;
571
572 let context_id = match init_response {
573 Response::ContextReady { context_id, .. } => context_id,
574 other => panic!("Unexpected init response: {:?}", other),
575 };
576
577 let collect_response = DaemonServer::process_request(
578 Request::Collect {
579 context_id: context_id.clone(),
580 force: true,
581 },
582 &storage,
583 &contexts,
584 )
585 .await;
586
587 match collect_response {
588 Response::CollectionComplete { node_count, .. } => assert!(node_count > 0),
589 other => panic!("Unexpected collect response: {:?}", other),
590 }
591
592 let inventory_response = DaemonServer::process_request(
593 Request::GetInventory {
594 context_id: context_id.clone(),
595 },
596 &storage,
597 &contexts,
598 )
599 .await;
600
601 let nodes = match inventory_response {
602 Response::InventoryData { nodes, .. } => nodes,
603 other => panic!("Unexpected inventory response: {:?}", other),
604 };
605 assert!(!nodes.is_empty());
606
607 let node_ids: Vec<String> = nodes.into_iter().map(|node| node.node_id).collect();
608
609 let run_response = DaemonServer::process_request(
610 Request::Run {
611 context_id: context_id.clone(),
612 node_ids: node_ids.clone(),
613 workers: Some(1),
614 maxfail: None,
615 },
616 &storage,
617 &contexts,
618 )
619 .await;
620
621 match run_response {
622 Response::RunComplete {
623 total,
624 failed,
625 errors,
626 ..
627 } => {
628 assert_eq!(total, node_ids.len());
629 assert_eq!(failed, 0);
630 assert_eq!(errors, 0);
631 }
632 other => panic!("Unexpected run response: {:?}", other),
633 }
634 });
635
636 std::env::remove_var("RPYTEST_FAKE_PYTEST");
637 }
638}