Skip to content

bug: InvalidCloseCode fault intermittently reports IoFailure on macOS #34

Description

@copyleftdev

Kāhea version

0.2.0 (e205bf6, and present since #28)

Operating system and architecture

macOS (macos-latest, arm64) under GitHub Actions. Not reproduced on ubuntu-latest or
windows-latest, and not reproduced locally on Linux.

Minimal reproduction

cargo test --workspace --no-fail-fast on macOS. The failure is intermittent: one failure in the
last 25 CI runs, and both outcomes have occurred on the same commit — run
31666317470 passed and run
31666319447 failed on the identical
head SHA of #33, which is what rules out a code change as the cause.

websocket::tests::controlled_oracle_fault_matrix_is_reproducible_and_fails_closed
crates/kahea-exec/src/websocket.rs:3275
assertion `left == right` failed: fault InvalidCloseCode mapped to the wrong terminal cause
  left: IoFailure
 right: ExpectationFailed

Observed and expected behavior

Expected: the InvalidCloseCode fault maps to WebSocketTerminalCause::ExpectationFailed, because
the server closes with code 1005, which the sealed plan does not accept.

Observed: WebSocketTerminalCause::IoFailure. The executor never reports the verdict it derived from
the received frame, because a socket error during teardown overwrites it.

Mechanism

inject_post_upgrade_fault writes the invalid close frame and returns immediately
(crates/kahea-test-server/src/websocket.rs:603, called at :508), after which the connection is
dropped. The executor, having read that close frame, sends its own close response per RFC 6455.
Those bytes reach a socket the server has already closed with data still unread, which yields an RST
rather than a clean FIN, so the executor's next read or write fails with ECONNRESET/EPIPE and the
observation is finalized as IoFailure. macOS resets sooner than Linux under this pattern, which is
why only that leg trips, and only sometimes.

The test server already anticipates this hazard elsewhere: SilentClose drains the client's close
frame with consume_client_close(socket.get_mut()) before waiting (:615). The post-upgrade fault
paths do not. InvalidClosePayload, InvalidUtf8, ReservedOpcode, ReservedBit,
FragmentedControlFrame, and TruncatedFrame share the same shape and are latent instances of the
same race; InvalidCloseCode is simply the case where the client is most likely to reply before the
server tears down.

Two candidate fixes, which are not equivalent

  1. Test server drains before teardown, as SilentClose already does. Narrow, matches existing
    practice in the same file, and fixes the flake.
  2. The executor preserves a terminal cause already determined from received data when a later I/O
    error occurs during teardown. This is the more consequential one and is arguably a product bug
    rather than a test bug: a peer that sends a protocol violation or an unacceptable close code and
    then resets the connection should still be reported as a protocol violation or an expectation
    failure. Today the diagnosis a user needs is replaced by IoFailure, in production as well as in
    tests. Terminal cause is a public kahea/k1 field, so a change here is a contract decision and
    should say what wins when two causes are available and why.

Suggest doing 2 with 1 as belt-and-braces, but 2 needs its own review of the terminal-cause
precedence rules rather than a local patch.

Impact

cross-platform (macos-latest) is a required status check on main, so every pull request has a
small chance of a spurious block. #33 hit exactly this and was re-run rather than merged on a red
check.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions