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
93 changes: 10 additions & 83 deletions Sources/Reeve/Models/PM2Process.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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?
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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<DynamicKey>) -> 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
}
}
16 changes: 8 additions & 8 deletions Sources/Reeve/Services/DemoData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -50,7 +50,7 @@ final class DemoData {
createdAt: createdAt,
outLogPath: "",
errLogPath: "",
port: port,
ports: ports,
lastLogModified: lastLog
)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
Expand All @@ -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))
]
Expand All @@ -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)
]
Expand All @@ -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)
]
)
Expand All @@ -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)
]
)

Expand Down
13 changes: 11 additions & 2 deletions Sources/Reeve/Services/PM2Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
111 changes: 111 additions & 0 deletions Sources/Reeve/Services/SocketScanner.swift
Original file line number Diff line number Diff line change
@@ -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<Int> = [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<Int>]
/// 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<Int>()
var visited = Set<Int>()
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<pid>` line starts a process, and each
/// following `n<addr>:<port>` 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<Int>] {
var result: [Int: Set<Int>] = [:]
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) ?? ""
}
}
2 changes: 1 addition & 1 deletion Sources/Reeve/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/Reeve/Views/EnvironmentSectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
}

Expand Down
Loading
Loading