Skip to content

Add ipv6_v6only config option for consistent dual-stack behavior - #3060

Open
pranjalm37 wants to merge 8 commits into
Kludex:mainfrom
pranjalm37:fix/ipv6-v6only-config-2945
Open

Add ipv6_v6only config option for consistent dual-stack behavior#3060
pranjalm37 wants to merge 8 commits into
Kludex:mainfrom
pranjalm37:fix/ipv6-v6only-config-2945

Conversation

@pranjalm37

@pranjalm37 pranjalm37 commented Aug 8, 2026

Copy link
Copy Markdown

Summary

Fixes #2945.

Binding an IPv6 host currently behaves inconsistently depending on worker count:

  • Single-worker (default): Server.startup() delegates to loop.create_server(host=..., port=...), and asyncio unconditionally sets IPV6_V6ONLY=True for IPv6 sockets.
  • Multi-worker: Config.bind_socket() creates the socket manually and never touches IPV6_V6ONLY, so it inherits the OS default (dual-stack on Linux).

So uvicorn app:app --host '::' silently drops IPv4 connectivity by default, but accepts it with --workers 2+ — as reported, this is a real problem for e.g. rootless Podman/pasta networking where IPv4 forwarding has nowhere to connect if the server is IPv6-only.

Approach

I deliberately did not change either existing default (that's a judgment call — dual-stack-by-default vs IPv6-only-by-default has security/deployment implications either way and isn't mine to make). Instead this adds an explicit, opt-in ipv6_v6only: bool | None = None Config option (+ --ipv6-v6only/--no-ipv6-v6only CLI flag) that's a no-op when left unset:

  • bind_socket() now sets IPV6_V6ONLY explicitly whenever ipv6_v6only is not None — this already covers the multi-worker path, which calls bind_socket() directly.
  • The single-worker path in Server.startup() now also routes through bind_socket() (passing the socket via sock= instead of host=/port=) whenever ipv6_v6only is explicitly set, since asyncio.create_server(host=, port=) would otherwise silently override any socket option set beforehand.
  • bind_socket() gained a log: bool = True parameter so the single-worker path can pass log=False and keep using the existing _log_started_message() for its startup banner, avoiding a duplicate log line.

With ipv6_v6only left at its default None, neither code path's behavior changes at all — purely additive.

Test plan

  • Reproduced the exact bug: single-worker --host '::' accepts IPv6 but refuses IPv4 by default; ipv6_v6only=False fixes it end-to-end (verified with a real asyncio.open_connection over both 127.0.0.1 and ::1).
  • Confirmed the unset default is byte-for-byte unchanged from current behavior (IPV6_V6ONLY still ends up True for single-worker with no explicit setting).
  • Confirmed no duplicate startup log line from routing through bind_socket(log=False).
  • Added test_bind_socket_ipv6_v6only_* in tests/test_config.py (explicit True/False, unset no-op, ignored for IPv4 hosts).
  • Added test_ipv6_v6only_false_accepts_ipv4_in_single_worker_mode in tests/test_server.py — real end-to-end server test over httpx, single-worker mode, both IPv4 and IPv6 clients.
  • Full suite: pytest tests/ --ignore=tests/benchmarks → 993 passed (up from 988 pre-change), 252 skipped.
  • ruff format --check, ruff check, and mypy uvicorn tests all clean.
  • Documented the new flag in docs/settings.md under Socket Binding.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added --ipv6-v6only and --no-ipv6-v6only options to control IPv6 socket behavior.
    • IPv6 servers can explicitly allow or restrict IPv4-compatible connections.
    • Single-worker IPv6 servers can accept both IPv4 and IPv6 requests when IPv6-only mode is disabled.
  • Bug Fixes

    • Improved handling of explicitly configured IPv6 socket options during server startup.
    • Ensured application shutdown cleanup runs when IPv6 binding fails.
  • Documentation

    • Documented IPv6-only settings, defaults, and IPv4 compatibility behavior.

Binding an IPv6 host currently behaves inconsistently depending on worker
count: single-worker mode delegates socket creation to
asyncio.loop.create_server(), which unconditionally forces
IPV6_V6ONLY=True; multi-worker mode creates its own socket in
Config.bind_socket() and never touches that option, so it inherits the OS
default (dual-stack on Linux). A server started with --host '::' silently
drops IPv4 connectivity in the (default) single-worker case but accepts it
with --workers 2+.

Add an explicit `ipv6_v6only: bool | None = None` Config option (and
--ipv6-v6only/--no-ipv6-v6only CLI flag) that, when set, is applied
consistently in both code paths:
- bind_socket() now sets IPV6_V6ONLY explicitly when ipv6_v6only is not
  None (used directly in multi-worker mode).
