Skip to content

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

@ogajduse

Description

@ogajduse

Summary

When binding to an IPv6 address, uvicorn behaves differently depending on whether it runs in single-worker or multi-worker mode:

  • Multi-worker (bind_socket() in config.py): Creates the socket directly, sets SO_REUSEADDR but does not set IPV6_V6ONLY. On Linux (where /proc/sys/net/ipv6/bindv6only defaults to 0), this inherits dual-stack behavior — the socket accepts both IPv4 and IPv6 connections.

  • Single-worker (default): Delegates to asyncio.loop.create_server(host=..., port=...), which unconditionally sets IPV6_V6ONLY=True on IPv6 sockets (cpython source). This makes the socket IPv6-only.

This means a server started with --host '::' silently drops IPv4 connectivity when running with a single worker, but accepts IPv4 when running with multiple workers.

Steps to Reproduce

# Single worker (default) — IPv4 fails
uvicorn myapp:app --host '::' --port 8080
curl http://127.0.0.1:8080/   # connection refused
curl http://[::1]:8080/        # works

# Multiple workers — IPv4 works
uvicorn myapp:app --host '::' --port 8080 --workers 2
curl http://127.0.0.1:8080/   # works
curl http://[::1]:8080/        # works

Root Cause

In config.py, bind_socket() (lines 538+) creates the socket manually:

sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# IPV6_V6ONLY is never set → inherits system default (0 on Linux = dual-stack)

In server.py, the single-worker path (lines 170-178) delegates to asyncio:

server = await loop.create_server(
    create_protocol,
    host=config.host,
    port=config.port,
    ...
)

asyncio's create_server always does:

if af == socket.AF_INET6:
    sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, True)

Impact

This is particularly problematic in rootless container environments using pasta (Podman's default rootless networking). Pasta does L4 socket translation — it creates separate IPv4 and IPv6 connections inside the container namespace. If the server only listens on IPv6, pasta's IPv4 forwarding has nowhere to connect and the connection is refused.

Suggested Fix

Explicitly set IPV6_V6ONLY in bind_socket() to make the behavior consistent, and consider adding a configuration option (e.g., --ipv6-v6only / ipv6_v6only in Config) so users can control dual-stack behavior regardless of worker count.

Alternatively, when the user specifies an IPv6 host in single-worker mode, uvicorn could create and bind the socket itself (like bind_socket() does) and pass it via loop.create_server(sock=sock), bypassing asyncio's socket option override.

Workaround

Create a pre-bound socket with IPV6_V6ONLY=0 and pass it via Server.serve(sockets=[sock]):

import socket
import uvicorn

sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
sock.bind(("::", 8080))

config = uvicorn.Config(app, lifespan="on")
server = uvicorn.Server(config)
await server.serve(sockets=[sock])

Environment

  • uvicorn 0.34.3
  • Python 3.13
  • Linux (Fedora 43, /proc/sys/net/ipv6/bindv6only = 0)
  • Rootless Podman 5.5.2 with pasta networking

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions