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 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
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::{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                // 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, execution_mode
278                        );
279                        return Response::ContextReady {
280                            protocol_version: PROTOCOL_VERSION,
281                            context_id,
282                            inventory_hash,
283                        };
284                    }
285                }
286
287                // Create context with the requested execution mode (async for pooled mode support)
288                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                // Store context wrapped in Arc<tokio::sync::Mutex> for concurrent access
309                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                // Get the Arc, then release the map lock before locking the context
329                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                // Get the Arc, then release the map lock before locking the context
361                // This prevents the race condition where context was removed/reinserted
362                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        // Create a test file
530        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}