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
2 changes: 1 addition & 1 deletion Sources/Reeve/Models/PM2Process.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ 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 arg.hasPrefix("--port="), let eq = arg.firstIndex(of: "=") {
if let port = validPort(arg[arg.index(after: eq)...]) { return port }
}
// Bind targets look like `host:port`, `:port`, or `[::1]:port` — the
Expand Down
16 changes: 13 additions & 3 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 ports: [Int]
var ports: [Int]
let cwd: String
let crashLoop: Bool // keep this process perpetually crash-looping
var lastLog: Date?
Expand Down Expand Up @@ -157,13 +157,23 @@ final class DemoData {
envs[i].procs = []
}

/// Simulate freeing a port: any process that was blocked waiting for it
/// clears its conflict and comes online.
/// Simulate freeing a port, mirroring the real action: kill whatever holds
/// the port (it goes offline and loses the port), then bring the blocked
/// process online bound to the now-free port.
func freePort(_ port: Int) {
for i in envs.indices {
for j in envs[i].procs.indices {
if envs[i].procs[j].portConflict != port, envs[i].procs[j].ports.contains(port) {
envs[i].procs[j].ports.removeAll { $0 == port }
envs[i].procs[j].status = "stopped"
}
}
}
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].ports = [port]
envs[i].procs[j].startedAt = DemoData.nowMs()
envs[i].procs[j].lastLog = Date()
}
Expand Down
13 changes: 12 additions & 1 deletion Sources/Reeve/Services/PM2Service+Parsing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,20 @@ extension PM2Service {

/// 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 {
///
/// `runStartMs` is the process's current run start (`pm_uptime`): when > 0,
/// a log last modified *before* that start predates the current run, so its
/// bind error is stale and ignored — this stops an old conflict from
/// prompting the user to kill whatever legitimately holds the port now.
nonisolated static func errorLogReportsAddressInUse(atPath path: String, runStartMs: Int64) -> Bool {
guard !path.isEmpty, let handle = FileHandle(forReadingAtPath: path) else { return false }
defer { try? handle.close() }
if runStartMs > 0,
let attrs = try? FileManager.default.attributesOfItem(atPath: path),
let modified = attrs[.modificationDate] as? Date,
modified.timeIntervalSince1970 * 1000 < Double(runStartMs) {
return false
}
let tailBytes: UInt64 = 8192
if let size = try? handle.seekToEnd(), size > tailBytes {
try? handle.seek(toOffset: size - tailBytes)
Expand Down
2 changes: 1 addition & 1 deletion Sources/Reeve/Services/PM2Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ 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)
errorLogReportsAddressInUse(atPath: processes[i].errLogPath, runStartMs: processes[i].uptime)
else { continue }
processes[i].portConflict = port
}
Expand Down
22 changes: 15 additions & 7 deletions Sources/Reeve/Views/ProcessRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ struct ProcessRowView: View {
@State private var isActing = false
@State private var showCrashPopover = false
@State private var copied = false
@State private var confirmingFreePort = false
/// The port awaiting a confirm click, or nil when not confirming. Keyed to
/// the port (not a Bool) so a poll that changes `portConflict` mid-confirm
/// can't let a stale confirmation authorize killing a different port's holder.
@State private var confirmingFreePort: Int?
@State private var freePortResetTask: Task<Void, Never>?

private static let tooltipFormatter: DateFormatter = {
Expand Down Expand Up @@ -176,29 +179,34 @@ struct ProcessRowView: View {
.font(.system(size: 10))
.foregroundColor(.secondary)

let isConfirming = confirmingFreePort == port
Button {
if confirmingFreePort {
confirmingFreePort = false
if isConfirming {
confirmingFreePort = nil
freePortResetTask?.cancel()
performAction {
await pm2Service.freePort(port)
await pm2Service.restart(process: process, environment: environment)
}
} else {
confirmingFreePort = true
confirmingFreePort = port
freePortResetTask?.cancel()
freePortResetTask = Task {
try? await Task.sleep(nanoseconds: 3_000_000_000)
if !Task.isCancelled {
await MainActor.run { confirmingFreePort = false }
// Only clear the confirmation this timer armed — a
// newer confirmation for a different port must stand.
await MainActor.run {
if confirmingFreePort == port { confirmingFreePort = nil }
}
}
}
}
} 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")
Text(isConfirming ? "Confirm free port :\(String(port)) and restart service?" : "Free port :\(String(port)) and restart service")
.font(.system(size: 10, weight: .medium))
}
.padding(.horizontal, 8)
Expand All @@ -209,7 +217,7 @@ struct ProcessRowView: View {
.buttonStyle(.plain)
.foregroundColor(.orange)
.disabled(isActing)
.help(confirmingFreePort ? "Click again to confirm" : "Kill whatever is listening on :\(String(port)), then restart this process")
.help(isConfirming ? "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() }
}
Expand Down
5 changes: 5 additions & 0 deletions Tests/reeveTests/ReeveTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1358,6 +1358,11 @@ struct ParsePortTests {
func nonNumeric() {
#expect(PM2Process.parsePort(fromArgs: ["--port", "auto"]) == nil)
}

@Test("Unrelated --port-prefixed flag is not mistaken for a port")
func portPrefixedFlag() {
#expect(PM2Process.parsePort(fromArgs: ["--port-range=9000"]) == nil)
}
}

// MARK: - Address-in-use log detection
Expand Down
Loading