From a1e7565f21e4696029a1b7b72d9a3d7143adcd85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20LIARD?= Date: Sun, 26 Jul 2026 11:48:59 +0200 Subject: [PATCH] fix(cli): make grob stop recognise its daemon on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `command_invokes_grob` split the process command line only on NUL. That fits Linux `/proc//cmdline` (`grob\0start\0`) but not macOS `ps -o command=`, which is space-separated (`/opt/homebrew/bin/grob start`). The whole string then reached `Path::file_name`, yielding `"grob start"` (≠ `"grob"`), so `is_process_running` returned false for a live daemon and `grob stop` / the restart path in `grob start` both refused to act — the daemon could not be stopped or cleanly restarted on macOS. Pick the separator by which one is present: NUL when the string contains it (Linux), whitespace otherwise (macOS). Existing negative cases still hold (`grob-upgrade`, `python …/grob.py`, `agrob-helper`). Verified end to end: a daemon started with the fixed binary is now stopped by `grob stop` ("Service stopped successfully") instead of "refusing to stop". Note: the pid file is a single global `~/.grob/grob.pid` (one-daemon model), so with identity now working, `grob start` on any port stops whatever that file points at. Running a second daemon on another port therefore replaces the first — expected for the single-daemon design, but worth knowing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/shared/pid.rs | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/shared/pid.rs b/src/shared/pid.rs index 3fe920a..69eef5a 100644 --- a/src/shared/pid.rs +++ b/src/shared/pid.rs @@ -135,12 +135,19 @@ pub fn is_process_running(pid: u32) -> bool { #[cfg(feature = "unix-signals")] fn command_invokes_grob(command: &str) -> bool { - let first_arg = command - .split('\0') - .find(|part| !part.trim().is_empty()) - .or_else(|| command.split_whitespace().next()) - .unwrap_or_default() - .trim(); + // The command string arrives in two shapes: Linux `/proc//cmdline` is + // NUL-separated (`grob\0start\0`), while macOS `ps -o command=` is + // space-separated (`/opt/homebrew/bin/grob start`). Splitting only on `\0` + // left the macOS form intact, so `Path::file_name` on + // `"…/grob start"` returned `"grob start"` (≠ `"grob"`) and `grob stop` + // refused to stop its own daemon. Pick the separator by which one is present. + let first_arg = if command.contains('\0') { + command.split('\0').next() + } else { + command.split_whitespace().next() + } + .unwrap_or_default() + .trim(); let executable = std::path::Path::new(first_arg) .file_name() .and_then(|name| name.to_str()) @@ -191,4 +198,17 @@ mod tests { assert!(!command_invokes_grob("/usr/bin/python /tmp/grob.py")); assert!(!command_invokes_grob("/usr/bin/agrob-helper")); } + + /// macOS `ps -o command=` is space-separated; the executable and its args + /// must not be read as one path component. This is the exact string that + /// made `grob stop` refuse to stop a live daemon. + #[cfg(feature = "unix-signals")] + #[test] + fn command_identity_accepts_space_separated_macos_form() { + assert!(command_invokes_grob("/opt/homebrew/bin/grob start")); + assert!(command_invokes_grob("/opt/homebrew/bin/grob start\n")); + assert!(command_invokes_grob("grob start -d")); + // A non-grob program with a grob-ish argument still must not match. + assert!(!command_invokes_grob("/usr/bin/python grob start")); + } }