- The single-worker path in Server.startup() now also routes through
  bind_socket() (passing the socket via `sock=` instead of `host=`/`port=`)
  whenever ipv6_v6only is explicitly set, since asyncio's create_server()
  would otherwise silently override any pre-set socket option.
- bind_socket() gained a `log` parameter so the single-worker path can
  keep using its own _log_started_message() and avoid a duplicate startup
  log line.

Left at None (the default) it's a no-op: neither code path's existing
default behavior changes, so this is purely additive.

Fixes Kludex#2945
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds optional IPv6 IPV6_V6ONLY control through the CLI, public API, and Config. Single-worker IPv6 startup preserves the configured socket option. Tests cover socket behavior, dual-stack requests, and lifespan cleanup. Documentation describes the new options.

Changes

IPv6 V6ONLY control

Layer / File(s) Summary
Socket configuration and binding
uvicorn/config.py, tests/test_config.py
Config stores the optional ipv6_v6only value. IPv6 sockets apply explicit values. IPv4 sockets ignore the option. Binding failures close sockets. Tests cover unset and explicit IPv6 values.
CLI and public API propagation
uvicorn/main.py
The CLI adds --ipv6-v6only and --no-ipv6-v6only. main() and run() pass the optional value to Config.
Single-worker startup integration
uvicorn/server.py, tests/test_server.py, tests/utils.py, tests/test_main.py, docs/settings.md
Single-worker IPv6 startup manually binds configured sockets before calling asyncio.create_server. Bind failures shut down lifespan state. Tests verify IPv4 and IPv6 requests and reuse shared IPv6 detection. Documentation describes the behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant CLI as main()
  participant Config
  participant Server
  participant Socket as IPv6 socket
  CLI->>Config: pass ipv6_v6only
  Config->>Socket: set IPV6_V6ONLY when configured
  Server->>Config: bind_socket(log=False)
  Config->>Socket: bind IPv6 socket
  Server->>Socket: pass socket to asyncio.create_server
Loading

Possibly related PRs

  • Kludex/uvicorn#3011: Both changes modify IPv6 socket binding, CLI propagation, server startup, documentation, and tests.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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 primary change: adding an ipv6_v6only configuration option for consistent dual-stack behavior.
Linked Issues check ✅ Passed The changes address issue #2945 by adding CLI and Config support, applying IPV6_V6ONLY consistently, and adding IPv4/IPv6 regression coverage.
Out of Scope Changes check ✅ Passed The documentation, implementation, tests, socket cleanup, and shared IPv6 helper are directly related to issue #2945 and the stated objectives.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@codspeed-hq

codspeed-hq Bot commented Aug 8, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 34 untouched benchmarks


Comparing pranjalm37:fix/ipv6-v6only-config-2945 (7a49571) with main (5c560da)

Open in CodSpeed

@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: 3

🤖 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 `@docs/settings.md`:
- Line 48: Update the `--ipv6-v6only` documentation to remove the claim that
Linux defaults to dual-stack; describe the multi-worker behavior as inheriting
the OS-configurable default, which may be IPv6-only or dual-stack. Preserve the
existing explanation of single-worker behavior and the `--no-ipv6-v6only`
option.

In `@uvicorn/config.py`:
- Line 197: Move the ipv6_v6only parameter in Config.__init__ to after all
existing constructor parameters, preserving the current positional order so
arguments following port retain their original meanings.

In `@uvicorn/server.py`:
- Line 182: Update the explicit socket-binding path around
config.bind_socket(log=False) to catch SystemExit in addition to the existing
bind-failure handling; when binding fails after lifespan startup, await
self.lifespan.shutdown() and then re-raise the same SystemExit.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c72bbd94-521c-497c-a42e-56665516a29a

📥 Commits

Reviewing files that changed from the base of the PR and between ee8e45c and a556406.

📒 Files selected for processing (6)
  • docs/settings.md
  • tests/test_config.py
  • tests/test_server.py
  • uvicorn/config.py
  • uvicorn/main.py
  • uvicorn/server.py

Comment thread docs/settings.md Outdated
Comment thread uvicorn/config.py Outdated
Comment thread uvicorn/server.py

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 6 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread uvicorn/config.py Outdated
Comment thread uvicorn/server.py
Comment thread tests/test_server.py
Comment thread docs/settings.md Outdated
… docs

- Config.__init__: move ipv6_v6only to the end of the parameter list
  instead of inserting it between port and uds. There's no keyword-only
  separator, so the insertion silently shifted every positional argument
  after it (e.g. Config(app, host, port, uds) would pass uds's value as
  ipv6_v6only instead). Caught independently by two automated reviewers.

- Server.startup(): the explicit-bind path calls config.bind_socket(),
  which does sys.exit() on a bind OSError -- but the surrounding
  `except OSError` doesn't catch that SystemExit, so an occupied IPv6
  port skipped lifespan.shutdown(), unlike the pre-existing failure path.
  Catch SystemExit too, run lifespan cleanup, then re-raise.

