database_replicator/
daemon.rs

1// ABOUTME: Daemon mode support for running sync as a background service
2// ABOUTME: Cross-platform: Unix (fork) and Windows (detached process)
3
4use anyhow::{Context, Result};
5use std::fs;
6use std::path::PathBuf;
7
8/// Get the directory for storing daemon state files.
9/// Returns ~/.seren-replicator/ on Unix or %APPDATA%\seren-replicator\ on Windows
10pub fn get_daemon_dir() -> Result<PathBuf> {
11    #[cfg(windows)]
12    let daemon_dir = {
13        let app_data = dirs::data_local_dir().context("Failed to determine AppData directory")?;
14        app_data.join("seren-replicator")
15    };
16
17    #[cfg(not(windows))]
18    let daemon_dir = {
19        let home = dirs::home_dir().context("Failed to determine home directory")?;
20        home.join(".seren-replicator")
21    };
22
23    // Create directory if it doesn't exist
24    if !daemon_dir.exists() {
25        fs::create_dir_all(&daemon_dir)
26            .with_context(|| format!("Failed to create daemon directory: {:?}", daemon_dir))?;
27    }
28
29    Ok(daemon_dir)
30}
31
32/// Get the path to the PID file.
33pub fn get_pid_file_path() -> Result<PathBuf> {
34    Ok(get_daemon_dir()?.join("sync.pid"))
35}
36
37/// Get the path to the log file for daemon mode.
38pub fn get_log_file_path() -> Result<PathBuf> {
39    Ok(get_daemon_dir()?.join("sync.log"))
40}
41
42/// Check if a process with the given PID is running.
43#[cfg(unix)]
44fn is_process_running(pid: i32) -> bool {
45    // Send signal 0 to check if process exists
46    unsafe { libc::kill(pid, 0) == 0 }
47}
48
49#[cfg(windows)]
50fn is_process_running(pid: i32) -> bool {
51    use std::ptr::null_mut;
52
53    // OpenProcess with PROCESS_QUERY_LIMITED_INFORMATION
54    const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
55    const SYNCHRONIZE: u32 = 0x00100000;
56
57    unsafe {
58        let handle = OpenProcess(
59            PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE,
60            0,
61            pid as u32,
62        );
63        if handle.is_null() {
64            return false;
65        }
66
67        // Check if process is still running
68        let mut exit_code: u32 = 0;
69        let result = GetExitCodeProcess(handle, &mut exit_code);
70        CloseHandle(handle);
71
72        // STILL_ACTIVE = 259
73        result != 0 && exit_code == 259
74    }
75}
76
77#[cfg(windows)]
78extern "system" {
79    fn OpenProcess(
80        dwDesiredAccess: u32,
81        bInheritHandle: i32,
82        dwProcessId: u32,
83    ) -> *mut std::ffi::c_void;
84    fn GetExitCodeProcess(hProcess: *mut std::ffi::c_void, lpExitCode: *mut u32) -> i32;
85    fn CloseHandle(hObject: *mut std::ffi::c_void) -> i32;
86    fn TerminateProcess(hProcess: *mut std::ffi::c_void, uExitCode: u32) -> i32;
87}
88
89/// Read the PID from the PID file.
90pub fn read_pid() -> Result<Option<i32>> {
91    let pid_file = get_pid_file_path()?;
92
93    if !pid_file.exists() {
94        return Ok(None);
95    }
96
97    let content = fs::read_to_string(&pid_file)
98        .with_context(|| format!("Failed to read PID file: {:?}", pid_file))?;
99
100    let pid: i32 = content
101        .trim()
102        .parse()
103        .with_context(|| format!("Invalid PID in file: {}", content.trim()))?;
104
105    Ok(Some(pid))
106}
107
108/// Write the current process PID to the PID file.
109pub fn write_pid() -> Result<()> {
110    let pid_file = get_pid_file_path()?;
111    let pid = std::process::id();
112
113    fs::write(&pid_file, pid.to_string())
114        .with_context(|| format!("Failed to write PID file: {:?}", pid_file))?;
115
116    Ok(())
117}
118
119/// Remove the PID file.
120pub fn remove_pid_file() -> Result<()> {
121    let pid_file = get_pid_file_path()?;
122
123    if pid_file.exists() {
124        fs::remove_file(&pid_file)
125            .with_context(|| format!("Failed to remove PID file: {:?}", pid_file))?;
126    }
127
128    Ok(())
129}
130
131/// Status information about the daemon.
132#[derive(Debug)]
133pub struct DaemonStatus {
134    pub running: bool,
135    pub pid: Option<i32>,
136    pub pid_file_exists: bool,
137}
138
139/// Check the status of the daemon.
140pub fn check_status() -> Result<DaemonStatus> {
141    let pid_file = get_pid_file_path()?;
142    let pid_file_exists = pid_file.exists();
143
144    let (running, pid) = match read_pid()? {
145        Some(pid) => {
146            let running = is_process_running(pid);
147            (running, Some(pid))
148        }
149        None => (false, None),
150    };
151
152    Ok(DaemonStatus {
153        running,
154        pid,
155        pid_file_exists,
156    })
157}
158
159/// Stop the running daemon.
160#[cfg(unix)]
161pub fn stop_daemon() -> Result<bool> {
162    let status = check_status()?;
163
164    if !status.running {
165        if status.pid_file_exists {
166            remove_pid_file()?;
167            println!("Removed stale PID file (process was not running)");
168        }
169        return Ok(false);
170    }
171
172    let pid = status.pid.unwrap();
173    println!("Sending SIGTERM to daemon (PID: {})", pid);
174
175    let result = unsafe { libc::kill(pid, libc::SIGTERM) };
176
177    if result != 0 {
178        anyhow::bail!(
179            "Failed to send SIGTERM to process {}: {}",
180            pid,
181            std::io::Error::last_os_error()
182        );
183    }
184
185    // Wait for process to exit
186    let start = std::time::Instant::now();
187    let timeout = std::time::Duration::from_secs(10);
188
189    while is_process_running(pid) {
190        if start.elapsed() > timeout {
191            println!("Process didn't exit within 10 seconds, sending SIGKILL");
192            unsafe { libc::kill(pid, libc::SIGKILL) };
193            std::thread::sleep(std::time::Duration::from_millis(500));
194            break;
195        }
196        std::thread::sleep(std::time::Duration::from_millis(100));
197    }
198
199    remove_pid_file()?;
200    Ok(true)
201}
202
203#[cfg(windows)]
204pub fn stop_daemon() -> Result<bool> {
205    let status = check_status()?;
206
207    if !status.running {
208        if status.pid_file_exists {
209            remove_pid_file()?;
210            println!("Removed stale PID file (process was not running)");
211        }
212        return Ok(false);
213    }
214
215    let pid = status.pid.unwrap();
216    println!("Terminating daemon (PID: {})", pid);
217
218    const PROCESS_TERMINATE: u32 = 0x0001;
219
220    unsafe {
221        let handle = OpenProcess(PROCESS_TERMINATE, 0, pid as u32);
222        if handle.is_null() {
223            anyhow::bail!(
224                "Failed to open process {}: {}",
225                pid,
226                std::io::Error::last_os_error()
227            );
228        }
229
230        let result = TerminateProcess(handle, 0);
231        CloseHandle(handle);
232
233        if result == 0 {
234            anyhow::bail!(
235                "Failed to terminate process {}: {}",
236                pid,
237                std::io::Error::last_os_error()
238            );
239        }
240    }
241
242    // Wait briefly for process to exit
243    std::thread::sleep(std::time::Duration::from_millis(500));
244
245    remove_pid_file()?;
246    Ok(true)
247}
248
249/// Daemonize the current process (Unix).
250#[cfg(unix)]
251pub fn daemonize() -> Result<()> {
252    use daemonize::Daemonize;
253    use std::fs::OpenOptions;
254
255    let pid_file = get_pid_file_path()?;
256    let log_file = get_log_file_path()?;
257
258    // Check if daemon is already running
259    let status = check_status()?;
260    if status.running {
261        anyhow::bail!(
262            "Daemon is already running (PID: {}). Use --stop to stop it first.",
263            status.pid.unwrap()
264        );
265    }
266
267    // Clean up stale PID file if present
268    if status.pid_file_exists {
269        remove_pid_file()?;
270    }
271
272    // Open log file for stdout/stderr
273    let stdout = OpenOptions::new()
274        .create(true)
275        .append(true)
276        .open(&log_file)
277        .with_context(|| format!("Failed to open log file: {:?}", log_file))?;
278
279    let stderr = OpenOptions::new()
280        .create(true)
281        .append(true)
282        .open(&log_file)
283        .with_context(|| format!("Failed to open log file: {:?}", log_file))?;
284
285    println!("Starting daemon...");
286    println!("PID file: {:?}", pid_file);
287    println!("Log file: {:?}", log_file);
288
289    let daemonize = Daemonize::new()
290        .pid_file(&pid_file)
291        .chown_pid_file(true)
292        .working_directory(".")
293        .stdout(stdout)
294        .stderr(stderr);
295
296    daemonize.start().context("Failed to daemonize process")?;
297
298    tracing::info!("Daemon started (PID: {})", std::process::id());
299    Ok(())
300}
301
302/// Daemonize by spawning a detached process (Windows).
303#[cfg(windows)]
304pub fn daemonize() -> Result<()> {
305    use std::os::windows::process::CommandExt;
306    use std::process::Command;
307
308    let pid_file = get_pid_file_path()?;
309    let log_file = get_log_file_path()?;
310
311    // Check if daemon is already running
312    let status = check_status()?;
313    if status.running {
314        anyhow::bail!(
315            "Daemon is already running (PID: {}). Use --stop to stop it first.",
316            status.pid.unwrap()
317        );
318    }
319
320    // Clean up stale PID file
321    if status.pid_file_exists {
322        remove_pid_file()?;
323    }
324
325    // Get current executable path
326    let exe = std::env::current_exe().context("Failed to get current executable path")?;
327
328    // Get original command line args, removing --daemon flag
329    let args: Vec<String> = std::env::args()
330        .skip(1) // Skip executable name
331        .filter(|arg| arg != "--daemon")
332        .collect();
333
334    // Add internal flag to indicate we're running as daemon child
335    let mut daemon_args = args.clone();
336    daemon_args.push("--daemon-child".to_string());
337
338    println!("Starting daemon...");
339    println!("PID file: {:?}", pid_file);
340    println!("Log file: {:?}", log_file);
341
342    // CREATE_NO_WINDOW = 0x08000000
343    // DETACHED_PROCESS = 0x00000008
344    const CREATE_NO_WINDOW: u32 = 0x08000000;
345
346    // Spawn detached process
347    let child = Command::new(exe)
348        .args(&daemon_args)
349        .creation_flags(CREATE_NO_WINDOW)
350        .spawn()
351        .context("Failed to spawn daemon process")?;
352
353    let pid = child.id();
354    println!("Daemon started with PID: {}", pid);
355
356    // Note: The child process will write its own PID file when it starts
357    Ok(())
358}
359
360/// Check if we're running as a daemon child process (Windows).
361/// On Unix this is handled by the daemonize crate.
362pub fn is_daemon_child() -> bool {
363    std::env::args().any(|arg| arg == "--daemon-child")
364}
365
366/// Initialize daemon child process (write PID file, setup logging).
367/// Call this at startup if is_daemon_child() returns true.
368pub fn init_daemon_child() -> Result<PathBuf> {
369    let log_file = get_log_file_path()?;
370
371    // Write PID file
372    write_pid()?;
373
374    Ok(log_file)
375}
376
377/// Print daemon status to stdout.
378pub fn print_status() -> Result<()> {
379    let status = check_status()?;
380    let log_file = get_log_file_path()?;
381
382    if status.running {
383        println!("Daemon status: RUNNING");
384        println!("PID: {}", status.pid.unwrap());
385        println!("Log file: {:?}", log_file);
386
387        // Show last few lines of log
388        if log_file.exists() {
389            println!("\nRecent log entries:");
390            println!("-------------------");
391            let content = fs::read_to_string(&log_file)?;
392            let lines: Vec<&str> = content.lines().collect();
393            let start = if lines.len() > 10 {
394                lines.len() - 10
395            } else {
396                0
397            };
398            for line in &lines[start..] {
399                println!("{}", line);
400            }
401        }
402    } else {
403        println!("Daemon status: NOT RUNNING");
404        if status.pid_file_exists {
405            println!(
406                "Note: Stale PID file exists (PID {} is not running)",
407                status.pid.unwrap_or(0)
408            );
409            println!("Run with --stop to clean up the stale PID file");
410        }
411    }
412
413    Ok(())
414}
415
416/// Clean up daemon resources (call on normal shutdown).
417pub fn cleanup() -> Result<()> {
418    remove_pid_file()
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn test_daemon_dir_creation() {
427        let dir = get_daemon_dir();
428        assert!(dir.is_ok());
429        let path = dir.unwrap();
430        assert!(path.to_string_lossy().contains("seren-replicator"));
431    }
432
433    #[test]
434    fn test_pid_file_path() {
435        let path = get_pid_file_path();
436        assert!(path.is_ok());
437        let path = path.unwrap();
438        assert!(path.to_string_lossy().ends_with("sync.pid"));
439    }
440
441    #[test]
442    fn test_log_file_path() {
443        let path = get_log_file_path();
444        assert!(path.is_ok());
445        let path = path.unwrap();
446        assert!(path.to_string_lossy().ends_with("sync.log"));
447    }
448
449    #[test]
450    fn test_check_status_no_daemon() {
451        let status = check_status();
452        assert!(status.is_ok());
453    }
454
455    #[test]
456    fn test_is_daemon_child_false() {
457        // In normal test execution, --daemon-child won't be present
458        // Note: This test may not be reliable if test runner adds unexpected args
459        let result = is_daemon_child();
460        // Just verify it doesn't panic
461        let _ = result;
462    }
463}