-
Notifications
You must be signed in to change notification settings - Fork 0
Detect port bind conflicts with one-click free + restart #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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) | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| 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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||
| else { continue } | ||
| processes[i].portConflict = port | ||
| } | ||
| return .success(processes) | ||
| } catch { | ||
| return .failure(error.localizedDescription) | ||
|
|
||
There was a problem hiding this comment.
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