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