Skip to content

feat(port-management): Add port conflict detection and auto-bump support#259

Merged
jdx merged 26 commits intojdx:mainfrom
benjaminwestern:main
Mar 4, 2026
Merged

feat(port-management): Add port conflict detection and auto-bump support#259
jdx merged 26 commits intojdx:mainfrom
benjaminwestern:main

Conversation

@benjaminwestern
Copy link
Contributor

This commit implements port management features for daemons:

COMPLETED:

  • Port field changed from Option to Vec to support multiple ports
  • Port conflict detection before daemon startup with process identification
  • Auto-bump feature that increments all ports by same offset to maintain spacing
  • Environment variables PORT, PORT0, PORT1, etc. injected for daemon use
  • Port display in daemon details view (press 'i' on daemon)
  • User-visible output when ports are bumped during startup
  • Error messages show which process is using conflicting port
  • CLI flags --expected-port and --auto-bump-port for ad-hoc daemons
  • Network view (press 'p' in TUI) to see all listening processes
  • Sorted network view by PID with pagination support
  • Port overlap highlighting in network view (red text for daemon ports)
  • Integration tests and BATS tests for port management
  • Docs daemon configured in pitchfork.toml with port=5173 and auto_bump_port=true

KNOWN LIMITATIONS:

  • Resolved ports are returned but CLI only displays them, doesn't track changes
  • Port bumping logs go to log file only, not always visible in CLI
  • Multiple port support works but only tested with single ports
  • Process termination feature removed from network view (safety)
  • Process details view removed (use daemon details instead)

TECHNICAL CHANGES:

  • Added PortError enum with InUse and NoAvailablePort variants
  • Added check_ports_available() function for multi-port checking
  • Updated RunResult and StartResult to include resolved_ports
  • Added listeners crate for network monitoring
  • Updated all struct definitions to use Vec for port field

Testing: Builds cleanly, passes clippy, all tests pass

Co-Authored by Kimi K2.5 and Gemini 3.0 Pro Preview

This commit implements port management features for daemons:

COMPLETED:
- Port field changed from Option<u16> to Vec<u16> to support multiple ports
- Port conflict detection before daemon startup with process identification
- Auto-bump feature that increments all ports by same offset to maintain spacing
- Environment variables PORT, PORT0, PORT1, etc. injected for daemon use
- Port display in daemon details view (press 'i' on daemon)
- User-visible output when ports are bumped during startup
- Error messages show which process is using conflicting port
- CLI flags --expected-port and --auto-bump-port for ad-hoc daemons
- Network view (press 'p' in TUI) to see all listening processes
- Sorted network view by PID with pagination support
- Port overlap highlighting in network view (red text for daemon ports)
- Integration tests and BATS tests for port management
- Docs daemon configured in pitchfork.toml with port=5173 and auto_bump_port=true

KNOWN LIMITATIONS:
- Resolved ports are returned but CLI only displays them, doesn't track changes
- Port bumping logs go to log file only, not always visible in CLI
- Multiple port support works but only tested with single ports
- Process termination feature removed from network view (safety)
- Process details view removed (use daemon details instead)

TECHNICAL CHANGES:
- Added PortError enum with InUse and NoAvailablePort variants
- Added check_ports_available() function for multi-port checking
- Updated RunResult and StartResult to include resolved_ports
- Added listeners crate for network monitoring
- Updated all struct definitions to use Vec<u16> for port field

Testing: Builds cleanly, passes clippy, all tests pass
@gemini-code-assist
Copy link

Summary of Changes

Hello @benjaminwestern, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the daemon management system by introducing comprehensive port handling capabilities. It allows daemons to specify multiple ports, intelligently detects and resolves port conflicts through an auto-bumping mechanism, and provides a dedicated TUI view for monitoring network listeners. These changes improve the reliability of daemon startup, offer greater flexibility in port assignment, and provide users with better visibility and control over network resource usage.

Highlights

  • Multi-Port Support: Daemons can now be configured with multiple expected ports, changing the port field from a single Option<u16> to a Vec<u16> across relevant structures and configurations.
  • Port Conflict Detection & Auto-Bump: Implemented logic to detect if specified ports are already in use before a daemon starts. If auto_bump_port is enabled, the system will automatically increment all configured ports by the same offset to find an available set, maintaining relative spacing. Detailed error messages are provided when conflicts occur without auto-bumping.
  • TUI Network View: A new 'Network' view (accessible via 'p' in the TUI) has been added, displaying all listening processes, their PIDs, protocols, IP addresses, and ports. This view includes search/filter capabilities, pagination, and highlights ports that overlap with configured daemon ports.
  • Environment Variable Injection: Resolved ports are now injected into the daemon's environment as PORT (for the first port) and PORT0, PORT1, etc., allowing daemons to dynamically adapt to assigned ports.
  • CLI & Configuration Updates: New CLI flags --expected-port and --auto-bump-port are available for ad-hoc daemons. The pitchfork.toml configuration now supports port = [...] and auto_bump_port = true for persistent daemon configurations.
  • Enhanced Error Handling: A new PortError enum has been introduced to provide specific error types for port-related issues, such as InUse (with process details) and NoAvailablePort.
