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}