mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
fix(health): stop external dashboards by port, not pkill -f
`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:<port> -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 <unixwzrd.register@mac.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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:<port> -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() {
|
||||
|
||||
Reference in New Issue
Block a user