From 7c41e783045eedf72a5d68c21542279eef1c4476 Mon Sep 17 00:00:00 2001 From: Fred Rivett Date: Wed, 1 Jul 2026 14:29:58 +0100 Subject: [PATCH 1/3] Detect port bind conflicts and offer one-click free + restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a process fails to bind its port because another process holds it, surface a banner on the process row with a confirm-to-kill action that frees the port (kills the listener) and restarts the service. Detection requires all of: a port parsed from pm2 args (or PORT env), the process not already serving that port, another process listening on it, and a recent "address already in use" line in the error log — so the banner clears itself once the conflict resolves. Also strips pm2's out-of-date banner from jlist stdout before decoding. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Reeve/Models/PM2Process.swift | 64 ++++++++- Sources/Reeve/Services/DemoData.swift | 25 +++- Sources/Reeve/Services/PM2Service.swift | 100 +++++++++++++- Sources/Reeve/Services/SocketScanner.swift | 7 + Sources/Reeve/Views/ProcessRowView.swift | 68 ++++++++++ Tests/reeveTests/ReeveTests.swift | 143 +++++++++++++++++++++ 6 files changed, 402 insertions(+), 5 deletions(-) diff --git a/Sources/Reeve/Models/PM2Process.swift b/Sources/Reeve/Models/PM2Process.swift index d6f0b15..c61e6ca 100644 --- a/Sources/Reeve/Models/PM2Process.swift +++ b/Sources/Reeve/Models/PM2Process.swift @@ -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? @@ -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" @@ -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 { @@ -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) + if portEnv == nil, let portInt = try? env.decodeIfPresent(Int.self, forKey: .port) { + portEnv = String(portInt) + } + desiredPort = PM2Process.parsePort(fromArgs: args, portEnv: portEnv ?? nil) + } + + /// 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") { + 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 } } diff --git a/Sources/Reeve/Services/DemoData.swift b/Sources/Reeve/Services/DemoData.swift index 2b8ba6f..5c4ff25 100644 --- a/Sources/Reeve/Services/DemoData.swift +++ b/Sources/Reeve/Services/DemoData.swift @@ -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" @@ -50,7 +51,9 @@ final class DemoData { createdAt: createdAt, outLogPath: "", errLogPath: "", + desiredPort: portConflict, ports: ports, + portConflict: portConflict, lastLogModified: lastLog ) } @@ -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) { + 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 } } @@ -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, @@ -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 ) } @@ -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) ] ) diff --git a/Sources/Reeve/Services/PM2Service.swift b/Sources/Reeve/Services/PM2Service.swift index de49d2a..30f8442 100644 --- a/Sources/Reeve/Services/PM2Service.swift +++ b/Sources/Reeve/Services/PM2Service.swift @@ -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 @@ -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) @@ -451,12 +488,73 @@ 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) + else { continue } + processes[i].portConflict = port + } return .success(processes) } catch { return .failure(error.localizedDescription) } } + /// 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. + private 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) + } + private nonisolated static func runPM2Sync( _ args: [String], environment: PM2Environment, diff --git a/Sources/Reeve/Services/SocketScanner.swift b/Sources/Reeve/Services/SocketScanner.swift index ac54afe..cefc31b 100644 --- a/Sources/Reeve/Services/SocketScanner.swift +++ b/Sources/Reeve/Services/SocketScanner.swift @@ -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. diff --git a/Sources/Reeve/Views/ProcessRowView.swift b/Sources/Reeve/Views/ProcessRowView.swift index c2f42e7..0788c86 100644 --- a/Sources/Reeve/Views/ProcessRowView.swift +++ b/Sources/Reeve/Views/ProcessRowView.swift @@ -9,6 +9,8 @@ struct ProcessRowView: View { @State private var isActing = false @State private var showCrashPopover = false @State private var copied = false + @State private var confirmingFreePort = false + @State private var freePortResetTask: Task? private static let tooltipFormatter: DateFormatter = { let f = DateFormatter() @@ -146,6 +148,10 @@ struct ProcessRowView: View { .disabled(isActing) } + if let port = process.portConflict { + portConflictBanner(port: port) + } + if showLogs { LogPanelView(process: process, environment: environment) } @@ -153,6 +159,68 @@ struct ProcessRowView: View { .padding(.vertical, 3) } + /// Shown when a process is failing to bind its port because something else + /// holds it. Offers a confirm-then-kill action to free the port. + @ViewBuilder + private func portConflictBanner(port: Int) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + .foregroundColor(.orange) + Text("Port :\(String(port)) already in use") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.primary.opacity(0.8)) + } + Text("Another process is holding this port.") + .font(.system(size: 10)) + .foregroundColor(.secondary) + + Button { + if confirmingFreePort { + confirmingFreePort = false + freePortResetTask?.cancel() + performAction { + await pm2Service.freePort(port) + await pm2Service.restart(process: process, environment: environment) + } + } else { + confirmingFreePort = true + freePortResetTask?.cancel() + freePortResetTask = Task { + try? await Task.sleep(nanoseconds: 3_000_000_000) + if !Task.isCancelled { + await MainActor.run { confirmingFreePort = false } + } + } + } + } label: { + HStack(spacing: 3) { + Image(systemName: "bolt.fill") + .font(.system(size: 8)) + Text(confirmingFreePort ? "Confirm free port :\(String(port)) and restart service?" : "Free port :\(String(port)) and restart service") + .font(.system(size: 10, weight: .medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.orange.opacity(0.15)) + .cornerRadius(4) + } + .buttonStyle(.plain) + .foregroundColor(.orange) + .disabled(isActing) + .help(confirmingFreePort ? "Click again to confirm" : "Kill whatever is listening on :\(String(port)), then restart this process") + .onHover { hovering in + if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(Color.orange.opacity(0.08)) + .cornerRadius(6) + } + private func crashLoopPrompt(process: PM2Process, environment: PM2Environment) -> String { let pm2Home = environment.path let envFlag = "PM2_HOME=\(pm2Home)" diff --git a/Tests/reeveTests/ReeveTests.swift b/Tests/reeveTests/ReeveTests.swift index 02f4ef0..cd74603 100644 --- a/Tests/reeveTests/ReeveTests.swift +++ b/Tests/reeveTests/ReeveTests.swift @@ -1242,3 +1242,146 @@ struct NormalizationTests { #expect(result == [0.5, 0.5, 0.5]) } } + +// MARK: - pm2 stdout JSON extraction + +@Suite("PM2Service.extractJSON") +struct ExtractJSONTests { + + private func string(_ data: Data) -> String { + String(data: data, encoding: .utf8)! + } + + @Test("Pure JSON array is returned unchanged") + func pureJSON() { + let json = #"[{"pid":1}]"# + let result = PM2Service.extractJSON(from: Data(json.utf8)) + #expect(string(result) == json) + } + + @Test("Strips the ANSI-colored out-of-date banner before the JSON") + func stripsVersionBanner() throws { + // Mirrors real `pm2 jlist` stdout when the in-memory daemon version + // differs from the local CLI: a leading blank line, ANSI-colored + // ">>>>" notices, then the JSON array on its own line. + let esc = "\u{1B}" + let banner = """ + \n\(esc)[31m\(esc)[1m>>>> In-memory PM2 is out-of-date, do:\(esc)[22m\(esc)[39m + \(esc)[31m\(esc)[1m>>>> $ pm2 update\(esc)[22m\(esc)[39m + In memory PM2 version: \(esc)[34m\(esc)[1m7.0.1\(esc)[22m\(esc)[39m + Local PM2 version: \(esc)[34m\(esc)[1m7.0.3\(esc)[22m\(esc)[39m\n\n + """ + let json = #"[{"pid":260,"name":"web","pm_id":0,"monit":{"memory":100,"cpu":0},"pm2_env":{"status":"online"}}]"# + let result = PM2Service.extractJSON(from: Data((banner + json).utf8)) + #expect(string(result) == json) + // And the trimmed payload decodes cleanly. + let procs = try JSONDecoder().decode([PM2Process].self, from: result) + #expect(procs.count == 1) + #expect(procs[0].name == "web") + } + + @Test("Empty JSON array after a banner is preserved") + func emptyArrayAfterBanner() { + let esc = "\u{1B}" + let raw = "\(esc)[31mnotice\(esc)[39m\n[]" + let result = PM2Service.extractJSON(from: Data(raw.utf8)) + #expect(string(result) == "[]") + } + + @Test("Returns input unchanged when no JSON bracket is present") + func noBracket() { + let raw = "pm2 daemon not responding" + let result = PM2Service.extractJSON(from: Data(raw.utf8)) + #expect(string(result) == raw) + } +} + +// MARK: - Bind port parsing + +@Suite("PM2Process.parsePort") +struct ParsePortTests { + + @Test("uvicorn --host --port form") + func uvicornForm() { + let args = ["src.scripts.asgi:app", "--host", "0.0.0.0", "--port", "5008", "--workers", "2"] + #expect(PM2Process.parsePort(fromArgs: args) == 5008) + } + + @Test("hypercorn/gunicorn --bind host:port form") + func bindForm() { + let args = ["--workers", "2", "--bind", "0.0.0.0:5004", "src.scripts.asgi:app"] + #expect(PM2Process.parsePort(fromArgs: args) == 5004) + } + + @Test("short -p flag") + func shortFlag() { + #expect(PM2Process.parsePort(fromArgs: ["-p", "3000"]) == 3000) + } + + @Test("--port=N equals form") + func equalsForm() { + #expect(PM2Process.parsePort(fromArgs: ["--port=8080"]) == 8080) + } + + @Test("-b=host:port equals form") + func bindEqualsForm() { + #expect(PM2Process.parsePort(fromArgs: ["-b=127.0.0.1:9000"]) == 9000) + } + + @Test("--bind :port with no host") + func bindNoHost() { + #expect(PM2Process.parsePort(fromArgs: ["--bind", ":5555"]) == 5555) + } + + @Test("Falls back to PORT env when args have no port") + func portEnvFallback() { + #expect(PM2Process.parsePort(fromArgs: ["server.js"], portEnv: "4321") == 4321) + } + + @Test("Args take precedence over PORT env") + func argsBeatEnv() { + #expect(PM2Process.parsePort(fromArgs: ["--port", "5008"], portEnv: "9999") == 5008) + } + + @Test("No port anywhere returns nil") + func noPort() { + #expect(PM2Process.parsePort(fromArgs: ["worker.js", "--workers", "2"]) == nil) + } + + @Test("Out-of-range port is rejected") + func outOfRange() { + #expect(PM2Process.parsePort(fromArgs: ["--port", "99999"]) == nil) + #expect(PM2Process.parsePort(fromArgs: ["--port", "0"]) == nil) + } + + @Test("Non-numeric port value is ignored") + func nonNumeric() { + #expect(PM2Process.parsePort(fromArgs: ["--port", "auto"]) == nil) + } +} + +// MARK: - Address-in-use log detection + +@Suite("PM2Service.logReportsAddressInUse") +struct AddressInUseTests { + + @Test("Detects Python errno phrasing") + func pythonErrno() { + #expect(PM2Service.logReportsAddressInUse("ERROR: [Errno 48] Address already in use")) + } + + @Test("Detects Node EADDRINUSE") + func nodeEaddrinuse() { + #expect(PM2Service.logReportsAddressInUse("Error: listen EADDRINUSE: address already in use :::3000")) + } + + @Test("Case-insensitive") + func caseInsensitive() { + #expect(PM2Service.logReportsAddressInUse("ADDRESS ALREADY IN USE")) + } + + @Test("Unrelated log lines are not flagged") + func unrelated() { + #expect(!PM2Service.logReportsAddressInUse("INFO: Application startup complete on port 5008")) + } +} From 326e5d44156d22c13d3b3fe49cea051d7ecdfef6 Mon Sep 17 00:00:00 2001 From: Fred Rivett Date: Wed, 1 Jul 2026 14:30:14 +0100 Subject: [PATCH 2/3] Document port conflict rescue in feature lists Add the feature to the README and swap it for launch-at-login on the homepage, keeping the homepage to nine feature cards. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + site/index.html | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 20903df..c419b7b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/site/index.html b/site/index.html index 17654b1..5fd64a8 100644 --- a/site/index.html +++ b/site/index.html @@ -85,8 +85,8 @@

