diff --git a/Sources/Reeve/Models/PM2Process.swift b/Sources/Reeve/Models/PM2Process.swift index 13ff9d6..d6f0b15 100644 --- a/Sources/Reeve/Models/PM2Process.swift +++ b/Sources/Reeve/Models/PM2Process.swift @@ -1,29 +1,5 @@ import Foundation -/// Decodes a value that may be a JSON string or number into an Int. -private enum StringOrInt: Decodable { - case string(String) - case int(Int) - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let v = try? container.decode(Int.self) { - self = .int(v) - } else if let v = try? container.decode(String.self) { - self = .string(v) - } else { - throw DecodingError.typeMismatch(StringOrInt.self, .init(codingPath: decoder.codingPath, debugDescription: "Expected String or Int")) - } - } - - var intValue: Int? { - switch self { - case .int(let v): return v - case .string(let v): return Int(v) - } - } -} - public struct PM2Process: Identifiable, Sendable { public let pid: Int public let name: String @@ -40,7 +16,11 @@ public struct PM2Process: Identifiable, Sendable { public let createdAt: Int64 public let outLogPath: String public let errLogPath: String - public let port: 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] = [] /// Most recent modification time of the process's log files (set after decoding). public var lastLogModified: Date? @@ -103,20 +83,11 @@ public struct PM2Process: Identifiable, Sendable { } } -// Custom decoding from pm2 jlist JSON — only extracts fields we need. -// pm2_env contains the full process environment, which can include secrets. -// We enumerate its key *names* to find port variables but only decode the -// values of port-shaped keys, so secret values never enter memory. +// Custom decoding from pm2 jlist JSON — only extracts the fields we need. +// pm2_env contains the full process environment, which can include secrets, +// so we never enumerate it; ports are resolved separately from the OS via +// `SocketScanner`. extension PM2Process: Decodable { - /// Coding key that accepts any string, used to enumerate `pm2_env` keys - /// without modelling the whole (secret-bearing) environment. - private struct DynamicKey: CodingKey { - let stringValue: String - var intValue: Int? { nil } - init?(stringValue: String) { self.stringValue = stringValue } - init?(intValue: Int) { return nil } - } - private enum TopKeys: String, CodingKey { case pid, name, monit case pmId = "pm_id" @@ -128,7 +99,7 @@ extension PM2Process: Decodable { } private enum EnvKeys: String, CodingKey { - case status, namespace, args + case status, namespace case pmExecPath = "pm_exec_path" case pmCwd = "pm_cwd" case execMode = "exec_mode" @@ -160,49 +131,5 @@ 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) ?? "" - - // Extract port: prefer an explicit `--port`/`-p` arg, then fall back to - // any `PORT` or `*_PORT` environment variable. - let args = try env.decodeIfPresent([String].self, forKey: .args) ?? [] - if let argPort = PM2Process.port(fromArgs: args) { - port = argPort - } else { - let envContainer = try top.nestedContainer(keyedBy: DynamicKey.self, forKey: .pm2Env) - port = PM2Process.port(fromEnv: envContainer) - } - } - - /// Parse a port from process args, e.g. `--port 3000` or `-p 8080`. - private static func port(fromArgs args: [String]) -> Int? { - let joined = args.joined(separator: " ") - guard let pattern = try? NSRegularExpression(pattern: #"(?:--port|-p)\s+(\d+)"#), - let match = pattern.firstMatch(in: joined, range: NSRange(joined.startIndex..., in: joined)), - let range = Range(match.range(at: 1), in: joined) else { - return nil - } - return Int(joined[range]) - } - - /// Find a port among the `pm2_env` keys: the bare `PORT`, or a specific - /// suffix like `SERVER_PORT`. A specific `*_PORT` wins over the generic - /// `PORT`, with ties broken alphabetically so the result is deterministic - /// regardless of JSON key order. - private static func port(fromEnv container: KeyedDecodingContainer) -> Int? { - let portKeys = container.allKeys.filter { key in - let upper = key.stringValue.uppercased() - return upper == "PORT" || upper.hasSuffix("_PORT") - } - let ordered = portKeys.sorted { lhs, rhs in - let lhsSpecific = lhs.stringValue.uppercased() != "PORT" - let rhsSpecific = rhs.stringValue.uppercased() != "PORT" - if lhsSpecific != rhsSpecific { return lhsSpecific } - return lhs.stringValue < rhs.stringValue - } - for key in ordered { - if let parsed = try? container.decode(StringOrInt.self, forKey: key), let value = parsed.intValue { - return value - } - } - return nil } } diff --git a/Sources/Reeve/Services/DemoData.swift b/Sources/Reeve/Services/DemoData.swift index ebe2c65..2b8ba6f 100644 --- a/Sources/Reeve/Services/DemoData.swift +++ b/Sources/Reeve/Services/DemoData.swift @@ -27,7 +27,7 @@ final class DemoData { var startedAt: Int64 // pm_uptime, ms — uptime ticks up naturally from here let createdAt: Int64 // ms var restartCount: Int - let port: Int? + let ports: [Int] let cwd: String let crashLoop: Bool // keep this process perpetually crash-looping var lastLog: Date? @@ -50,7 +50,7 @@ final class DemoData { createdAt: createdAt, outLogPath: "", errLogPath: "", - port: port, + ports: ports, lastLogModified: lastLog ) } @@ -186,7 +186,7 @@ final class DemoData { // has a default, keeping the call sites terse and readable. func proc(_ name: String, _ pmId: Int, cpu: Double = 0, mem: Double = 0, - status: String = "online", port: Int? = nil, + 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 { @@ -203,7 +203,7 @@ final class DemoData { startedAt: online ? ms(started ?? age) : 0, createdAt: ms(age), restartCount: restarts, - port: port, + ports: ports, cwd: cwd, crashLoop: crash, lastLog: lastLog ?? (online ? recent() : Date(timeIntervalSince1970: now - 600)) @@ -224,7 +224,7 @@ final class DemoData { gitInfo: sidetrack("main"), error: nil, procs: [ - proc("web", 0, cpu: 12, mem: 184, port: 3000, cwd: main), + proc("web", 0, cpu: 12, mem: 184, ports: [3000], cwd: main), proc("worker", 1, cpu: 4, mem: 96, cwd: main), proc("scheduler", 2, status: "stopped", lastLog: Date(timeIntervalSince1970: now - 3600)) ] @@ -236,7 +236,7 @@ final class DemoData { gitInfo: sidetrack("fredrivett/eng-3338-rate-limiting"), error: nil, procs: [ - proc("web", 0, cpu: 18, mem: 206, port: 3010, age: 6 * 3600, cwd: rateLimit), + proc("web", 0, cpu: 18, mem: 206, ports: [3010], age: 6 * 3600, cwd: rateLimit), proc("worker", 1, cpu: 6, mem: 112, age: 6 * 3600, cwd: rateLimit), proc("migrations", 2, status: "errored", age: 6 * 3600, restarts: 1, cwd: rateLimit) ] @@ -249,7 +249,7 @@ final class DemoData { gitInfo: sidetrack("fredrivett/eng-3401-dark-mode"), error: nil, procs: [ - proc("web", 0, cpu: 25, mem: 232, port: 3020, age: 90 * 60, cwd: darkMode), + 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) ] ) @@ -262,7 +262,7 @@ final class DemoData { gitInfo: GitInfo(repoName: "marketing-site", branch: "main"), error: nil, procs: [ - proc("next-dev", 0, cpu: 28, mem: 418, port: 4000, age: 4 * 3600, cwd: marketing) + proc("next-dev", 0, cpu: 28, mem: 418, ports: [4000], age: 4 * 3600, cwd: marketing) ] ) diff --git a/Sources/Reeve/Services/PM2Service.swift b/Sources/Reeve/Services/PM2Service.swift index 282548c..de49d2a 100644 --- a/Sources/Reeve/Services/PM2Service.swift +++ b/Sources/Reeve/Services/PM2Service.swift @@ -110,9 +110,13 @@ public class PM2Service: ObservableObject { var gitResults: [String: GitInfo] = [:] var errors: [String: String] = [:] + // One machine-wide snapshot of listening sockets + process tree, + // shared (read-only) across all environment fetches below. + let sockets = SocketScanner.scan() + DispatchQueue.concurrentPerform(iterations: environmentsToFetch.count) { index in let env = environmentsToFetch[index] - let fetchResult = PM2Service.fetchProcessesSync(for: env, using: resolution) + let fetchResult = PM2Service.fetchProcessesSync(for: env, using: resolution, sockets: sockets) var processes: [PM2Process] = [] var errorMessage: String? @@ -419,7 +423,8 @@ public class PM2Service: ObservableObject { private nonisolated static func fetchProcessesSync( for environment: PM2Environment, - using resolution: PM2BinaryResolver.Resolution + using resolution: PM2BinaryResolver.Resolution, + sockets: SocketScanner.Snapshot ) -> FetchResult { // If no daemon is running, return empty — don't call pm2 which would spawn one guard isDaemonRunning(for: environment) else { @@ -428,6 +433,10 @@ public class PM2Service: ObservableObject { do { let data = try runPM2Sync(["jlist"], environment: environment, using: resolution) var processes = try JSONDecoder().decode([PM2Process].self, 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) + } // Stat log files to determine last activity time let fm = FileManager.default for i in processes.indices { diff --git a/Sources/Reeve/Services/SocketScanner.swift b/Sources/Reeve/Services/SocketScanner.swift new file mode 100644 index 0000000..ac54afe --- /dev/null +++ b/Sources/Reeve/Services/SocketScanner.swift @@ -0,0 +1,111 @@ +import Foundation + +/// Resolves the TCP ports a process is actually listening on by inspecting the +/// OS, rather than guessing from environment variables or CLI args. This is the +/// ground truth: it reflects what a process bound to regardless of how the port +/// was configured (`PORT`, `--port`, a custom var, or hard-coded), and it +/// correctly attributes ports opened by child processes (e.g. `pm2 start npm -- +/// run dev`, where the real server is a grandchild of the pm2-managed pid). +/// +/// One `lsof` and one `ps` call snapshot the whole machine per refresh; each +/// process then resolves its ports with a cheap dictionary lookup over its own +/// pid plus descendants. +struct SocketScanner { + /// Listening ports we treat as noise and never surface. Node's V8 inspector + /// defaults to 9229 and increments for additional inspectors / cluster + /// workers, so it shows up alongside the real app port under `--inspect`. + static let ignoredPorts: Set = [9229, 9230, 9231] + + /// Immutable snapshot of the machine's listening sockets and process tree. + struct Snapshot: Sendable { + /// pid → ports that pid is directly listening on. + let portsByPID: [Int: Set] + /// ppid → its direct child pids. + let childrenByPID: [Int: [Int]] + + static let empty = Snapshot(portsByPID: [:], childrenByPID: [:]) + } + + /// Take a fresh snapshot of all listening TCP sockets and the process tree. + static func scan() -> Snapshot { + let lsofOutput = run("/usr/sbin/lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-Fpn"]) + let psOutput = run("/bin/ps", ["-axo", "pid=,ppid="]) + return Snapshot( + portsByPID: parseLsof(lsofOutput), + childrenByPID: parsePS(psOutput) + ) + } + + /// All listening ports for the process tree rooted at `pid` (the process + /// itself plus every descendant), sorted ascending with noise filtered out. + static func ports(forRoot pid: Int, in snapshot: Snapshot) -> [Int] { + guard pid > 0 else { return [] } + var found = Set() + var visited = Set() + var stack = [pid] + while let current = stack.popLast() { + guard visited.insert(current).inserted else { continue } + if let direct = snapshot.portsByPID[current] { found.formUnion(direct) } + if let kids = snapshot.childrenByPID[current] { stack.append(contentsOf: kids) } + } + return found.subtracting(ignoredPorts).sorted() + } + + // MARK: - Parsing (pure, unit-tested) + + /// Parse `lsof -FpnL`-style field output into a pid → listening-ports map. + /// Output is a stream of records: a `p` line starts a process, and each + /// following `n:` line is one of its listening sockets. IPv4 and + /// IPv6 bindings of the same port collapse naturally into the Set. + static func parseLsof(_ output: String) -> [Int: Set] { + var result: [Int: Set] = [:] + var currentPID: Int? + for line in output.split(separator: "\n") { + guard let tag = line.first else { continue } + let value = line.dropFirst() + switch tag { + case "p": + currentPID = Int(value) + case "n": + guard let pid = currentPID, + let colon = value.lastIndex(of: ":"), + let port = Int(value[value.index(after: colon)...]) else { continue } + result[pid, default: []].insert(port) + default: + continue + } + } + return result + } + + /// Parse `ps -axo pid=,ppid=` output (two whitespace-separated columns per + /// line) into a parent → children map. + static func parsePS(_ output: String) -> [Int: [Int]] { + var children: [Int: [Int]] = [:] + for line in output.split(separator: "\n") { + let cols = line.split(whereSeparator: { $0 == " " || $0 == "\t" }) + guard cols.count >= 2, let pid = Int(cols[0]), let ppid = Int(cols[1]) else { continue } + children[ppid, default: []].append(pid) + } + return children + } + + // MARK: - Process execution + + private static func run(_ launchPath: String, _ args: [String]) -> String { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: launchPath) + process.arguments = args + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + return "" + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/Sources/Reeve/Views/ContentView.swift b/Sources/Reeve/Views/ContentView.swift index 28b294c..e18ba3c 100644 --- a/Sources/Reeve/Views/ContentView.swift +++ b/Sources/Reeve/Views/ContentView.swift @@ -346,7 +346,7 @@ public struct ContentView: View { let query = filterText.lowercased() return processes.filter { process in process.name.lowercased().contains(query) || - (process.port.map { String($0).contains(query) } ?? false) + process.ports.contains { String($0).contains(query) } } } } diff --git a/Sources/Reeve/Views/EnvironmentSectionView.swift b/Sources/Reeve/Views/EnvironmentSectionView.swift index d2453f0..e92f54a 100644 --- a/Sources/Reeve/Views/EnvironmentSectionView.swift +++ b/Sources/Reeve/Views/EnvironmentSectionView.swift @@ -207,7 +207,7 @@ struct EnvironmentSectionView: View { } if let portRange = formattedPortRange() { - PortLinkText(text: portRange, port: processes.compactMap(\.port).min()) + PortLinkText(text: portRange, port: processes.flatMap(\.ports).min()) } let totalCPU = processes.filter(\.isOnline).reduce(0.0) { $0 + $1.cpuPercent } @@ -308,7 +308,7 @@ struct EnvironmentSectionView: View { } private func formattedPortRange() -> String? { - let ports = processes.compactMap(\.port).sorted() + let ports = processes.flatMap(\.ports).sorted() return formatPortRange(from: ports) } diff --git a/Sources/Reeve/Views/PortLinkText.swift b/Sources/Reeve/Views/PortLinkText.swift index f23de0e..501a499 100644 --- a/Sources/Reeve/Views/PortLinkText.swift +++ b/Sources/Reeve/Views/PortLinkText.swift @@ -23,3 +23,49 @@ struct PortLinkText: View { .help(port.map { "Open http://localhost:\(String($0))" } ?? "") } } + +/// Pure layout decision for how a process's ports are displayed, extracted from +/// the view so it can be unit-tested. +enum PortDisplay { + struct Summary: Equatable { + /// Ports rendered inline as links (at most `limit`). + let shown: [Int] + /// How many ports are hidden behind the `+N` badge. + let overflow: Int + /// Tooltip listing every port (shown + hidden). + let tooltip: String + } + + static func summarize(_ ports: [Int], limit: Int = 2) -> Summary { + Summary( + shown: Array(ports.prefix(limit)), + overflow: Swift.max(0, ports.count - limit), + tooltip: ports.map { ":\($0)" }.joined(separator: " ") + ) + } +} + +/// Displays the ports a process is listening on: up to two as clickable links, +/// collapsing any extras into a `+N` badge whose tooltip lists every port. +/// Renders nothing when the process has no listening ports. +struct ProcessPortsView: View { + let ports: [Int] + + private var summary: PortDisplay.Summary { PortDisplay.summarize(ports) } + + var body: some View { + if !ports.isEmpty { + HStack(spacing: 4) { + ForEach(summary.shown, id: \.self) { port in + PortLinkText(text: ":\(String(port))", port: port) + } + if summary.overflow > 0 { + Text("+\(summary.overflow)") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) + .hoverTooltip(summary.tooltip) + } + } + } + } +} diff --git a/Sources/Reeve/Views/ProcessRowView.swift b/Sources/Reeve/Views/ProcessRowView.swift index 3910ebf..c2f42e7 100644 --- a/Sources/Reeve/Views/ProcessRowView.swift +++ b/Sources/Reeve/Views/ProcessRowView.swift @@ -87,9 +87,7 @@ struct ProcessRowView: View { // Stats if process.isOnline { - if let port = process.port { - PortLinkText(text: ":\(String(port))", port: port) - } + ProcessPortsView(ports: process.ports) HStack(spacing: 2) { if let samples = pm2Service.metricsHistory.history["\(environment.path):\(process.pmId)"], samples.count > 1 { SparklineView(values: samples.map(\.cpu), color: .blue) diff --git a/Tests/reeveTests/ReeveTests.swift b/Tests/reeveTests/ReeveTests.swift index 2e9cf49..02f4ef0 100644 --- a/Tests/reeveTests/ReeveTests.swift +++ b/Tests/reeveTests/ReeveTests.swift @@ -120,7 +120,7 @@ struct PM2ProcessDecodingTests { #expect(process.createdAt == 0) #expect(process.outLogPath == "") #expect(process.errLogPath == "") - #expect(process.port == nil) + #expect(process.ports.isEmpty) } @Test("Decode array of processes") @@ -144,154 +144,234 @@ struct PM2ProcessDecodingTests { } } -// MARK: - Port Extraction +// MARK: - Socket-based Port Resolution -@Suite("Port Extraction") -struct PortExtractionTests { +@Suite("Socket Port Resolution") +struct SocketScannerTests { - @Test("Port from --port arg") - func portFromArgs() throws { - let json = """ - { - "pid": 1, "name": "srv", "pm_id": 0, - "monit": { "memory": 0, "cpu": 0.0 }, - "pm2_env": { "status": "online", "args": ["--port", "3000"] } - } + // MARK: lsof parsing + + @Test("Parses pid → ports from lsof -Fpn output") + func parsesLsof() { + let output = """ + p73377 + n127.0.0.1:4321 + p84604 + n*:8888 + """ + let map = SocketScanner.parseLsof(output) + #expect(map[73377] == [4321]) + #expect(map[84604] == [8888]) + } + + @Test("Collapses IPv4 + IPv6 bindings of the same port") + func collapsesDualStack() { + let output = """ + p100 + n*:3000 + n[::1]:3000 + n127.0.0.1:3000 """ - #expect(try decode(json).port == 3000) + #expect(SocketScanner.parseLsof(output)[100] == [3000]) } - @Test("Port from -p arg") - func portFromShortFlag() throws { - let json = """ - { - "pid": 1, "name": "srv", "pm_id": 0, - "monit": { "memory": 0, "cpu": 0.0 }, - "pm2_env": { "status": "online", "args": ["-p", "8080"] } - } + @Test("A process listening on several distinct ports") + func multiplePortsPerProcess() { + let output = """ + p200 + n*:3000 + n*:9090 """ - #expect(try decode(json).port == 8080) + #expect(SocketScanner.parseLsof(output)[200] == [3000, 9090]) } - @Test("Port from a *_PORT env var (int)") - func portFromSuffixInt() throws { - let json = """ - { - "pid": 1, "name": "srv", "pm_id": 0, - "monit": { "memory": 0, "cpu": 0.0 }, - "pm2_env": { "status": "online", "SERVER_PORT": 55001 } - } - """ - #expect(try decode(json).port == 55001) + @Test("Empty lsof output yields no ports") + func emptyLsof() { + #expect(SocketScanner.parseLsof("").isEmpty) } - @Test("Port from a *_PORT env var (string)") - func portFromSuffixString() throws { - let json = """ - { - "pid": 1, "name": "srv", "pm_id": 0, - "monit": { "memory": 0, "cpu": 0.0 }, - "pm2_env": { "status": "online", "HTTP_PORT": "55002" } - } + @Test("An 'n' line before any 'p' line is ignored") + func orphanNameLine() { + let output = """ + n*:3000 + p500 + n*:4000 """ - #expect(try decode(json).port == 55002) + let map = SocketScanner.parseLsof(output) + #expect(map.count == 1) + #expect(map[500] == [4000]) } - @Test("Port from PORT env var (int)") - func portFromPORTInt() throws { - let json = """ - { - "pid": 1, "name": "srv", "pm_id": 0, - "monit": { "memory": 0, "cpu": 0.0 }, - "pm2_env": { "status": "online", "PORT": 4000 } - } + @Test("Non-numeric / wildcard port tails are skipped") + func skipsNonNumericPorts() { + let output = """ + p600 + n*:* + n*:5000 """ - #expect(try decode(json).port == 4000) + #expect(SocketScanner.parseLsof(output)[600] == [5000]) } - @Test("Port from PORT env var (string)") - func portFromPORTString() throws { - let json = """ - { - "pid": 1, "name": "srv", "pm_id": 0, - "monit": { "memory": 0, "cpu": 0.0 }, - "pm2_env": { "status": "online", "PORT": "4001" } - } + @Test("Unrecognised field tags are ignored") + func ignoresOtherFieldTags() { + let output = """ + p700 + f12 + TST=LISTEN + n*:6000 """ - #expect(try decode(json).port == 4001) + #expect(SocketScanner.parseLsof(output)[700] == [6000]) } - @Test("Args port takes precedence over env vars") - func argsPrecedence() throws { - let json = """ - { - "pid": 1, "name": "srv", "pm_id": 0, - "monit": { "memory": 0, "cpu": 0.0 }, - "pm2_env": { "status": "online", "args": ["--port", "9000"], "PORT": 4000, "SERVER_PORT": 5000 } - } + // MARK: ps parsing + + @Test("Parses parent → children from ps output") + func parsesPS() { + let output = """ + 84570 84001 + 84604 84570 + 84605 84570 """ - #expect(try decode(json).port == 9000) + let children = SocketScanner.parsePS(output) + #expect(children[84570]?.sorted() == [84604, 84605]) + #expect(children[84001] == [84570]) + } + + @Test("Garbage / malformed ps lines are skipped") + func parsePSSkipsGarbage() { + let output = """ + PID PPID + 100 1 + not a number + 200 100 + """ + let children = SocketScanner.parsePS(output) + #expect(children[1] == [100]) + #expect(children[100] == [200]) + #expect(children.count == 2) } - @Test("A specific *_PORT takes precedence over the generic PORT") - func suffixPortPrecedence() throws { - let json = """ - { - "pid": 1, "name": "srv", "pm_id": 0, - "monit": { "memory": 0, "cpu": 0.0 }, - "pm2_env": { "status": "online", "PORT": 4000, "SERVER_PORT": 5000 } - } - """ - #expect(try decode(json).port == 5000) + // MARK: tree resolution + + @Test("Resolves ports from the process itself") + func resolvesOwnPort() { + let snap = SocketScanner.Snapshot(portsByPID: [10: [4321]], childrenByPID: [:]) + #expect(SocketScanner.ports(forRoot: 10, in: snap) == [4321]) } - @Test("Multiple *_PORT vars resolve deterministically (alphabetical)") - func multipleSuffixPortsDeterministic() throws { - let json = """ - { - "pid": 1, "name": "srv", "pm_id": 0, - "monit": { "memory": 0, "cpu": 0.0 }, - "pm2_env": { "status": "online", "SERVER_PORT": 5000, "APP_PORT": 6000 } - } - """ - // APP_PORT sorts before SERVER_PORT, so it wins regardless of JSON order. - #expect(try decode(json).port == 6000) + @Test("Resolves a child's port (npm-wrapped server case)") + func resolvesChildPort() { + // pid 10 = npm wrapper (no socket); its child 11 is the real server. + let snap = SocketScanner.Snapshot( + portsByPID: [11: [8888]], + childrenByPID: [10: [11]] + ) + #expect(SocketScanner.ports(forRoot: 10, in: snap) == [8888]) } - @Test("Keys merely containing 'port' are ignored") - func ignoresNonPortKeys() throws { - let json = """ - { - "pid": 1, "name": "srv", "pm_id": 0, - "monit": { "memory": 0, "cpu": 0.0 }, - "pm2_env": { "status": "online", "SUPPORT_EMAIL": "a@b.com", "EXPORT_DIR": "/tmp" } - } - """ - #expect(try decode(json).port == nil) + @Test("Resolves ports across a deeper descendant chain") + func resolvesGrandchildPort() { + let snap = SocketScanner.Snapshot( + portsByPID: [12: [5000]], + childrenByPID: [10: [11], 11: [12]] + ) + #expect(SocketScanner.ports(forRoot: 10, in: snap) == [5000]) } - @Test("Non-numeric port value falls back to the next candidate") - func nonNumericPortFallback() throws { - let json = """ - { - "pid": 1, "name": "srv", "pm_id": 0, - "monit": { "memory": 0, "cpu": 0.0 }, - "pm2_env": { "status": "online", "APP_PORT": "auto", "SERVER_PORT": 7000 } - } - """ - #expect(try decode(json).port == 7000) + @Test("Merges and sorts ports from the whole subtree") + func mergesSubtreePorts() { + let snap = SocketScanner.Snapshot( + portsByPID: [10: [9090], 11: [3000]], + childrenByPID: [10: [11]] + ) + #expect(SocketScanner.ports(forRoot: 10, in: snap) == [3000, 9090]) } - @Test("No port when none specified") - func noPort() throws { - let json = """ - { - "pid": 1, "name": "srv", "pm_id": 0, - "monit": { "memory": 0, "cpu": 0.0 }, - "pm2_env": { "status": "online", "args": ["--verbose"] } - } - """ - #expect(try decode(json).port == nil) + @Test("Filters out known debug/inspector ports") + func filtersDebugPorts() { + let snap = SocketScanner.Snapshot(portsByPID: [10: [3000, 9229]], childrenByPID: [:]) + #expect(SocketScanner.ports(forRoot: 10, in: snap) == [3000]) + } + + @Test("Filters debug ports and sorts the remainder") + func filtersAndSorts() { + let snap = SocketScanner.Snapshot(portsByPID: [10: [9090, 3000, 9229]], childrenByPID: [:]) + #expect(SocketScanner.ports(forRoot: 10, in: snap) == [3000, 9090]) + } + + @Test("A process listening on nothing resolves to no ports") + func noListeningSockets() { + let snap = SocketScanner.Snapshot(portsByPID: [:], childrenByPID: [:]) + #expect(SocketScanner.ports(forRoot: 10, in: snap).isEmpty) + } + + @Test("A non-running process (pid 0) resolves to no ports") + func zeroPid() { + let snap = SocketScanner.Snapshot(portsByPID: [0: [3000]], childrenByPID: [:]) + #expect(SocketScanner.ports(forRoot: 0, in: snap).isEmpty) + } + + @Test("Cyclic process references terminate") + func cyclesTerminate() { + // Defensive: a malformed tree where pids reference each other. + let snap = SocketScanner.Snapshot( + portsByPID: [10: [3000]], + childrenByPID: [10: [11], 11: [10]] + ) + #expect(SocketScanner.ports(forRoot: 10, in: snap) == [3000]) + } +} + +// MARK: - Port Display + +@Suite("Port Display") +struct PortDisplayTests { + + @Test("No ports shows nothing") + func noPorts() { + let s = PortDisplay.summarize([]) + #expect(s.shown.isEmpty) + #expect(s.overflow == 0) + #expect(s.tooltip == "") + } + + @Test("Single port, no overflow badge") + func singlePort() { + let s = PortDisplay.summarize([3000]) + #expect(s.shown == [3000]) + #expect(s.overflow == 0) + #expect(s.tooltip == ":3000") + } + + @Test("Exactly two ports, no overflow badge") + func twoPorts() { + let s = PortDisplay.summarize([3000, 9090]) + #expect(s.shown == [3000, 9090]) + #expect(s.overflow == 0) + #expect(s.tooltip == ":3000 :9090") + } + + @Test("Three ports shows two plus +1") + func threePorts() { + let s = PortDisplay.summarize([6001, 6002, 6003]) + #expect(s.shown == [6001, 6002]) + #expect(s.overflow == 1) + #expect(s.tooltip == ":6001 :6002 :6003") + } + + @Test("Many ports collapse into +N with full tooltip") + func manyPorts() { + let s = PortDisplay.summarize([8000, 8001, 8002, 8003, 8004]) + #expect(s.shown == [8000, 8001]) + #expect(s.overflow == 3) + #expect(s.tooltip == ":8000 :8001 :8002 :8003 :8004") + } + + @Test("Custom limit is respected") + func customLimit() { + let s = PortDisplay.summarize([1, 2, 3], limit: 1) + #expect(s.shown == [1]) + #expect(s.overflow == 2) } }