Detect port bind conflicts with one-click free + restart#32
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
There was a problem hiding this comment.
4 issues found and verified against the latest diff
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="Sources/Reeve/Models/PM2Process.swift">
<violation number="1" location="Sources/Reeve/Models/PM2Process.swift:176">
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.</violation>
</file>
<file name="Sources/Reeve/Services/DemoData.swift">
<violation number="1" location="Sources/Reeve/Services/DemoData.swift:162">
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.</violation>
</file>
<file name="Sources/Reeve/Views/ProcessRowView.swift">
<violation number="1" location="Sources/Reeve/Views/ProcessRowView.swift:180">
P2: Key confirmation state to the port being confirmed, not a Bool. Otherwise a changing `process.portConflict` can make a stale confirmation authorize killing a different port holder.</violation>
</file>
<file name="Sources/Reeve/Services/PM2Service.swift">
<violation number="1" location="Sources/Reeve/Services/PM2Service.swift:498">
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 `portConflict`.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| if arg == "--port" || arg == "-p", let next, let port = validPort(next[...]) { | ||
| return port | ||
| } | ||
| if let eq = arg.firstIndex(of: "="), arg.hasPrefix("--port") { |
There was a problem hiding this comment.
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
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Reeve/Models/PM2Process.swift, line 176:
<comment>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.</comment>
<file context>
@@ -131,5 +143,55 @@ 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 let port = validPort(arg[arg.index(after: eq)...]) { return port }
+ }
</file context>
| if let eq = arg.firstIndex(of: "="), arg.hasPrefix("--port") { | |
| if let eq = arg.firstIndex(of: "="), arg.hasPrefix("--port=") { |
|
|
||
| /// 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.
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
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Reeve/Services/DemoData.swift, line 162:
<comment>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.</comment>
<file context>
@@ -154,6 +157,19 @@ final class DemoData {
+ /// 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 {
</file context>
| .foregroundColor(.secondary) | ||
|
|
||
| Button { | ||
| if confirmingFreePort { |
There was a problem hiding this comment.
P2: Key confirmation state to the port being confirmed, not a Bool. Otherwise a changing process.portConflict can make a stale confirmation authorize killing a different port holder.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Reeve/Views/ProcessRowView.swift, line 180:
<comment>Key confirmation state to the port being confirmed, not a Bool. Otherwise a changing `process.portConflict` can make a stale confirmation authorize killing a different port holder.</comment>
<file context>
@@ -146,13 +148,79 @@ struct ProcessRowView: View {
+ .foregroundColor(.secondary)
+
+ Button {
+ if confirmingFreePort {
+ confirmingFreePort = false
+ freePortResetTask?.cancel()
</file context>
| 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.
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 portConflict.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Reeve/Services/PM2Service.swift, line 498:
<comment>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 `portConflict`.</comment>
<file context>
@@ -451,12 +488,73 @@ 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)
+ else { continue }
+ processes[i].portConflict = port
</file context>
What
Adds port conflict rescue: when a process fails to bind its port because another process is holding it, reeve surfaces a banner on the process row with a confirm-to-kill action that frees the port and restarts the service in one click.
Also fixes a decoding bug found along the way: pm2 prepends an ANSI-colored "In-memory PM2 is out-of-date" banner to
jliststdout when a daemon's in-memory version differs from the local CLI, which broke JSON decoding ("The data couldn't be read because it isn't in the correct format"). We now strip any preamble before decoding.How detection works
The banner only shows when all of these hold, so it self-clears once the conflict resolves:
--port N,--port=N,-p N,--bind host:N,-b=host:N,--bind :N) or aPORTenv var — only that single named env key is read, never the whole environment.address already in use/EADDRINUSE/Errno 48).Free port :N and restart service runs
lsof -ti:N -sTCP:LISTEN, SIGKILLs the holder (excluding reeve itself), then restarts the pm2 process.Notable choices
web-previewcan't bind:3000held by main'sweb) so it appears in marketing runs.Docs
Tests
15 new unit tests (port parsing across launcher forms, address-in-use log detection, jlist banner stripping). Full suite green (132 tests).
🤖 Generated with Claude Code
Summary by cubic
Adds port conflict rescue: detects when a process can’t bind its port and offers a one‑click action to free the port and restart the service. Also fixes
pm2 jlistJSON decoding when a colored banner is present, and refactors parsing helpers intoPM2Service+Parsing.swiftto satisfy SwiftLint.New Features
PORT, confirming another process is listening, and seeing recent “address already in use” lines in the error log.lsof -tiTCP:N -sTCP:LISTEN, SIGKILLs them (excluding this app), then restarts thepm2process.Bug Fixes
pm2’s ANSI-colored “In-memory PM2 is out-of-date” preamble fromjliststdout before JSON decoding.PM2Service+Parsing.swiftand cleans up the port parser to resolve SwiftLint warnings.Written for commit 65ab20d. Summary will update on new commits.