Changelog
  • Cargo.lock
    • Added byteorder and listeners dependencies.
    • Updated windows and related windows-* dependencies to newer versions.
  • Cargo.toml
    • Added listeners dependency.
  • mise.lock
    • Updated usage tool version to 2.18.1 and refreshed associated checksums.
  • pitchfork.toml
    • Added example configuration for docs daemon including port and auto_bump_port.
  • src/cli/config/add.rs
    • Added port: Vec<u16> and auto_bump_port: bool fields to the Add command for configuring daemons.
  • src/cli/restart.rs
    • Modified the result.started tuple to include _resolved_ports for consistency.
  • src/cli/run.rs
    • Initialized expected_port and auto_bump_port fields for ad-hoc daemon RunOptions.
  • src/cli/start.rs
    • Added expected_port: Vec<u16> and auto_bump_port: bool CLI flags to the Start command.
    • Implemented logic to display resolved ports in the console output upon successful daemon startup.
  • src/daemon.rs
    • Updated Daemon and RunOptions structs to use Vec<u16> for the port field instead of Option<u16>.
    • Added auto_bump_port: bool field to Daemon and RunOptions structs.
  • src/daemon_list.rs
    • Initialized new port and auto_bump_port fields when building daemon lists.
  • src/deps.rs
    • Initialized new port and auto_bump_port fields in test daemon configurations.
  • src/error.rs
    • Introduced PortError enum with InUse and NoAvailablePort variants for specific port-related error reporting.
  • src/ipc/batch.rs
    • Updated RunResult and StartResult structs to include resolved_ports: Vec<u16>.
    • Added expected_port: Vec<u16> and auto_bump_port: bool to StartOptions.
    • Modified build_run_options to correctly merge expected_port and auto_bump_port from CLI and daemon configuration.
    • Adjusted return types and logic of spawn_start_task and spawn_adhoc_start_task to propagate resolved_ports.
  • src/ipc/client.rs
    • Updated RunResult creation to include resolved_ports from daemon status or an empty vector.
  • src/pitchfork_toml.rs
    • Added port: Vec<u16> and auto_bump_port: bool fields to the PitchforkTomlDaemon struct for configuration parsing.
  • src/supervisor/autostop.rs
    • Updated UpsertDaemonOpts creation to include port and auto_bump_port.
  • src/supervisor/lifecycle.rs
    • Imported PortError and std::net::TcpListener.
    • Implemented check_ports_available function to verify port availability, handle auto-bumping, and identify conflicting processes using lsof on Unix-like systems.
    • Injected PORT, PORT0, PORT1, etc., environment variables into daemon processes based on resolved ports.
    • Updated UpsertDaemonOpts creation to include port and auto_bump_port.
  • src/supervisor/retry.rs
    • Updated UpsertDaemonOpts creation to include port and auto_bump_port.
  • src/supervisor/state.rs
    • Added port: Vec<u16> and auto_bump_port: Option<bool> fields to UpsertDaemonOpts.
    • Updated default and merge logic for UpsertDaemonOpts to handle new port-related fields.
  • src/supervisor/watchers.rs
    • Updated UpsertDaemonOpts creation to include port and auto_bump_port.
  • src/tui/app.rs
    • Imported listeners::Listener for network monitoring.
    • Added Network variant to the View enum.
    • Introduced state fields for the network view: network_listeners, network_search_query, network_search_active, network_selected, network_scroll_offset, and network_selected_pid.
    • Implemented refresh_network to fetch and update network listener data.
    • Added filtered_network_listeners, toggle_network_search, and clear_network_search methods for the network view.
  • src/tui/event.rs
    • Added handle_network_event and handle_network_search_input functions to manage user interactions in the network view.
    • Mapped 'p' key to switch to the new Network view from the dashboard.
  • src/tui/mod.rs
    • Added logic to automatically refresh network data when the Network view is active.
  • src/tui/ui.rs
    • Imported listeners::Listener for rendering network data.
    • Added draw_network, draw_network_search_bar, and draw_network_table functions to render the network view.
    • Updated draw_main to include the Network view rendering.
    • Modified footer text to include keybindings for the new Network view.
    • Updated the help overlay to document network view keybindings.
    • Enhanced draw_details_overlay to display resolved daemon ports.
  • test/port.bats
    • Added BATS tests for config add with port and auto_bump_port.
    • Included tests for start command with --expected-port and --auto-bump-port flags.
    • Added tests to verify port conflict failure without auto-bump and success with auto-bump.
    • Tested correct injection of the PORT environment variable.
  • tests/test_e2e_port.rs
    • Added end-to-end Rust tests for port conflict detection, ensuring failure when auto_bump_port is disabled.
    • Included tests for successful port auto-bumping when enabled.
    • Verified correct injection of the PORT environment variable into daemon processes.
    • Tested the functionality of CLI flags --expected-port and --auto-bump-port.
    • Added a test for ready_port synchronization with resolved ports.
  • tests/test_pitchfork_toml.rs
    • Initialized new port and auto_bump_port fields in test PitchforkTomlDaemon instances.
Activity
  • The pull request was authored by benjaminwestern and co-authored by Kimi K2.5 and Gemini 3.0 Pro Preview.
  • The PR description provides a detailed breakdown of completed features, known limitations, and technical changes, indicating a thorough self-review and planning phase.
  • Extensive testing has been performed, including integration tests and BATS tests, covering various scenarios for port management, conflict detection, and auto-bumping.
  • The docs daemon in pitchfork.toml was configured with port=5173 and auto_bump_port=true as an example of the new features.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a significant and well-implemented feature for port management, including conflict detection and an auto-bump mechanism. The changes are comprehensive, touching the CLI, supervisor logic, TUI, and adding extensive tests. The addition of a network view in the TUI is a great enhancement for visibility.

