mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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 Foundation
|
||||||
|
import Darwin
|
||||||
import ScarfCore
|
import ScarfCore
|
||||||
#if canImport(AppKit)
|
#if canImport(AppKit)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -592,8 +593,10 @@ final class HealthViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the dashboard. If Scarf spawned it, send SIGTERM directly. If an
|
/// Stop the dashboard. If Scarf spawned it, send SIGTERM directly. If an
|
||||||
/// external instance is running, fall back to `pkill -f "hermes dashboard"`
|
/// external instance is running, find the PID listening on the dashboard
|
||||||
/// so the Stop button works regardless of who launched it.
|
/// 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() {
|
func stopDashboard() {
|
||||||
guard !context.isRemote else { return }
|
guard !context.isRemote else { return }
|
||||||
dashboardStatus = WebDashboardStatus(
|
dashboardStatus = WebDashboardStatus(
|
||||||
@@ -606,13 +609,11 @@ final class HealthViewModel {
|
|||||||
if let proc = dashboardProcess, proc.isRunning {
|
if let proc = dashboardProcess, proc.isRunning {
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
dashboardProcess = nil
|
dashboardProcess = nil
|
||||||
} else {
|
} else if let pid = Self.dashboardListenerPID(port: dashboardStatus.port) {
|
||||||
// External instance — best-effort pkill.
|
// External instance — signal only the process actually
|
||||||
let kill = Process()
|
// bound to our dashboard port, not anything that happens
|
||||||
kill.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
|
// to mention "hermes dashboard" in its argv.
|
||||||
kill.arguments = ["-f", "hermes dashboard"]
|
_ = Darwin.kill(pid, SIGTERM)
|
||||||
_ = try? kill.run()
|
|
||||||
kill.waitUntilExit()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
|
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
|
/// Open the dashboard in the default browser. Safe to call only when the
|
||||||
/// probe reports `running: true` — UI gates the button on that.
|
/// probe reports `running: true` — UI gates the button on that.
|
||||||
func openDashboardInBrowser() {
|
func openDashboardInBrowser() {
|
||||||
|
|||||||
Reference in New Issue
Block a user