- docs/settings.md: don't overstate "dual-stack on Linux" as a hard
  guarantee -- it's controlled by the net.ipv6.bindv6only sysctl and can
  be configured either way.

- tests: extract the ad-hoc _has_ipv6() helper from test_main.py into a
  shared tests/utils.py::has_ipv6(), and use it to skip the new IPv6
  tests (in test_config.py and test_server.py) in environments without
  IPv6 support, instead of hard-failing there.
…host

Reproduced on real Linux (Docker): binding specifically to the loopback
address '::1' silently forces IPV6_V6ONLY=True regardless of the
requested socket option -- confirmed empirically that the sockopt is
correctly False immediately after setsockopt(), and only gets reset by
the kernel at bind() time, specifically for '::1' and not for the
wildcard '::'. This is sensible kernel behavior (a loopback-only bind can
never be meaningfully dual-stack, since there's no IPv4-loopback
equivalent of '::1'), not a bug -- but it meant the parametrized test
could never observe ipv6_v6only=False actually taking effect.

Switch the test to bind '::' instead, which is also the actual real-world
case the whole PR is about. Verified passing on real Linux via Docker
(python:3.11-slim), all 6 IPv6-related tests across test_config.py,
test_server.py, and test_main.py green.
@pranjalm37

Copy link
Copy Markdown
Author

Pushed fixes for everything flagged by review:

Fixed:

  • Positional-arg break (caught independently by both CodeRabbit and cubic — thank you): moved ipv6_v6only to the end of Config.__init__'s parameter list instead of inserting it after port, which was silently shifting every subsequent positional argument. Verified Config(app, host, port, uds)-style positional calls now work exactly as before.
  • Missing lifespan cleanup on bind failure: config.bind_socket()'s internal sys.exit() wasn't caught by the surrounding except OSError, so an occupied IPv6 port skipped lifespan.shutdown(). Now catches SystemExit too, runs cleanup, then re-raises. Verified with a direct repro (occupied port + fake lifespan tracking whether shutdown fired).
  • Docs wording: no longer implies Linux is unconditionally dual-stack (it's net.ipv6.bindv6only-configurable).
  • Test robustness: extracted the ad-hoc _has_ipv6() from test_main.py into a shared tests/utils.py::has_ipv6(), used to skip the new IPv6 tests in environments without IPv6.

CI failure — root cause found and fixed:
The Linux CI failure on test_bind_socket_ipv6_v6only_explicit[False] turned out to be a real, interesting kernel behavior, not a flake. I reproduced it directly on Linux (Docker, python:3.11-slim) and isolated it precisely:

host='::1' set=False -> before_bind=0 after_bind=1
host='::1' set=True  -> before_bind=1 after_bind=1
host='::'  set=False -> before_bind=0 after_bind=0
host='::'  set=True  -> before_bind=1 after_bind=1

Setting IPV6_V6ONLY=False takes effect immediately after setsockopt(), but the kernel silently resets it back to True at bind() time, specifically when binding to ::1 — not ::. That's sensible: ::1 is loopback-only and can never be meaningfully dual-stack (there's no IPv4-loopback equivalent of ::1, unlike ::/0.0.0.0). My test used ::1 and could therefore never observe ipv6_v6only=False actually sticking. Switched it to ::, which is also the actual real-world case from the original bug report. Verified all 6 IPv6-related tests green on real Linux via Docker.

All 993 local tests still pass, ruff/mypy clean.

The new `except SystemExit: await self.lifespan.shutdown(); raise` branch
in the explicit-bind path had no test exercising it, dropping this
project's required 100% coverage to 99.96% (server.py 191/194 -> 98.43%).

