Skip to content

fix(dev): probe both loopback families for port availability, and de-flake its test - #3650

Merged
kojiwakayama merged 4 commits into
mainfrom
fix/port-availability-ipv6
Aug 12, 2026
Merged

fix(dev): probe both loopback families for port availability, and de-flake its test#3650
kojiwakayama merged 4 commits into
mainfrom
fix/port-availability-ipv6

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Two separate defects in cli/commands/dev/port-fallback.ts, one commit each.

1. Product: isPortAvailable probed only IPv4

isPortAvailable bound only 127.0.0.1, so a port held on ::1 probed as free.

That is not a hypothetical family. veryfront dev runs two listeners and they do not land on the same one:

listener path default hostname resolves to
app HTTP (--port, 3000) DenoHttpServer.serve (src/platform/compat/http/deno-server.ts:59) LOCALHOST.IPV4 127.0.0.1
MCP (--port + 2, 3002) Node adapter (src/platform/adapters/runtime/node/http-server.ts:573), reached via cli/mcp/server.ts:224 which passes no hostname "localhost" ::1 first on any dual-stack host

Observed against a real install of veryfront@0.1.1231-rc.11370: with instance A holding [::1]:3002 for MCP, a second veryfront dev reported ! Port 3000 is in use, using 3002 instead and bound its HTTP server to 127.0.0.1:3002 — a port instance A had already reserved. It did not hard-fail only because the two listeners landed on different families. That is luck, not correctness.

Fix: probe 127.0.0.1 and ::1 both; a port is available only when neither holds it. The literal addresses are used rather than the name localhost, because a listen on a name binds only the first address it resolves to — which would leave the other family unchecked exactly as before.

The node:net branch had the same IPv4-only bias (it hardcoded host: LOCALHOST.IPV4 too) and is fixed with it.

Inverse regression guarded: probing more families must not make a genuinely free port report busy. isAddressFamilyUnavailableError distinguishes EADDRNOTAVAIL / EAFNOSUPPORT (this host has no such family — skip it) from EADDRINUSE (a real collision), so an IPv4-only container still falls forward normally. Anything else still propagates, so an out-of-range --port keeps surfacing the runtime's own complaint.

2. Test isolation: the flake that ejected #3597

isPortAvailable ... accepts a port nothing is holding reserved an ephemeral port, released it, and asserted it was still free. Nothing can hold a port open and leave it bindable at the same time, so that gap is open to any other process — and CI runs ~30 jobs against one host. It ejected #3597, an unrelated CSS-hint change, from the merge queue.

This is a test-isolation defect, distinct from the product bug: the assertion is unsound whatever the product does. Kept in its own commit.

Fix: retry on a freshly reserved port. Nothing is softened — a probe that wrongly rejects free ports rejects all 25 of them and still fails the test. Only the assumption that one particular port survives the gap is gone.

Verification

  • TDD: the IPv6 test was written first and failed with Values are not equal: actual true / expected falseisPortAvailable accepting a port held on [::1].
  • Reproduced the reported scenario end-to-end: with 127.0.0.1:P and [::1]:P+2 held, findAvailablePort(P) now returns P+1 instead of colliding on P+2.
  • Race window proven deterministically: claiming the released port inside the window makes the old test body fail and the new one pass.
  • Flake fix: 60/60 green against a host holding 13000 of its 16384 ephemeral ports with 8 processes cycling more. (The natural race does not reproduce on macOS — 0/20000 cycles — because of randomized ephemeral allocation with reuse avoidance; it is a Linux-CI phenomenon, hence the deterministic proof.)
  • deno lint, deno fmt --check, deno check clean on both files.
  • Full cli/ unit suite: 271 passed, 0 failed.

No existing test was weakened or removed.

Summary by CodeRabbit

  • Bug Fixes

    • Improved development server port detection across IPv4 and IPv6 loopback addresses.
    • Correctly handles environments where an address family is unavailable, allowing startup to continue when possible.
    • More reliably distinguishes occupied ports from unrelated networking failures.
    • Added resilience when checking ports recently released during parallel development activity.
  • Tests

    • Expanded coverage for IPv6-only conflicts, unavailable address families, port collisions, and retry behavior.

`isPortAvailable` bound only 127.0.0.1, so a port held on ::1 probed as
free. That is not a hypothetical family: `veryfront dev` serves the app
through an adapter defaulting to `LOCALHOST.IPV4`, but starts its MCP
server (`--port + 2`) through the Node adapter, which defaults to the
name `localhost` and so resolves to `::1` first on any dual-stack host.

With one dev server holding [::1]:3002 for MCP, a second one announced
"Port 3000 is in use, using 3002 instead" and bound its HTTP server to
127.0.0.1:3002 - a port the first instance had already reserved. It did
not hard-fail only because the two listeners landed on different
families, which is luck rather than correctness.

Probe 127.0.0.1 and ::1 both, and treat a port as available only when
neither is holding it. The literal addresses are used rather than the
name `localhost` because a listen on a name binds just the first address
it resolves to, leaving the other family unchecked exactly as before.

The node:net branch had the same IPv4-only bias and is fixed with it.

