Skip to content

fix: detect IPv6/dual-stack listeners in is_port_available (avoid silent 5432 collision)#20

Closed
ShahNewazKhan wants to merge 3 commits into
vectorize-io:mainfrom
ShahNewazKhan:fix/port-availability-ipv6-dualstack
Closed

fix: detect IPv6/dual-stack listeners in is_port_available (avoid silent 5432 collision)#20
ShahNewazKhan wants to merge 3 commits into
vectorize-io:mainfrom
ShahNewazKhan:fix/port-availability-ipv6-dualstack

Conversation

@ShahNewazKhan

Copy link
Copy Markdown

Problem

is_port_available() probes only IPv4 loopback:

std::net::TcpListener::bind(("127.0.0.1", port)).is_ok()

PostgreSQL with the default listen_addresses = 'localhost' binds both 127.0.0.1 and ::1. A port held by an IPv6 or dual-stack listener — e.g. a Docker-published [::]:5432 — is invisible to an IPv4-only probe, so is_port_available(5432) returns true. The auto-allocation in start():

let port = if !port_was_specified && !is_port_available(port) { find_available_port(port) } else { port };

therefore never triggers, and pg0 binds the already-used port. The result is a silent collision: a client connecting to 127.0.0.1:5432 may reach the other PostgreSQL and fail auth, with no indication that two servers are fighting over the port.

Repro

  1. Run any Postgres published on [::]:5432 (e.g. a Docker container with -p 5432:5432, which binds both 0.0.0.0 and [::]).
  2. pg0 start (no --port) → it reports port 5432 instead of auto-allocating, colliding with the container.

Fix

Treat a port as taken if binding either IPv4 or IPv6 loopback returns AddrInUse; ignore other bind errors (e.g. IPv6 disabled on the host) so they don't make every port look unavailable. This makes the existing find_available_port auto-allocation fire on real collisions — including IPv6/dual-stack ones — matching the documented "auto-allocates if the default port is in use" behavior.

fn is_port_available(port: u16) -> bool {
    fn in_use(result: std::io::Result<std::net::TcpListener>) -> 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)
}

Scope is limited to this one pure-stdlib function; find_available_port already calls it, so it's covered too.

Notes

  • I couldn't run the full cargo build/tests locally — build.rs bundles the PostgreSQL/pgvector/pgbouncer platform binaries and I don't have the toolchain set up here — so this relies on CI to verify the build. The change is isolated and uses only std. Happy to adjust naming/style or add a test if you'd like.

Refs vectorize-io/hindsight#2379 (surfaced while running Hindsight local-embedded mode, where the embedded PG silently took a Docker-occupied 5432).

🤖 Generated with Claude Code

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 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR updates is_port_available() to correctly detect ports already occupied by IPv6 or dual-stack listeners (e.g. [::]:5432), preventing pg0 start from silently colliding with an existing Postgres that is not visible to an IPv4-only probe.

Changes:

  • Expand the port probe to check both IPv4 (127.0.0.1) and IPv6 (::1) loopback bindings.
  • Add an explanatory doc comment describing why IPv6/dual-stack listeners must be considered.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/main.rs Outdated
Comment on lines +337 to +341
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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — fixed in 60cd794. The IPv4 loopback bind is now required to succeed (any IPv4 error, including PermissionDenied on low ports like 80, returns unavailable), and the IPv6 probe is used solely to catch AddrInUse from an IPv6/dual-stack listener (other IPv6 errors like IPv6-disabled are ignored).

Comment thread src/main.rs Outdated
Comment on lines +331 to +334
/// 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated the doc comment in 60cd794 to state that the IPv4 loopback bind must succeed (pg0 connects over 127.0.0.1) and that only AddrInUse from the IPv6 probe is treated as taken — other IPv6 errors (e.g. IPv6 disabled) are ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ShahNewazKhan

Copy link
Copy Markdown
Author

Update: installed the Rust toolchain and verified locally — cargo build and cargo test both pass. Added 3 unit tests for is_port_available (IPv4-held, IPv6-held, and free port); the IPv6-held case is the regression the old IPv4-only probe missed.

running 3 tests
test tests::ipv4_held_port_is_unavailable ... ok
test tests::free_port_is_available ... ok
test tests::ipv6_held_port_is_unavailable ... ok
test result: ok. 3 passed; 0 failed

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 <noreply@anthropic.com>
@ShahNewazKhan

Copy link
Copy Markdown
Author

Closing as it's no longer needed vectorize-io/hindsight#2379 (comment)

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