feat(port-management): Add port conflict detection and auto-bump support#259
feat(port-management): Add port conflict detection and auto-bump support#259
Conversation
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
Summary of ChangesHello @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
Changelog
Activity
Using Gemini Code AssistThe 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
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 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
|
There was a problem hiding this comment.
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.
src/supervisor/lifecycle.rs
Outdated
| #[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 | ||
| } |
There was a problem hiding this comment.
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))
}
src/supervisor/lifecycle.rs
Outdated
| return Ok(Vec::new()); | ||
| } | ||
|
|
||
| const MAX_BUMP_ATTEMPTS: u32 = 3; |
There was a problem hiding this comment.
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.
| const MAX_BUMP_ATTEMPTS: u32 = 3; | |
| const MAX_BUMP_ATTEMPTS: u32 = 10; |
| Err(PortError::NoAvailablePort { | ||
| start_port: expected_ports[0], | ||
| attempts: MAX_BUMP_ATTEMPTS, | ||
| } | ||
| .into()) |
There was a problem hiding this comment.
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.
| 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()) |
src/supervisor/lifecycle.rs
Outdated
| match TcpListener::bind(("127.0.0.1", port)) { | ||
| Ok(listener) => { | ||
| drop(listener); | ||
| } |
There was a problem hiding this comment.
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.
|
@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:
|
|
Attempting to address this Discussion point: |
… 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.
There was a problem hiding this comment.
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.
src/supervisor/lifecycle.rs
Outdated
|
|
||
| // 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)) { |
There was a problem hiding this comment.
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.
| 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
| expected_port: None, | ||
| auto_bump_port: false, |
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
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.
| pub port: Vec<u16>, | |
| pub expected_port: Vec<u16>, |
src/supervisor/retry.rs
Outdated
| port: daemon.port, | ||
| auto_bump_port: false, |
There was a problem hiding this comment.
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.
| port: daemon.port, | |
| auto_bump_port: false, | |
| port: daemon.original_port.clone(), | |
| auto_bump_port: daemon.auto_bump_port, |
src/supervisor/watchers.rs
Outdated
| ready_http: daemon.ready_http.clone(), | ||
| ready_port: daemon.ready_port, | ||
| ready_cmd: daemon.ready_cmd.clone(), | ||
| port: daemon.port, |
There was a problem hiding this comment.
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.
| port: daemon.port, | |
| port: daemon.original_port.clone(), |
src/supervisor/watchers.rs
Outdated
| ready_http: daemon.ready_http.clone(), | ||
| ready_port: daemon.ready_port, | ||
| ready_cmd: daemon.ready_cmd.clone(), | ||
| port: daemon.port, |
There was a problem hiding this comment.
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.
| port: daemon.port, | |
| port: daemon.original_port.clone(), |
- 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
There was a problem hiding this comment.
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.
| let candidate_ports: Vec<u16> = expected_ports | ||
| .iter() | ||
| .map(|&p| p.saturating_add(bump_offset as u16)) | ||
| .collect(); |
There was a problem hiding this comment.
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.
| 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(); |
src/supervisor/lifecycle.rs
Outdated
| 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)) | ||
| } |
There was a problem hiding this comment.
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.
| 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
| expected_port: if self.expected_port.is_empty() { | ||
| None | ||
| } else { | ||
| Some(self.expected_port.clone()) | ||
| }, |
src/cli/start.rs
Outdated
| expected_port: if self.expected_port.is_empty() { | ||
| None | ||
| } else { | ||
| Some(self.expected_port.clone()) | ||
| }, |
src/supervisor/lifecycle.rs
Outdated
| return Err(PortError::NoAvailablePort { | ||
| start_port: expected_ports[0], | ||
| attempts: MAX_BUMP_ATTEMPTS + 1, | ||
| } |
There was a problem hiding this comment.
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.
| 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
| 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() | ||
| }; |
- 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
There was a problem hiding this comment.
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.
src/cli/config/add.rs
Outdated
| /// Ports the daemon is expected to bind to (can be specified multiple times or comma-separated) | ||
| #[clap(long, value_delimiter = ',')] | ||
| port: Vec<u16>, |
There was a problem hiding this comment.
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.
| /// 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
| expected_port: opts | ||
| .expected_port | ||
| .clone() | ||
| .unwrap_or(daemon_config.expected_port.clone()), |
There was a problem hiding this comment.
Using unwrap_or_else would be slightly more efficient here, as it avoids cloning daemon_config.expected_port if opts.expected_port is Some.
| 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()), |
- 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
There was a problem hiding this comment.
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.
src/cli/restart.rs
Outdated
| 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}"); | ||
| } |
There was a problem hiding this comment.
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}");
}
}There was a problem hiding this comment.
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.
src/cli/restart.rs
Outdated
| let port_str = resolved_ports | ||
| .iter() | ||
| .map(|p| p.to_string()) | ||
| .collect::<Vec<_>>() | ||
| .join(", "); |
There was a problem hiding this comment.
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.
| 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
| let port_str = resolved_ports | ||
| .iter() | ||
| .map(|p| p.to_string()) | ||
| .collect::<Vec<_>>() | ||
| .join(", "); |
There was a problem hiding this comment.
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.
| 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
| 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>)> { |
There was a problem hiding this comment.
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>.
src/supervisor/lifecycle.rs
Outdated
| return Ok(Vec::new()); | ||
| } | ||
|
|
||
| const MAX_BUMP_ATTEMPTS: u32 = 10; |
There was a problem hiding this comment.
- 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
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.
There was a problem hiding this comment.
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:214src/ipc/batch.rs:452and:679src/supervisor/state.rsdefaultssrc/pitchfork_toml.rsdefault 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
PORTandPORT0are 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_portisboolinDaemon/RunOptionsbutOption<bool>inUpsertDaemonOpts— the inconsistency is handled correctly but adds cognitive overhead.get_process_using_portonly queries process info on the first bump attempt (bump_offset == 0). For subsequent failed attempts, the user getsNoAvailablePortwith no process info, which is correct but could be more informative.
What's good
- Clean error types with
PortErrorenum and good diagnostic messages - Proper use of
spawn_blockingfor 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.
Greptile SummaryThis PR implements comprehensive port management features for pitchfork daemons: Core Changes:
Architecture: Integration: Testing: Confidence Score: 4/5
Important Files Changed
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]
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.
… 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.
Head branch was pushed to by a user without write access
|
Hopefully I have properly merged the changes for the daemon id, please let me know if there is something I need to address. |
|
Will address these issues today, apologies! |
- 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
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
This commit implements port management features for daemons:
COMPLETED:
KNOWN LIMITATIONS:
TECHNICAL CHANGES:
Testing: Builds cleanly, passes clippy, all tests pass
Co-Authored by Kimi K2.5 and Gemini 3.0 Pro Preview