Skip to content

The gRPC Kestrel test owns its ports — closes the FreePort() TOCTOU race (#2379) - #2427

Merged
rbuergi merged 1 commit into
mainfrom
fix/2379-freeport-toctou
Aug 26, 2026
Merged

rbuergi merged 1 commit into
mainfrom
fix/2379-freeport-toctou

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes #2379.

The race

MeshGrpcTrustedKestrelTest.FreePort() bound a socket to :0, read the port the kernel assigned, and then released it. Kestrel bound that number later. Between the release and the bind the port was owned by nobody, so anything else on the machine — another test, another CI shard's process, an ephemeral outbound connection — could take it, and Kestrel then failed to start:

System.IO.IOException : Failed to bind to address http://127.0.0.1:35145: address already in use.

Narrow window on an idle box, wide one on a loaded runner — which is why it reads as a flake while being a plain ownership bug. It redded #2364, whose diff is three workflow YAMLs and one Documentation.Test guard.

The fix — structural, not a retry

LoopbackPort.Reserve() binds and listens and never closes: the kernel refuses every rival bind for as long as it lives. Kestrel does not open a second socket for that port either — it is handed this one via SocketTransportOptions.CreateBoundListenSocket, so discovery and use are the same socket and there is nothing in between to lose.

No retry loop, no wider range, no sleep — each of those only makes the window smaller (AGENTS.md → "No band-aids": never raise a bound to make a race less likely).

The issue's option 1 — bind :0 and read the port back from IServerAddressesFeature — is not available here: the test's whole subject is Grpc:TrustedPort bound from configuration before the host starts, so the number has to exist up front. Option 2 (SO_REUSEADDR alongside) and option 3 (reserving from the OS ephemeral range) are both declined for the reasons the issue gives.

ListenLocalhost → Listen(IPAddress.Loopback, …): the former binds the IPv4 and IPv6 loopback, i.e. two sockets for one port number, and only one of them can be the reserved one. The clients now dial 127.0.0.1 explicitly — also the address GrpcOptions.TrustedPort documents (http://127.0.0.1:{TrustedPort}).

The test fails without the fix — deterministically

LoopbackPortReservationTest.Kestrel_binds_a_reserved_port_no_rival_can_take runs the losing interleaving on purpose rather than waiting for a loaded runner to produce it. Both halves are load-bearing:

  1. the rival must be refused — with a released port it wins;
  2. Kestrel must bind that port while the reservation is still held — only possible because it adopts the reserved socket. Drop the handover and this half fails, because the reservation itself would be what blocks Kestrel.

Evidence (macOS 25.5.0, .NET 10.0.301, -c Release -warnaserror, 0 warnings / 0 errors):

  • Reserve() temporarily regressed to the old release-after-discovery shape ⇒ the new test fails in 17 ms: Assert.Equal() Failure — Expected: AddressAlreadyInUse, Actual: null (the rival took the port). No load needed.
  • A standalone probe of the same regressed shape, with the rival holding the port, reproduces the issue's exact failure: IOException: Failed to bind to address http://127.0.0.1:56067: address already in use.
  • With the fix: 20/20 iterations of the new test and 5/5 of the full 12-test project pass (68 s wall clock).

Not in scope

Two other sites reserve-then-release the same way, and neither can use this cure:

  • test/MeshWeaver.Graph.Test/TestOgServer.cs:43 — HttpListener cannot be handed a socket;
  • test/MeshWeaver.Persistence.Test/StorageAndDb.cs:10 — hands the port to Azurite, an external process.

Both need a different fix; filing separately rather than widening this one. (MeshGrpcLiveTest already does the right thing — it binds :0, because it does not need the number up front.)

🤖 Generated with Claude Code

…ace (#2379)

`FreePort()` bound a socket to `:0`, read the port the kernel assigned and then
RELEASED it, leaving the port owned by nobody until Kestrel's own bind. Anything
else on the box — another shard's process, an ephemeral outbound connection —
could take it in that window, and Kestrel then failed to start with
`IOException: Failed to bind to address http://127.0.0.1:NNNNN: address already
in use`. Narrow on an idle machine, wide on a loaded CI runner, which is why it
read as a flake while being a plain ownership bug. It redded #2364, whose diff
is three workflow YAMLs and a Documentation.Test guard.

The cure is structural, not a retry (AGENTS.md → "No band-aids": never raise a
bound to make a race less likely — a retry loop here still races, just less
often). `LoopbackPort.Reserve()` binds AND listens and never closes: the kernel
refuses every rival bind for as long as it lives. Kestrel does not open a second
socket for that port either — it is handed THIS one through
`SocketTransportOptions.CreateBoundListenSocket`, so discovery and use are the
same socket and there is nothing in between to lose.

Binding `:0` and reading the port back from `IServerAddressesFeature` is not
available here: the test's whole subject is `Grpc:TrustedPort` bound from
configuration BEFORE the host starts, so the number has to exist up front.

`ListenLocalhost` → `Listen(IPAddress.Loopback, …)`: the former binds the IPv4
AND IPv6 loopback, i.e. two sockets for one port number, and only one of them
can be the reserved one. The clients dial `127.0.0.1` explicitly, which is also
the address `GrpcOptions.TrustedPort` documents.

`LoopbackPortReservationTest` runs the losing interleaving deterministically
instead of waiting for a loaded runner to produce it: a rival grabs the port at
exactly the wrong instant. Both halves are load-bearing — the rival must be
REFUSED (with a released port it wins), and Kestrel must bind that port WHILE
the reservation is still held (only possible because it adopts the reserved
socket; drop the handover and the reservation is what blocks Kestrel).

Verified on macOS 25.5.0 / .NET 10.0.301, Release + `-warnaserror`, 0 warnings:
- regressed `Reserve()` to the old release-after-discovery shape ⇒ the new test
  fails in 17 ms with `Expected: AddressAlreadyInUse / Actual: null` (the rival
  took the port), and a standalone probe of the same shape reproduces the issue's
  exact text: `IOException: Failed to bind to address http://127.0.0.1:56067:
  address already in use.`
- with the fix: 20/20 iterations of the new test and 5/5 of the full 12-test
  project pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 26, 2026 19:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 fixes a deterministic test flake in the gRPC/Kestrel integration tests by eliminating a TOCTOU race in port selection: the test now reserves (binds and keeps) loopback ports and hands the already-owned sockets directly to Kestrel, ensuring no other process can steal the ports between “find” and “bind”.

Changes:

  • Replaces the old “find a free port then release it” helper with a LoopbackPort reservation that keeps ownership until Kestrel shuts down, and configures Kestrel to adopt the reserved sockets via SocketTransportOptions.CreateBoundListenSocket.
  • Updates Kestrel binding and client dialing to use IPv4 loopback explicitly (Listen(IPAddress.Loopback, ...) + 127.0.0.1) to avoid dual-stack ListenLocalhost ambiguity.
  • Adds a regression test that forces the losing interleaving (rival bind attempt) to pin the ownership behavior deterministically.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
test/MeshWeaver.Hosting.Grpc.Test/MeshGrpcTrustedKestrelTest.cs Reserves loopback ports, hands sockets to Kestrel to avoid TOCTOU, switches to explicit IPv4 loopback, and adds a deterministic regression test for port ownership.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-26-grpc-test-no-longer-loses-its-port.md Adds a What’s New entry documenting the test-flake fix and the new deterministic guard.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@rbuergi

rbuergi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Filed the two sibling sites named under Not in scope as #2436 — both are the same bug class, both need a different structural fix (HttpListener cannot be handed a socket; Azurite is an external process), so neither belongs in this PR.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

616 tests   612 ✅  13m 24s ⏱️
  7 suites    4 💤
  7 files      0 ❌

Results for commit b1d3b4e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

    9 files      9 suites   5m 19s ⏱️
1 952 tests 1 950 ✅ 2 💤 0 ❌
2 525 runs  2 523 ✅ 2 💤 0 ❌

Results for commit b1d3b4e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    9 files      9 suites   6m 44s ⏱️
1 610 tests 1 609 ✅ 1 💤 0 ❌
1 611 runs  1 610 ✅ 1 💤 0 ❌

Results for commit b1d3b4e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 878 tests   2 775 ✅  8m 19s ⏱️
    9 suites    103 💤
    9 files        0 ❌

Results for commit b1d3b4e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

2 036 tests   1 843 ✅  9m 19s ⏱️
    9 suites    193 💤
    9 files        0 ❌

Results for commit b1d3b4e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

2 195 tests   2 193 ✅  13m 35s ⏱️
   10 suites      2 💤
   10 files        0 ❌

Results for commit b1d3b4e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

    53 files      53 suites   56m 43s ⏱️
11 285 tests 10 980 ✅ 305 💤 0 ❌
11 861 runs  11 556 ✅ 305 💤 0 ❌

Results for commit b1d3b4e.

@rbuergi
rbuergi merged commit 1486d26 into main Aug 26, 2026
27 checks passed
@rbuergi
rbuergi deleted the fix/2379-freeport-toctou branch August 28, 2026 08:56
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.

MeshGrpcTrustedKestrelTest: FreePort() is a TOCTOU race — Kestrel binds a port another process took in between

2 participants