Skip to content

Detect port bind conflicts with one-click free + restart#32

Merged
fredrivett merged 3 commits into
mainfrom
fredrivett/port-conflict-rescue
Jul 1, 2026
Merged

Detect port bind conflicts with one-click free + restart#32
fredrivett merged 3 commits into
mainfrom
fredrivett/port-conflict-rescue

Conversation

@fredrivett

@fredrivett fredrivett commented Jul 1, 2026

Copy link
Copy Markdown
Owner

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 jlist stdout 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:

  1. A bind port is parsed from the pm2 args (--port N, --port=N, -p N, --bind host:N, -b=host:N, --bind :N) or a PORT env var — only that single named env key is read, never the whole environment.
  2. The process isn't already serving that port (from OS-resolved ports).
  3. Another process is currently listening on it.
  4. The process's error-log tail contains a recent bind failure (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

  • Port is parsed from args only for the conflict diagnosis; the ports reeve displays still come from the OS, never from args — preserving the "OS ground truth" principle.
  • Demo mode gets a showcase conflict (a dark-mode worktree's web-preview can't bind :3000 held by main's web) so it appears in marketing runs.

Docs

  • README: added 🔌 Port conflict rescue.
  • Homepage: replaced the "Launch at login" card with it, keeping the homepage to nine feature cards.

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 jlist JSON decoding when a colored banner is present, and refactors parsing helpers into PM2Service+Parsing.swift to satisfy SwiftLint.

  • New Features

    • Detects live bind conflicts by parsing desired port from args/PORT, confirming another process is listening, and seeing recent “address already in use” lines in the error log.
    • Shows a banner with “Free port :N and restart service”; finds listeners via lsof -tiTCP:N -sTCP:LISTEN, SIGKILLs them (excluding this app), then restarts the pm2 process.
    • Demo mode showcases a real conflict; docs and site updated.
  • Bug Fixes

    • Strips pm2’s ANSI-colored “In-memory PM2 is out-of-date” preamble from jlist stdout before JSON decoding.
    • Moves pure stdout/log parsing helpers to PM2Service+Parsing.swift and cleans up the port parser to resolve SwiftLint warnings.
    • Adds tests for JSON extraction, port parsing, and address‑in‑use detection.

Written for commit 65ab20d. Summary will update on new commits.

Review in cubic

fredrivett and others added 3 commits July 1, 2026 14:29
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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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") {

Copy link
Copy Markdown

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
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>
Suggested change
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 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>

@fredrivett fredrivett merged commit 08e5ea2 into main Jul 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant