From bfd9bab9a0f538acbcee0cfd6abe2a76ae7bf51a Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 7 May 2026 12:08:23 +0200 Subject: [PATCH] fix(health): stop external dashboards by port, not pkill -f MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stopDashboard()` used to fall back to `pkill -f "hermes dashboard"` when the running dashboard wasn't a Scarf-spawned subprocess. That's broad enough to match shell history, log tails, README readers, and this very source file — anything with the substring "hermes dashboard" in its argv was a kill target. Replace with a port-anchored lookup: `lsof -tiTCP: -sTCP:LISTEN` returns the PID actually bound to the dashboard port, then we `SIGTERM` only that one process. Trusting the port is correct here: Scarf owns the configured port and the user-visible intent is "stop the thing on this port." We deliberately omit `lsof -c hermes`. Hermes installs as a Python shebang script (verified locally — `file ~/.local/bin/hermes` → "a python3 script text executable"), so the kernel COMM is `python` / `python3`, never `hermes`. A `-c hermes` filter would silently miss every standard install. Cherry-picked from #76 with thanks to @unixwzrd for the direction; this version drops the `-c hermes` filter to actually fire on real Hermes installs. Co-Authored-By: M S Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Health/ViewModels/HealthViewModel.swift | 57 ++++++++++++++++--- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift index 3f8c3af..81a2db5 100644 --- a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift +++ b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Darwin import ScarfCore #if canImport(AppKit) import AppKit @@ -592,8 +593,10 @@ final class HealthViewModel { } /// Stop the dashboard. If Scarf spawned it, send SIGTERM directly. If an - /// external instance is running, fall back to `pkill -f "hermes dashboard"` - /// so the Stop button works regardless of who launched it. + /// external instance is running, find the PID listening on the dashboard + /// port via `lsof` and signal that one process — never broadcast a + /// `pkill -f "hermes dashboard"` that could match shell history, log + /// tails, or any unrelated argv containing the substring. func stopDashboard() { guard !context.isRemote else { return } dashboardStatus = WebDashboardStatus( @@ -606,13 +609,11 @@ final class HealthViewModel { if let proc = dashboardProcess, proc.isRunning { proc.terminate() dashboardProcess = nil - } else { - // External instance — best-effort pkill. - let kill = Process() - kill.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") - kill.arguments = ["-f", "hermes dashboard"] - _ = try? kill.run() - kill.waitUntilExit() + } else if let pid = Self.dashboardListenerPID(port: dashboardStatus.port) { + // External instance — signal only the process actually + // bound to our dashboard port, not anything that happens + // to mention "hermes dashboard" in its argv. + _ = Darwin.kill(pid, SIGTERM) } DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in @@ -631,6 +632,44 @@ final class HealthViewModel { } } + /// Resolve the PID currently listening on the dashboard port via + /// `lsof -tiTCP: -sTCP:LISTEN`. Returns nil when nothing is + /// bound or lsof fails. Trusting the port is correct here: Scarf + /// owns the configured port, and stopping the listener is exactly + /// the user-visible "Stop Dashboard" intent. We deliberately skip + /// `lsof -c hermes` — Hermes installs as a Python shebang script, + /// so the process COMM is `python` / `python3` and a `-c hermes` + /// filter silently misses every standard install. + private static func dashboardListenerPID(port: Int) -> pid_t? { + let lsof = Process() + lsof.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") + lsof.arguments = ["-tiTCP:\(port)", "-sTCP:LISTEN"] + + let output = Pipe() + lsof.standardOutput = output + lsof.standardError = FileHandle.nullDevice + + do { + try lsof.run() + lsof.waitUntilExit() + // lsof exits 1 when nothing matches — that's "no listener", + // not an error. Anything else is something we can't recover + // from in this code path; log and bail. + guard lsof.terminationStatus == 0 else { return nil } + let data = output.fileHandleForReading.readDataToEndOfFile() + let text = String(data: data, encoding: .utf8) ?? "" + return text + .split(whereSeparator: \.isNewline) + .compactMap { pid_t($0.trimmingCharacters(in: .whitespaces)) } + .first + } catch { + Self.dashboardLogger.warning( + "Failed to locate dashboard listener: \(error.localizedDescription, privacy: .public)" + ) + return nil + } + } + /// Open the dashboard in the default browser. Safe to call only when the /// probe reports `running: true` — UI gates the button on that. func openDashboardInBrowser() {