Skip to main content

rpytest_daemon/
server.rs

1//! IPC server using NNG for communication with the CLI.
2
3use 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
25/// Type alias for the contexts map - each context is wrapped in Arc<tokio::sync::Mutex>
26/// to allow concurrent access without removing/reinserting from the map
27type 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/// Main daemon server.
35#[derive(Clone)]
36pub struct DaemonServer {
37    /// Socket URL for IPC (e.g., "ipc:///tmp/rpytest.sock")
38    socket_url: String,
39    /// Storage backend
40    storage: DaemonStorage,
41    /// Active contexts (context_id -> RepoContext)
42    contexts: Arc<ContextMap>,
43    /// Server configuration (planned feature)
44    #[allow(dead_code)]
45    config: DaemonConfig,
46}
47
48impl DaemonServer {
49    /// Create a new daemon server.
50    pub fn new(socket_path: PathBuf, storage_path: PathBuf) -> Result<Self> {
51        let storage = DaemonStorage::open(&storage_path)?;
52
53        // Convert path to NNG IPC URL
54        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    /// Start the server in a background thread.
65    pub fn run(&mut self) -> Result<()> {
66        // Create NNG socket with rep protocol (request-response)
67        let socket = Socket::new(Protocol::Rep0)?;
68
69        // Clean up old socket file if exists (for ipc:// transport)
70        if self.socket_url.starts_with("ipc://") {
71            let path = &self.socket_url[4..]; // Remove "ipc://" prefix
72            let path = PathBuf::from(path);
73            if path.exists() {
74                std::fs::remove_file(&path)?;
75            }
76        }
77
78        // Listen on socket
79        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        // Shared state for shutdown
85        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        // Create a tokio runtime for the server thread
94        let runtime = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
95
96        // Spawn the processing thread
97        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        // Spawn socket loop thread to bridge blocking NNG with async runtime
122        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        // Wait for Ctrl+C or signal
170        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        // Wait while running
177        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    /// Process a single message.
187    async fn process_message(
188        msg_bytes: Vec<u8>,
189        storage: &DaemonStorage,
190        contexts: &Arc<ContextMap>,
191    ) -> Result<Vec<u8>> {
192        // Parse length-prefixed framing
193        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        // Deserialize request
213        let mut deserializer = Deserializer::new(payload);
214        let request: Request = Deserialize::deserialize(&mut deserializer)?;
215
216        // Process request
217        let response = Self::process_request(request, storage, contexts).await;
218
219        // Serialize response with length-prefixed framing
220        let response_buf = rpytest_ipc::framing::encode(&response)?;
221
222        Ok(response_buf)
223    }
224
225    /// Process a single request.
226    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                // Check protocol version
239                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                // Parse execution mode from request (default to Auto)
250                let mode = execution_mode
251                    .as_deref()
252                    .and_then(|s| s.parse::<ExecutionMode>().ok())
253                    .unwrap_or(ExecutionMode::Auto);
254
255                // Generate stable context ID based on repo_path and execution_mode
256                // This allows reusing contexts with warm workers across CLI invocations
257                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                // Check if context already exists
269                {
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                // Create context with the requested execution mode (async for pooled mode support)
289                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                // Store context wrapped in Arc<tokio::sync::Mutex> for concurrent access
308                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                // Get the Arc, then release the map lock before locking the context
326                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                // Get the Arc, then release the map lock before locking the context
358                // This prevents the race condition where context was removed/reinserted
359                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        // Create a test file
527        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}