Add test_ipv6_v6only_bind_failure_runs_lifespan_shutdown: occupies an
IPv6 port, configures the server to explicit-bind that same port (via
ipv6_v6only=<any value>), and asserts both that SystemExit propagates and
that lifespan.shutdown() actually ran. Verified 100% coverage on
server.py locally via Docker (python:3.11-slim) with the project's own
coverage config (COVERAGE_PROCESS_START + parallel combine, matching
scripts/coverage).

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 1 file (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread tests/test_server.py
@pranjalm37

Copy link
Copy Markdown
Author

Looked into the latest CI run — every platform/version job is failing on exactly one different, unrelated test each time (e.g. `test_run_chain_only` on 3.12-ubuntu, `test_limit_max_requests_jitter[zttp]` on 3.12-macos, `test_request_than_limit_max_requests_warn_log[h11]` on 3.11-ubuntu, etc.), with the actual functional test counts all passing (1000–1270 tests green in each job). Windows jobs passed cleanly with 0 errors. That pattern — one random unrelated test erroring per run, varying every time — looks like CI resource contention under full parallel load rather than anything in this PR; I don't see how the changes here would cause an unrelated test like test_run_chain_only to fail at setup.

I don't have permission to re-run the workflow as an outside contributor. Happy to take another look if a maintainer re-triggers it and something reproducible shows up.

bind_socket() created a socket, and on a bind OSError logged + called
sys.exit() without closing it first, leaking the fd. The new IPv6
bind-failure test was the first test to actually exercise this path,
so the leaked socket surfaced as a ResourceWarning at teardown
(pytest turns these into a hard failure), which is why every job in
the last CI run failed identically across all platforms including
Windows.

Also move the test's own blocker-socket setup inside try/finally, per
cubic review feedback, so a bind/listen failure on the test's side
can't leak a socket either.
@pranjalm37

Copy link
Copy Markdown
Author

Found the real root cause of the CI failures — retracting my earlier "unrelated flakiness" theory, it wasn't that.

Config.bind_socket() creates a socket and, on a bind() OSError, logs the error and calls sys.exit() without closing the socket first — a pre-existing leak in uvicorn/config.py. My new test_ipv6_v6only_bind_failure_runs_lifespan_shutdown test is the first test in the suite to actually exercise a real bind failure on this path, so the leaked socket surfaced as a ResourceWarning at teardown, which pytest escalates to a hard failure. That's why every job failed identically across all platforms (including Windows, which had been clean before).

Fixed in 293af33:

  • bind_socket() now closes sock in both except OSError branches (UDS and inet) before sys.exit().
  • Also addressed cubic's review comment: moved the test's own blocker socket setup inside try/finally so a bind/listen failure there can't leak either.

Verified locally with pytest -W error::ResourceWarning across test_server.py, test_config.py, test_main.py — all pass clean, no warnings.

The test's win32 skip was copied from a sibling test that genuinely
depends on '::' dual-stack accept semantics differing on Windows. This
test doesn't: it only needs bind() to raise on an already-bound port,
which is standard OS behavior everywhere, and Python normalizes socket
errors to OSError cross-platform. Skipping it left the new
`except SystemExit` cleanup path in server.py uncovered specifically
on Windows CI, failing the 100%-coverage gate on every Windows job.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 1 file (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread tests/test_server.py
…m its coverage gate instead

Un-skipping it last commit was wrong: cubic's review correctly called
out that Windows' looser SO_REUSEADDR semantics let both sockets bind
the same wildcard address, so the bind conflict this test relies on
doesn't reliably happen there. Confirmed directly -- the Windows CI
jobs hung for ~8.5 minutes and were cancelled rather than failing
cleanly, exactly matching that prediction (pytest.raises(SystemExit)
never firing because bind() never raised).

Restored the win32 skip, and instead excluded the corresponding
`except SystemExit` branch in server.py from Windows' 100%-coverage
requirement via `# pragma: py-win32`, matching how this codebase
already excludes other platform-untestable branches (e.g. the UDS
path, which doesn't exist on Windows either).
@pranjalm37

Copy link
Copy Markdown
Author

Good catch, and confirmed the hard way: the Windows CI jobs on the un-skip commit hung for ~8.5 minutes and got cancelled rather than failing cleanly — exactly matching your prediction that `SO_REUSEADDR` lets both sockets bind on Windows, so `bind()` never raises and `pytest.raises(SystemExit)` never sees anything.

Reverted in `7784f59`: restored the win32 skip on `test_ipv6_v6only_bind_failure_runs_lifespan_shutdown`, and instead excluded the corresponding `except SystemExit` branch in `server.py` from Windows' 100%-coverage requirement via `# pragma: py-win32` (same pattern this codebase already uses for other platform-untestable branches, e.g. the UDS path). Verified locally and confirmed Linux/macOS still cover that branch via the non-Windows run of the same test.

…erage gate

The last commit's pragma on `except SystemExit` wasn't enough: the
Windows coverage failure it left behind (182-183, now 173-186) was
actually the *whole* explicit-bind `if` branch in startup() -- reachable
only via the two ipv6_v6only tests, both skipped on Windows for the same
underlying reason (SO_REUSEADDR/dual-stack quirks). Since neither test
runs there, the branch itself was never exercised on that platform.

Excluded it with `# pragma: py-win32` on the `if`, mirroring how this
file already excludes the equally Windows-untestable UDS branch
(`elif config.uds is not None: # pragma: py-win32`). Also added the
same pragma to the two win32-skipped test functions themselves
(matching test_config.py's existing convention for win32-skipped
tests), since their bodies were showing up as additional missing lines
in tests/test_server.py.
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.

Inconsistent IPv6 dual-stack behavior between single-worker and multi-worker modes

1 participant