Skip to content

Multi-threaded accept: a reuse-port request that names its promise, and the handoff proven - #83

Merged
proggeramlug merged 7 commits into
mainfrom
feat/multi-threaded-accept
Sep 16, 2026
Merged

proggeramlug merged 7 commits into
mainfrom
feat/multi-threaded-accept

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Implements DESIGN §5a.6, multi-threaded accept: one server on more than one core, by the two routes the design already names. Closes #49.

1. reuse_port says which behaviour it is asking for

SO_REUSEPORT is spelled the same on Linux and on the BSDs and does not mean the same thing, so ListenOpts::reuse_port and UdpOpts::reuse_port are no longer a bool:

pub enum ReusePort { No, Share, Distribute }
  • Share — permit the duplicate bind, promise nothing about delivery. The traditional BSD use: multicast/broadcast receivers, and zero-downtime restarts where last-binder-wins is the effect you want.
  • Distribute — permit the duplicate bind and spread incoming connections across the listeners.
Share Distribute
Linux, Android SO_REUSEPORT SO_REUSEPORT
FreeBSD SO_REUSEPORT SO_REUSEPORT_LB (12.0+)
macOS / other Apple, NetBSD, OpenBSD, DragonFly SO_REUSEPORT Unsupported
Windows Unsupported Unsupported
WASI 0.2/0.3, web Unsupported Unsupported
AF_UNIX listeners Unsupported Unsupported

There is deliberately no third outcome: a platform distributes, or the listener is refused when it is created. The failure that prevents is silent and was measured, not assumed — two loops sharing one port under plain SO_REUSEPORT on macOS 26.5 split 32 connections [0, 32]. The first listener never accepts anything, ever, with no error anywhere. A host that developed the Linux path and shipped it would have a server that looks correct and uses one core.

Two corrections fall out of this. DESIGN said the kernel balances "on Linux/FreeBSD"; that is right for Linux and wrong for FreeBSD, where plain SO_REUSEPORT keeps its original BSD meaning — SO_REUSEPORT_LB is the one that distributes, and it is what Distribute now sets there. And the existing contract test asserted distribution on FreeBSD, which would have failed there; FreeBSD is not in CI, so it had never run.

Share and Distribute are the same setsockopt on Linux, which is fine: a variant states a floor, not a ceiling.

It distributes by hash, not by load

The kernel picks a listener by hashing the connection's 4-tuple. Nothing asks how busy a loop is, so a loop inside a long turn keeps being handed its share and those connections wait while another loop is idle. Measured, 64 connections over 4 loops on Linux 6.17: [7, 12, 21, 24]. The docs say this rather than implying even distribution.

2. The handoff works today

detach/attach already worked; this establishes it with a test that accepts on one loop and then drives reads and writes on a sibling loop on another thread, and documents it as the supported multi-core route for macOS, and the only one for Windows, where a socket joins exactly one completion port permanently.

accept_handoff_distribution on main already covered the basic shape and passes. What it did not cover is exactly-once delivery and handle orphans, which is what the new tests add.

3. Correctness tests

multi_threaded_accept_by_handoff and multi_threaded_accept_by_reuse_port: 64 connections, 4 loops on 4 threads, one port, by each route.

  • Exactly once, nothing lost. Every client sends a distinct id and requires that exact id back; the union of ids served across the loops must equal 0..64 with no duplicate and no gap. A connection dropped at handoff never answers and fails the client's read.
  • No orphaned handles. Every serving loop runs at max_handles: 8 while serving 64 connections. The workload fits comfortably one connection at a time, but a handle leaked per connection — at accept, attach, detach or close — exhausts the ceiling long before the run ends and turns an invisible orphan into a ResourceLimit failure.
  • Clean shutdown. Every loop must report !alive() at the end.
  • Both use single-shot accept, re-armed per connection. A multishot accept can outrun the handle ceiling within one turn #77 is open — a multishot accept can outrun the handle ceiling within one turn — and a test running multishot at a low ceiling would be exercising that open issue instead of the accept route.
  • The per-loop share is deliberately not asserted for the kernel route. Distribution is by hash, so shares are uneven by nature and asserting evenness would assert something the kernel never promised.

Measured while writing these (Linux 6.17 x86-64 / macOS 26.5 arm64, both pass):

Linux macOS
Share, 2 listeners, 32 conns [17, 15] [0, 32]
Distribute, 2 listeners, 64 conns [30, 34] refused, Unsupported
kernel route, 4 loops, 64 conns [7, 12, 21, 24] skipped (refused)
handoff route, 4 loops, 64 conns [16, 16, 16, 16] [16, 16, 16, 16]

4. Scaling harness — built, deliberately not measured

turnloop-bench --accept-scaling --route handoff|reuse-port --loops N --connections C --clients K. One JSON line with connections_per_second and, beside it, per_loop — the service count for each loop, so the distribution is visible next to the total rather than inferred from it.

It asserts its own subject ran before printing anything: the per-loop counts must sum to the budget, every loop must have served at least one connection, and --connections must be at least 100 per loop so a loop cannot be given nothing by chance. A flat curve produced by three of four loops sitting idle is the failure mode this makes impossible. --route reuse-port exits 2 with one line naming the alternative where the kernel cannot distribute.

No timing numbers are reported. The harness has been run only for correctness, on machines at load ~11/10 cores and ~24/64 cores, where a figure would be worse than none. The runbook in docs/multi-threaded-accept.md says what to run, in what order, and what a result would mean in each direction — starting with the step that has to come first: the client side is synchronous, so its rate is bounded by K / RTT, and --clients must be raised until the number stops moving before any server-scaling curve means anything.

Does the handoff work today? Yes, including on Windows

Independently of this PR: main's own CI run for 164e8e4 shows handoff_distribution ... ok on test-native (windows-2025, all-features), alongside pending_operations_refuse_handoff and closing_and_closed_handles_refuse_handoff. Accepting on one loop and driving I/O on a sibling loop on another thread already works on every native backend, Windows included. The gap was never the mechanism; it was that nothing asserted exactly-once delivery or checked for orphaned handles, and that no document said this is the route macOS and Windows have to take.

Verified

  • macOS 26.5 arm64 (kqueue): cargo test -p turnloop -p turnloop-contract --no-fail-fast -- --test-threads=1 green; fmt, clippy, rustdoc clean.
  • Linux 6.17 x86-64 (epoll): same suites green apart from a pre-existing, unrelated filesystem::permission_denied_is_reported, which fails identically on clean main on that host because it runs as root and root bypasses the permission check. Both harness routes run there at 1, 2 and 4 loops with exact per-loop accounting.
  • Cross-compiled clean: x86_64-pc-windows-msvc, wasm32-wasip2, wasm32-unknown-unknown, x86_64-unknown-freebsd (the SO_REUSEPORT_LB path).
  • Not run anywhere: FreeBSD. DistributeSO_REUSEPORT_LB compiles for the target and is unverified at runtime, like the rest of the FreeBSD lane. It corrects a mapping that was wrong before; it is not a claim that it has been observed working.
  • Windows exactly-once is CI's to prove. handoff_accept_exactly_once and kernel_accept_exactly_once are registered in the Windows contract list, so test-native (windows-2025) is the first run of the exactly-once, handle-ceilinged handoff on the platform where the handoff is the only route.

Summary by CodeRabbit

  • New Features

    • Added explicit port-reuse modes: disabled, shared binding, or kernel-distributed connections.
    • Added multi-threaded accept support through kernel distribution where available, with handoff support as a fallback and the only route on Windows.
    • Unsupported reuse requests are now rejected clearly at listener creation.
  • Documentation

    • Added platform support guidance and multi-threaded accept documentation.
    • Added an accept-scaling benchmark with configurable routes and JSON results.
  • Tests

    • Expanded coverage for reuse behavior and exactly-once connection handling.

Ralph Küpper added 3 commits September 16, 2026 13:24
SO_REUSEPORT is spelled the same on Linux and the BSDs and does not mean
the same thing. On Linux it permits the duplicate bind and distributes
incoming connections across the listeners holding the address. On macOS
and the other BSDs it permits the duplicate bind and then gives new
connections to whichever socket bound last.

A bool cannot express that, so ListenOpts::reuse_port and UdpOpts::reuse_port
are now a ReusePort enum: No, Share (duplicate binding, delivery unspecified)
and Distribute (the kernel spreads connections across listeners). Distribute
is SO_REUSEPORT on Linux and Android, SO_REUSEPORT_LB on FreeBSD 12.0+, and
Unsupported everywhere else - refused when the listener is created rather
than answered with plain SO_REUSEPORT, which is what produced a listener that
is never given any work. Measured on macOS 15: two loops sharing one port
under SO_REUSEPORT split 32 connections [0, 32].

AF_UNIX listeners now report Unsupported rather than InvalidInput, matching
the rule the rest of the option surface follows.

Contract tests:
  - reuse_port_share asserts the duplicate bind works and that every
    connection is accepted by someone, and deliberately asserts nothing
    about which listener, because Share promises nothing about it.
  - reuse_port_distribute asserts there is no third outcome: a platform
    either distributes or refuses the listener.
  - reuse_port_refused covers Windows and WASI, which have neither.
  - handoff_accept_exactly_once and kernel_accept_exactly_once serve 64
    connections across 4 loops on 4 threads by each route and assert every
    connection was served exactly once, on loops whose handle ceiling is
    low enough that a leaked handle exhausts it.
turnloop-bench --accept-scaling drives N loops on N threads serving one
port by either route and reports connections per second with the per-loop
service counts beside it, so the distribution is visible next to the total
rather than inferred from it.

It asserts its own subject ran before it prints anything: every loop must
have served at least one connection, the per-loop counts must sum to the
budget, and --connections must be at least 100 per loop so a loop cannot be
given nothing by chance. A scaling number from a run where three of four
loops sat idle would read as a flat curve and mean nothing.

--route reuse-port is refused up front, with a status and one line naming
the alternative, on the platforms that cannot distribute in the kernel.
DESIGN 5a.6 said the kernel balances on Linux/FreeBSD. That is right for
Linux and wrong for FreeBSD, where plain SO_REUSEPORT keeps its original
BSD meaning and SO_REUSEPORT_LB is the option that distributes. It also
said macOS does not balance without saying what it does instead, which is
give the whole port to the last binder.

docs/multi-threaded-accept.md is the page: the two routes, the per-platform
table, why Windows has only one of them, what the contract tests assert, and
the runbook for the scaling harness - including the step that has to come
first, which is raising --clients until the number stops moving, because the
client side is synchronous and a server-scaling curve measured against a
saturated client is a picture of the client.

It also says what a result would mean in each direction, so the sweep can be
read rather than interpreted.

No timing numbers are recorded: the harness has only been run for correctness,
on loaded machines, where a figure would be worse than none.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 2a054e7d-ca37-4e3a-9666-5f9d25a47e06

📥 Commits

Reviewing files that changed from the base of the PR and between 164e8e4 and 9346020.

📒 Files selected for processing (17)
  • DESIGN.md
  • README.md
  • crates/turnloop-bench/src/main.rs
  • crates/turnloop-bench/src/scaling.rs
  • crates/turnloop-contract/src/extended.rs
  • crates/turnloop-contract/src/lib.rs
  • crates/turnloop-contract/tests/wasi.rs
  • crates/turnloop-contract/tests/windows.rs
  • crates/turnloop/src/backend/iocp/mod.rs
  • crates/turnloop/src/backend/ipc.rs
  • crates/turnloop/src/backend/socket.rs
  • crates/turnloop/src/backend/unix.rs
  • crates/turnloop/src/backend/wasi_p2.rs
  • crates/turnloop/src/backend/wasi_p3.rs
  • crates/turnloop/src/types.rs
  • docs/lanes/sockopts.md
  • docs/multi-threaded-accept.md

