From dbf2f729025d41b173f80758f00fa359c8bc687a Mon Sep 17 00:00:00 2001 From: Shah Newaz Khan <3241700+ShahNewazKhan@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:02:44 -0700 Subject: [PATCH 1/3] fix: detect IPv6/dual-stack listeners in is_port_available is_port_available() only probed 127.0.0.1, so a port held by an IPv6 or dual-stack listener (e.g. a Docker-published [::]:5432) looked free. The auto-allocation in start() then never triggered and pg0 silently bound the same port, colliding with the existing server (the connecting client then hits the wrong PostgreSQL). Treat a port as taken if binding either IPv4 or IPv6 loopback returns AddrInUse; ignore other bind errors (e.g. IPv6 disabled) so they don't make every port look unavailable. Refs vectorize-io/hindsight#2379 Co-Authored-By: Claude Opus 4.8 --- src/main.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 940b462..98a0ffc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -322,9 +322,23 @@ fn expand_path(path: &str) -> PathBuf { PathBuf::from(path) } -/// Check if a port is available for binding +/// Check if a port is available for binding. +/// +/// PostgreSQL started with the default `listen_addresses = 'localhost'` binds +/// *both* IPv4 (`127.0.0.1`) and IPv6 (`::1`) loopback. Probing only IPv4 +/// reports a port as free when it is actually held by an IPv6 or dual-stack +/// listener — e.g. a Docker-published `[::]:5432` — which then causes a silent +/// port collision instead of auto-allocating a free port. Treat a port as taken +/// if binding either loopback family returns `AddrInUse`; ignore other errors +/// (such as IPv6 being disabled on the host) so they don't mark the port +/// unavailable. fn is_port_available(port: u16) -> bool { - std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() + fn in_use(result: std::io::Result) -> bool { + matches!(result, Err(ref e) if e.kind() == std::io::ErrorKind::AddrInUse) + } + let v4 = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)); + let v6 = std::net::TcpListener::bind((std::net::Ipv6Addr::LOCALHOST, port)); + !in_use(v4) && !in_use(v6) } /// Find an available port, starting from the given port From 9feb6e18ebeb8c6d5a0511474bb3ea7af0dc68f8 Mon Sep 17 00:00:00 2001 From: Shah Newaz Khan <3241700+ShahNewazKhan@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:12:15 -0700 Subject: [PATCH 2/3] test: cover is_port_available (IPv4/IPv6-held + free ports) Co-Authored-By: Claude Opus 4.8 --- src/main.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/main.rs b/src/main.rs index 98a0ffc..9d07751 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1618,3 +1618,41 @@ fn main() { process::exit(1); } } + +#[cfg(test)] +mod tests { + use super::is_port_available; + use std::net::{Ipv4Addr, Ipv6Addr, TcpListener}; + + /// A port held only on IPv6 loopback must be reported unavailable. The old + /// IPv4-only probe returned `true` here, which let pg0 collide with an + /// IPv6/dual-stack listener (e.g. Docker's `[::]:5432`). + #[test] + fn ipv6_held_port_is_unavailable() { + let listener = match TcpListener::bind((Ipv6Addr::LOCALHOST, 0)) { + Ok(l) => l, + Err(_) => return, // IPv6 unavailable on this host — skip. + }; + let port = listener.local_addr().unwrap().port(); + assert!(!is_port_available(port), "IPv6-held port should be unavailable"); + } + + /// A port held on IPv4 loopback must be reported unavailable. + #[test] + fn ipv4_held_port_is_unavailable() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + assert!(!is_port_available(port), "IPv4-held port should be unavailable"); + } + + /// A free port (bound then released) is reported available. + #[test] + fn free_port_is_available() { + let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .unwrap() + .local_addr() + .unwrap() + .port(); + assert!(is_port_available(port)); + } +} From 60cd794d983e9876e29ff754cf39f27d3fb17580 Mon Sep 17 00:00:00 2001 From: Shah Newaz Khan <3241700+ShahNewazKhan@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:54:49 -0700 Subject: [PATCH 3/3] Require IPv4 loopback bind; use IPv6 probe only for AddrInUse Addresses review: pg0 connects over 127.0.0.1, so a port is only usable if the IPv4 loopback bind succeeds. Any IPv4 error (incl. PermissionDenied on low ports like 80) now means unavailable, instead of being treated as free. The IPv6 probe is used solely to catch AddrInUse from an IPv6/dual-stack listener; other IPv6 errors (e.g. IPv6 disabled) are ignored. Co-Authored-By: Claude Opus 4.8 --- src/main.rs | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9d07751..ca67821 100644 --- a/src/main.rs +++ b/src/main.rs @@ -324,21 +324,28 @@ fn expand_path(path: &str) -> PathBuf { /// Check if a port is available for binding. /// -/// PostgreSQL started with the default `listen_addresses = 'localhost'` binds -/// *both* IPv4 (`127.0.0.1`) and IPv6 (`::1`) loopback. Probing only IPv4 -/// reports a port as free when it is actually held by an IPv6 or dual-stack -/// listener — e.g. a Docker-published `[::]:5432` — which then causes a silent -/// port collision instead of auto-allocating a free port. Treat a port as taken -/// if binding either loopback family returns `AddrInUse`; ignore other errors -/// (such as IPv6 being disabled on the host) so they don't mark the port -/// unavailable. +/// pg0 connects to the instance over `127.0.0.1`, so the **IPv4 loopback bind +/// must succeed** for the port to be usable — any IPv4 error (`AddrInUse`, or +/// `PermissionDenied` for low ports such as 80, …) means unavailable. +/// +/// PostgreSQL with the default `listen_addresses = 'localhost'` also binds +/// `::1`, so a port already held by an IPv6 or dual-stack listener — e.g. a +/// Docker-published `[::]:5432` — must be rejected too; an IPv4-only probe +/// misses it and silently collides. Only `AddrInUse` from the IPv6 probe is +/// treated as taken; other IPv6 errors (e.g. IPv6 disabled on the host) are +/// ignored so they don't make every port look unavailable. fn is_port_available(port: u16) -> bool { - fn in_use(result: std::io::Result) -> bool { - matches!(result, Err(ref e) if e.kind() == std::io::ErrorKind::AddrInUse) + use std::net::{Ipv4Addr, Ipv6Addr, TcpListener}; + // pg0 connects over IPv4 loopback — require it to be bindable. + if TcpListener::bind((Ipv4Addr::LOCALHOST, port)).is_err() { + return false; } - let v4 = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)); - let v6 = std::net::TcpListener::bind((std::net::Ipv6Addr::LOCALHOST, port)); - !in_use(v4) && !in_use(v6) + // Reject ports held by an IPv6/dual-stack listener; ignore non-AddrInUse + // IPv6 errors (e.g. IPv6 disabled). + !matches!( + TcpListener::bind((Ipv6Addr::LOCALHOST, port)), + Err(ref e) if e.kind() == std::io::ErrorKind::AddrInUse + ) } /// Find an available port, starting from the given port