Skip to main content

tldr_cli/commands/daemon/
start.rs

1//! Daemon start command implementation
2//!
3//! CLI command: `tldr daemon start [--project PATH] [--foreground]`
4//!
5//! This module handles starting the TLDR daemon process with:
6//! - PID file locking to ensure single instance per project
7//! - Daemonization (background mode) or foreground mode
8//! - Socket binding for IPC communication
9//!
10//! # Security Mitigations
11//!
12//! - TIGER-P1-01: Exclusive file lock on PID file prevents race conditions
13//! - TIGER-P2-02: Stale socket cleanup on startup
14
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use clap::Args;
19use serde::Serialize;
20
21use crate::output::OutputFormat;
22
23use super::daemon::{start_daemon_background, wait_for_daemon, TLDRDaemon};
24use super::daemon_active::write_active;
25use super::error::DaemonError;
26use super::ipc::{check_socket_alive, cleanup_socket, compute_socket_path, IpcListener};
27use super::pid::{compute_pid_path, try_acquire_lock};
28use super::types::DaemonConfig;
29
30// =============================================================================
31// CLI Arguments
32// =============================================================================
33
34/// Arguments for the `daemon start` command.
35#[derive(Debug, Clone, Args)]
36pub struct DaemonStartArgs {
37    /// Project root directory (default: current directory)
38    #[arg(long, short = 'p', default_value = ".")]
39    pub project: PathBuf,
40
41    /// Run daemon in foreground (don't daemonize)
42    #[arg(long)]
43    pub foreground: bool,
44}
45
46// =============================================================================
47// Output Types
48// =============================================================================
49
50/// Output structure for successful daemon start.
51#[derive(Debug, Clone, Serialize)]
52pub struct DaemonStartOutput {
53    /// Status message
54    pub status: String,
55    /// PID of the daemon process
56    pub pid: u32,
57    /// Path to the socket file
58    pub socket: PathBuf,
59    /// Optional message
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub message: Option<String>,
62}
63
64// =============================================================================
65// Command Implementation
66// =============================================================================
67
68impl DaemonStartArgs {
69    /// Run the daemon start command.
70    pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
71        // Create a new tokio runtime for the async operations
72        let runtime = tokio::runtime::Runtime::new()?;
73        runtime.block_on(self.run_async(format, quiet))
74    }
75
76    /// Async implementation of the daemon start command.
77    async fn run_async(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
78        // Resolve project path to absolute
79        let project = self.project.canonicalize().unwrap_or_else(|_| {
80            std::env::current_dir()
81                .unwrap_or_else(|_| PathBuf::from("."))
82                .join(&self.project)
83        });
84
85        // Note: stale PID cleanup is intentionally NOT performed here. The
86        // flock-based `try_acquire_lock` (called below in foreground mode and
87        // inside `start_daemon_background` for background mode) handles stale
88        // PIDs safely INSIDE the lock — pre-lock cleanup would reopen the
89        // TOCTOU window from issue #14 (two concurrent starts could both
90        // pass the staleness check before either acquired the lock).
91
92        // Check for stale socket and clean up. The socket-side TOCTOU is
93        // additionally guarded by `IpcListener::bind_unix`, which now treats
94        // an existing socket as `AddressInUse` rather than silently
95        // unlink-and-rebind (issue #14).
96        let socket_path = compute_socket_path(&project);
97        if socket_path.exists() && !check_socket_alive(&project).await {
98            // Socket exists but daemon is not responding - stale
99            cleanup_socket(&project)?;
100        }
101
102        if self.foreground {
103            // Run in foreground
104            self.run_foreground(&project, format, quiet).await
105        } else {
106            // Run in background
107            self.run_background(&project, format, quiet).await
108        }
109    }
110
111    /// Run the daemon in foreground mode.
112    async fn run_foreground(
113        &self,
114        project: &Path,
115        format: OutputFormat,
116        quiet: bool,
117    ) -> anyhow::Result<()> {
118        // Try to acquire PID lock
119        let pid_path = compute_pid_path(project);
120        let _pid_guard = try_acquire_lock(&pid_path).map_err(|e| match e {
121            DaemonError::AlreadyRunning { pid } => {
122                anyhow::anyhow!("Daemon already running (PID: {})", pid)
123            }
124            DaemonError::StalePidFile { pid } => {
125                anyhow::anyhow!("Stale PID file (process {} not running)", pid)
126            }
127            other => anyhow::anyhow!("Failed to acquire lock: {}", other),
128        })?;
129
130        // Bind IPC listener
131        let listener = IpcListener::bind(project).await.map_err(|e| match e {
132            DaemonError::AddressInUse { addr } => {
133                anyhow::anyhow!("Address already in use: {}", addr)
134            }
135            DaemonError::SocketBindFailed(io_err) => {
136                anyhow::anyhow!("Failed to bind socket: {}", io_err)
137            }
138            other => anyhow::anyhow!("Socket error: {}", other),
139        })?;
140
141        let socket_path = compute_socket_path(project);
142        let our_pid = std::process::id();
143
144        // VAL-013 (issue #20): record the active daemon for cross-cwd
145        // discovery. Failures are logged but non-fatal — the file is
146        // auxiliary state. A missing file degrades to the legacy
147        // behaviour where `daemon status` from a different cwd reports
148        // `not_running`.
149        if let Err(e) = write_active(project, our_pid, &socket_path) {
150            eprintln!(
151                "warning: could not write daemon-active discovery file: {}",
152                e
153            );
154        }
155
156        // Print startup message
157        let output = DaemonStartOutput {
158            status: "ok".to_string(),
159            pid: our_pid,
160            socket: socket_path.clone(),
161            message: Some("Daemon started in foreground".to_string()),
162        };
163
164        if !quiet {
165            match format {
166                OutputFormat::Json | OutputFormat::Compact => {
167                    println!("{}", serde_json::to_string_pretty(&output)?);
168                }
169                OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
170                    println!("Daemon started with PID {}", our_pid);
171                    println!("Socket: {}", socket_path.display());
172                }
173            }
174        }
175
176        // Create and run daemon
177        let config = DaemonConfig::default();
178        let daemon = Arc::new(TLDRDaemon::new(project.to_path_buf(), config));
179        daemon.run(listener).await?;
180
181        // Cleanup socket and active-daemon discovery file on exit.
182        let _ = cleanup_socket(project);
183        let _ = super::daemon_active::remove_active();
184
185        Ok(())
186    }
187
188    /// Run the daemon in background mode.
189    async fn run_background(
190        &self,
191        project: &Path,
192        format: OutputFormat,
193        quiet: bool,
194    ) -> anyhow::Result<()> {
195        // First check if daemon is already running
196        if check_socket_alive(project).await {
197            // Try to get PID from PID file
198            let pid_path = compute_pid_path(project);
199            let pid = std::fs::read_to_string(&pid_path)
200                .ok()
201                .and_then(|s| s.trim().parse().ok())
202                .unwrap_or(0);
203
204            return Err(anyhow::anyhow!("Daemon already running (PID: {})", pid));
205        }
206
207        // Start the daemon in background
208        let pid = start_daemon_background(project).await?;
209
210        // Wait for daemon to become ready
211        wait_for_daemon(project, 10)
212            .await
213            .map_err(|_| anyhow::anyhow!("Daemon failed to start within timeout"))?;
214
215        let socket_path = compute_socket_path(project);
216
217        // Print output
218        let output = DaemonStartOutput {
219            status: "ok".to_string(),
220            pid,
221            socket: socket_path.clone(),
222            message: Some("Daemon started".to_string()),
223        };
224
225        if !quiet {
226            match format {
227                OutputFormat::Json | OutputFormat::Compact => {
228                    println!("{}", serde_json::to_string_pretty(&output)?);
229                }
230                OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
231                    println!("Daemon started with PID {}", pid);
232                    println!("Socket: {}", socket_path.display());
233                }
234            }
235        }
236
237        Ok(())
238    }
239}
240
241// =============================================================================
242// Tests
243// =============================================================================
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_daemon_start_args_default() {
251        let args = DaemonStartArgs {
252            project: PathBuf::from("."),
253            foreground: false,
254        };
255
256        assert_eq!(args.project, PathBuf::from("."));
257        assert!(!args.foreground);
258    }
259
260    #[test]
261    fn test_daemon_start_args_foreground() {
262        let args = DaemonStartArgs {
263            project: PathBuf::from("/test/project"),
264            foreground: true,
265        };
266
267        assert!(args.foreground);
268    }
269
270    #[test]
271    fn test_daemon_start_output_serialization() {
272        let output = DaemonStartOutput {
273            status: "ok".to_string(),
274            pid: 12345,
275            socket: PathBuf::from("/tmp/tldr-abc123.sock"),
276            message: Some("Daemon started".to_string()),
277        };
278
279        let json = serde_json::to_string(&output).unwrap();
280        assert!(json.contains("ok"));
281        assert!(json.contains("12345"));
282        assert!(json.contains("tldr-abc123.sock"));
283    }
284}