📝 Walkthrough

Walkthrough

Changes

ReusePort and multi-threaded accept

Layer / File(s) Summary
ReusePort contract and backend handling
crates/turnloop/src/types.rs, crates/turnloop/src/backend/...
ListenOpts and UdpOpts now use ReusePort. Unix backends map requests to platform socket options. Unsupported requests return Unsupported.
Reuse-port and exactly-once contract tests
crates/turnloop-contract/src/..., crates/turnloop-contract/tests/...
Tests cover shared binding, distribution refusal, platform refusal, handoff acceptance, and kernel acceptance with exactly-once delivery.
Accept-scaling benchmark
crates/turnloop-bench/src/main.rs, crates/turnloop-bench/src/scaling.rs
The benchmark adds handoff and reuse-port routes, validates liveness, verifies echoed payloads, and emits JSON metrics.
Accept design and platform documentation
DESIGN.md, docs/multi-threaded-accept.md, docs/lanes/sockopts.md, README.md
Documentation describes reuse-port semantics, platform support, handoff behavior, contract guarantees, and benchmark usage.

Estimated code review effort: 5 (Critical) | ~90 minutes

Change: Feature · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Listener
  participant AcceptWorker
  participant ConnectionWorker
  Client->>Listener: connect
  Listener->>AcceptWorker: accept
  AcceptWorker->>ConnectionWorker: detach and attach connection
  ConnectionWorker-->>Client: echo payload and close
Loading
✨ 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 feat/multi-threaded-accept

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.

Ralph Küpper and others added 4 commits September 16, 2026 13:39
A request the platform cannot honour now fails before socket() is called
rather than after SO_REUSEADDR has been set on a descriptor that is about
to be dropped. Nothing reaches the kernel for a listener that is refused.
The figures were cited against macOS 15 and Linux 6.8; they were taken on
macOS 26.5 arm64 and Linux 6.17 x86-64. A measurement whose host is wrong in
the record cannot be reproduced or contradicted later.

The runbook also claimed QueryProcessCycleTime on Windows. turnloop-bench's
counter has a Linux arm and a macOS arm and nothing else, so Windows falls
back to elapsed nanoseconds - which is the right unit for a scaling sweep in
any case, because connections per second is the quantity of interest and an
instruction count attributes only the measuring thread's work.
It is the usual objection to multi-threaded accept and it applies to a shape
turnloop does not have: a herd comes from several threads waiting on one
listening socket. Under Distribute each loop has its own listener and its own
accept queue and exactly one loop wakes; under the handoff route exactly one
loop is accepting at all. Each route's real cost is named instead.
The client threads shared the connection budget through a countdown:
`try_update(|n| n.checked_sub(1))`, which is a compare-exchange loop that
retries whenever two client threads reach for the same connection at the
same moment. That contention sits on the load-generating side of a harness
whose whole job is to not be the bottleneck while the server scales, and it
grows with `--clients` — exactly the knob the runbook says to raise first,
until the client rate stops moving, before any server curve means anything.

A ticket counter claims the same work with one wait-free `fetch_add`: each
thread takes the next number and stops once the numbers run past the budget.
Exactly `--connections` tickets are below the budget, so exactly that many
connections are driven, which `driven == args.connections` still asserts.

Verified on macOS 26.5 arm64 at 1, 2 and 4 loops: the budget is spent exactly
and the per-loop counts sum to it.
@proggeramlug
proggeramlug marked this pull request as ready for review September 16, 2026 12:02
@proggeramlug
proggeramlug merged commit bccd379 into main Sep 16, 2026
38 of 39 checks passed
@proggeramlug
proggeramlug deleted the feat/multi-threaded-accept branch September 16, 2026 12:03
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.

ListenOpts has no SO_REUSEPORT: cluster workers cannot share a port

1 participant