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}