From e00939d7b71bc08eb9220d5626286d41f98a5a2c Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 16 Oct 2026 14:21:48 -0500 Subject: [PATCH] ci: compile the unit tests before running them On Windows, Zig 0.16 spawns each child inheriting every inheritable handle the build runner holds, including the pipe ends it is making for its other children. A compiler spawned while a test's pipes exist keeps a copy of that test's stdout. The test passes and exits, its stdout reaches EOF only when the compiler exits, and the runner reports "test runner failed to respond" a minute later, on whichever tiny test was unlucky (4 of the last 140 Windows jobs). `zig build check` now compiles every test first, so `zig build test` spawns only test binaries. The transport cancel test reads the request before it closes the held connection: closing with bytes unread resets it, and Zig 0.16 reads a reset on Windows as error.Unexpected with a stack trace on stderr, which looked like the culprit in every failing log. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 11 +++++++++++ core/transport/NativeTransport.zig | 9 +++++++++ docs/PITFALLS.md | 7 +++++++ 3 files changed, 27 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d4506360..b94414be8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,17 @@ jobs: - name: Fetch dependencies (with retries) shell: bash run: bash scripts/ci-retry.sh zig build --fetch + # Every test binary is compiled before any of them runs, so no compiler is alive beside a + # running test. On Windows, Zig 0.16 spawns each child inheriting every inheritable handle + # the build runner holds, the pipes it is making for its other children included. A + # compiler spawned while a test's pipes exist keeps a copy of that test's stdout: the test + # passes and exits, but its stdout reaches EOF only when the compiler exits, and the runner + # gives up after a minute with "test runner failed to respond", on whichever tiny test was + # unlucky, about one run in thirty. `check` is exactly the compiles `test` needs, so the + # step after it spawns only test binaries, which live for milliseconds. + - name: Compile tests + shell: bash + run: bash scripts/ci-retry.sh zig build check --summary all - name: Run tests shell: bash run: bash scripts/ci-retry.sh zig build test --summary all diff --git a/core/transport/NativeTransport.zig b/core/transport/NativeTransport.zig index f62f70090..5b8cf3262 100644 --- a/core/transport/NativeTransport.zig +++ b/core/transport/NativeTransport.zig @@ -441,6 +441,15 @@ test "cancel returns at once while the server is still holding the connection" { const Hold = struct { fn run(s: *std.Io.net.Server, held_io: std.Io, release: *std.atomic.Value(bool)) void { const stream = s.accept(held_io) catch return; + // Read the request before holding: a socket closed with bytes still unread is reset, + // not closed, and Zig 0.16 reads a reset on Windows as `error.Unexpected`, dumping a + // stack trace into the test's stderr that reads as a failure. + var in_buf: [1024]u8 = undefined; + var out_buf: [64]u8 = undefined; + var reader = stream.reader(held_io, &in_buf); + var writer = stream.writer(held_io, &out_buf); + var http_server = std.http.Server.init(&reader.interface, &writer.interface); + _ = http_server.receiveHead() catch {}; while (!release.load(.acquire)) std.Io.sleep(held_io, .fromMicroseconds(1000), .awake) catch {}; stream.close(held_io); } diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index 7f01b1c27..ea08b5a11 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -15,6 +15,13 @@ goes beside the code if it has an area, here if not. for function-local structs with `var`s inside generic functions. - **Tests run only from files their root module analyzes** (`tests/AGENTS.md`). `zig build check-tests-reached` names any that don't. +- **On Windows, "test runner failed to respond" with every test passed means a leaked pipe.** + Zig 0.16's Windows spawn makes each child inherit every inheritable handle the build runner + holds, its other children's pipes among them. A long-lived child (a compiler, a sleeper) + spawned beside a test keeps that test's stdout open, so after the test exits the runner waits a + minute for EOF and fails it. CI compiles the tests (`zig build check`) before running them for + this; don't fold the two steps back together, or start anything long-lived inside `zig build + test`. ## Running a second fizzy