A family the host does not have is skipped rather than counted as a
collision, so probing more families cannot make a genuinely free port
report as busy: `isAddressFamilyUnavailableError` tells EADDRNOTAVAIL /
EAFNOSUPPORT apart from EADDRINUSE, and an IPv4-only container still
falls forward normally.
"isPortAvailable ... accepts a port nothing is holding" reserved an
ephemeral port, released it, and asserted it was still free. Nothing can
hold a port open and leave it bindable at the same time, so that gap is
open to any other process - and CI runs ~30 jobs against one host. It
ejected PR #3597, an unrelated CSS-hint change, from the merge queue.

This is a test-isolation defect, not a symptom of the IPv4-only probe
fixed in the previous commit: the assertion is unsound whatever the
product does.

Retry on a freshly reserved port instead. A probe that wrongly rejects
free ports rejects all 25 of them and still fails the test, so nothing is
softened - only the assumption that one particular port survives the gap
is gone.

Verified by reproducing the race deterministically (claiming the released
port inside the window makes the old body fail and the new body pass),
then running the file 60x against a host holding 13000 of its 16384
ephemeral ports with 8 processes cycling more: 60/60 green.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e4a9e80357

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread cli/commands/dev/port-fallback.test.ts Outdated
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 20b8de13-4655-4b50-969d-c7cfaa92169e

📥 Commits

Reviewing files that changed from the base of the PR and between 406522c and 5149623.

📒 Files selected for processing (1)
  • cli/commands/dev/port-fallback.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • cli/commands/dev/port-fallback.test.ts

📝 Walkthrough

Walkthrough

Port fallback probing now checks IPv4 and IPv6 loopback addresses in Deno and Node. It skips unsupported address families, detects occupied ports across families, retries released-port checks, and validates error classification.

Changes

Port probing

Layer / File(s) Summary
Cross-family port probing
cli/commands/dev/port-fallback.ts
Port probing checks IPv4 and IPv6 loopback addresses. It classifies unavailable address-family errors and uses runtime-specific bind-and-release probes.
Port probing validation
cli/commands/dev/port-fallback.test.ts
Tests cover loopback binding, IPv6-only collisions, retries for recently released ports, and runtime-specific unavailable-address errors.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant isPortAvailable
  participant RuntimeProbe
  participant LoopbackAddress
  isPortAvailable->>RuntimeProbe: probe IPv4 and IPv6 loopback hosts
  RuntimeProbe->>LoopbackAddress: bind and release port
  LoopbackAddress-->>RuntimeProbe: success or address-family error
  RuntimeProbe-->>isPortAvailable: available or occupied
Loading

Possibly related PRs

Suggested reviewers: kwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main IPv4/IPv6 probing fix and the related test stabilization.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/port-availability-ipv6

Comment @coderabbitai help to get the list of available commands.

Review catch on #3650, and it is the same defect this PR exists to
remove. The new classifier test provoked its error by binding `::2` and
asserting the bind fails - swapping "an arbitrary port stays free" for
"an arbitrary address stays unbindable". Both are assumptions about
ambient host state. On a host where `::2` is assigned, or on Linux with
`net.ipv6.ip_nonlocal_bind=1`, the bind succeeds and the test fails
against a correct implementation.

Construct the error shapes instead. `Deno.errors.AddrNotAvailable` is the
runtime's own constructor rather than a hand-rolled Error with a spoofed
name, so a rename in Deno fails the test loudly instead of letting it
drift away from what the probe actually catches.

Constructing also reaches coverage a live bind cannot: the Node `code`
shapes (EADDRNOTAVAIL, EAFNOSUPPORT) are unreachable from a bind on a
Deno host, and the message-only path now has explicit cases for both the
macOS and Linux wordings.

Preferred over skipping when the host permits the bind: a skip stops
asserting on exactly the hosts where the behaviour is most interesting,
and nothing reports that it went quiet.

Mutation-checked - dropping any one of the name, code, or message
branches of the classifier is caught by at least one of these assertions.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli/commands/dev/port-fallback.test.ts`:
- Around line 28-46: Update listenOnLoopback so it returns null only when
isAddressFamilyUnavailableError(error) is true; rethrow all other bind errors,
including those not identified by isPortInUseError, so permission and resource
failures propagate.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 641037d5-bab7-45a2-8d72-345b3cfa2646

📥 Commits

Reviewing files that changed from the base of the PR and between cb313d5 and e4a9e80.

📒 Files selected for processing (2)
  • cli/commands/dev/port-fallback.test.ts
  • cli/commands/dev/port-fallback.ts

Comment thread cli/commands/dev/port-fallback.test.ts
…ipping

Second review catch on #3650, same theme as the first. `listenOnLoopback`
returned null for every bind error that was not a port collision, so a
permission failure or a resource limit silently turned "skips a port held
on IPv6 only" into a no-op that still reported green.

Skip only for a missing address family - the one condition where there is
genuinely nothing to test - and rethrow everything else.

Verified: a hostname that fails to resolve used to return null and skip;
it now propagates and fails the test. The real IPv4-only skip path is
unchanged.
@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit c8c20c5 Aug 12, 2026
33 checks passed
@kojiwakayama
kojiwakayama deleted the fix/port-availability-ipv6 branch August 12, 2026 21:07
@kojiwakayama kojiwakayama mentioned this pull request Aug 12, 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.

1 participant