🔍 Quick filter

Search processes by name, repo or branch, with ⌘K to jump straight to the filter.

-

🚀 Launch at login

-

Toggle it on and reeve is in your menu bar the moment you sign in — no need to remember to start it.

+

🔌 Port conflict rescue

+

Spots when a process can't start because something else is squatting its port — one click frees the port and restarts the service.

From 65ab20d9895bdcc0bb915894b2496e2a09eb74a1 Mon Sep 17 00:00:00 2001 From: Fred Rivett Date: Wed, 1 Jul 2026 14:35:58 +0100 Subject: [PATCH 3/3] Fix swiftlint violations Drop redundant parentheses and nil-coalescing in the port parser, and move the pure pm2 output/log parsing helpers into PM2Service+Parsing.swift to keep PM2Service.swift under the file-length limit. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Reeve/Models/PM2Process.swift | 6 +- .../Reeve/Services/PM2Service+Parsing.swift | 55 +++++++++++++++++++ Sources/Reeve/Services/PM2Service.swift | 50 ----------------- 3 files changed, 58 insertions(+), 53 deletions(-) create mode 100644 Sources/Reeve/Services/PM2Service+Parsing.swift diff --git a/Sources/Reeve/Models/PM2Process.swift b/Sources/Reeve/Models/PM2Process.swift index c61e6ca..d8e62d4 100644 --- a/Sources/Reeve/Models/PM2Process.swift +++ b/Sources/Reeve/Models/PM2Process.swift @@ -149,11 +149,11 @@ extension PM2Process: Decodable { // 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) + 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 ?? nil) + desiredPort = PM2Process.parsePort(fromArgs: args, portEnv: portEnv) } /// Extract a bind port from pm2 launch args, falling back to a `PORT` env @@ -182,7 +182,7 @@ extension PM2Process: Decodable { let port = validPort(next[next.index(after: colon)...]) { return port } - if (arg.hasPrefix("--bind=") || arg.hasPrefix("-b=")), + 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)...]) { diff --git a/Sources/Reeve/Services/PM2Service+Parsing.swift b/Sources/Reeve/Services/PM2Service+Parsing.swift new file mode 100644 index 0000000..7dac6f7 --- /dev/null +++ b/Sources/Reeve/Services/PM2Service+Parsing.swift @@ -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) + } +} diff --git a/Sources/Reeve/Services/PM2Service.swift b/Sources/Reeve/Services/PM2Service.swift index 30f8442..d7206f8 100644 --- a/Sources/Reeve/Services/PM2Service.swift +++ b/Sources/Reeve/Services/PM2Service.swift @@ -505,56 +505,6 @@ public class PM2Service: ObservableObject { } } - /// 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. - private 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) - } - private nonisolated static func runPM2Sync( _ args: [String], environment: PM2Environment,