Skip to main content

kasl/libs/
daemon.rs

1//! Background process management for `kasl watch`: spawn detached,
2//! track by PID file, stop, and the daemon's own signal-handled entry
3//! point.
4//!
5//! ```rust,no_run
6//! # async fn f() -> anyhow::Result<()> {
7//! use kasl::libs::daemon;
8//!
9//! daemon::spawn()?;                           // Start background monitoring
10//! daemon::stop()?;                            // Stop background monitoring
11//! daemon::run_with_signal_handling().await?;  // Run with signal handling
12//! # Ok(())
13//! # }
14//! ```
15
16use crate::libs::config::Config;
17use crate::libs::data_storage::DataStorage;
18use crate::libs::messages::Message;
19use crate::libs::monitor::Monitor;
20use crate::{msg_bail_anyhow, msg_error, msg_error_anyhow, msg_info, msg_warning};
21use anyhow::Result;
22use std::process::Stdio;
23use std::time::Duration;
24use tracing::{debug, info, instrument, warn};
25
26/// PID file in the app data directory; written by the watcher that holds
27/// [`WatcherLock`], removed on its shutdown, and what `--stop` reads.
28const PID_FILE: &str = "kasl-watch.pid";
29
30/// Lock file in the app data directory, held for a watcher's whole life.
31const LOCK_FILE: &str = "kasl-watch.lock";
32
33/// Proof that this process is the one watcher for this user.
34///
35/// Two watchers are two pollers with two toast budgets, and every toast
36/// shown twice; they also count the same activity into one database. The PID
37/// file cannot prevent that: it is read and written by whoever starts a
38/// watcher, and two starts at the same moment (a scheduled task and a Run
39/// key both firing at login) both read "nobody" and both write. An exclusive
40/// lock on a file is taken atomically by the OS and let go by the OS when
41/// the process dies however it dies, so there is no stale state to clean up.
42///
43/// The lock sits in the data directory, not beside the binary: two copies of
44/// kasl installed in two places share one data directory and one lock.
45#[derive(Debug)]
46pub struct WatcherLock {
47    _file: std::fs::File,
48}
49
50impl WatcherLock {
51    /// Takes the lock, or returns `None` when another watcher holds it.
52    pub fn acquire() -> Result<Option<Self>> {
53        let path = DataStorage::new().get_path(LOCK_FILE)?;
54        let file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&path)?;
55        match file.try_lock() {
56            Ok(()) => Ok(Some(Self { _file: file })),
57            Err(std::fs::TryLockError::WouldBlock) => Ok(None),
58            Err(std::fs::TryLockError::Error(e)) => Err(e.into()),
59        }
60    }
61}
62
63/// The PID the PID file names, if it names one.
64fn recorded_pid() -> Option<String> {
65    let path = DataStorage::new().get_path(PID_FILE).ok()?;
66    std::fs::read_to_string(path).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
67}
68
69/// The daemon entry point: runs the monitor and the Jira inbox poller,
70/// shutting both down on SIGTERM/SIGINT (Ctrl+C on Windows) and removing
71/// the PID file on the way out.
72#[instrument]
73pub async fn run_with_signal_handling() -> Result<()> {
74    info!("Starting daemon with signal handling");
75
76    // A second watcher leaves quietly: the one holding the lock is already
77    // doing this job, and the PID file is its to own - not touched here.
78    let Some(_lock) = WatcherLock::acquire()? else {
79        info!(
80            "Another watcher holds the lock (PID {}); exiting",
81            recorded_pid().unwrap_or_else(|| "?".to_string())
82        );
83        return Ok(());
84    };
85    // Written here, by the winner, rather than only by whoever spawned it:
86    // when two starts race, the spawner's write may name the loser.
87    let pid_path = DataStorage::new().get_path(PID_FILE)?;
88    std::fs::write(&pid_path, std::process::id().to_string())?;
89
90    // Set up a channel to handle shutdown signals
91    // This allows coordinated shutdown between signal handlers and the monitor
92    let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
93
94    // Spawn the signal handler in a separate task
95    // This ensures signal handling doesn't block the main monitoring loop
96    #[cfg(unix)]
97    {
98        tokio::spawn(async move {
99            use tokio::signal::unix::{SignalKind, signal};
100
101            // Set up handlers for standard Unix termination signals
102            let mut sigterm = signal(SignalKind::terminate()).unwrap_or_else(|_| panic!("{}", Message::FailedToCreateSigtermHandler));
103            let mut sigint = signal(SignalKind::interrupt()).unwrap_or_else(|_| panic!("{}", Message::FailedToCreateSigintHandler));
104
105            // Wait for any termination signal
106            tokio::select! {
107                _ = sigterm.recv() => {
108                    msg_info!(Message::WatcherReceivedSigterm);
109                }
110                _ = sigint.recv() => {
111                    msg_info!(Message::WatcherReceivedSigint);
112                }
113            }
114
115            // Signal the main loop to shut down gracefully
116            let _ = shutdown_tx.send(());
117        });
118    }
119
120    #[cfg(windows)]
121    {
122        tokio::spawn(async move {
123            // Handle Windows console events
124            match tokio::signal::ctrl_c().await {
125                Ok(()) => {
126                    msg_info!(Message::WatcherReceivedCtrlC);
127                }
128                Err(e) => {
129                    msg_error!(Message::WatcherCtrlCListenFailed(e.to_string()));
130                }
131            }
132
133            // Signal the main loop to shut down gracefully
134            let _ = shutdown_tx.send(());
135        });
136    }
137
138    #[cfg(not(any(unix, windows)))]
139    {
140        // For other platforms, just run without signal handling
141        // This ensures the application still works on unsupported platforms
142        msg_warning!(Message::WatcherSignalHandlingNotSupported);
143    }
144
145    // Run the monitor in a separate task
146    // This allows concurrent execution with signal handling
147    let monitor_handle = tokio::spawn(async move {
148        match run_monitor().await {
149            Ok(()) => Ok(()),
150            Err(e) => Err(Message::MonitorError(e.to_string())),
151        }
152    });
153
154    // Poll Jira inbox in a sibling task (independent cadence from activity monitor)
155    let inbox_handle = tokio::spawn(async move {
156        crate::libs::jira_inbox::run_poller().await;
157    });
158
159    // Toast buttons are answered on their own, much faster cadence: this is
160    // the task that makes the daemon the one that receives a click.
161    let mailbox_handle = tokio::spawn(async move {
162        crate::libs::jira_inbox::run_mailbox_watcher().await;
163    });
164
165    // Wait for either the monitor to finish or a shutdown signal
166    // This provides coordinated shutdown between different components
167    tokio::select! {
168        result = monitor_handle => {
169            // Monitor task completed (either successfully or with error)
170            inbox_handle.abort();
171            mailbox_handle.abort();
172            match result {
173                Ok(Ok(())) => msg_info!(Message::MonitorExitedNormally),
174                Ok(Err(e)) => msg_error!(Message::MonitorError(e.to_string())),
175                Err(e) => msg_error!(Message::MonitorTaskPanicked(e.to_string())),
176            }
177        }
178        _ = shutdown_rx => {
179            // Received shutdown signal
180            inbox_handle.abort();
181            mailbox_handle.abort();
182            msg_info!(Message::MonitorShuttingDown);
183            // The monitor will be dropped when this function exits
184        }
185    }
186
187    // Clean up PID file on exit
188    // This ensures the PID file doesn't become stale
189    if pid_path.exists() {
190        let _ = std::fs::remove_file(&pid_path);
191    }
192
193    Ok(())
194}
195
196/// Loads config (defaults for missing sections) and runs the monitor loop.
197async fn run_monitor() -> Result<()> {
198    let config = Config::read()?;
199    let monitor_config = config.monitor.unwrap_or_default();
200
201    let mut monitor = Monitor::new(monitor_config)?;
202    monitor.run().await
203}
204
205/// Re-launches the current executable detached (`--daemon-run`), first
206/// stopping any daemon the PID file points at, and records the new PID.
207/// Detachment is `setsid` on Unix, `CREATE_NO_WINDOW` on Windows; a
208/// failed stop of the old daemon is a warning, not a blocker.
209///
210/// ```rust,no_run
211/// # fn main() -> anyhow::Result<()> {
212/// use kasl::libs::daemon;
213///
214/// // Start background monitoring
215/// daemon::spawn()?;
216/// println!("Background monitoring started");
217/// # Ok(())
218/// # }
219/// ```
220#[instrument]
221pub fn spawn() -> Result<()> {
222    debug!("Attempting to spawn daemon process");
223    let pid_path = DataStorage::new().get_path(PID_FILE)?;
224
225    // Check if a daemon is already running and stop it
226    // This ensures only one daemon instance is active at a time
227    if pid_path.exists()
228        && let Ok(pid_str) = std::fs::read_to_string(&pid_path)
229    {
230        msg_info!(Message::WatcherStoppingExisting(pid_str.trim().to_string()));
231
232        // Try to stop the existing daemon
233        if let Err(e) = stop_internal() {
234            msg_warning!(Message::WatcherFailedToStopExisting(e.to_string()));
235            // Remove the PID file anyway in case the process is already dead
236            let _ = std::fs::remove_file(&pid_path);
237        }
238
239        // Give the old process time to clean up
240        std::thread::sleep(Duration::from_millis(1000));
241    }
242
243    // Get the current executable path for spawning
244    let current_exe = std::env::current_exe().unwrap_or_else(|_| panic!("{}", Message::FailedToGetCurrentExecutable.to_string()));
245
246    // The daemon outlives this command, so it gets none of its stdio: an
247    // inherited stdout prints the monitor's chatter into whatever ran
248    // `watch`, and an inherited pipe keeps a caller that reads to the end
249    // (self-update, an installer, a script) waiting for as long as the
250    // daemon lives.
251
252    #[cfg(unix)]
253    {
254        use std::os::unix::process::CommandExt;
255
256        // Spawn daemon process with session detachment
257        let mut command = std::process::Command::new(current_exe);
258        command.arg("--daemon-run").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
259        // SAFETY: setsid is async-signal-safe and touches no shared state,
260        // which is all pre_exec requires between fork and exec.
261        unsafe {
262            command.pre_exec(|| {
263                // Detach from the current session to become a daemon
264                // This ensures the process continues running after parent exits
265                nix::unistd::setsid()?;
266                Ok(())
267            });
268        }
269        let child = command.spawn()?;
270
271        report_spawned(child)?;
272    }
273
274    #[cfg(windows)]
275    {
276        use std::os::windows::process::CommandExt;
277
278        // Windows-specific flags for background process creation
279        const CREATE_NO_WINDOW: u32 = 0x08000000;
280
281        stop_inheriting_stdio();
282
283        // Spawn daemon process without console window
284        let child = std::process::Command::new(current_exe)
285            .arg("--daemon-run")
286            .stdin(Stdio::null())
287            .stdout(Stdio::null())
288            .stderr(Stdio::null())
289            .creation_flags(CREATE_NO_WINDOW)
290            .spawn()?;
291
292        report_spawned(child)?;
293    }
294
295    #[cfg(not(any(unix, windows)))]
296    {
297        // Platform not supported for daemon mode
298        msg_bail_anyhow!(Message::DaemonModeNotSupported);
299    }
300
301    Ok(())
302}
303
304/// How long `spawn` waits for its child to take the lock and say so.
305const REGISTER_TIMEOUT: Duration = Duration::from_secs(5);
306
307/// Waits until the spawned watcher has registered itself, and says what happened.
308///
309/// The child writes the PID file once it holds [`WatcherLock`], so a PID
310/// file naming the child means it is the watcher. A child that exits first
311/// lost the lock to a watcher this `spawn` did not know about - one started
312/// by another copy of kasl, or at the same moment - and saying "started"
313/// would name a process that is already gone.
314fn report_spawned(mut child: std::process::Child) -> Result<()> {
315    let pid = child.id();
316    let deadline = std::time::Instant::now() + REGISTER_TIMEOUT;
317    while std::time::Instant::now() < deadline {
318        if recorded_pid().as_deref() == Some(pid.to_string().as_str()) {
319            msg_info!(Message::WatcherStarted(pid));
320            return Ok(());
321        }
322        if child.try_wait()?.is_some() {
323            msg_info!(Message::WatcherAlreadyRunningPid(recorded_pid().unwrap_or_else(|| "?".to_string())));
324            return Ok(());
325        }
326        std::thread::sleep(Duration::from_millis(50));
327    }
328    // Still alive and still not registered: slow to start, not refused.
329    msg_info!(Message::WatcherStarted(pid));
330    Ok(())
331}
332
333/// True when the PID file exists, parses, and names a live process.
334pub fn is_running() -> bool {
335    let pid_path = match DataStorage::new().get_path(PID_FILE) {
336        Ok(path) => path,
337        Err(_) => return false,
338    };
339
340    // Check if PID file exists
341    if !pid_path.exists() {
342        return false;
343    }
344
345    // Read and parse the PID from the file
346    let pid_str = match std::fs::read_to_string(&pid_path) {
347        Ok(content) => content,
348        Err(_) => return false,
349    };
350
351    let pid: u32 = match pid_str.trim().parse() {
352        Ok(pid) => pid,
353        Err(_) => return false,
354    };
355
356    // Check if process is actually running
357    is_kasl_process(pid)
358}
359
360/// Keeps this process's standard handles out of the daemon it spawns.
361///
362/// `Stdio::null()` decides what the child calls its stdout, not which
363/// handles it carries: std spawns with handle inheritance on, and a pipe
364/// this process was given is inheritable, so the daemon would hold the
365/// caller's pipe open for its whole life and a caller reading to the end
366/// would never finish. Unix closes them on exec by itself. `watch` exits
367/// right after spawning, so its own handles lose nothing.
368#[cfg(windows)]
369fn stop_inheriting_stdio() {
370    use winapi::um::handleapi::{INVALID_HANDLE_VALUE, SetHandleInformation};
371    use winapi::um::processenv::GetStdHandle;
372    use winapi::um::winbase::{HANDLE_FLAG_INHERIT, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE};
373
374    for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
375        // SAFETY: GetStdHandle has no preconditions; SetHandleInformation is
376        // only called on a handle it returned, and fails harmlessly on one
377        // that is not inheritable to begin with.
378        unsafe {
379            let handle = GetStdHandle(which);
380            if !handle.is_null() && handle != INVALID_HANDLE_VALUE {
381                SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0);
382            }
383        }
384    }
385}
386
387/// Whether `pid` is a live kasl process - the only kind `stop` may kill.
388///
389/// A PID file outlives its process whenever the watcher is killed rather
390/// than stopped (an installer, Task Manager, a crash), and the OS hands the
391/// number to the next process that starts. Killing by the number alone then
392/// hits whatever runs under it now, or fails on a process that is still
393/// being torn down. The image name is what makes the number ours.
394fn is_kasl_process(pid: u32) -> bool {
395    #[cfg(windows)]
396    {
397        use winapi::shared::minwindef::DWORD;
398        use winapi::um::handleapi::CloseHandle;
399        use winapi::um::processthreadsapi::{GetExitCodeProcess, OpenProcess};
400        use winapi::um::winbase::QueryFullProcessImageNameW;
401        use winapi::um::winnt::PROCESS_QUERY_LIMITED_INFORMATION;
402
403        const STILL_ACTIVE: DWORD = 259;
404
405        unsafe {
406            let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
407            if handle.is_null() {
408                return false;
409            }
410            let mut exit_code: DWORD = 0;
411            let alive = GetExitCodeProcess(handle, &mut exit_code) != 0 && exit_code == STILL_ACTIVE;
412            let mut buffer = [0u16; 1024];
413            let mut len = buffer.len() as DWORD;
414            let named = QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut len) != 0;
415            CloseHandle(handle);
416            alive && named && is_kasl_image(&String::from_utf16_lossy(&buffer[..len as usize]))
417        }
418    }
419
420    #[cfg(unix)]
421    {
422        let pid = pid.to_string();
423        match std::process::Command::new("ps").args(["-p", pid.as_str(), "-o", "comm="]).output() {
424            Ok(output) if output.status.success() => is_kasl_image(String::from_utf8_lossy(&output.stdout).trim()),
425            _ => false,
426        }
427    }
428
429    #[cfg(not(any(unix, windows)))]
430    {
431        let _ = pid;
432        false
433    }
434}
435
436/// Whether an executable path or process name is kasl or its `ka` alias.
437fn is_kasl_image(image: &str) -> bool {
438    let stem = std::path::Path::new(image)
439        .file_stem()
440        .map(|s| s.to_string_lossy().to_ascii_lowercase())
441        .unwrap_or_default();
442    stem == "kasl" || stem == "ka"
443}
444
445/// Stops the daemon; "already stopped" counts as success, so cleanup
446/// scripts can call it unconditionally.
447///
448/// ```rust,no_run
449/// # fn main() -> anyhow::Result<()> {
450/// use kasl::libs::daemon;
451///
452/// daemon::stop()?;
453/// println!("Monitoring stopped");
454/// # Ok(())
455/// # }
456/// ```
457pub fn stop() -> Result<()> {
458    match stop_internal() {
459        Ok(()) => Ok(()),
460        Err(e) => {
461            // If the daemon wasn't running, that's okay
462            // This provides a better user experience than reporting errors
463            if e.to_string().contains("not found") || e.to_string().contains("not running") {
464                msg_info!(Message::WatcherNotRunning);
465                Ok(())
466            } else {
467                Err(e)
468            }
469        }
470    }
471}
472
473/// Termination with precise errors, shared by [`stop`] and [`spawn`];
474/// the PID file is removed even when the process is already gone.
475fn stop_internal() -> Result<()> {
476    let pid_path = DataStorage::new().get_path(PID_FILE)?;
477
478    // The daemon removes its own PID file on shutdown, so every file
479    // operation below can race with a dying daemon: a file that has
480    // disappeared at any step means the watcher is already stopped.
481    let pid_str = match std::fs::read_to_string(&pid_path) {
482        Ok(content) => content,
483        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
484            msg_bail_anyhow!(Message::WatcherNotRunningPidNotFound);
485        }
486        Err(e) => return Err(e.into()),
487    };
488    let pid: u32 = pid_str.trim().parse().map_err(|_| msg_error_anyhow!(Message::InvalidPidFileContent))?;
489
490    // Attempt to terminate the process - only if the number still names one
491    // of ours; otherwise the file is stale and removing it is the whole job.
492    let killed = is_kasl_process(pid) && kill_process(pid)?;
493
494    // Clean up the PID file regardless of whether the process was found
495    // This prevents stale PID files from interfering with future operations
496    if let Err(e) = std::fs::remove_file(&pid_path)
497        && e.kind() != std::io::ErrorKind::NotFound
498    {
499        return Err(e.into());
500    }
501
502    if killed {
503        msg_info!(Message::WatcherStopped(pid));
504    } else {
505        // The process was already gone; removing the stale PID file is all
506        // that stopping requires, so this is a success, not an error.
507        msg_info!(Message::WatcherNotRunning);
508    }
509    Ok(())
510}
511
512/// Terminates the process via `TerminateProcess` - Windows has no
513/// SIGTERM equivalent, so forceful is the reliable option. Returns
514/// `Ok(false)` when the process does not exist.
515#[cfg(windows)]
516fn kill_process(pid: u32) -> Result<bool> {
517    use winapi::um::errhandlingapi::GetLastError;
518    use winapi::um::handleapi::CloseHandle;
519    use winapi::um::processthreadsapi::{OpenProcess, TerminateProcess};
520    use winapi::um::winnt::PROCESS_TERMINATE;
521
522    unsafe {
523        // Open a handle to the target process with termination rights
524        let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
525        if handle.is_null() {
526            let error = GetLastError();
527            if error == 87 {
528                // ERROR_INVALID_PARAMETER - process doesn't exist
529                return Ok(false);
530            }
531            msg_bail_anyhow!(Message::FailedToOpenProcess(error));
532        }
533
534        // Attempt to terminate the process
535        let result = TerminateProcess(handle, 0);
536
537        // Always close the handle to prevent resource leaks
538        CloseHandle(handle);
539
540        if result == 0 {
541            // Termination failed - get error details
542            let error = GetLastError();
543            msg_bail_anyhow!(Message::FailedToTerminateProcess(error));
544        } else {
545            // Give the process time to actually terminate
546            std::thread::sleep(Duration::from_millis(100));
547            Ok(true)
548        }
549    }
550}
551
552/// SIGTERM first, up to a second of grace, then SIGKILL; uses `ps` and
553/// `kill` rather than raw syscalls. Returns `Ok(false)` when the process
554/// does not exist.
555#[cfg(unix)]
556fn kill_process(pid: u32) -> Result<bool> {
557    use std::process::Command;
558
559    // Check if process exists using ps
560    let output = Command::new("ps").arg("-p").arg(pid.to_string()).output()?;
561
562    if !output.status.success() {
563        // Process doesn't exist
564        return Ok(false);
565    }
566
567    // Send SIGTERM for graceful shutdown
568    Command::new("kill").arg("-TERM").arg(pid.to_string()).output()?;
569
570    // Give the process time to terminate gracefully
571    for _ in 0..10 {
572        std::thread::sleep(Duration::from_millis(100));
573
574        // Check if process still exists
575        let check = Command::new("ps").arg("-p").arg(pid.to_string()).output()?;
576
577        if !check.status.success() {
578            // Process terminated gracefully
579            return Ok(true);
580        }
581    }
582
583    // Process didn't terminate gracefully, force kill
584    Command::new("kill").arg("-9").arg(pid.to_string()).output()?;
585
586    // Give a brief moment for forced termination
587    std::thread::sleep(Duration::from_millis(100));
588    Ok(true)
589}
590
591#[cfg(not(any(unix, windows)))]
592fn kill_process(_pid: u32) -> Result<bool> {
593    msg_bail_anyhow!(Message::ProcessTerminationNotSupported);
594}