I've identified a few areas for improvement, mainly in the supervisor's port checking logic. One key suggestion is to use the existing listeners crate for process discovery instead of shelling out to lsof, which would make the implementation more robust and portable. I also have some suggestions regarding constants and error reporting accuracy.

Overall, this is a solid contribution that greatly improves the daemon management capabilities.

Comment on lines +867 to +907
#[cfg(unix)]
fn get_process_using_port(port: u16) -> Option<(u32, String)> {
use std::process::Command;

// Use lsof to find the process using the port
// lsof -i :port -n -P -t returns just the PID
let output = Command::new("lsof")
.args(["-i", &format!(":{port}"), "-n", "-P", "-t"])
.output()
.ok()?;

if !output.status.success() {
return None;
}

let pid_str = String::from_utf8_lossy(&output.stdout);
let pid: u32 = pid_str.trim().parse().ok()?;

// Get the process name
let ps_output = Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm="])
.output()
.ok()?;

let process_name = if ps_output.status.success() {
String::from_utf8_lossy(&ps_output.stdout)
.trim()
.to_string()
} else {
"unknown".to_string()
};

Some((pid, process_name))
}

#[cfg(not(unix))]
fn get_process_using_port(_port: u16) -> Option<(u32, String)> {
// Windows implementation would use different APIs
// For now, return None to fall back to generic error
None
}

Choose a reason for hiding this comment

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

high

The get_process_using_port function shells out to lsof and ps on Unix systems. This approach has a few drawbacks:

  • It creates a dependency on external command-line tools that may not be installed.
  • It's less portable and can be fragile if the output format of these tools changes.

The listeners crate, which is already a dependency in this project, can provide this information in a more robust and platform-independent way. You can use listeners::get_all() to get a list of all listening sockets and their associated processes, and then find the process using the conflicting port.

This change would replace both the unix and not(unix) implementations of get_process_using_port with a single, more reliable one. You'll also need to add use listeners; at the top of the file.

fn get_process_using_port(port: u16) -> Option<(u32, String)> {
    listeners::get_all()
        .ok()?
        .into_iter()
        .find(|listener| listener.socket.port() == port)
        .map(|listener| (listener.process.pid, listener.process.name))
}

return Ok(Vec::new());
}

const MAX_BUMP_ATTEMPTS: u32 = 3;

Choose a reason for hiding this comment

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

medium

The maximum number of auto-bump attempts is set to 3. This might be too low in a busy environment where several consecutive ports are taken. Consider increasing this to a higher value, for example, 10, to make the auto-bump feature more robust.

Suggested change
const MAX_BUMP_ATTEMPTS: u32 = 3;
const MAX_BUMP_ATTEMPTS: u32 = 10;

Comment on lines +857 to +861
Err(PortError::NoAvailablePort {
start_port: expected_ports[0],
attempts: MAX_BUMP_ATTEMPTS,
}
.into())

Choose a reason for hiding this comment

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

medium

The number of attempts reported in the NoAvailablePort error is MAX_BUMP_ATTEMPTS. However, the loop runs from 0..=MAX_BUMP_ATTEMPTS, which is MAX_BUMP_ATTEMPTS + 1 total attempts (including the initial one). The error message should reflect the total number of attempts to avoid confusion.

Suggested change
Err(PortError::NoAvailablePort {
start_port: expected_ports[0],
attempts: MAX_BUMP_ATTEMPTS,
}
.into())
Err(PortError::NoAvailablePort {
start_port: expected_ports[0],
attempts: MAX_BUMP_ATTEMPTS + 1,
}
.into())

Comment on lines +799 to +802
match TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => {
drop(listener);
}

Choose a reason for hiding this comment

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

medium

The current check for port availability doesn't correctly handle port 0. In networking, binding to port 0 is a special case that asks the OS for any available ephemeral port. This implementation binds to port 0 and immediately drops the listener, which succeeds but doesn't reserve a port or determine which port was assigned. The daemon would then receive PORT=0 and might fail or bind to a different ephemeral port than intended, and readiness checks on port 0 would fail.

If you intend to support dynamic port assignment, you should handle port 0 by binding to it, retrieving the assigned port number from the TcpListener, and using that port number going forward. This would need to be coordinated with the other ports being checked.

@benjaminwestern
Copy link
Contributor Author

@jdx before I complete this type of feature, I want to make sure it aligns with what you want the tool to own and operate, I know this type of behaviour is super useful for MY workflows so thought I would take a swing at the architecture and process flow.

My goal here is as follows:

  • to be able to get the daemon runner to self-bump ports (if enabled) (unfinished)
  • notify to users what ports conflict with the daemons you have listed in your closest pitchfork.toml
  • allow users to view active PIDs and their associated ports easily in the same window (I would want to merge the 'process view' in the network view to see where each PID was launched from, what resources its using etc)

@benjaminwestern
Copy link
Contributor Author

Attempting to address this Discussion point:
#96

benjaminwestern and others added 4 commits February 27, 2026 07:55
… drift

- Rename TOML config field from port to expected_port for CLI consistency
- Fix port array syntax in tests (expected_port = [port] not expected_port = port)
- Make port checking async with spawn_blocking to avoid blocking runtime
- Add structured IPC error responses for PortConflict and NoAvailablePort
- Add original_port tracking to distinguish requested vs resolved ports
- Update all references across codebase to use expected_port
The tests expected 'port = 8080' but the actual TOML field is
'expected_port = [8080]'. Updated assertions to match the
serialized output.
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a significant and well-implemented feature for port management, including conflict detection and auto-bumping. The implementation is thorough, covering CLI flags, configuration, state management, and even a new TUI view for network listeners. The addition of tests for these new features is also commendable. My review includes a few suggestions for improving robustness and consistency, particularly around port checking and the behavior of restarts/retries.


