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