Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Keep an eye on your [PM2](https://pm2.keymetrics.io/) processes from the macOS m
- 🗂️ **Multiple environments** — automatically discovers all PM2 workspaces (`~/.pm2`, `~/.pm2-*`)
- 🔁 **Crash-loop detection** — flags processes that are rapidly restarting and provides debug info
- 🛟 **Daemon error recovery** — surfaces PM2 daemon errors inline with a one-click kill to clear stuck or duplicate daemons
- 🔌 **Port conflict rescue** — detects when a process can't bind its port because another process holds it, and frees the port and restarts the service in one click
- 🪵 **Live log streaming** — view process logs in real-time with ANSI color stripping
- 🔔 **Desktop notifications** — get alerted when processes crash or restart
- 🔀 **Git repo & branch info** — shows the git repo name and branch for each environment, with configurable prefix/ticket stripping
Expand Down
64 changes: 63 additions & 1 deletion Sources/Reeve/Models/PM2Process.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,22 @@ public struct PM2Process: Identifiable, Sendable {
public let outLogPath: String
public let errLogPath: String

/// The port this process is configured to bind, parsed from its pm2 args
/// (or a `PORT` env var). Best-effort: nil when we can't determine it. Used
/// only to diagnose bind conflicts — the ports actually served come from the
/// OS via `ports`, never from here.
public let desiredPort: Int?

/// Ports this process (and its child processes) are actively listening on,
/// resolved from the OS via `SocketScanner` after decoding. Empty when the
/// process isn't serving anything (e.g. a worker, or still booting).
public var ports: [Int] = []

/// Set after decoding when the process is failing to bind `desiredPort`
/// because another process is holding it ("Address already in use"). The
/// value is the contended port. `nil` when there's no conflict.
public var portConflict: Int?

/// Most recent modification time of the process's log files (set after decoding).
public var lastLogModified: Date?

Expand Down Expand Up @@ -99,7 +110,7 @@ extension PM2Process: Decodable {
}

private enum EnvKeys: String, CodingKey {
case status, namespace
case status, namespace, args
case pmExecPath = "pm_exec_path"
case pmCwd = "pm_cwd"
case execMode = "exec_mode"
Expand All @@ -108,6 +119,7 @@ extension PM2Process: Decodable {
case createdAt = "created_at"
case pmOutLogPath = "pm_out_log_path"
case pmErrLogPath = "pm_err_log_path"
case port = "PORT"
}

public init(from decoder: Decoder) throws {
Expand All @@ -131,5 +143,55 @@ extension PM2Process: Decodable {
createdAt = try env.decodeIfPresent(Int64.self, forKey: .createdAt) ?? 0
outLogPath = try env.decodeIfPresent(String.self, forKey: .pmOutLogPath) ?? ""
errLogPath = try env.decodeIfPresent(String.self, forKey: .pmErrLogPath) ?? ""

// Best-effort bind port: prefer the CLI args (uvicorn/gunicorn/hypercorn/
// node all pass it there), falling back to a PORT env var. Only a single,
// named env key is read — never the full environment (secrets).
let args = try env.decodeIfPresent([String].self, forKey: .args) ?? []
// PORT may be encoded as a string or a number depending on the launcher.
var portEnv = (try? env.decodeIfPresent(String.self, forKey: .port)).flatMap { $0 }
if portEnv == nil, let portInt = try? env.decodeIfPresent(Int.self, forKey: .port) {
portEnv = String(portInt)
}
desiredPort = PM2Process.parsePort(fromArgs: args, portEnv: portEnv)
}

/// Extract a bind port from pm2 launch args, falling back to a `PORT` env
/// value. Handles the common forms: `--port N`, `--port=N`, `-p N`,
/// `--bind host:N`, `-b host:N` (and their `=` variants). Returns the first
/// port found, or nil.
static func parsePort(fromArgs args: [String], portEnv: String? = nil) -> Int? {
func validPort(_ value: Substring) -> Int? {
guard let n = Int(value), (1...65535).contains(n) else { return nil }
return n
}
var index = 0
while index < args.count {
let arg = args[index]
let next: String? = index + 1 < args.count ? args[index + 1] : nil

if arg == "--port" || arg == "-p", let next, let port = validPort(next[...]) {
return port
}
if let eq = arg.firstIndex(of: "="), arg.hasPrefix("--port") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Restrict long-form port parsing to --port=. Current prefix match can mis-detect unrelated --port*= flags and trigger the kill/restart rescue for the wrong port.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Reeve/Models/PM2Process.swift, line 176:

<comment>Restrict long-form port parsing to `--port=`. Current prefix match can mis-detect unrelated `--port*=` flags and trigger the kill/restart rescue for the wrong port.</comment>

<file context>
@@ -131,5 +143,55 @@ extension PM2Process: Decodable {
+            if arg == "--port" || arg == "-p", let next, let port = validPort(next[...]) {
+                return port
+            }
+            if let eq = arg.firstIndex(of: "="), arg.hasPrefix("--port") {
+                if let port = validPort(arg[arg.index(after: eq)...]) { return port }
+            }
</file context>
Suggested change
if let eq = arg.firstIndex(of: "="), arg.hasPrefix("--port") {
if let eq = arg.firstIndex(of: "="), arg.hasPrefix("--port=") {

if let port = validPort(arg[arg.index(after: eq)...]) { return port }
}
// Bind targets look like `host:port`, `:port`, or `[::1]:port` — the
// port is always after the final colon.
if arg == "--bind" || arg == "-b", let next, let colon = next.lastIndex(of: ":"),
let port = validPort(next[next.index(after: colon)...]) {
return port
}
if arg.hasPrefix("--bind=") || arg.hasPrefix("-b="),
let eq = arg.firstIndex(of: "=") {
let value = arg[arg.index(after: eq)...]
if let colon = value.lastIndex(of: ":"), let port = validPort(value[value.index(after: colon)...]) {
return port
}
}
index += 1
}
if let portEnv, let port = validPort(portEnv[...]) { return port }
return nil
}
}
25 changes: 22 additions & 3 deletions Sources/Reeve/Services/DemoData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ final class DemoData {
let cwd: String
let crashLoop: Bool // keep this process perpetually crash-looping
var lastLog: Date?
var portConflict: Int? // failing to bind this port ("Address already in use")

func snapshot() -> PM2Process {
let online = status == "online"
Expand All @@ -50,7 +51,9 @@ final class DemoData {
createdAt: createdAt,
outLogPath: "",
errLogPath: "",
desiredPort: portConflict,
ports: ports,
portConflict: portConflict,
lastLogModified: lastLog
)
}
Expand Down Expand Up @@ -154,6 +157,19 @@ final class DemoData {
envs[i].procs = []
}

/// Simulate freeing a port: any process that was blocked waiting for it
/// clears its conflict and comes online.
func freePort(_ port: Int) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Demo free-port rescue does not update synthetic port ownership. The one-click action leaves the old holder still showing :3000 and the rescued service showing no port, contradicting the feature being demonstrated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Reeve/Services/DemoData.swift, line 162:

<comment>Demo free-port rescue does not update synthetic port ownership. The one-click action leaves the old holder still showing :3000 and the rescued service showing no port, contradicting the feature being demonstrated.</comment>

<file context>
@@ -154,6 +157,19 @@ final class DemoData {
 
+    /// Simulate freeing a port: any process that was blocked waiting for it
+    /// clears its conflict and comes online.
+    func freePort(_ port: Int) {
+        for i in envs.indices {
+            for j in envs[i].procs.indices where envs[i].procs[j].portConflict == port {
</file context>

for i in envs.indices {
for j in envs[i].procs.indices where envs[i].procs[j].portConflict == port {
envs[i].procs[j].portConflict = nil
envs[i].procs[j].status = "online"
envs[i].procs[j].startedAt = DemoData.nowMs()
envs[i].procs[j].lastLog = Date()
}
}
}

func clearEnvironment(envPath: String) {
envs.removeAll { $0.path == envPath }
}
Expand Down Expand Up @@ -189,7 +205,7 @@ final class DemoData {
status: String = "online", ports: [Int] = [],
age: TimeInterval = 2 * 86_400, started: TimeInterval? = nil,
restarts: Int = 0, crash: Bool = false,
cwd: String = "", lastLog: Date? = nil) -> Proc {
cwd: String = "", lastLog: Date? = nil, portConflict: Int? = nil) -> Proc {
let online = status == "online"
return Proc(
pid: online ? 4000 + pmId * 7 + 13 : 0,
Expand All @@ -206,7 +222,8 @@ final class DemoData {
ports: ports,
cwd: cwd,
crashLoop: crash,
lastLog: lastLog ?? (online ? recent() : Date(timeIntervalSince1970: now - 600))
lastLog: lastLog ?? (online ? recent() : Date(timeIntervalSince1970: now - 600)),
portConflict: portConflict
)
}

Expand Down Expand Up @@ -250,7 +267,9 @@ final class DemoData {
error: nil,
procs: [
proc("web", 0, cpu: 25, mem: 232, ports: [3020], age: 90 * 60, cwd: darkMode),
proc("worker", 1, cpu: 16, mem: 88, age: 3 * 3600, started: 8, restarts: 7, crash: true, cwd: darkMode)
proc("worker", 1, cpu: 16, mem: 88, age: 3 * 3600, started: 8, restarts: 7, crash: true, cwd: darkMode),
// Preview server can't bind :3000 — the main workspace's web holds it.
proc("web-preview", 2, status: "errored", age: 90 * 60, restarts: 4, cwd: darkMode, portConflict: 3000)
]
)

Expand Down
55 changes: 55 additions & 0 deletions Sources/Reeve/Services/PM2Service+Parsing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation

// Pure, off-actor parsing helpers for pm2 output and log files. Kept out of
// `PM2Service` itself so the service file stays focused on state and control.
extension PM2Service {
/// Extract the JSON payload from raw pm2 stdout.
///
/// pm2 prepends human-readable notices to stdout — most notably a colored
/// "In-memory PM2 is out-of-date" banner when a daemon's in-memory version
/// differs from the local CLI. That preamble makes the buffer invalid JSON
/// and breaks decoding, so we trim to the first JSON bracket before handing
/// it to `JSONDecoder`. The banner is ANSI-colored, so its own '[' bytes are
/// preceded by the ESC (0x1B) byte of a CSI escape — we skip those and stop
/// at the first real JSON bracket. Returns the input unchanged if none is
/// found, so decode errors stay meaningful.
nonisolated static func extractJSON(from data: Data) -> Data {
let esc: UInt8 = 0x1B // ESC — starts an ANSI escape sequence
let openBracket: UInt8 = 0x5B // [
let openBrace: UInt8 = 0x7B // {
let bytes = [UInt8](data)
for i in bytes.indices where bytes[i] == openBracket || bytes[i] == openBrace {
// A '[' immediately after ESC belongs to an ANSI color code, not JSON.
if bytes[i] == openBracket && i > 0 && bytes[i - 1] == esc { continue }
return Data(bytes[i...])
}
return data
}

/// Whether `text` (a chunk of an error log) reports a port bind failure.
/// Covers the common phrasings across Node (EADDRINUSE), Python/errno
/// (Errno 48), and the shared "address already in use" message.
nonisolated static func logReportsAddressInUse(_ text: String) -> Bool {
let lower = text.lowercased()
return lower.contains("address already in use")
|| lower.contains("eaddrinuse")
|| lower.contains("errno 48")
}

/// Read the tail of an error-log file and check for a bind-conflict message.
/// Only the last few KB are read, so this stays cheap on the poll loop.
nonisolated static func errorLogReportsAddressInUse(atPath path: String) -> Bool {
guard !path.isEmpty, let handle = FileHandle(forReadingAtPath: path) else { return false }
defer { try? handle.close() }
let tailBytes: UInt64 = 8192
if let size = try? handle.seekToEnd(), size > tailBytes {
try? handle.seek(toOffset: size - tailBytes)
} else {
try? handle.seek(toOffset: 0)
}
guard let data = try? handle.readToEnd(), let text = String(data: data, encoding: .utf8) else {
return false
}
return logReportsAddressInUse(text)
}
}
50 changes: 49 additions & 1 deletion Sources/Reeve/Services/PM2Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,43 @@ public class PM2Service: ObservableObject {
await refresh()
}

/// Kill whatever process is currently listening on `port`, freeing it for a
/// process that's failing to bind ("Address already in use"). Kills the
/// listening pid(s) directly via the OS — the holder may be a pm2 process in
/// another environment, an orphan, or an unrelated app.
public func freePort(_ port: Int) async {
if let demo {
demo.freePort(port)
applyDemoSnapshot(tick: false)
return
}
await Task.detached {
PM2Service.killListeners(onPort: port)
}.value
await refresh()
}

/// Find and SIGKILL every process listening on `port`, excluding reeve itself.
private nonisolated static func killListeners(onPort port: Int) {
let lsof = Process()
let pipe = Pipe()
lsof.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
// -t: pid-only output, restricted to the LISTEN socket on this TCP port.
lsof.arguments = ["-nP", "-t", "-iTCP:\(port)", "-sTCP:LISTEN"]
lsof.standardOutput = pipe
lsof.standardError = FileHandle.nullDevice
guard (try? lsof.run()) != nil else { return }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
lsof.waitUntilExit()

let selfPID = getpid()
guard let output = String(data: data, encoding: .utf8) else { return }
for line in output.split(separator: "\n") {
guard let pid = pid_t(line.trimmingCharacters(in: .whitespaces)), pid != selfPID else { continue }
kill(pid, SIGKILL)
}
}

/// Finds and kills all PM2 God Daemon processes associated with a PM2_HOME directory.
private nonisolated static func forceKillDaemon(for environment: PM2Environment) {
let pm2Home = environment.path
Expand Down Expand Up @@ -432,7 +469,7 @@ public class PM2Service: ObservableObject {
}
do {
let data = try runPM2Sync(["jlist"], environment: environment, using: resolution)
var processes = try JSONDecoder().decode([PM2Process].self, from: data)
var processes = try JSONDecoder().decode([PM2Process].self, from: extractJSON(from: data))
// Resolve listening ports from the OS snapshot (own pid + descendants)
for i in processes.indices {
processes[i].ports = SocketScanner.ports(forRoot: processes[i].pid, in: sockets)
Expand All @@ -451,6 +488,17 @@ public class PM2Service: ObservableObject {
}
processes[i].lastLogModified = newest
}
// Detect port bind conflicts: the process wants a port it isn't
// serving, something else is holding that port, and its error log
// recently said so ("Address already in use").
for i in processes.indices {
guard let port = processes[i].desiredPort,
!processes[i].ports.contains(port),
SocketScanner.isPortListening(port, in: sockets),
errorLogReportsAddressInUse(atPath: processes[i].errLogPath)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Port-conflict detection ignores the promised recency check, so stale EADDRINUSE logs can prompt users to kill the wrong current listener. Gate the log match by timestamp or recent error-log modification before setting portConflict.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Reeve/Services/PM2Service.swift, line 498:

<comment>Port-conflict detection ignores the promised recency check, so stale EADDRINUSE logs can prompt users to kill the wrong current listener. Gate the log match by timestamp or recent error-log modification before setting `portConflict`.</comment>

<file context>
@@ -451,12 +488,73 @@ public class PM2Service: ObservableObject {
+                guard let port = processes[i].desiredPort,
+                      !processes[i].ports.contains(port),
+                      SocketScanner.isPortListening(port, in: sockets),
+                      errorLogReportsAddressInUse(atPath: processes[i].errLogPath)
+                else { continue }
+                processes[i].portConflict = port
</file context>

else { continue }
processes[i].portConflict = port
}
return .success(processes)
} catch {
return .failure(error.localizedDescription)
Expand Down
7 changes: 7 additions & 0 deletions Sources/Reeve/Services/SocketScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ struct SocketScanner {
return found.subtracting(ignoredPorts).sorted()
}

/// True when any process on the machine is currently listening on `port`.
/// Used to confirm a bind conflict is live (someone is holding the port)
/// before surfacing it — so the banner clears itself once the squatter goes.
static func isPortListening(_ port: Int, in snapshot: Snapshot) -> Bool {
snapshot.portsByPID.values.contains { $0.contains(port) }
}

// MARK: - Parsing (pure, unit-tested)

/// Parse `lsof -FpnL`-style field output into a pid → listening-ports map.
Expand Down
Loading
Loading