// Use spawn_blocking to avoid blocking the async runtime during TCP bind checks
let port_check =
tokio::task::spawn_blocking(move || match TcpListener::bind(("127.0.0.1", port)) {

Choose a reason for hiding this comment

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

high

The port availability check binds to 127.0.0.1. This may not correctly detect a port conflict if a process is listening on 0.0.0.0 (all interfaces) or another specific network interface. A process can bind to 0.0.0.0:8080 and another to 127.0.0.1:8080 simultaneously on some systems, which would lead to this check passing incorrectly. To make the check more robust, you should bind to 0.0.0.0.

Suggested change
tokio::task::spawn_blocking(move || match TcpListener::bind(("127.0.0.1", port)) {
tokio::task::spawn_blocking(move || match TcpListener::bind(("0.0.0.0", port)) {

src/cli/run.rs Outdated
Comment on lines +82 to +83
expected_port: None,
auto_bump_port: false,

Choose a reason for hiding this comment

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

medium

The run command, which is used for ad-hoc daemons, doesn't seem to support the new port management features (--expected-port, --auto-bump-port). The StartOptions are hardcoded with expected_port: None and auto_bump_port: false. To be consistent with the start command and provide full functionality for ad-hoc daemons, consider adding these flags to the Run struct and passing them through.

src/daemon.rs Outdated
pub ready_http: Option<String>,
pub ready_port: Option<u16>,
pub ready_cmd: Option<String>,
pub port: Vec<u16>,

Choose a reason for hiding this comment

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

medium

The field port: Vec<u16> in RunOptions is a bit ambiguous. It represents the expected ports for the daemon to use. However, in the Daemon struct, port represents the resolved ports after auto-bumping. For better clarity and consistency across the codebase (e.g., with StartOptions), consider renaming this field to expected_port. This change would require updates in other parts of the code where RunOptions is constructed or used, but it would improve overall code clarity.

Suggested change
pub port: Vec<u16>,
pub expected_port: Vec<u16>,

Comment on lines +97 to +98
port: daemon.port,
auto_bump_port: false,

Choose a reason for hiding this comment

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

medium

When retrying a failed daemon, the RunOptions are created using port: daemon.port, which is the previously resolved port. This could cause issues if that port is no longer available. It would be more robust to use the daemon.original_port and let the port resolution logic (including auto-bumping) run again, ensuring consistent behavior with an initial start. Also, auto_bump_port is hardcoded to false, which prevents finding a new port if the previously resolved one is now taken. It should use daemon.auto_bump_port.

Suggested change
port: daemon.port,
auto_bump_port: false,
port: daemon.original_port.clone(),
auto_bump_port: daemon.auto_bump_port,

ready_http: daemon.ready_http.clone(),
ready_port: daemon.ready_port,
ready_cmd: daemon.ready_cmd.clone(),
port: daemon.port,

Choose a reason for hiding this comment

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

medium

When restarting a daemon due to a cron trigger, the RunOptions are created using port: daemon.port, which is the previously resolved port. It would be more robust and consistent to use the daemon.original_port and let the port resolution logic (including auto-bumping) run again. This ensures the behavior is the same as a fresh start, in case the port availability has changed.

Suggested change
port: daemon.port,
port: daemon.original_port.clone(),

ready_http: daemon.ready_http.clone(),
ready_port: daemon.ready_port,
ready_cmd: daemon.ready_cmd.clone(),
port: daemon.port,

Choose a reason for hiding this comment

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

medium

When restarting a watched daemon, it's using the previously resolved port (daemon.port). For consistency and robustness, it's better to use daemon.original_port and let the full port resolution logic run again. This ensures the behavior is the same as a fresh start.

Suggested change
port: daemon.port,
port: daemon.original_port.clone(),

benjaminwestern and others added 2 commits February 27, 2026 11:13
- Change port check from 127.0.0.1 to 0.0.0.0 for robust conflict detection
- Add --expected-port and --auto-bump-port flags to run command
- Rename RunOptions.port to expected_port for clarity
- Use original_port and auto_bump_port in retry logic
- Use original_port for cron and file watch restarts
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces significant and well-implemented features for port management, including conflict detection and an auto-bump mechanism. The changes are extensive, touching CLI, configuration, supervisor logic, and the TUI. The inclusion of both BATS and e2e tests is excellent. I've identified a few areas for improvement, primarily concerning correctness in edge cases and performance. Specifically, there's a potential bug in the port bumping logic when dealing with high port numbers and a blocking I/O call in an async context that could impact performance. I've also suggested some minor refactorings to improve code clarity and idiomatic Rust usage.

Comment on lines +812 to +815
let candidate_ports: Vec<u16> = expected_ports
.iter()
.map(|&p| p.saturating_add(bump_offset as u16))
.collect();

Choose a reason for hiding this comment

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

high

Using saturating_add here is problematic. If an expected_port is near u16::MAX, it will get stuck at 65535 on subsequent bumps, repeatedly checking a port that might be taken. More importantly, the overflow check on line 885 is currently dead code because saturating_add never wraps to 0, and the comment on line 884 is also incorrect.

Using wrapping_add will make the overflow check work as intended and correctly handle bumping ports near the top of the u16 range. You should also update the comment on line 884 to mention wrapping_add.

Suggested change
let candidate_ports: Vec<u16> = expected_ports
.iter()
.map(|&p| p.saturating_add(bump_offset as u16))
.collect();
let candidate_ports: Vec<u16> = expected_ports
.iter()
.map(|&p| p.wrapping_add(bump_offset as u16))
.collect();

Comment on lines +905 to +911
fn get_process_using_port(port: u16) -> Option<(u32, String)> {
listeners::get_all()
.ok()?
.into_iter()
.find(|listener| listener.socket.port() == port)
.map(|listener| (listener.process.pid, listener.process.name))
}

Choose a reason for hiding this comment

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

high

The listeners::get_all() call inside this function performs blocking I/O (e.g., reading from /proc or running lsof). Since this function is called from an async context in check_ports_available, it should be wrapped in tokio::task::spawn_blocking to avoid blocking the async runtime. This can cause performance issues and stalls.

You'll need to make this function async and then .await the call to it in check_ports_available on line 863.

Suggested change
fn get_process_using_port(port: u16) -> Option<(u32, String)> {
listeners::get_all()
.ok()?
.into_iter()
.find(|listener| listener.socket.port() == port)
.map(|listener| (listener.process.pid, listener.process.name))
}
async fn get_process_using_port(port: u16) -> Option<(u32, String)> {
tokio::task::spawn_blocking(move || {
listeners::get_all()
.ok()?
.into_iter()
.find(|listener| listener.socket.port() == port)
.map(|listener| (listener.process.pid, listener.process.name))
})
.await
.ok()
.flatten()
}

src/cli/run.rs Outdated
Comment on lines +88 to +92
expected_port: if self.expected_port.is_empty() {
None
} else {
Some(self.expected_port.clone())
},

Choose a reason for hiding this comment

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

medium

This if/else block can be expressed more concisely using bool::then_some, which is idiomatic in modern Rust.

            expected_port: (!self.expected_port.is_empty()).then_some(self.expected_port.clone()),

src/cli/start.rs Outdated
Comment on lines +93 to +97
expected_port: if self.expected_port.is_empty() {
None
} else {
Some(self.expected_port.clone())
},

Choose a reason for hiding this comment

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

medium

This if/else block can be expressed more concisely using bool::then_some, which is idiomatic in modern Rust.

            expected_port: (!self.expected_port.is_empty()).then_some(self.expected_port.clone()),

Comment on lines +886 to +889
return Err(PortError::NoAvailablePort {
start_port: expected_ports[0],
attempts: MAX_BUMP_ATTEMPTS + 1,
}

Choose a reason for hiding this comment

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

medium

The attempts count here is incorrect. It should reflect the number of attempts made so far, which is bump_offset + 1, not the maximum number of attempts.

Suggested change
return Err(PortError::NoAvailablePort {
start_port: expected_ports[0],
attempts: MAX_BUMP_ATTEMPTS + 1,
}
return Err(PortError::NoAvailablePort {
start_port: expected_ports[0],
attempts: bump_offset + 1,
}

src/tui/ui.rs Outdated
Comment on lines +1507 to +1515
let ports_to_show = if let Some(d) = daemon {
if !d.port.is_empty() {
d.port.clone()
} else {
cfg.expected_port.clone()
}
} else {
cfg.expected_port.clone()
};

Choose a reason for hiding this comment

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

medium

This logic for determining which ports to display can be simplified using Option combinators for better readability.

        let ports_to_show = daemon
            .filter(|d| !d.port.is_empty())
            .map(|d| d.port.clone())
            .unwrap_or_else(|| cfg.expected_port.clone());

- Change saturating_add to wrapping_add for correct port bumping near u16::MAX
- Make get_process_using_port async with spawn_blocking to avoid blocking
- Fix attempts count in NoAvailablePort error (bump_offset + 1)
- Update comment to reference wrapping_add
- Simplify Option handling with bool::then_some and combinators
- Update tests to bind to 0.0.0.0 for proper port conflict detection
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This is a great feature addition that significantly improves daemon management by handling port conflicts automatically. The implementation is thorough, covering CLI flags, configuration options, core supervisor logic, and even a new TUI view for network inspection. The addition of comprehensive tests (both unit and e2e) is excellent.

I've found a couple of minor areas for improvement regarding CLI consistency and a small performance optimization. Overall, this is a high-quality contribution.

Comment on lines +75 to +77
/// Ports the daemon is expected to bind to (can be specified multiple times or comma-separated)
#[clap(long, value_delimiter = ',')]
port: Vec<u16>,

Choose a reason for hiding this comment

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

medium

For consistency with the run and start commands, consider renaming this flag to --expected-port and the field to expected_port. The run and start commands use --expected-port for this purpose, while using --port for the readiness check port. This change would make the CLI more consistent and less confusing for users.

If you make this change, you'll also need to update line 215 to use self.expected_port.

Suggested change
/// Ports the daemon is expected to bind to (can be specified multiple times or comma-separated)
#[clap(long, value_delimiter = ',')]
port: Vec<u16>,
/// Ports the daemon is expected to bind to (can be specified multiple times or comma-separated)
#[clap(long = "expected-port", value_delimiter = ',')]
expected_port: Vec<u16>,

src/ipc/batch.rs Outdated
Comment on lines +101 to +104
expected_port: opts
.expected_port
.clone()
.unwrap_or(daemon_config.expected_port.clone()),

Choose a reason for hiding this comment

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

medium

Using unwrap_or_else would be slightly more efficient here, as it avoids cloning daemon_config.expected_port if opts.expected_port is Some.

Suggested change
expected_port: opts
.expected_port
.clone()
.unwrap_or(daemon_config.expected_port.clone()),
expected_port: opts
.expected_port
.clone()
.unwrap_or_else(|| daemon_config.expected_port.clone()),

benjaminwestern and others added 3 commits February 27, 2026 15:57
- Rename --port flag to --expected-port in config add command
- Update field name from port to expected_port
- Use unwrap_or_else for slight performance optimization
- Change --port to --expected-port in config add tests
- Fix port binding to use 0.0.0.0 to match supervisor checks
- Add proper wait logic for port blocker processes
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a comprehensive port management system, including conflict detection, auto-bumping for available ports, and a new network view in the TUI. The implementation is robust, with new error types for port conflicts, updated IPC messages, and thorough integration tests. The changes are well-documented across the CLI, configuration schema, and usage files. I have one suggestion to improve consistency in the user feedback for the restart command.

Comment on lines 86 to 89
for (id, start_time, _resolved_ports) in &result.started {
if let Err(e) = print_startup_logs(id, *start_time) {
debug!("Failed to print startup logs for {id}: {e}");
}

Choose a reason for hiding this comment

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

medium

For consistency with the start command, it would be beneficial to also print the resolved ports here. This provides useful feedback to the user, especially when port auto-bumping occurs during the restart.

            for (id, start_time, resolved_ports) in &result.started {
                if let Err(e) = print_startup_logs(id, *start_time) {
                    debug!("Failed to print startup logs for {id}: {e}");
                }
                if !resolved_ports.is_empty() {
                    let port_str = resolved_ports
                        .iter()
                        .map(|p| p.to_string())
                        .collect::<Vec<_>>()
                        .join(", ");
                    println!("Daemon '{id}' restarted on port(s): {port_str}");
                }
            }

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This is an excellent and comprehensive pull request that adds crucial port management features. The implementation of port conflict detection with process identification and the auto-bump mechanism will significantly improve the user experience when running daemons that require network ports.

The changes are well-structured across the CLI, supervisor, and TUI. I particularly appreciate the new 'Network' view in the TUI, which is a great diagnostic tool. The testing is also very thorough, with both BATS and end-to-end Rust tests covering the new functionality well.

My review includes a few suggestions for improving maintainability and consistency, such as using a dedicated struct for complex return types and making the number of port bump attempts configurable.

One inherent limitation of this port-checking approach is the possibility of a race condition, where a port becomes occupied between the check and the daemon startup. This is a well-known problem and the current best-effort implementation is reasonable.

Comment on lines +91 to +95
let port_str = resolved_ports
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");

Choose a reason for hiding this comment

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

medium

This can be made more concise and efficient by using Itertools::join, which is already a dependency in the project. This avoids creating an intermediate Vec.

You'll need to add use itertools::Itertools; to the top of the file.

Suggested change
let port_str = resolved_ports
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");
let port_str = resolved_ports.iter().map(ToString::to_string).join(", ");

src/cli/start.rs Outdated
Comment on lines +107 to +111
let port_str = resolved_ports
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");

Choose a reason for hiding this comment

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

medium

This can be made more concise and efficient by using Itertools::join, which is already a dependency in the project. This avoids creating an intermediate Vec.

You'll need to add use itertools::Itertools; to the top of the file.

Suggested change
let port_str = resolved_ports
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");
let port_str = resolved_ports.iter().map(ToString::to_string).join(", ");

src/ipc/batch.rs Outdated
Comment on lines +362 to +368
fn spawn_start_task(
ipc: Arc<Self>,
id: String,
daemon_config: &PitchforkTomlDaemon,
is_explicitly_requested: bool,
opts: &StartOptions,
) -> tokio::task::JoinHandle<(String, Option<DateTime<Local>>, Option<i32>)> {
) -> tokio::task::JoinHandle<(String, Option<DateTime<Local>>, Option<i32>, Vec<u16>)> {

Choose a reason for hiding this comment

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

medium

The return type tuple (String, Option<DateTime<Local>>, Option<i32>, Vec<u16>) is becoming complex, as indicated by the #[allow(clippy::type_complexity)]. To improve readability and maintainability, consider defining a dedicated struct for this result. This would make the code easier to understand and modify in the future.

For example:

struct SpawnTaskResult {
    id: String,
    start_time: Option<DateTime<Local>>,
    exit_code: Option<i32>,
    resolved_ports: Vec<u16>,
}

You could then change the return type to tokio::task::JoinHandle<SpawnTaskResult>.

return Ok(Vec::new());
}

const MAX_BUMP_ATTEMPTS: u32 = 10;

Choose a reason for hiding this comment

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

medium

The maximum number of auto-bump attempts is hardcoded. Consider making this configurable in pitchfork.toml to provide more flexibility for users who might need more or fewer attempts depending on their environment (e.g., a port_bump_attempts field in the daemon config).

benjaminwestern and others added 2 commits March 2, 2026 15:27
- Use itertools::Itertools::join for concise port string formatting in CLI
- Create SpawnTaskResult struct to replace complex tuple return types
- Make port_bump_attempts configurable in pitchfork.toml (default: 10)
- Remove clippy::type_complexity allow annotations
@benjaminwestern benjaminwestern marked this pull request as ready for review March 2, 2026 04:32
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

37 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

Move the overflow check inside the all_available block to ensure it
runs before returning Ok when ports wrap to 0 due to wrapping_add.
Previously the check was unreachable because ports that wrapped to 0
were skipped by the continue statement, leaving all_available = true.
Copy link
Owner

@jdx jdx left a comment

Choose a reason for hiding this comment

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

Code Review

This is a well-structured feature PR that adds port conflict detection, auto-bumping, and a network view to the TUI. The overall architecture is sound — detecting conflicts before spawn, injecting PORT/PORT0/PORT1 env vars, and propagating resolved ports through the IPC layer back to the CLI. Good test coverage with both BATS and e2e Rust tests.

That said, I have several concerns ranging from a correctness bug to design/naming issues:

Bug: wrapping_add overflow in check_ports_available

src/supervisor/lifecycle.rs — The use of wrapping_add is dangerous:

let candidate_ports: Vec<u16> = expected_ports
    .iter()
    .map(|&p| p.wrapping_add(bump_offset as u16))
    .collect();

If a port is 65530 and offset is 10, this wraps to port 4 — a privileged port. The overflow check only catches wrapping to exactly 0. You should use checked_add instead and treat None as an exhaustion signal:

let candidate_ports: Result<Vec<u16>, _> = expected_ports
    .iter()
    .map(|&p| p.checked_add(bump_offset as u16).ok_or(()))
    .collect();
let candidate_ports = match candidate_ports {
    Ok(ports) => ports,
    Err(_) => break, // overflow, stop trying
};

Naming inconsistency: expected_port vs original_port vs port

The config (PitchforkTomlDaemon) uses expected_port, but the Daemon struct introduces original_port (copy of expected) and port (resolved). This is confusing — port is an extremely generic name that could mean anything. Consider resolved_port or actual_port for the resolved value, and keeping expected_port consistently in Daemon instead of renaming it to original_port.

TOCTOU race condition

The port availability check (TCP bind → drop → spawn daemon) has a time-of-check-to-time-of-use gap. Another process could grab the port between the check and the daemon actually binding. This is inherent to the approach and probably acceptable, but worth documenting with a comment in check_ports_available.

Hardcoded magic number 10 for port_bump_attempts

The default of 10 appears in at least 5 places:

  • src/cli/config/add.rs:214
  • src/ipc/batch.rs:452 and :679
  • src/supervisor/state.rs defaults
  • src/pitchfork_toml.rs default function

Extract this to a const DEFAULT_PORT_BUMP_ATTEMPTS: u32 = 10; and reference it everywhere.

VISIBLE_ROWS hardcoded in event handler

In src/tui/event.rs, VISIBLE_ROWS is hardcoded to 20, but the actual visible rows are dynamically calculated from area.height in src/tui/ui.rs. This will cause scroll desync when the terminal is resized to something other than ~20 rows.

Minor issues

  • PORT and PORT0 are both set to the same value when there's a single port. This is conventional but should be documented somewhere (config docs or help text).
  • auto_bump_port is bool in Daemon/RunOptions but Option<bool> in UpsertDaemonOpts — the inconsistency is handled correctly but adds cognitive overhead.
  • get_process_using_port only queries process info on the first bump attempt (bump_offset == 0). For subsequent failed attempts, the user gets NoAvailablePort with no process info, which is correct but could be more informative.

What's good

  • Clean error types with PortError enum and good diagnostic messages
  • Proper use of spawn_blocking for TCP bind checks and listener queries
  • The network TUI view is a nice addition with search, scroll, and port overlap highlighting
  • Environment variable injection pattern (PORT, PORT0, PORT1) follows common conventions
  • IPC response variants for port errors are well-designed

This comment was generated by Claude Code.

Repository owner deleted a comment from greptile-apps bot Mar 2, 2026
@jdx
Copy link
Owner

jdx commented Mar 2, 2026

@greptileai

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 2, 2026

Greptile Summary

This PR implements comprehensive port management features for pitchfork daemons:

Core Changes:

  • Changed port field from Option<u16> to Vec<u16> throughout codebase to support multiple ports per daemon
  • Added port conflict detection before daemon startup with process identification using the listeners crate
  • Implemented auto-bump feature that increments all ports by the same offset to maintain relative spacing
  • Environment variables PORT, PORT0, PORT1, etc. are now injected for daemon processes to consume
  • Added CLI flags --expected-port and --auto-bump-port to run, start, and config add commands
  • Added network view in TUI (press 'p') showing all listening processes with port overlap highlighting

Architecture:
The port management logic in src/supervisor/lifecycle.rs checks port availability before spawning daemons. When auto_bump_port is enabled, it iterates through port offsets (0 to max_attempts) using wrapping_add to handle overflow. The ready_port adjustment logic correctly applies the same bump offset when it matches an expected port. Port conflict errors include detailed process information (name, PID) via the PortError enum.

Integration:
All daemon-related structures (Daemon, RunOptions, UpsertDaemonOpts) now track both expected_port (configured) and resolved_port (after auto-bump). IPC responses include new PortConflict and NoAvailablePort variants. The TUI network view uses spawn_blocking to avoid blocking the async runtime during listener enumeration.

Testing:
Comprehensive test coverage with E2E tests (tests/test_e2e_port.rs) and BATS tests (test/port.bats) covering conflict detection, auto-bump scenarios, and CLI flag combinations.

Confidence Score: 4/5

  • This PR is safe to merge with minimal risk
  • Score reflects well-tested implementation with proper error handling. Previous review comments have been addressed in commit acddb2a. Core logic is sound with acknowledged TOCTOU race condition inherent to the approach. Edge cases with port 0 and ready_port are documented in previous threads.
  • src/supervisor/lifecycle.rs warrants final review of edge cases documented in previous threads (port 0 handling, ready_port adjustment when port 0 is used)

Important Files Changed

Filename Overview
src/supervisor/lifecycle.rs Added port conflict detection, auto-bump logic, and environment variable injection (PORT, PORT0, PORT1, etc.); ready_port adjustment logic handles bump offset correctly
src/daemon.rs Changed port fields from Option<u16> to Vec<u16> for multiple port support; added expected_port, resolved_port, auto_bump_port, and port_bump_attempts fields
src/error.rs Added PortError enum with InUse and NoAvailablePort variants for structured error reporting with process details
src/ipc/batch.rs Updated RunResult and StartResult to include resolved_ports; added port configuration to StartOptions and build_run_options
src/cli/run.rs Added --expected-port and --auto-bump-port flags with value_delimiter for comma-separated ports (consistent with start.rs and config/add.rs)
src/cli/start.rs Added --expected-port and --auto-bump-port flags; displays resolved ports on successful daemon start
src/tui/app.rs Added network view state management with listener data, search/filter functionality, and selection tracking by PID
src/tui/ui.rs Added network view rendering with port overlap highlighting (red text for daemon ports), sorted by PID with pagination
tests/test_e2e_port.rs Added comprehensive E2E tests for port conflict detection and auto-bump functionality

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    Start[Daemon Start Request] --> CheckPorts{expected_port<br/>configured?}
    CheckPorts -->|No| SpawnDaemon[Spawn Daemon Process]
    CheckPorts -->|Yes| CheckAvail[check_ports_available]
    
    CheckAvail --> Loop{For each<br/>bump_offset<br/>0..max_attempts}
    Loop --> TryPorts[Try candidate ports<br/>with offset]
    TryPorts --> BindTest{All ports<br/>available?}
    
    BindTest -->|Yes| CheckOverflow{Wrapped to<br/>port 0?}
    CheckOverflow -->|Yes| ErrNoPort[Error: NoAvailablePort]
    CheckOverflow -->|No| AdjustReady[Adjust ready_port<br/>if needed]
    AdjustReady --> InjectEnv[Inject PORT env vars]
    InjectEnv --> SpawnDaemon
    
    BindTest -->|No, First Attempt| GetProc[Get process using port]
    GetProc --> AutoBump{auto_bump_port<br/>enabled?}
    AutoBump -->|No| ErrConflict[Error: PortConflict<br/>with process info]
    AutoBump -->|Yes| Loop
    
    BindTest -->|No, Later Attempt| Loop
    Loop -->|Max attempts| ErrNoPort
    
    SpawnDaemon --> UpdateState[Update daemon state<br/>with resolved_ports]
    UpdateState --> IPCResponse[Send IPC Response]
    
    ErrConflict --> IPCErr[IPC: PortConflict]
    ErrNoPort --> IPCErr2[IPC: NoAvailablePort]
Loading

Last reviewed commit: 694345d

…TTEMPTS env var

Bug Fixes:
- Fix wrapping_add overflow in check_ports_available using checked_add
- Prevent port wrapping to privileged ports (0) when bumping

Refactoring:
- Rename port fields for consistency:
  - original_port -> expected_port (config ports before auto-bump)
  - port -> resolved_port (actual ports after auto-bump)

Code Quality:
- Extract DEFAULT_PORT_BUMP_ATTEMPTS to env var PITCHFORK_PORT_BUMP_ATTEMPTS
- Replace hardcoded 10s with environment variable
- Fix VISIBLE_ROWS hardcoding in TUI event handler
- Add TOCTOU race condition documentation
- Document PORT/PORT0 behavior for single-port daemons

Tests:
- Add test_port_bump_attempts_env_var for env var coverage

All changes follow existing architectural patterns and maintain backward compatibility.
@jdx jdx enabled auto-merge (squash) March 2, 2026 23:50
… refactor

- Fix doctest to use try_new() instead of new() for DaemonId
- Add missing port fields to PitchforkTomlDaemonRaw and test structs
- Update SpawnTaskResult.id type from String to DaemonId

All tests, clippy, and formatting pass.
auto-merge was automatically disabled March 3, 2026 00:22

Head branch was pushed to by a user without write access

@benjaminwestern
Copy link
Contributor Author

Hopefully I have properly merged the changes for the daemon id, please let me know if there is something I need to address.

@jdx
Copy link
Owner

jdx commented Mar 3, 2026

@greptileai

@benjaminwestern
Copy link
Contributor Author

Will address these issues today, apologies!

benjaminwestern and others added 3 commits March 4, 2026 14:44
- Use wrapping_add for port overflow handling instead of checked_add
- Add overflow check when ports wrap around to 0
- Fix ready_port adjustment when auto-bump changes the port
- Filter out port 0 from readiness checks (ephemeral port special case)
- Add value_delimiter to run.rs --expected-port flag for consistency
@socket-security
Copy link

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​listeners@​0.4.09910093100100

View full report

@benjaminwestern
Copy link
Contributor Author

@greptileai

@jdx jdx merged commit 59117c3 into jdx:main Mar 4, 2026
5 checks passed
@jdx jdx mentioned this pull request Mar 3, 2026
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.

2 participants